feat: V2 microservices stack — backend services, gateway, JWT auth

Add full V2 architecture: identity, content, studio (.NET 10) and file,
render, notification, gateway (Go) services with vendored deps, plus DB
migrations, event/API contracts, and an init-db script.

Wire the Next.js frontend to the gateway: server-side JWT auth routes
(login/register/refresh/logout/me), gateway fetch helper, and session/
cookie/jwt helpers under src/lib.

Containerize the stack via docker-compose.v2.yml and per-service
Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and
MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via
next/font/local to avoid Google Fonts (geo-blocked).

Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-29 23:29:31 +03:30
parent 53ea78a00d
commit 90ac0b81d1
7636 changed files with 3707504 additions and 240 deletions
+10 -1
View File
@@ -1,4 +1,13 @@
# FlatRender Admin API (optional — set to enable dynamic templates/categories)
# ── FlatRender V2 API Gateway ────────────────────────────────────────────────
# Single public entrypoint to all microservices. Auth + data flow through this.
# Local dev: gateway on :8088 (GATEWAY_PORT in .env.v2). In Docker: http://gateway:8080
API_GATEWAY_URL=http://localhost:8088
# Client-side base (future direct data calls). Include the /v1 prefix.
NEXT_PUBLIC_API_URL=http://localhost:8088/v1
# Tenant the public site authenticates against (Identity service).
NEXT_PUBLIC_TENANT_SLUG=flatrender
# FlatRender Admin API (LEGACY V1 — being replaced by the gateway above)
# Run the admin-api service at D:\Projects\flatrender-admin\admin-api
# Leave empty to use hardcoded fallback data
ADMIN_API_URL=http://localhost:5000
+60
View File
@@ -0,0 +1,60 @@
# FlatRender V2 environment — copy to .env.v2 and fill in secrets
# Usage: docker compose -f docker-compose.v2.yml --env-file .env.v2 up -d
# ── JWT — MUST be >= 32 chars, same value across all services ─────────────────
JWT_SECRET=p9Xv7Lm2Qq8Nz4TfKc1Hs6YwRe3Ud0BafwefWEFw324234QEWF
# ── PostgreSQL ────────────────────────────────────────────────────────────────
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
# ── MinIO (S3-compatible object store) ───────────────────────────────────────
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin-secret
MINIO_BUCKET=flatrender-exports
# ── Render farm ───────────────────────────────────────────────────────────────
NODE_HMAC_SECRET=node-secret-change-me
# ── Notification service ─────────────────────────────────────────────────────
SERVICE_TOKEN=internal-service-secret
# ── Frontend CORS origin (passed to studio-svc) ───────────────────────────────
CORS_ORIGIN=http://localhost:3000
# ── API Gateway published host port ──────────────────────────────────────────
# The only backend port exposed to the host. Change if 8080 is taken locally.
GATEWAY_PORT=8080
# ── ZarinPal (Iranian payment gateway) ───────────────────────────────────────
# Get your merchant ID from https://www.zarinpal.com/
ZARINPAL_MERCHANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
ZARINPAL_CALLBACK_URL=https://yourdomain.com/v1/payments/callback/zarinpal
# Set to false in production
ZARINPAL_SANDBOX=true
# ── SnapPay (Iranian payment gateway) ────────────────────────────────────────
# Get credentials from https://snappay.ir/
SNAPPAY_CLIENT_ID=your-snappay-client-id
SNAPPAY_CLIENT_SECRET=your-snappay-client-secret
SNAPPAY_BASE_URL=https://api.snappay.ir
SNAPPAY_CALLBACK_URL=https://yourdomain.com/v1/payments/callback/snappay
# ── Tara (Iranian payment gateway) ───────────────────────────────────────────
# Get your API key from https://tara.ir/
TARA_API_KEY=your-tara-api-key
TARA_BASE_URL=https://api.tara.ir
TARA_CALLBACK_URL=https://yourdomain.com/v1/payments/callback/tara
# ── Stripe (international payment gateway) ───────────────────────────────────
# Get keys from https://dashboard.stripe.com/apikeys
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
# ── Next.js frontend (NEXT_PUBLIC_* baked at build time) ─────────────────────
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
NEXT_PUBLIC_SITE_URL=http://localhost:3000
SUPABASE_SERVICE_ROLE_KEY=eyJ...
+52
View File
@@ -0,0 +1,52 @@
name: Build backend images
# Builds all 7 V2 microservice images with BuildKit + GitHub Actions layer cache.
# Cache is scoped per service, so each image only re-runs the steps that changed:
# - .NET services skip `dotnet restore` unless a .csproj changes
# - Go services skip nothing network-bound (deps are vendored) and reuse compile cache
on:
push:
branches: [master, main]
paths:
- "services/**"
- ".github/workflows/build.yml"
pull_request:
paths:
- "services/**"
- ".github/workflows/build.yml"
workflow_dispatch:
jobs:
build:
name: build ${{ matrix.service.name }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
service:
- { name: identity-svc, context: ./services/identity }
- { name: content-svc, context: ./services/content }
- { name: studio-svc, context: ./services/studio }
- { name: file-svc, context: ./services/file }
- { name: render-svc, context: ./services/render }
- { name: notification-svc, context: ./services/notification }
- { name: gateway, context: ./services/gateway }
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build ${{ matrix.service.name }}
uses: docker/build-push-action@v6
with:
context: ${{ matrix.service.context }}
file: ${{ matrix.service.context }}/Dockerfile
push: false
load: false
# GitHub Actions cache backend — persists layers across CI runs.
# `scope` keeps each service's cache isolated so they don't evict each other.
cache-from: type=gha,scope=${{ matrix.service.name }}
cache-to: type=gha,scope=${{ matrix.service.name }},mode=max
tags: flatrender2-${{ matrix.service.name }}:ci
+8
View File
@@ -40,3 +40,11 @@ next-env.d.ts
# local secrets
.env.local
.env.v2
# windows / msys crash dumps
*.stackdump
# .NET build output
[Bb]in/
[Oo]bj/
+6 -1
View File
@@ -4,7 +4,7 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
RUN npm ci --registry http://171.22.25.73:8081/repository/npm-group/
# ── Stage 2: build ───────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
@@ -20,11 +20,16 @@ ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000
# V2: browser-facing gateway base (host-exposed port) + tenant for Identity auth
ARG NEXT_PUBLIC_API_URL=http://localhost:8088/v1
ARG NEXT_PUBLIC_TENANT_SLUG=flatrender
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_TENANT_SLUG=$NEXT_PUBLIC_TENANT_SLUG
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
+61
View File
@@ -0,0 +1,61 @@
# FlatRender V2 — Service Contracts
This directory defines all inter-service contracts. Services are loosely
coupled — they communicate via REST APIs (synchronous) and RabbitMQ
events (asynchronous). The browser connects to the Gateway over HTTPS +
WebSocket.
## Layout
```
contracts/
├── common/ # Reusable OpenAPI components (Error, Pagination, ...)
│ └── types.yaml
├── events/ # RabbitMQ event schemas (JSON Schema)
│ ├── README.md # Event catalog with routing keys
│ ├── render.yaml
│ ├── node.yaml
│ ├── identity.yaml
│ ├── file.yaml
│ ├── tenant.yaml
│ └── notification.yaml
├── websocket/ # WebSocket message protocol
│ └── render-progress.md
└── rest/ # Per-service OpenAPI 3.0 specs
├── identity.openapi.yaml # internal
├── content.openapi.yaml # internal
├── studio.openapi.yaml # internal
├── render.openapi.yaml # internal
├── file.openapi.yaml # internal
├── notification.openapi.yaml # internal
├── node-agent.openapi.yaml # called by render orchestrator
├── gateway-public.openapi.yaml # what frontend uses
└── reseller-api.openapi.yaml # B2B API (api-key auth)
```
## Authentication
| Surface | Auth | Carries |
|----------------------|---------------------------------|------------------------|
| Gateway public API | JWT (Bearer) | user_id, tenant_id |
| Reseller API | API key (X-API-Key + signature) | tenant_id, scopes |
| Internal service API | Service token (mTLS in prod) | service_name |
| Node Agent API | Shared secret (HMAC) | orchestrator_signature |
| WebSocket | JWT in query param `?token=...` | user_id, job_id |
## Versioning
- REST: URL prefix `/v1/...`
- Events: routing key suffix `.v1`
- WebSocket: protocol negotiation header
## Conventions
- All IDs are UUID v4
- All timestamps ISO 8601 with timezone (`2026-05-27T10:15:00Z`)
- All money fields are `_minor` integers (rial / cent)
- All durations are seconds (numeric)
- All file sizes are bytes (integer)
- Pagination: `?page=1&page_size=20` returning `PaginatedResponse<T>`
- Errors: `{ "error": { "code", "message", "details", "trace_id" } }`
- Tenant context: `X-Tenant-Id` header on internal calls (JWT carries it on public)
+305
View File
@@ -0,0 +1,305 @@
# =====================================================================
# Common OpenAPI Components — referenced by every service spec via $ref
# =====================================================================
openapi: 3.0.3
info:
title: FlatRender Common Types
version: "1.0"
components:
# -------------------------------------------------------------------
# Errors
# -------------------------------------------------------------------
schemas:
Error:
type: object
required: [error]
properties:
error:
type: object
required: [code, message]
properties:
code:
type: string
example: NOT_FOUND
message:
type: string
example: "Render job not found"
details:
type: object
additionalProperties: true
field_errors:
type: array
items:
type: object
properties:
field: { type: string }
message: { type: string }
trace_id:
type: string
format: uuid
timestamp:
type: string
format: date-time
# ---------------------------------------------------------------
# Pagination
# ---------------------------------------------------------------
PaginationMeta:
type: object
required: [page, page_size, total, has_more]
properties:
page: { type: integer, minimum: 1, example: 1 }
page_size: { type: integer, minimum: 1, maximum: 200, example: 20 }
total: { type: integer, minimum: 0, example: 137 }
has_more: { type: boolean }
next_cursor:
type: string
nullable: true
description: Optional cursor for keyset pagination
PaginatedEnvelope:
type: object
required: [data, meta]
properties:
data:
type: array
items:
type: object
meta:
$ref: '#/components/schemas/PaginationMeta'
# ---------------------------------------------------------------
# Money
# ---------------------------------------------------------------
Money:
type: object
required: [amount_minor, currency]
properties:
amount_minor:
type: integer
format: int64
description: Amount in minor units (rial, cent, etc.)
example: 1500000
currency:
type: string
enum: [IRR, USD, EUR]
example: IRR
# ---------------------------------------------------------------
# Audit
# ---------------------------------------------------------------
Timestamps:
type: object
properties:
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at: { type: string, format: date-time, nullable: true }
# ---------------------------------------------------------------
# Localized text (Persian + English)
# ---------------------------------------------------------------
LocalizedString:
type: object
additionalProperties:
type: string
example:
fa: "عنوان"
en: "Title"
# ---------------------------------------------------------------
# Tenant
# ---------------------------------------------------------------
TenantContext:
type: object
required: [tenant_id, slug]
properties:
tenant_id: { type: string, format: uuid }
slug: { type: string }
kind:
type: string
enum: [Internal, Reseller, Enterprise]
# ---------------------------------------------------------------
# User reference (lightweight)
# ---------------------------------------------------------------
UserRef:
type: object
required: [id]
properties:
id: { type: string, format: uuid }
full_name: { type: string, nullable: true }
avatar_url: { type: string, nullable: true }
# ---------------------------------------------------------------
# File reference
# ---------------------------------------------------------------
FileRef:
type: object
required: [id, url, file_type]
properties:
id: { type: string, format: uuid }
url: { type: string }
thumbnail_url: { type: string, nullable: true }
file_type:
type: string
enum: [Video, Image, Audio, Voiceover, Document, Other]
mime_type: { type: string, nullable: true }
size_bytes: { type: integer, format: int64 }
duration_sec: { type: number, nullable: true }
width: { type: integer, nullable: true }
height: { type: integer, nullable: true }
# ---------------------------------------------------------------
# Enums (reused everywhere)
# ---------------------------------------------------------------
ChooseMode:
type: string
enum: [FIX, FLEXIBLE, MockUp, MusicVisualizer, VoiceOver]
Resolution:
type: string
enum: [HD, FullHD, TwoK, FourK]
SceneType:
type: string
enum: [Normal, Config, DesignStart, DesignEnd]
JustifyKind:
type: string
enum: [LEFT_JUSTIFY, CENTER_JUSTIFY, RIGHT_JUSTIFY, FULL_JUSTIFY]
RenderStep:
type: string
enum:
- Queued
- Preparing
- TemplateCache
- JsxGen
- Music
- Rendering
- Validating
- Repairing
- Optimisation
- Video
- Mixing
- Final
- Uploading
- Done
- Failed
- Cancelled
RenderPriorityQueue:
type: string
enum: [snapshot, vip, paid, preview, mockup, voiceover]
PriceType:
type: string
enum: [Free, Preview, Cash, Plan, Snapshot, Reseller]
RenderQuality:
type: string
enum: [Low, Medium, High, Full, Lossless]
# -------------------------------------------------------------------
# Common headers
# -------------------------------------------------------------------
parameters:
TenantIdHeader:
name: X-Tenant-Id
in: header
required: true
schema: { type: string, format: uuid }
description: Tenant context (carried by JWT on public APIs)
RequestIdHeader:
name: X-Request-Id
in: header
required: false
schema: { type: string, format: uuid }
description: Optional client-supplied correlation ID
PageParam:
name: page
in: query
schema: { type: integer, minimum: 1, default: 1 }
PageSizeParam:
name: page_size
in: query
schema: { type: integer, minimum: 1, maximum: 200, default: 20 }
CursorParam:
name: cursor
in: query
schema: { type: string }
description: Keyset pagination cursor
# -------------------------------------------------------------------
# Standard responses
# -------------------------------------------------------------------
responses:
BadRequest:
description: Validation error or malformed request
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
Unauthorized:
description: Missing or invalid auth
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
Forbidden:
description: Authenticated but not allowed
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
NotFound:
description: Resource not found
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
Conflict:
description: Resource conflict
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
TooManyRequests:
description: Rate limited
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
InternalError:
description: Unexpected server error
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
# -------------------------------------------------------------------
# Security schemes
# -------------------------------------------------------------------
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: JWT with claims sub, tenant_id, scope
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
description: B2B reseller API key (fr_live_..., fr_test_...)
ApiKeySignature:
type: apiKey
in: header
name: X-API-Signature
description: HMAC-SHA256 of (timestamp + body) using key secret
ServiceToken:
type: http
scheme: bearer
description: Inter-service short-lived JWT issued by control plane
NodeHmac:
type: apiKey
in: header
name: X-Node-Signature
description: HMAC for orchestrator <-> node-agent comms
+129
View File
@@ -0,0 +1,129 @@
# RabbitMQ Event Catalog
All async communication between services uses RabbitMQ. Events follow
strict naming: `{domain}.{entity}.{verb}.v{n}` (past tense).
## Exchanges
| Exchange | Type | Purpose |
|---------------------------|----------|--------------------------------------|
| `flatrender.events` | topic | Domain events (fan-out by routing) |
| `flatrender.render` | direct | Render job dispatch (per queue) |
| `flatrender.notify` | direct | Notification dispatch (per channel) |
| `flatrender.dlq` | fanout | Dead-letter queue |
## Common envelope
Every message body has this shape:
```json
{
"event_id": "uuid",
"event_type": "render.job.completed.v1",
"event_time": "2026-05-27T10:15:00Z",
"tenant_id": "uuid",
"user_id": "uuid",
"trace_id": "uuid",
"correlation_id": "uuid",
"producer": "render-orchestrator",
"data": { ... }
}
```
## Headers (AMQP)
| Header | Required | Notes |
|---------------------|----------|------------------------------------------------|
| `content-type` | yes | `application/json` |
| `content-encoding` | yes | `utf-8` |
| `x-event-type` | yes | Same as `event_type` (for routing convenience) |
| `x-tenant-id` | yes | For tenant-aware consumers |
| `x-trace-id` | yes | Distributed tracing |
| `x-retry-count` | optional | Incremented on requeue |
| `x-max-retries` | optional | Default 3 |
## Routing keys (topic exchange `flatrender.events`)
```
identity.user.registered.v1
identity.user.email_verified.v1
identity.user.banned.v1
identity.tenant.created.v1
identity.tenant.suspended.v1
identity.plan.activated.v1
identity.plan.expired.v1
identity.payment.succeeded.v1
identity.payment.failed.v1
identity.payment.refunded.v1
identity.api_key.created.v1
identity.api_key.revoked.v1
content.template.published.v1
content.template.unpublished.v1
content.font.installed.v1
content.svg_preview.generated.v1
studio.project.saved.v1
studio.project.deleted.v1
render.job.queued.v1
render.job.started.v1
render.job.progress.v1
render.job.completed.v1
render.job.failed.v1
render.job.cancelled.v1
render.snapshot.requested.v1
render.snapshot.ready.v1
node.online.v1
node.offline.v1
node.crashed.v1
node.heartbeat.v1
node.cache.updated.v1
file.uploaded.v1
file.processed.v1
file.deleted.v1
file.quota_warning.v1
file.quota_exceeded.v1
file.cleanup.scheduled.v1
file.cleanup.executed.v1
notification.created.v1
notification.delivered.v1
notification.failed.v1
tenant.usage.recorded.v1
tenant.webhook.fired.v1
tenant.webhook.failed.v1
```
## Render dispatch (direct exchange `flatrender.render`)
```
render.queue.snapshot
render.queue.vip
render.queue.paid
render.queue.preview
render.queue.mockup
render.queue.voiceover
```
Each queue has priority `x-max-priority: 10`. Job priority encoded in
message priority property.
## Notification dispatch (direct exchange `flatrender.notify`)
```
notify.channel.push
notify.channel.email
notify.channel.sms
notify.channel.telegram
notify.channel.webhook
```
## Dead-letter routing
Failed messages (after `x-max-retries` exhausted) are republished to
`flatrender.dlq` with original routing key preserved in
`x-original-routing-key` header.
+61
View File
@@ -0,0 +1,61 @@
# =====================================================================
# Content Events — published by Content Service
# =====================================================================
events:
content.template.published.v1:
routing_key: content.template.published.v1
description: A template (project_container) was published.
payload:
type: object
required: [container_id, slug, primary_mode]
properties:
container_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid, nullable: true }
slug: { type: string }
name: { type: string }
primary_mode: { type: string }
project_ids:
type: array
items: { type: string, format: uuid }
content.template.unpublished.v1:
routing_key: content.template.unpublished.v1
payload:
type: object
required: [container_id, reason]
properties:
container_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid, nullable: true }
slug: { type: string }
reason: { type: string }
content.font.installed.v1:
routing_key: content.font.installed.v1
description: Font registered as installed on render nodes.
payload:
type: object
required: [font_id, name, system_name, node_ids]
properties:
font_id: { type: string, format: uuid }
name: { type: string }
system_name: { type: string }
node_ids:
type: array
items: { type: string, format: uuid }
content.svg_preview.generated.v1:
routing_key: content.svg_preview.generated.v1
description: AI service produced an SVG color preview for a scene/project.
payload:
type: object
required: [svg_preview_id, svg_url, color_zones_count]
properties:
svg_preview_id: { type: string, format: uuid }
project_id: { type: string, format: uuid, nullable: true }
scene_id: { type: string, format: uuid, nullable: true }
svg_url: { type: string }
thumbnail_url: { type: string, nullable: true }
color_zones_count: { type: integer }
quality_score: { type: number, minimum: 0, maximum: 1 }
generated_by_ai: { type: boolean }
+114
View File
@@ -0,0 +1,114 @@
# =====================================================================
# File Events — published by File Service
# =====================================================================
events:
file.uploaded.v1:
routing_key: file.uploaded.v1
description: A file upload has completed and is ready to use.
payload:
type: object
required: [file_id, user_id, file_type, size_bytes, url]
properties:
file_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
folder_id: { type: string, format: uuid, nullable: true }
name: { type: string }
file_type: { type: string, enum: [Video, Image, Audio, Voiceover, Document, Other] }
mime_type: { type: string }
size_bytes: { type: integer, format: int64 }
url: { type: string }
thumbnail_url: { type: string, nullable: true }
duration_sec: { type: number, nullable: true }
width: { type: integer, nullable: true }
height: { type: integer, nullable: true }
source:
type: string
enum: [upload, export, snapshot, voiceover_record, stock]
file.processed.v1:
routing_key: file.processed.v1
description: Post-upload processing (thumbnail, waveform, transcode) finished.
payload:
type: object
required: [file_id, status]
properties:
file_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
status: { type: string, enum: [Ready, Failed, Quarantined] }
thumbnail_url: { type: string, nullable: true }
waveform_generated: { type: boolean }
duration_sec: { type: number, nullable: true }
error_message: { type: string, nullable: true }
file.deleted.v1:
routing_key: file.deleted.v1
payload:
type: object
required: [file_id, user_id, size_bytes_freed]
properties:
file_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
size_bytes_freed: { type: integer, format: int64 }
deleted_by:
type: string
enum: [user, auto_cleanup, admin, quota_exceeded]
file.quota_warning.v1:
routing_key: file.quota_warning.v1
description: User passed 90% of storage quota.
payload:
type: object
required: [user_id, used_bytes, quota_bytes, percent_used]
properties:
user_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
used_bytes: { type: integer, format: int64 }
quota_bytes: { type: integer, format: int64 }
percent_used: { type: number }
file.quota_exceeded.v1:
routing_key: file.quota_exceeded.v1
description: User hit 100% storage quota — uploads blocked.
payload:
type: object
required: [user_id, used_bytes, quota_bytes]
properties:
user_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
used_bytes: { type: integer, format: int64 }
quota_bytes: { type: integer, format: int64 }
attempted_upload_size_bytes: { type: integer, format: int64, nullable: true }
file.cleanup.scheduled.v1:
routing_key: file.cleanup.scheduled.v1
description: An entity has been queued for automatic deletion.
payload:
type: object
required: [cleanup_id, entity_type, entity_id, scheduled_delete_at]
properties:
cleanup_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid, nullable: true }
user_id: { type: string, format: uuid, nullable: true }
entity_type:
type: string
enum: [Export, TempRenderFolder, OrphanedFile, UnusedUpload, SnapshotExpired]
entity_id: { type: string, format: uuid }
scheduled_delete_at: { type: string, format: date-time }
notify_user_at: { type: string, format: date-time, nullable: true }
file.cleanup.executed.v1:
routing_key: file.cleanup.executed.v1
payload:
type: object
required: [cleanup_id, status, bytes_freed]
properties:
cleanup_id: { type: string, format: uuid }
entity_type: { type: string }
entity_id: { type: string, format: uuid }
status: { type: string, enum: [Done, Skipped, Failed] }
bytes_freed: { type: integer, format: int64 }
error_message: { type: string, nullable: true }
+177
View File
@@ -0,0 +1,177 @@
# =====================================================================
# Identity Events — published by Identity Service
# =====================================================================
events:
identity.user.registered.v1:
routing_key: identity.user.registered.v1
description: New user account created (any tenant).
payload:
type: object
required: [user_id, tenant_id, register_mode]
properties:
user_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
email: { type: string, nullable: true }
phone_number: { type: string, nullable: true }
full_name: { type: string, nullable: true }
register_mode:
type: string
enum: [Email, Mobile, Google, Telegram, SSO, Reseller]
affiliate_owner_id: { type: string, format: uuid, nullable: true }
registered_with_mobile_app: { type: boolean }
identity.user.email_verified.v1:
routing_key: identity.user.email_verified.v1
payload:
type: object
required: [user_id, email]
properties:
user_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
email: { type: string }
identity.user.banned.v1:
routing_key: identity.user.banned.v1
payload:
type: object
required: [user_id, banned_by, reason]
properties:
user_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
banned_by: { type: string, format: uuid }
reason: { type: string }
unblock_date: { type: string, format: date-time, nullable: true }
identity.tenant.created.v1:
routing_key: identity.tenant.created.v1
payload:
type: object
required: [tenant_id, slug, kind]
properties:
tenant_id: { type: string, format: uuid }
slug: { type: string }
name: { type: string }
kind: { type: string, enum: [Internal, Reseller, Enterprise] }
contact_email: { type: string, nullable: true }
identity.tenant.suspended.v1:
routing_key: identity.tenant.suspended.v1
payload:
type: object
required: [tenant_id, reason]
properties:
tenant_id: { type: string, format: uuid }
reason: { type: string }
suspended_at: { type: string, format: date-time }
identity.plan.activated.v1:
routing_key: identity.plan.activated.v1
payload:
type: object
required: [user_id, user_plan_id, plan_code, expires_at]
properties:
user_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_plan_id: { type: string, format: uuid }
plan_id: { type: string, format: uuid }
plan_code: { type: string }
plan_name: { type: string }
seconds_charge: { type: integer }
storage_gb: { type: integer }
parallel_renders: { type: integer }
expires_at: { type: string, format: date-time }
payment_id: { type: string, format: uuid, nullable: true }
identity.plan.expired.v1:
routing_key: identity.plan.expired.v1
payload:
type: object
required: [user_id, user_plan_id]
properties:
user_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_plan_id: { type: string, format: uuid }
plan_code: { type: string }
days_overdue: { type: integer }
identity.payment.succeeded.v1:
routing_key: identity.payment.succeeded.v1
payload:
type: object
required: [payment_id, user_id, amount_minor, gateway, action]
properties:
payment_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
amount_minor: { type: integer, format: int64 }
currency: { type: string }
gateway:
type: string
enum: [ZarinPal, IdPay, Bazaar, Stripe, Balance, Manual]
action:
type: string
enum: [PlanPurchase, BalanceCharge, ProjectRender, UserProject, StorageUpgrade, Other]
gateway_track_id: { type: string, nullable: true }
plan_id: { type: string, format: uuid, nullable: true }
affiliate_owner_id: { type: string, format: uuid, nullable: true }
affiliate_profit_minor: { type: integer, format: int64 }
identity.payment.failed.v1:
routing_key: identity.payment.failed.v1
payload:
type: object
required: [payment_id, user_id, gateway, failure_reason]
properties:
payment_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
amount_minor: { type: integer, format: int64 }
currency: { type: string }
gateway: { type: string }
failure_reason: { type: string }
action: { type: string }
identity.payment.refunded.v1:
routing_key: identity.payment.refunded.v1
payload:
type: object
required: [payment_id, user_id, refund_amount_minor, reason]
properties:
payment_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
refund_amount_minor: { type: integer, format: int64 }
currency: { type: string }
reason: { type: string }
refunded_to:
type: string
enum: [Balance, OriginalMethod, Plan]
identity.api_key.created.v1:
routing_key: identity.api_key.created.v1
payload:
type: object
required: [api_key_id, tenant_id, name, environment, created_by_user_id]
properties:
api_key_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
name: { type: string }
environment: { type: string, enum: [Live, Test] }
key_prefix: { type: string }
last4: { type: string }
scopes:
type: array
items: { type: string }
created_by_user_id: { type: string, format: uuid }
identity.api_key.revoked.v1:
routing_key: identity.api_key.revoked.v1
payload:
type: object
required: [api_key_id, tenant_id, revoked_by_user_id, reason]
properties:
api_key_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
revoked_by_user_id: { type: string, format: uuid }
reason: { type: string }
+122
View File
@@ -0,0 +1,122 @@
# =====================================================================
# Node Events — published by Node Agent → Render Orchestrator
# Routing: flatrender.events (topic) — key: node.*.v1
# =====================================================================
events:
# -------------------------------------------------------------------
# node.online.v1 — node agent starts up
# -------------------------------------------------------------------
node.online.v1:
routing_key: node.online.v1
payload:
type: object
required: [node_id, region, node_agent_version, current_ae_version]
properties:
node_id: { type: string, format: uuid }
node_ip: { type: string, format: ipv4 }
region: { type: string }
node_agent_version: { type: string }
current_ae_version: { type: string }
available_ae_versions:
type: array
items: { type: string }
ram_gb: { type: integer }
cpu_cores: { type: integer }
cache_used_gb: { type: integer }
cached_template_md5s:
type: array
items: { type: string }
# -------------------------------------------------------------------
# node.offline.v1 — graceful shutdown OR detected missed heartbeats
# -------------------------------------------------------------------
node.offline.v1:
routing_key: node.offline.v1
payload:
type: object
required: [node_id, reason]
properties:
node_id: { type: string, format: uuid }
reason: { type: string, enum: [Shutdown, HeartbeatLost, Maintenance, Disabled] }
last_heartbeat_at: { type: string, format: date-time }
current_job_id: { type: string, format: uuid, nullable: true }
# -------------------------------------------------------------------
# node.heartbeat.v1 — every 5s (NOT broadcast on topic exchange,
# sent direct to orchestrator HTTP endpoint OR a dedicated stream)
# Documented here for completeness.
# -------------------------------------------------------------------
node.heartbeat.v1:
routing_key: node.heartbeat.v1
transport: HTTP POST /v1/internal/nodes/{node_id}/heartbeat
payload:
type: object
required: [node_id, status, recorded_at]
properties:
node_id: { type: string, format: uuid }
status: { type: string, enum: [Ready, Busy, Crashed, Updating] }
recorded_at: { type: string, format: date-time }
cpu_pct: { type: integer, minimum: 0, maximum: 100 }
ram_available_mb: { type: integer }
ae_running: { type: boolean }
current_job_id: { type: string, format: uuid, nullable: true }
current_frame_job_id: { type: string, format: uuid, nullable: true }
current_frame: { type: integer, nullable: true }
cache_used_gb: { type: integer }
# -------------------------------------------------------------------
# node.crashed.v1 — AfterFX crashed mid-render
# -------------------------------------------------------------------
node.crashed.v1:
routing_key: node.crashed.v1
description: AE process exited unexpectedly while rendering.
payload:
type: object
required: [node_id, crashed_at]
properties:
node_id: { type: string, format: uuid }
render_job_id: { type: string, format: uuid, nullable: true }
frame_job_id: { type: string, format: uuid, nullable: true }
crashed_at: { type: string, format: date-time }
last_known_frame: { type: integer, nullable: true }
crash_signal: { type: string, nullable: true }
ae_version: { type: string }
error_log_tail: { type: string, description: "Last ~50 lines of AE log" }
log_file_url: { type: string, nullable: true }
auto_recovery_started: { type: boolean }
# -------------------------------------------------------------------
# node.cache.updated.v1 — template cache changed (download or evict)
# -------------------------------------------------------------------
node.cache.updated.v1:
routing_key: node.cache.updated.v1
payload:
type: object
required: [node_id, action, project_id, aep_file_md5]
properties:
node_id: { type: string, format: uuid }
action: { type: string, enum: [Downloaded, Evicted, Verified, Failed] }
project_id: { type: string, format: uuid }
aep_file_md5: { type: string }
file_size_bytes: { type: integer, format: int64 }
cache_used_gb: { type: integer, description: "Total cache size after action" }
duration_ms: { type: integer, nullable: true }
error_message: { type: string, nullable: true }
# -------------------------------------------------------------------
# node.frame.completed.v1 — single frame done (high-frequency)
# NOT on topic; sent via direct push to orchestrator.
# -------------------------------------------------------------------
node.frame.completed.v1:
routing_key: node.frame.completed.v1
transport: HTTP POST /v1/internal/render/jobs/{job_id}/frames
payload:
type: object
required: [render_job_id, frame_job_id, frame_number]
properties:
render_job_id: { type: string, format: uuid }
frame_job_id: { type: string, format: uuid }
frame_number: { type: integer }
file_size_bytes: { type: integer }
completed_at: { type: string, format: date-time }
@@ -0,0 +1,66 @@
# =====================================================================
# Notification Events
# =====================================================================
events:
notification.created.v1:
routing_key: notification.created.v1
description: A new in-app notification was created (also fans out to channels).
payload:
type: object
required: [notification_id, user_id, notification_type, title, message]
properties:
notification_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
notification_type:
type: string
enum: [RenderCompleted, RenderFailed, RenderProgress,
PlanExpiring, PlanExpired, PaymentSuccess, PaymentFailed,
StorageWarning, StorageFull, ExportExpiring, ExportDeleted,
GiftEarned, QuestCompleted, LevelUp,
AccountSecurity, SystemAnnouncement, TenantInvite,
Marketing, Other]
priority: { type: string, enum: [Low, Normal, High, Urgent] }
title: { type: string }
message: { type: string }
action_url: { type: string, nullable: true }
# Fan-out routing — which channels to deliver to
channels:
type: array
items: { type: string, enum: [InApp, Push, Email, SMS, Telegram, Webhook] }
notification.delivered.v1:
routing_key: notification.delivered.v1
payload:
type: object
required: [delivery_id, notification_id, channel, status]
properties:
delivery_id: { type: string, format: uuid }
notification_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
channel: { type: string, enum: [InApp, Push, Email, SMS, Telegram, Webhook] }
status: { type: string, enum: [Sent, Delivered] }
provider: { type: string }
provider_message_id: { type: string, nullable: true }
sent_at: { type: string, format: date-time }
delivered_at: { type: string, format: date-time, nullable: true }
notification.failed.v1:
routing_key: notification.failed.v1
payload:
type: object
required: [delivery_id, channel, error_message]
properties:
delivery_id: { type: string, format: uuid }
notification_id: { type: string, format: uuid, nullable: true }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
channel: { type: string }
attempt: { type: integer }
max_attempts: { type: integer }
error_code: { type: string, nullable: true }
error_message: { type: string }
will_retry: { type: boolean }
next_retry_at: { type: string, format: date-time, nullable: true }
+219
View File
@@ -0,0 +1,219 @@
# =====================================================================
# Render Events — published by Render Orchestrator
# Routing: flatrender.events (topic) — key: render.*.v1
# =====================================================================
$schema: "http://json-schema.org/draft-07/schema#"
title: Render Event Schemas
type: object
definitions:
EventEnvelope:
type: object
required: [event_id, event_type, event_time, tenant_id, data]
properties:
event_id: { type: string, format: uuid }
event_type: { type: string }
event_time: { type: string, format: date-time }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
trace_id: { type: string, format: uuid }
correlation_id: { type: string, format: uuid }
producer: { type: string, const: render-orchestrator }
data: { type: object }
events:
# -------------------------------------------------------------------
# render.job.queued.v1
# Emitted when a job is accepted into the queue.
# Consumed by: notification (none yet), analytics
# -------------------------------------------------------------------
render.job.queued.v1:
routing_key: render.job.queued.v1
description: New render job has been accepted into a priority queue.
payload:
type: object
required: [render_job_id, saved_project_id, priority_queue, price_type, region]
properties:
render_job_id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
original_project_id: { type: string, format: uuid }
priority_queue:
type: string
enum: [snapshot, vip, paid, preview, mockup, voiceover]
priority_score: { type: integer, minimum: 0, maximum: 100 }
price_type:
type: string
enum: [Free, Preview, Cash, Plan, Snapshot, Reseller]
paid_price_minor: { type: integer, format: int64 }
region: { type: string }
duration_sec: { type: number }
resolution: { type: string }
is_60_fps: { type: boolean }
has_music: { type: boolean }
has_sfx: { type: boolean }
has_voiceover: { type: boolean }
# -------------------------------------------------------------------
# render.job.started.v1
# Emitted once nodes are assigned and rendering begins.
# -------------------------------------------------------------------
render.job.started.v1:
routing_key: render.job.started.v1
description: Render job has begun processing on at least one node.
payload:
type: object
required: [render_job_id, started_at]
properties:
render_job_id: { type: string, format: uuid }
started_at: { type: string, format: date-time }
node_ids:
type: array
items: { type: string, format: uuid }
region: { type: string }
total_frames: { type: integer }
# -------------------------------------------------------------------
# render.job.progress.v1 (high-frequency, optional pub/sub)
# NOT broadcast on the main events exchange — only on per-job
# ephemeral fanout for WebSocket fan-out. Schema documented here.
# -------------------------------------------------------------------
render.job.progress.v1:
routing_key: render.job.progress.{job_id}
description: Progress tick for live UI update.
exchange: render.progress # separate dedicated exchange (auto-delete fanout)
payload:
type: object
required: [render_job_id, step, progress]
properties:
render_job_id: { type: string, format: uuid }
step:
type: string
enum: [Queued, Preparing, TemplateCache, JsxGen, Music,
Rendering, Validating, Repairing, Optimisation, Video,
Mixing, Final, Uploading, Done, Failed, Cancelled]
progress: { type: integer, minimum: 0, maximum: 100 }
current_frame: { type: integer, nullable: true }
total_frames: { type: integer, nullable: true }
eta_seconds: { type: integer, nullable: true }
preview_b64:
type: string
nullable: true
description: Last rendered frame thumbnail (small, ~5-15 KB)
message: { type: string, nullable: true }
# -------------------------------------------------------------------
# render.job.completed.v1
# Consumed by: notification, studio (mark project), file (cleanup),
# tenant (usage metering), webhook dispatcher
# -------------------------------------------------------------------
render.job.completed.v1:
routing_key: render.job.completed.v1
description: Render job successfully produced an export.
payload:
type: object
required: [render_job_id, export_id, output_url, duration_sec, size_bytes]
properties:
render_job_id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
original_project_id: { type: string, format: uuid }
export_id: { type: string, format: uuid }
output_url: { type: string }
thumbnail_url: { type: string, nullable: true }
duration_sec: { type: number }
size_bytes: { type: integer, format: int64 }
resolution: { type: string }
width: { type: integer }
height: { type: integer }
render_compute_seconds: { type: integer, description: "Sum of node-seconds spent" }
node_ids_used:
type: array
items: { type: string, format: uuid }
price_type: { type: string }
paid_price_minor: { type: integer, format: int64 }
# -------------------------------------------------------------------
# render.job.failed.v1
# Consumed by: notification, identity (refund), tenant (metering)
# -------------------------------------------------------------------
render.job.failed.v1:
routing_key: render.job.failed.v1
description: Render job failed permanently (after retries exhausted).
payload:
type: object
required: [render_job_id, failed_at_step, error_message]
properties:
render_job_id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
failed_at_step:
type: string
enum: [Queued, Preparing, TemplateCache, JsxGen, Music,
Rendering, Validating, Repairing, Optimisation, Video,
Mixing, Final, Uploading, Done, Failed, Cancelled]
error_message: { type: string }
error_code: { type: string, nullable: true }
retry_count: { type: integer }
refund_required: { type: boolean, default: true }
price_type: { type: string }
paid_price_minor: { type: integer, format: int64 }
node_ids_attempted:
type: array
items: { type: string, format: uuid }
# -------------------------------------------------------------------
# render.job.cancelled.v1
# User cancelled via UI before completion.
# -------------------------------------------------------------------
render.job.cancelled.v1:
routing_key: render.job.cancelled.v1
description: Render job was cancelled by user.
payload:
type: object
required: [render_job_id, cancelled_by_user_id]
properties:
render_job_id: { type: string, format: uuid }
cancelled_by_user_id: { type: string, format: uuid }
cancelled_at_step: { type: string }
progress_when_cancelled: { type: integer }
refund_required: { type: boolean, default: true }
# -------------------------------------------------------------------
# render.snapshot.requested.v1
# User asked for a single-frame snapshot of a scene.
# -------------------------------------------------------------------
render.snapshot.requested.v1:
routing_key: render.snapshot.requested.v1
description: Scene snapshot was queued.
payload:
type: object
required: [snapshot_id, saved_project_id, scene_key, frame_number]
properties:
snapshot_id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
scene_key: { type: string }
frame_number: { type: integer }
cached:
type: boolean
description: True if served from cache (no render needed)
# -------------------------------------------------------------------
# render.snapshot.ready.v1
# Single-frame snapshot finished.
# -------------------------------------------------------------------
render.snapshot.ready.v1:
routing_key: render.snapshot.ready.v1
description: Snapshot image is ready.
payload:
type: object
required: [snapshot_id, image_url]
properties:
snapshot_id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
scene_key: { type: string }
frame_number: { type: integer }
image_url: { type: string }
thumbnail_url: { type: string, nullable: true }
width: { type: integer }
height: { type: integer }
size_bytes: { type: integer }
duration_ms: { type: integer }
expires_at: { type: string, format: date-time }
+32
View File
@@ -0,0 +1,32 @@
# =====================================================================
# Studio Events — published by Studio Service
# =====================================================================
events:
studio.project.saved.v1:
routing_key: studio.project.saved.v1
description: User saved their project (used to trigger autosave events, analytics).
payload:
type: object
required: [saved_project_id, user_id]
properties:
saved_project_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
original_project_id: { type: string, format: uuid }
name: { type: string }
choose_mode: { type: string }
scene_count: { type: integer }
is_first_save: { type: boolean }
studio.project.deleted.v1:
routing_key: studio.project.deleted.v1
description: User deleted a saved project (moved to trash or hard-deleted).
payload:
type: object
required: [saved_project_id, user_id, hard_delete]
properties:
saved_project_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
hard_delete: { type: boolean }
+72
View File
@@ -0,0 +1,72 @@
# =====================================================================
# Tenant Events — multi-tenancy / reseller-specific
# =====================================================================
events:
tenant.usage.recorded.v1:
routing_key: tenant.usage.recorded.v1
description: Daily usage aggregate published (by usage aggregator cron).
payload:
type: object
required: [tenant_id, usage_date, renders_completed, render_seconds]
properties:
tenant_id: { type: string, format: uuid }
usage_date: { type: string, format: date }
renders_started: { type: integer }
renders_completed: { type: integer }
renders_failed: { type: integer }
render_seconds: { type: integer, format: int64 }
render_compute_sec: { type: integer, format: int64 }
storage_bytes: { type: integer, format: int64 }
api_calls: { type: integer, format: int64 }
active_users: { type: integer }
new_users: { type: integer }
amount_billed_minor: { type: integer, format: int64 }
currency: { type: string }
tenant.webhook.fired.v1:
routing_key: tenant.webhook.fired.v1
description: A webhook was successfully delivered to a reseller.
payload:
type: object
required: [webhook_id, tenant_id, event_type, response_status]
properties:
webhook_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
delivery_id: { type: string, format: uuid }
event_type: { type: string }
request_url: { type: string }
response_status: { type: integer }
duration_ms: { type: integer }
attempt: { type: integer }
tenant.webhook.failed.v1:
routing_key: tenant.webhook.failed.v1
description: A webhook delivery exhausted retries.
payload:
type: object
required: [webhook_id, tenant_id, event_type, last_status, last_error]
properties:
webhook_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
delivery_id: { type: string, format: uuid }
event_type: { type: string }
request_url: { type: string }
last_status: { type: integer, nullable: true }
last_error: { type: string }
attempts: { type: integer }
webhook_disabled: { type: boolean, description: "True if auto-disabled" }
tenant.api.rate_limited.v1:
routing_key: tenant.api.rate_limited.v1
description: A tenant exceeded its API rate limit (informational).
payload:
type: object
required: [tenant_id, api_key_id, limit_rpm, window_start]
properties:
tenant_id: { type: string, format: uuid }
api_key_id: { type: string, format: uuid }
limit_rpm: { type: integer }
actual_rpm: { type: integer }
window_start: { type: string, format: date-time }
ip_address: { type: string }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+823
View File
@@ -0,0 +1,823 @@
openapi: 3.0.3
info:
title: FlatRender Render Orchestrator (internal)
version: 1.0.0
description: |
Render job orchestration, node pool management, snapshots, exports.
Owned by Go service. The browser connects to WebSocket via Gateway
for live progress.
servers:
- url: http://render-svc.internal/v1
security:
- BearerAuth: []
- ServiceToken: []
tags:
- name: Jobs
- name: Snapshots
- name: Exports
- name: Nodes
- name: Admin
- name: Internal
paths:
# ===================== JOBS =====================
/renders:
get:
tags: [Jobs]
summary: List user's render jobs
parameters:
- { name: status, in: query, schema: { type: string } }
- { name: page, in: query, schema: { type: integer } }
- { name: page_size, in: query, schema: { type: integer } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/RenderJob' } }
meta: { $ref: '#/components/schemas/PaginationMeta' }
post:
tags: [Jobs]
summary: Submit new render job
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/RenderJobCreate' }
responses:
'201':
content:
application/json:
schema: { $ref: '#/components/schemas/RenderJob' }
'402': { description: Insufficient balance/charge }
/renders/{job_id}:
get:
tags: [Jobs]
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/RenderJobDetail' }
/renders/{job_id}/cancel:
post:
tags: [Jobs]
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
content:
application/json:
schema:
type: object
properties:
reason: { type: string }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
cancelled: { type: boolean }
refund_amount_minor: { type: integer, format: int64 }
/renders/{job_id}/retry:
post:
tags: [Jobs]
summary: Retry a failed render (uses same config)
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'201':
content:
application/json:
schema: { $ref: '#/components/schemas/RenderJob' }
/renders/{job_id}/progress:
get:
tags: [Jobs]
summary: |
Fallback polling endpoint when WebSocket isn't usable.
Browser primary is WS at /ws/v1/render/{job_id}.
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/RenderProgress' }
/renders/{job_id}/logs:
get:
tags: [Jobs]
summary: Get render execution logs (admin or owner)
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
- { name: level, in: query, schema: { type: string, enum: [debug, info, warn, error] } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
logs:
type: array
items:
type: object
properties:
timestamp: { type: string, format: date-time }
level: { type: string }
node_id: { type: string, format: uuid, nullable: true }
message: { type: string }
meta: { type: object }
# ===================== SNAPSHOTS =====================
/snapshots:
post:
tags: [Snapshots]
summary: Request single-frame snapshot of a scene
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [saved_project_id, scene_key, frame_number]
properties:
saved_project_id: { type: string, format: uuid }
scene_key: { type: string }
frame_number: { type: integer, minimum: 0 }
responses:
'202':
description: Snapshot queued (or returned immediately if cached)
content:
application/json:
schema:
type: object
properties:
snapshot_id: { type: string, format: uuid }
status: { type: string, enum: [Cached, Pending, Rendering] }
image_url: { type: string, nullable: true }
thumbnail_url: { type: string, nullable: true }
expires_at: { type: string, format: date-time, nullable: true }
/snapshots/{snapshot_id}:
get:
tags: [Snapshots]
parameters:
- { name: snapshot_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/Snapshot' }
# ===================== EXPORTS =====================
/exports:
get:
tags: [Exports]
summary: List user's exports
parameters:
- { name: page, in: query, schema: { type: integer } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/Export' } }
meta: { $ref: '#/components/schemas/PaginationMeta' }
/exports/{export_id}:
get:
tags: [Exports]
parameters:
- { name: export_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/ExportDetail' }
delete:
tags: [Exports]
parameters:
- { name: export_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'204': { description: Deleted }
/exports/{export_id}/extend:
post:
tags: [Exports]
summary: Extend auto-delete date (paid feature)
parameters:
- { name: export_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
days: { type: integer, minimum: 1, maximum: 365 }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
new_auto_delete_date: { type: string, format: date-time }
/exports/{export_id}/download-url:
get:
tags: [Exports]
summary: Get presigned MinIO URL (short-lived)
parameters:
- { name: export_id, in: path, required: true, schema: { type: string, format: uuid } }
- { name: format, in: query, schema: { type: string, default: mp4 } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
url: { type: string }
expires_at: { type: string, format: date-time }
# ===================== NODES (admin) =====================
/nodes:
get:
tags: [Nodes]
summary: List nodes
parameters:
- { name: region, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/RenderNode' } }
post:
tags: [Nodes]
summary: (Admin) Register new node
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/RenderNodeCreate' }
responses:
'201':
content:
application/json:
schema: { $ref: '#/components/schemas/RenderNode' }
/nodes/{node_id}:
get:
tags: [Nodes]
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/RenderNodeDetail' }
patch:
tags: [Nodes]
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
priority: { type: integer }
is_active: { type: boolean }
accepts_new_jobs: { type: boolean }
node_kind: { type: string }
owner_user_id: { type: string, format: uuid }
next_maintenance_at: { type: string, format: date-time }
maintenance_reason: { type: string }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/RenderNode' }
/nodes/{node_id}/restart:
post:
tags: [Nodes]
summary: Reboot node OS
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'202': { description: Restart issued }
/nodes/{node_id}/release:
post:
tags: [Nodes]
summary: Force-release node from any current job
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'204': { description: Released }
/nodes/{node_id}/close-ae:
post:
tags: [Nodes]
summary: Force-kill AfterFX on a node
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'204': { description: AE closed }
/nodes/{node_id}/health:
get:
tags: [Nodes]
summary: Current node health
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/NodeHealth' }
/nodes/{node_id}/health/history:
get:
tags: [Nodes]
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
- { name: from, in: query, schema: { type: string, format: date-time } }
- { name: to, in: query, schema: { type: string, format: date-time } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/NodeHealth' }
/nodes/{node_id}/crashes:
get:
tags: [Nodes]
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/NodeCrash' } }
/node-updates:
get:
tags: [Nodes]
summary: Available node software updates
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/NodeUpdate' } }
/node-updates/{update_id}/rollout:
post:
tags: [Nodes]
summary: Trigger rollout to selected nodes
parameters:
- { name: update_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
node_ids: { type: array, items: { type: string, format: uuid } }
responses:
'202': { description: Rollout queued }
# ===================== INTERNAL (called by node agents) =====================
/internal/nodes/{node_id}/heartbeat:
post:
tags: [Internal]
summary: Node sends heartbeat (every 5s)
security: [NodeHmac: []]
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NodeHeartbeat' }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
next_heartbeat_in_sec: { type: integer }
pending_commands:
type: array
description: e.g. "cancel current job", "update software"
items: { type: object }
/internal/nodes/{node_id}/online:
post:
tags: [Internal]
summary: Node agent reports it just started
security: [NodeHmac: []]
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
node_agent_version: { type: string }
current_ae_version: { type: string }
available_ae_versions: { type: array, items: { type: string } }
ram_gb: { type: integer }
cpu_cores: { type: integer }
cache_used_gb: { type: integer }
cached_template_md5s: { type: array, items: { type: string } }
responses:
'200': { description: Acknowledged }
/internal/render/jobs/{job_id}/frames:
post:
tags: [Internal]
summary: Node pushes per-frame progress
security: [NodeHmac: []]
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [frame_job_id, frame_number]
properties:
frame_job_id: { type: string, format: uuid }
frame_number: { type: integer }
file_size_bytes: { type: integer }
completed_at: { type: string, format: date-time }
responses:
'204': { description: Recorded }
/internal/render/jobs/{job_id}/crash:
post:
tags: [Internal]
summary: Node reports an AE crash on this job
security: [NodeHmac: []]
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [node_id]
properties:
node_id: { type: string, format: uuid }
frame_job_id: { type: string, format: uuid }
last_known_frame: { type: integer }
crash_signal: { type: string }
ae_version: { type: string }
error_log_tail: { type: string }
log_file_url: { type: string }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
action_recommended:
type: string
enum: [ResetAndRestart, ReassignWork, RestartNode]
reassigned_to_node_id:
type: string
format: uuid
nullable: true
/internal/render/jobs/{job_id}/replica-ready:
post:
tags: [Internal]
summary: Node reports replica .aep saved (after JSX run)
security: [NodeHmac: []]
parameters:
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [node_id, replica_path]
properties:
node_id: { type: string, format: uuid }
replica_path: { type: string }
replica_md5: { type: string }
responses:
'204': { description: Acknowledged }
/internal/nodes/{node_id}/cache-update:
post:
tags: [Internal]
summary: Node reports a template cache change
security: [NodeHmac: []]
parameters:
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [action, aep_file_md5]
properties:
action: { type: string, enum: [Downloaded, Evicted, Verified, Failed] }
project_id: { type: string, format: uuid }
aep_file_md5: { type: string }
file_size_bytes: { type: integer, format: int64 }
cache_used_gb: { type: integer }
error_message: { type: string }
responses:
'204': { description: Recorded }
components:
securitySchemes:
BearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
ServiceToken: { type: http, scheme: bearer }
NodeHmac:
type: apiKey
in: header
name: X-Node-Signature
schemas:
PaginationMeta:
type: object
properties:
page: { type: integer }
page_size: { type: integer }
total: { type: integer }
has_more: { type: boolean }
RenderJob:
type: object
properties:
id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
name: { type: string }
step: { type: string }
render_progress: { type: integer }
priority_queue: { type: string }
price_type: { type: string }
paid_price_minor: { type: integer, format: int64 }
quality: { type: string }
resolution: { type: string }
frame_rate: { type: integer }
duration_sec: { type: number }
has_voiceover: { type: boolean }
image_preview_b64: { type: string, nullable: true }
failed_message: { type: string, nullable: true }
export_id: { type: string, format: uuid, nullable: true }
queued_at: { type: string, format: date-time }
started_at: { type: string, format: date-time, nullable: true }
completed_at: { type: string, format: date-time, nullable: true }
RenderJobCreate:
type: object
required: [saved_project_id, quality, resolution]
properties:
saved_project_id: { type: string, format: uuid }
quality: { type: string, enum: [Low, Medium, High, Full, Lossless] }
resolution: { type: string, enum: [HD, FullHD, TwoK, FourK] }
frame_rate: { type: integer, enum: [24, 25, 30, 60] }
is_60_fps: { type: boolean }
price_type:
type: string
enum: [Free, Preview, Cash, Plan, Snapshot, Reseller]
discount_code: { type: string }
support_flatrender: { type: boolean }
tell_me_when_done: { type: boolean }
preferred_region: { type: string }
RenderJobDetail:
allOf:
- $ref: '#/components/schemas/RenderJob'
- type: object
properties:
frame_jobs:
type: array
items: { $ref: '#/components/schemas/FrameJob' }
retry_count: { type: integer }
repair_attempts: { type: integer }
export: { $ref: '#/components/schemas/Export', nullable: true }
FrameJob:
type: object
properties:
id: { type: string, format: uuid }
node_id: { type: string, format: uuid }
start_frame: { type: integer }
end_frame: { type: integer }
order_value: { type: integer }
status: { type: string }
frames_rendered: { type: integer }
frames_validated: { type: integer }
attempt: { type: integer }
last_error: { type: string, nullable: true }
RenderProgress:
type: object
properties:
job_id: { type: string, format: uuid }
step: { type: string }
progress: { type: integer }
current_frame: { type: integer, nullable: true }
total_frames: { type: integer, nullable: true }
eta_seconds: { type: integer, nullable: true }
preview_b64: { type: string, nullable: true }
active_nodes: { type: integer }
message: { type: string, nullable: true }
Snapshot:
type: object
properties:
id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
scene_key: { type: string }
frame_number: { type: integer }
status: { type: string }
image_url: { type: string, nullable: true }
thumbnail_url: { type: string, nullable: true }
width: { type: integer, nullable: true }
height: { type: integer, nullable: true }
duration_ms: { type: integer, nullable: true }
expires_at: { type: string, format: date-time }
requested_at: { type: string, format: date-time }
completed_at: { type: string, format: date-time, nullable: true }
Export:
type: object
properties:
id: { type: string, format: uuid }
saved_project_id: { type: string, format: uuid }
path: { type: string }
image: { type: string }
size_bytes: { type: integer, format: int64 }
duration_sec: { type: number }
width: { type: integer }
height: { type: integer }
file_extension: { type: string }
file_type: { type: string }
render_quality: { type: string }
create_type: { type: string }
produce_date: { type: string, format: date-time }
auto_delete_date: { type: string, format: date-time }
ExportDetail:
allOf:
- $ref: '#/components/schemas/Export'
- type: object
properties:
files:
type: array
items:
type: object
properties:
id: { type: string, format: uuid }
thumbnail: { type: string }
path: { type: string }
size_bytes: { type: integer, format: int64 }
file_type: { type: string }
RenderNode:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
region: { type: string }
node_ip: { type: string }
worker_port: { type: integer }
status: { type: string }
node_kind: { type: string }
owner_user_id: { type: string, format: uuid, nullable: true }
current_ae_version: { type: string }
node_agent_version: { type: string }
ram_gb: { type: integer }
cpu_cores: { type: integer }
priority: { type: integer }
is_active: { type: boolean }
accepts_new_jobs: { type: boolean }
last_heartbeat_at: { type: string, format: date-time }
current_job_id: { type: string, format: uuid, nullable: true }
last_cpu_pct: { type: integer }
last_ram_available_mb: { type: integer }
ae_running: { type: boolean }
cache_used_gb: { type: integer }
cached_template_count: { type: integer }
lifetime_task_count: { type: integer, format: int64 }
lifetime_crash_count: { type: integer }
consecutive_failures: { type: integer }
RenderNodeCreate:
type: object
required: [name, region, node_ip, worker_port, current_ae_version]
properties:
name: { type: string }
region: { type: string }
node_ip: { type: string }
worker_port: { type: integer }
current_ae_version: { type: string }
ram_gb: { type: integer }
cpu_cores: { type: integer }
node_kind: { type: string }
owner_user_id: { type: string, format: uuid }
priority: { type: integer }
RenderNodeDetail:
allOf:
- $ref: '#/components/schemas/RenderNode'
- type: object
properties:
available_ae_versions: { type: array, items: { type: string } }
cached_template_md5s: { type: array, items: { type: string } }
last_maintenance_at: { type: string, format: date-time }
next_maintenance_at: { type: string, format: date-time, nullable: true }
NodeHealth:
type: object
properties:
node_id: { type: string, format: uuid }
recorded_at: { type: string, format: date-time }
status: { type: string }
cpu_pct: { type: integer }
ram_available_mb: { type: integer }
ae_running: { type: boolean }
current_job_id: { type: string, format: uuid, nullable: true }
current_frame: { type: integer, nullable: true }
cache_used_gb: { type: integer }
NodeHeartbeat:
allOf:
- $ref: '#/components/schemas/NodeHealth'
NodeCrash:
type: object
properties:
id: { type: string, format: uuid }
node_id: { type: string, format: uuid }
render_job_id: { type: string, format: uuid, nullable: true }
crashed_at: { type: string, format: date-time }
last_known_frame: { type: integer, nullable: true }
crash_signal: { type: string }
error_log: { type: string }
log_file_url: { type: string }
auto_recovered: { type: boolean }
recovery_action: { type: string }
recovered_at: { type: string, format: date-time, nullable: true }
NodeUpdate:
type: object
properties:
id: { type: string, format: uuid }
update_file_name: { type: string }
update_number: { type: integer }
description: { type: string }
target_ae_version: { type: string }
in_update_queue: { type: boolean }
rolled_out_to_node_ids: { type: array, items: { type: string, format: uuid } }
create_date: { type: string, format: date-time }
+661
View File
@@ -0,0 +1,661 @@
openapi: 3.0.3
info:
title: FlatRender Studio Service (internal)
version: 1.0.0
description: |
User's saved projects (the editor's state). Includes voiceover +
audio mix settings.
servers:
- url: http://studio-svc.internal/v1
security:
- BearerAuth: []
- ServiceToken: []
tags:
- name: SavedProjects
- name: SavedScenes
- name: Audio
- name: Internal
paths:
/saved-projects:
get:
tags: [SavedProjects]
summary: List user's saved projects
parameters:
- { name: q, in: query, schema: { type: string } }
- { name: type, in: query, schema: { type: string, enum: [Draft, Active, Archived, Trash] } }
- { name: page, in: query, schema: { type: integer } }
- { name: page_size, in: query, schema: { type: integer } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/SavedProjectSummary' } }
meta: { $ref: '#/components/schemas/PaginationMeta' }
post:
tags: [SavedProjects]
summary: Create new saved project from a template
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [original_project_id]
properties:
original_project_id: { type: string, format: uuid }
name: { type: string }
preset_story_id: { type: string, format: uuid }
copy_default_values: { type: boolean, default: true }
responses:
'201':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProject' }
/saved-projects/{id}:
get:
tags: [SavedProjects]
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProjectFull' }
patch:
tags: [SavedProjects]
summary: Update top-level fields (name, audio, etc.)
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProjectUpdate' }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProject' }
delete:
tags: [SavedProjects]
summary: Soft-delete (move to trash)
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'204': { description: Trashed }
/saved-projects/{id}/restore:
post:
tags: [SavedProjects]
summary: Restore from trash
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProject' }
/saved-projects/{id}/duplicate:
post:
tags: [SavedProjects]
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
content:
application/json:
schema:
type: object
properties:
new_name: { type: string }
responses:
'201':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProject' }
/saved-projects/{id}/autosave:
put:
tags: [SavedProjects]
summary: Autosave entire project graph (debounced from UI)
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProjectFull' }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
saved_at: { type: string, format: date-time }
version: { type: integer }
# ===================== AUDIO (NEW) =====================
/saved-projects/{id}/audio:
get:
tags: [Audio]
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/AudioSettings' }
put:
tags: [Audio]
summary: Update audio mix
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/AudioSettings' }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/AudioSettings' }
/saved-projects/{id}/voiceover:
post:
tags: [Audio]
summary: |
Upload or record voiceover. Returns target file_id.
Use file service upload endpoints for actual binary; this
attaches an existing file to the project.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [file_id]
properties:
file_id: { type: string, format: uuid, description: existing user_file_id }
recorded_in_browser: { type: boolean, default: false }
volume: { type: number, minimum: 0, maximum: 1, default: 1.0 }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/AudioSettings' }
delete:
tags: [Audio]
summary: Remove voiceover
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'204': { description: Removed }
/saved-projects/{id}/music:
put:
tags: [Audio]
summary: Set music track (from library or upload)
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
music_track_id: { type: string, format: uuid }
music_file_id: { type: string, format: uuid }
volume: { type: number, minimum: 0, maximum: 1 }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/AudioSettings' }
# ===================== SCENES =====================
/saved-projects/{id}/scenes:
get:
tags: [SavedScenes]
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/SavedSceneFull' } }
post:
tags: [SavedScenes]
summary: Add a scene from project template
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [original_scene_id]
properties:
original_scene_id: { type: string, format: uuid }
sort: { type: integer }
scene_length_sec: { type: number }
responses:
'201':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedSceneFull' }
/saved-scenes/{scene_id}:
patch:
tags: [SavedScenes]
parameters:
- { name: scene_id, in: path, required: true, schema: { type: integer, format: int64 } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
scene_length_sec: { type: number }
manual_color_selection: { type: boolean }
selected_color_preset_id: { type: integer, format: int64 }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedScene' }
delete:
tags: [SavedScenes]
parameters:
- { name: scene_id, in: path, required: true, schema: { type: integer, format: int64 } }
responses:
'204': { description: Removed }
/saved-projects/{id}/scenes/reorder:
post:
tags: [SavedScenes]
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ordered_ids]
properties:
ordered_ids:
type: array
items: { type: integer, format: int64 }
responses:
'204': { description: Reordered }
/saved-scenes/{scene_id}/contents:
put:
tags: [SavedScenes]
summary: Bulk-update contents for a scene
parameters:
- { name: scene_id, in: path, required: true, schema: { type: integer, format: int64 } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
contents:
type: array
items: { $ref: '#/components/schemas/SavedSceneContent' }
responses:
'200': { description: Updated }
/saved-scenes/{scene_id}/colors:
put:
tags: [SavedScenes]
summary: Bulk-update colors for a scene
parameters:
- { name: scene_id, in: path, required: true, schema: { type: integer, format: int64 } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
colors:
type: array
items: { $ref: '#/components/schemas/SavedSceneColor' }
responses:
'200': { description: Updated }
/saved-projects/{id}/shared-colors:
put:
tags: [SavedProjects]
summary: Bulk update project-level shared colors
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
colors:
type: array
items: { $ref: '#/components/schemas/SavedSharedColor' }
responses:
'200': { description: Updated }
/saved-projects/{id}/shared-layers:
put:
tags: [SavedProjects]
summary: Bulk update project-level shared layers
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
layers:
type: array
items: { $ref: '#/components/schemas/SavedSharedLayer' }
responses:
'200': { description: Updated }
# ===================== INTERNAL =====================
/internal/saved-projects/{id}/snapshot-for-render:
get:
tags: [Internal]
summary: |
Called by Render Orchestrator to get the full JSX-ready payload.
Returns everything needed to generate JSX (FIX/FLEXIBLE/Mockup/MV).
security: [ServiceToken: []]
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/SavedProjectSnapshot' }
components:
securitySchemes:
BearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
ServiceToken: { type: http, scheme: bearer }
schemas:
PaginationMeta:
type: object
properties:
page: { type: integer }
page_size: { type: integer }
total: { type: integer }
has_more: { type: boolean }
SavedProjectSummary:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
image: { type: string }
type: { type: string }
original_project_id: { type: string, format: uuid }
original_project_name: { type: string }
original_container_slug: { type: string }
choose_mode: { type: string }
resolution: { type: string }
project_duration_sec: { type: number }
scene_count: { type: integer }
last_edit_date: { type: string, format: date-time }
created_at: { type: string, format: date-time }
SavedProject:
allOf:
- $ref: '#/components/schemas/SavedProjectSummary'
- type: object
properties:
frame_rate: { type: integer }
vip_factor: { type: number }
manual_color_picker: { type: boolean }
selected_preset_story_id: { type: string, format: uuid, nullable: true }
audio: { $ref: '#/components/schemas/AudioSettings' }
SavedProjectUpdate:
type: object
properties:
name: { type: string }
type: { type: string, enum: [Draft, Active, Archived, Trash] }
manual_color_picker: { type: boolean }
selected_preset_story_id: { type: string, format: uuid, nullable: true }
last_edit_step: { type: string }
SavedProjectFull:
allOf:
- $ref: '#/components/schemas/SavedProject'
- type: object
properties:
scenes: { type: array, items: { $ref: '#/components/schemas/SavedSceneFull' } }
shared_colors: { type: array, items: { $ref: '#/components/schemas/SavedSharedColor' } }
shared_color_presets: { type: array, items: { $ref: '#/components/schemas/SavedSharedColorPreset' } }
shared_layers: { type: array, items: { $ref: '#/components/schemas/SavedSharedLayer' } }
AudioSettings:
type: object
properties:
music_track_id: { type: string, format: uuid, nullable: true }
music_file_id: { type: string, format: uuid, nullable: true }
music_url: { type: string, nullable: true }
music_duration_sec: { type: number, nullable: true }
music_volume: { type: number, minimum: 0, maximum: 1 }
voiceover_file_id: { type: string, format: uuid, nullable: true }
voiceover_url: { type: string, nullable: true }
voiceover_duration_sec: { type: number, nullable: true }
voiceover_volume: { type: number, minimum: 0, maximum: 1 }
voiceover_recorded_in_browser: { type: boolean }
sfx_enabled: { type: boolean }
sfx_volume: { type: number, minimum: 0, maximum: 1 }
SavedScene:
type: object
properties:
id: { type: integer, format: int64 }
saved_project_id: { type: string, format: uuid }
original_scene_id: { type: string, format: uuid }
key: { type: string }
title: { type: string }
image: { type: string }
demo: { type: string }
scene_type: { type: string }
sort: { type: integer }
scene_length_sec: { type: number }
min_duration_sec: { type: number }
max_duration_sec: { type: number }
overlap_at_end_sec: { type: number }
manual_color_selection: { type: boolean }
SavedSceneFull:
allOf:
- $ref: '#/components/schemas/SavedScene'
- type: object
properties:
contents: { type: array, items: { $ref: '#/components/schemas/SavedSceneContent' } }
colors: { type: array, items: { $ref: '#/components/schemas/SavedSceneColor' } }
color_presets: { type: array, items: { $ref: '#/components/schemas/SavedSceneColorPreset' } }
characters: { type: array, items: { $ref: '#/components/schemas/SavedSceneCharacter' } }
SavedSceneContent:
type: object
properties:
id: { type: integer, format: int64 }
key: { type: string }
type: { type: string }
value: { type: string }
value_file_id: { type: string, format: uuid, nullable: true }
file_url_cached: { type: string, nullable: true }
inserted_file_type: { type: string, nullable: true }
font_face: { type: string, nullable: true }
font_size: { type: integer, nullable: true }
justify: { type: string }
position_in_container: { type: integer }
direction_layer_value: { type: integer }
is_text_box: { type: boolean }
ai_input_type: { type: string, nullable: true }
selected_dp: { type: integer, nullable: true }
repeater_item_key: { type: string, nullable: true }
repeater_index: { type: integer, nullable: true }
sort: { type: integer }
SavedSceneColor:
type: object
properties:
id: { type: integer, format: int64 }
element_key: { type: string }
title: { type: string }
icon: { type: string }
attr_value: { type: string }
value: { type: string }
is_selected: { type: boolean }
sort: { type: integer }
SavedSceneColorPreset:
type: object
properties:
id: { type: integer, format: int64 }
is_selected: { type: boolean }
sort: { type: integer }
items:
type: array
items:
type: object
properties:
element_key: { type: string }
value: { type: string }
sort: { type: integer }
SavedSceneCharacter:
type: object
properties:
id: { type: integer, format: int64 }
key: { type: string, format: uuid }
name: { type: string }
icon: { type: string }
controllers:
type: array
items:
type: object
properties:
name: { type: string }
key: { type: string }
value: { type: string }
sort: { type: integer }
SavedSharedColor:
type: object
properties:
id: { type: integer, format: int64 }
element_key: { type: string }
title: { type: string }
attr_value: { type: string }
value: { type: string }
is_selected: { type: boolean }
sort: { type: integer }
SavedSharedColorPreset:
type: object
properties:
id: { type: integer, format: int64 }
name: { type: string }
is_selected: { type: boolean }
sort: { type: integer }
items:
type: array
items:
type: object
properties:
element_key: { type: string }
value: { type: string }
SavedSharedLayer:
type: object
properties:
id: { type: integer, format: int64 }
key: { type: string }
title: { type: string }
type: { type: string }
value: { type: string }
value_file_id: { type: string, format: uuid, nullable: true }
file_url_cached: { type: string, nullable: true }
font_face: { type: string }
font_size: { type: integer }
justify: { type: string }
position_in_container: { type: integer }
direction_layer_value: { type: integer }
is_text_box: { type: boolean }
sort: { type: integer }
SavedProjectSnapshot:
type: object
description: |
Complete payload for JSX generation. Same shape returned for any
choose_mode; render service decides which JSX generator to use.
properties:
saved_project_id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
user_id: { type: string, format: uuid }
original_project_id: { type: string, format: uuid }
original_project_name: { type: string }
choose_mode: { type: string }
resolution: { type: string }
frame_rate: { type: integer }
project_duration_sec: { type: number }
vip_factor: { type: number }
aep:
type: object
properties:
url: { type: string }
md5: { type: string }
size_bytes: { type: integer, format: int64 }
render_comp: { type: string }
original_width: { type: integer }
original_height: { type: integer }
audio: { $ref: '#/components/schemas/AudioSettings' }
shared_colors:
type: array
items: { $ref: '#/components/schemas/SavedSharedColor' }
shared_layers:
type: array
items: { $ref: '#/components/schemas/SavedSharedLayer' }
scenes:
type: array
items: { $ref: '#/components/schemas/SavedSceneFull' }
@@ -0,0 +1,199 @@
# WebSocket Protocol — Render Progress
Live render progress is pushed to the browser via WebSocket.
## Connection
```
URL: wss://api.flatrender.ir/ws/v1/render/{job_id}?token={jwt}
Headers: Sec-WebSocket-Protocol: flatrender.v1
```
The JWT carries `user_id` + `tenant_id`. The Gateway validates that the
caller owns the `job_id`. Connection is closed with code `4403` if not.
## Server → Client messages
All server messages are JSON with `type` discriminator.
### `hello` — sent on connect
```json
{
"type": "hello",
"job_id": "uuid",
"current_state": {
"step": "Rendering",
"progress": 47,
"current_frame": 470,
"total_frames": 720,
"started_at": "2026-05-27T10:00:00Z"
}
}
```
### `progress` — frequent (max every 500ms)
```json
{
"type": "progress",
"job_id": "uuid",
"step": "Rendering",
"progress": 67,
"current_frame": 482,
"total_frames": 720,
"eta_seconds": 45,
"preview_b64": "data:image/jpeg;base64,/9j/4AAQ...",
"active_nodes": 3,
"message": null
}
```
`preview_b64` is sent at most every 2s (last rendered frame thumbnail,
~5-15 KB).
### `step_change` — pipeline transition
```json
{
"type": "step_change",
"job_id": "uuid",
"from_step": "Rendering",
"to_step": "Validating",
"at": "2026-05-27T10:05:00Z"
}
```
### `frame_repair` — frames being re-rendered
```json
{
"type": "frame_repair",
"job_id": "uuid",
"missing_frames": [347, 348, 521],
"corrupt_frames": [402],
"attempt": 1,
"max_attempts": 3
}
```
### `node_event` — node-related notice
```json
{
"type": "node_event",
"job_id": "uuid",
"event": "node_crashed",
"node_id": "uuid",
"auto_recovered": true,
"message": "AE crashed on node-7, work reassigned"
}
```
### `done` — terminal success
```json
{
"type": "done",
"job_id": "uuid",
"export_id": "uuid",
"output_url": "https://cdn.flatrender.ir/exports/abc.mp4",
"thumbnail_url": "https://cdn.flatrender.ir/exports/abc.jpg",
"duration_sec": 30,
"size_bytes": 14523456,
"compute_seconds": 124
}
```
### `failed` — terminal failure
```json
{
"type": "failed",
"job_id": "uuid",
"failed_at_step": "Rendering",
"error_message": "AE crashed too many times on this template",
"error_code": "AE_REPEATED_CRASH",
"refund_issued": true,
"trace_id": "uuid"
}
```
### `cancelled` — terminal cancellation
```json
{
"type": "cancelled",
"job_id": "uuid",
"cancelled_at": "2026-05-27T10:03:12Z",
"progress_when_cancelled": 42
}
```
### `error` — protocol-level error (kept open)
```json
{
"type": "error",
"code": "RATE_LIMIT",
"message": "Too many messages; slow down"
}
```
### `ping` — keepalive
```json
{ "type": "ping", "t": 1714294800 }
```
Client SHOULD respond with `{"type":"pong","t":1714294800}` within 30s
or the server may close the connection.
## Client → Server messages
### `pong`
```json
{ "type": "pong", "t": 1714294800 }
```
### `subscribe_snapshot` — also get scene snapshot updates over same socket
```json
{ "type": "subscribe_snapshot", "snapshot_id": "uuid" }
```
### `cancel_job` — request cancellation
```json
{ "type": "cancel_job", "job_id": "uuid" }
```
The server replies with a `cancelled` message once accepted.
## Close codes
| Code | Meaning |
|-------|------------------------------------------|
| 1000 | Normal close (job completed/failed) |
| 1001 | Server going away (deploy) |
| 1008 | Policy violation (bad message) |
| 4401 | Unauthorized (bad/expired JWT) |
| 4403 | Forbidden (don't own this job) |
| 4404 | Job not found |
| 4429 | Rate limited |
## Reconnect strategy (client)
- Reconnect with exponential backoff (1s, 2s, 4s, 8s, max 30s)
- On reconnect, `hello` carries `current_state` so UI catches up
- WebSocket is best-effort; UI should also poll `GET /v1/renders/{id}`
if it hasn't received `progress` in > 15s
## Rate limits
| Direction | Limit |
|-----------|-------|
| Server → Client `progress` | Max 2 Hz |
| Server → Client total messages | 10 per second |
| Client → Server | 5 per second |
+90
View File
@@ -0,0 +1,90 @@
# FlatRender V2 — Database Schemas
PostgreSQL 15+. Single database, one schema per microservice.
## Run order
Apply migrations in numerical order:
```bash
psql -d flatrender -f migrations/00_setup.sql
psql -d flatrender -f migrations/01_identity_tenants.sql
psql -d flatrender -f migrations/02_identity_users.sql
psql -d flatrender -f migrations/03_identity_billing.sql
psql -d flatrender -f migrations/04_identity_gamification.sql
psql -d flatrender -f migrations/05_content_taxonomy.sql
psql -d flatrender -f migrations/06_content_projects.sql
psql -d flatrender -f migrations/07_content_scenes.sql
psql -d flatrender -f migrations/08_content_characters_presets.sql
psql -d flatrender -f migrations/09_content_cms.sql
psql -d flatrender -f migrations/10_studio_saved_projects.sql
psql -d flatrender -f migrations/11_render_nodes.sql
psql -d flatrender -f migrations/12_render_jobs.sql
psql -d flatrender -f migrations/13_file_manager.sql
psql -d flatrender -f migrations/14_notification.sql
```
## Schemas
| Schema | Owner Service | Purpose |
|---|---|---|
| `identity` | Identity Service (.NET) | tenants, users, auth, plans, payments, gamification |
| `content` | Content Service (.NET) | templates, scenes, presets, blogs, CMS |
| `studio` | Studio Service (.NET) | user's saved projects + audio (music/voiceover/sfx) |
| `render` | Render Orchestrator (Go) | jobs, nodes, frame jobs, snapshots, exports |
| `file_mgr` | File Service (Go) | user files, folders, quotas, cleanup |
| `notification` | Notification Service (Go) | in-app, push, email, SMS, telegram |
## Cross-schema design
Schemas are **loosely coupled**. Where it matters for integrity (within a
service), FKs are used. Across services, FKs are deliberately omitted so
services can evolve independently — referential integrity is enforced
via service code and events.
### Hard FKs across schemas (intentional)
- `identity.earned_gifts.notification_id``notification.notifications.id`
Everything else uses **soft references** (column documented but no FK).
## Multi-tenancy
`identity.tenants` is the root of multi-tenancy. The default FlatRender
tenant has UUID `00000000-0000-0000-0000-000000000001`.
Every user, project, render job, file, and notification carries a
`tenant_id`. Resellers (B2B API customers) are tenants. White-label
branding, API keys, webhooks, and usage metering all hang off
`identity.tenants.*`.
## New features (vs V1)
- **Multi-tenancy / Reseller API**: `identity.tenants`, `tenant_branding`,
`tenant_api_keys`, `tenant_webhooks`, `tenant_usage_daily`
- **Voiceover support**: `studio.saved_projects.voiceover_*`,
`render.render_jobs.has_voiceover`
- **Per-track volume**: `music_volume`, `sfx_volume`, `voiceover_volume`
- **Scene snapshots**: `render.snapshots` with cache key
- **AE crash tracking**: `render.node_crashes` + auto-recovery
- **Frame repair jobs**: `render.frame_repair_jobs`
- **AEP local cache on nodes**: `render.node_template_cache` (LRU)
- **SVG color previews**: `content.template_svg_previews` (drop image → traced SVG)
- **PWA push subscriptions**: `identity.push_subscriptions`
- **MFA**: `identity.mfa_factors`
- **Multipart uploads**: `file_mgr.upload_sessions`
- **Cleanup scheduler**: `file_mgr.cleanup_schedules`
- **Per-user / per-channel notification preferences**:
`notification.notification_preferences`
## Partitioning
Time-series tables are partitioned monthly (initial partition for
2026-01 created; ops creates new ones via cron):
- `identity.tenant_api_request_logs`
- `render.node_health_logs`
## Service user grants
Each microservice connects with its own DB role limited to its schema.
See top of `00_setup.sql` for the recipe.
+51
View File
@@ -0,0 +1,51 @@
-- =====================================================================
-- FlatRender V2 — Database Setup
-- Single PostgreSQL database with per-service schemas
-- =====================================================================
-- Extensions
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS "citext"; -- case-insensitive text (emails)
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- fuzzy text search
-- =====================================================================
-- Schemas (one per microservice)
-- =====================================================================
CREATE SCHEMA IF NOT EXISTS identity;
CREATE SCHEMA IF NOT EXISTS content;
CREATE SCHEMA IF NOT EXISTS studio;
CREATE SCHEMA IF NOT EXISTS render;
CREATE SCHEMA IF NOT EXISTS file_mgr;
CREATE SCHEMA IF NOT EXISTS notification;
-- =====================================================================
-- Service users (each microservice connects with limited grants)
-- =====================================================================
-- Run separately by ops:
-- CREATE USER svc_identity WITH PASSWORD '...';
-- CREATE USER svc_content WITH PASSWORD '...';
-- CREATE USER svc_studio WITH PASSWORD '...';
-- CREATE USER svc_render WITH PASSWORD '...';
-- CREATE USER svc_file WITH PASSWORD '...';
-- CREATE USER svc_notification WITH PASSWORD '...';
-- GRANT ALL ON SCHEMA identity TO svc_identity;
-- GRANT ALL ON SCHEMA content TO svc_content;
-- ... etc.
-- Read-only cross-schema grants where needed (defined per service)
-- =====================================================================
-- Common helper: auto-update updated_at on row update
-- =====================================================================
CREATE OR REPLACE FUNCTION public.tg_set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- =====================================================================
-- Common helper: soft-delete check (used in policies/views later)
-- =====================================================================
-- Convention: every soft-deletable table has `deleted_at TIMESTAMPTZ NULL`
-- Active rows: WHERE deleted_at IS NULL
@@ -0,0 +1,352 @@
-- =====================================================================
-- IDENTITY SCHEMA — Part 1: Tenants (Multi-tenancy / Reseller API)
-- =====================================================================
-- Every user, project, render, export belongs to a tenant.
-- FlatRender's own customers belong to the DEFAULT tenant.
-- Resellers (B2B) are their own tenants with their own users.
-- =====================================================================
SET search_path TO identity, public;
-- ---------------------------------------------------------------------
-- tenants — companies / resellers (and FlatRender itself = default)
-- ---------------------------------------------------------------------
CREATE TYPE tenant_status AS ENUM ('Active','Trial','Suspended','Cancelled');
CREATE TYPE tenant_kind AS ENUM ('Internal','Reseller','Enterprise');
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug CITEXT UNIQUE NOT NULL, -- 'flatrender', 'acme', ...
name TEXT NOT NULL,
kind tenant_kind NOT NULL DEFAULT 'Reseller',
status tenant_status NOT NULL DEFAULT 'Trial',
-- Domains
custom_domain CITEXT UNIQUE, -- videos.acme.com
domain_verified BOOLEAN NOT NULL DEFAULT FALSE,
allowed_origins TEXT[] NOT NULL DEFAULT '{}', -- CORS
-- Contact
contact_name TEXT,
contact_email CITEXT,
contact_phone TEXT,
billing_email CITEXT,
-- Limits (overrideable from default plan)
max_users INT, -- NULL = unlimited
max_storage_gb INT,
monthly_render_qty INT,
monthly_render_sec INT,
-- Lifecycle
trial_ends_at TIMESTAMPTZ,
suspended_at TIMESTAMPTZ,
suspension_reason TEXT,
-- Metadata
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_tenants_status ON tenants(status) WHERE deleted_at IS NULL;
CREATE INDEX idx_tenants_custom_domain ON tenants(custom_domain) WHERE custom_domain IS NOT NULL;
CREATE TRIGGER tg_tenants_updated_at
BEFORE UPDATE ON tenants
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- Seed the default (internal) FlatRender tenant
INSERT INTO tenants (id, slug, name, kind, status)
VALUES (
'00000000-0000-0000-0000-000000000001',
'flatrender',
'FlatRender',
'Internal',
'Active'
);
-- ---------------------------------------------------------------------
-- tenant_branding — white-label appearance
-- ---------------------------------------------------------------------
CREATE TABLE tenant_branding (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
-- Identity
display_name TEXT,
logo_url TEXT,
logo_dark_url TEXT,
favicon_url TEXT,
og_image_url TEXT,
-- Theme
primary_color TEXT NOT NULL DEFAULT '#3B82F6',
secondary_color TEXT NOT NULL DEFAULT '#8B5CF6',
accent_color TEXT,
background_color TEXT,
font_family TEXT,
-- Email
email_from_name TEXT,
email_from_address CITEXT,
email_reply_to CITEXT,
email_footer_html TEXT,
-- Links
support_url TEXT,
terms_url TEXT,
privacy_url TEXT,
-- Studio embed
embed_enabled BOOLEAN NOT NULL DEFAULT FALSE,
embed_allowed_hosts TEXT[] NOT NULL DEFAULT '{}',
-- Custom CSS for advanced clients (sanitized)
custom_css TEXT,
-- Watermark for free renders (reseller can override)
watermark_text TEXT,
watermark_image_url TEXT,
watermark_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TRIGGER tg_tenant_branding_updated_at
BEFORE UPDATE ON tenant_branding
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- tenant_settings — feature flags + integration config
-- ---------------------------------------------------------------------
CREATE TABLE tenant_settings (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
-- Locale
default_locale TEXT NOT NULL DEFAULT 'fa',
supported_locales TEXT[] NOT NULL DEFAULT '{fa,en}',
default_currency TEXT NOT NULL DEFAULT 'IRR',
-- Payments allowed for this tenant's users
payment_gateways TEXT[] NOT NULL DEFAULT '{ZarinPal}', -- {ZarinPal,IdPay,Bazaar,Stripe}
-- Render
max_resolution TEXT NOT NULL DEFAULT 'FullHD', -- HD/FullHD/FourK
max_duration_sec INT NOT NULL DEFAULT 600,
allow_4k BOOLEAN NOT NULL DEFAULT FALSE,
allow_voiceover BOOLEAN NOT NULL DEFAULT TRUE,
allow_music_visualizer BOOLEAN NOT NULL DEFAULT TRUE,
allow_mockup BOOLEAN NOT NULL DEFAULT TRUE,
-- Webhooks
webhook_signing_secret TEXT,
-- Feature flags
features JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TRIGGER tg_tenant_settings_updated_at
BEFORE UPDATE ON tenant_settings
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- tenant_api_keys — server-to-server API access
-- ---------------------------------------------------------------------
CREATE TYPE api_key_environment AS ENUM ('Live','Test');
CREATE TABLE tenant_api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- "Production server"
environment api_key_environment NOT NULL DEFAULT 'Live',
-- Storage
key_prefix TEXT NOT NULL, -- "fr_live_abc..." (first 12 chars visible)
key_hash TEXT NOT NULL, -- SHA-256 of full key
last4 TEXT NOT NULL, -- last 4 chars for UI
-- Permissions
scopes TEXT[] NOT NULL DEFAULT '{}', -- ["renders:create","projects:read",...]
allowed_ips INET[], -- NULL = any
rate_limit_rpm INT NOT NULL DEFAULT 600,
-- Lifecycle
is_active BOOLEAN NOT NULL DEFAULT TRUE,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoked_reason TEXT,
last_used_at TIMESTAMPTZ,
last_used_ip INET,
usage_count BIGINT NOT NULL DEFAULT 0,
created_by_user_id UUID, -- FK added after users table exists
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_api_keys_hash ON tenant_api_keys(key_hash);
CREATE INDEX idx_api_keys_tenant ON tenant_api_keys(tenant_id) WHERE is_active = TRUE;
CREATE INDEX idx_api_keys_prefix ON tenant_api_keys(key_prefix);
CREATE TRIGGER tg_tenant_api_keys_updated_at
BEFORE UPDATE ON tenant_api_keys
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- tenant_webhooks — outbound events to reseller systems
-- ---------------------------------------------------------------------
CREATE TABLE tenant_webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
url TEXT NOT NULL,
events TEXT[] NOT NULL, -- ["render.completed","render.failed",...]
secret TEXT NOT NULL, -- HMAC signing secret
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_triggered_at TIMESTAMPTZ,
last_status_code INT,
consecutive_failures INT NOT NULL DEFAULT 0,
disabled_at TIMESTAMPTZ, -- auto-disable after N failures
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_webhooks_tenant_active ON tenant_webhooks(tenant_id) WHERE is_active = TRUE;
CREATE TRIGGER tg_tenant_webhooks_updated_at
BEFORE UPDATE ON tenant_webhooks
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- tenant_webhook_deliveries — log of attempts
-- ---------------------------------------------------------------------
CREATE TABLE tenant_webhook_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
webhook_id UUID NOT NULL REFERENCES tenant_webhooks(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL, -- denormalized for partitioning
event_type TEXT NOT NULL,
event_id UUID NOT NULL,
payload JSONB NOT NULL,
request_url TEXT NOT NULL,
response_status INT,
response_body TEXT,
duration_ms INT,
attempt INT NOT NULL DEFAULT 1,
succeeded BOOLEAN NOT NULL DEFAULT FALSE,
next_retry_at TIMESTAMPTZ,
error_message TEXT,
delivered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_webhook_deliveries_webhook ON tenant_webhook_deliveries(webhook_id, created_at DESC);
CREATE INDEX idx_webhook_deliveries_pending ON tenant_webhook_deliveries(next_retry_at) WHERE succeeded = FALSE AND next_retry_at IS NOT NULL;
-- ---------------------------------------------------------------------
-- tenant_usage_daily — aggregate metering for billing the reseller
-- ---------------------------------------------------------------------
CREATE TABLE tenant_usage_daily (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
usage_date DATE NOT NULL,
-- Render
renders_started INT NOT NULL DEFAULT 0,
renders_completed INT NOT NULL DEFAULT 0,
renders_failed INT NOT NULL DEFAULT 0,
render_seconds BIGINT NOT NULL DEFAULT 0, -- output duration sum
render_compute_sec BIGINT NOT NULL DEFAULT 0, -- node compute time
-- Storage
storage_bytes BIGINT NOT NULL DEFAULT 0, -- snapshot at end of day
-- API
api_calls BIGINT NOT NULL DEFAULT 0,
api_4xx BIGINT NOT NULL DEFAULT 0,
api_5xx BIGINT NOT NULL DEFAULT 0,
-- Users
active_users INT NOT NULL DEFAULT 0, -- DAU
new_users INT NOT NULL DEFAULT 0,
-- Billing
amount_billed NUMERIC(14,2) NOT NULL DEFAULT 0,
billing_currency TEXT NOT NULL DEFAULT 'IRR',
billing_status TEXT NOT NULL DEFAULT 'Pending', -- Pending/Invoiced/Paid
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, usage_date)
);
CREATE INDEX idx_tenant_usage_tenant_date ON tenant_usage_daily(tenant_id, usage_date DESC);
CREATE TRIGGER tg_tenant_usage_updated_at
BEFORE UPDATE ON tenant_usage_daily
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- tenant_api_request_logs — per-call audit (partitioned by month)
-- ---------------------------------------------------------------------
CREATE TABLE tenant_api_request_logs (
id BIGSERIAL,
tenant_id UUID NOT NULL,
api_key_id UUID,
method TEXT NOT NULL,
path TEXT NOT NULL,
status_code INT NOT NULL,
duration_ms INT NOT NULL,
request_id UUID NOT NULL,
user_id UUID,
ip_address INET,
user_agent TEXT,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_api_logs_tenant ON tenant_api_request_logs(tenant_id, created_at DESC);
CREATE INDEX idx_api_logs_key ON tenant_api_request_logs(api_key_id, created_at DESC);
-- Initial partition (ops creates monthly going forward)
CREATE TABLE tenant_api_request_logs_y2026m01
PARTITION OF tenant_api_request_logs
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- ---------------------------------------------------------------------
-- tenant_domain_verifications — DNS / file verification
-- ---------------------------------------------------------------------
CREATE TABLE tenant_domain_verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
domain CITEXT NOT NULL,
method TEXT NOT NULL, -- 'DNS_TXT' or 'HTTP_FILE'
challenge_token TEXT NOT NULL,
verified BOOLEAN NOT NULL DEFAULT FALSE,
verified_at TIMESTAMPTZ,
last_checked_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_domain_verif_tenant ON tenant_domain_verifications(tenant_id);
+227
View File
@@ -0,0 +1,227 @@
-- =====================================================================
-- IDENTITY SCHEMA — Part 2: Users, Auth, Sessions
-- =====================================================================
SET search_path TO identity, public;
CREATE TYPE register_mode AS ENUM ('Email','Mobile','Google','Telegram','SSO','Reseller');
CREATE TYPE gender_kind AS ENUM ('Male','Female','Other','PreferNotToSay');
-- ---------------------------------------------------------------------
-- users
-- ---------------------------------------------------------------------
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
-- Auth
email CITEXT,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
email_verified_at TIMESTAMPTZ,
phone_number TEXT,
phone_country_code TEXT,
phone_verified BOOLEAN NOT NULL DEFAULT FALSE,
phone_verified_at TIMESTAMPTZ,
password_hash TEXT, -- bcrypt; NULL for OAuth-only
password_set_at TIMESTAMPTZ,
last_password_reset_date TIMESTAMPTZ,
register_mode register_mode NOT NULL DEFAULT 'Email',
external_provider TEXT, -- google_oauth_subject etc.
external_provider_id TEXT,
-- Profile
full_name TEXT,
avatar_url TEXT,
birth_date DATE,
gender gender_kind,
national_code TEXT, -- Iran-specific
country_code TEXT,
company_name TEXT,
website_name TEXT,
slogan TEXT,
about_me TEXT,
method_of_introduction TEXT,
-- Balances (cents/rial; use BIGINT to avoid float)
balance_minor BIGINT NOT NULL DEFAULT 0,
affiliate_balance_minor BIGINT NOT NULL DEFAULT 0,
affiliate_owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
profit_percentage NUMERIC(5,2) NOT NULL DEFAULT 0,
-- Gamification (kept lean)
loyalty_score INT NOT NULL DEFAULT 0,
purple_point INT NOT NULL DEFAULT 0,
-- Render quotas (computed from plan + bonuses)
daily_remain_render_count INT NOT NULL DEFAULT 0,
max_daily_render_count INT NOT NULL DEFAULT 0,
parallel_rendering_ceiling INT NOT NULL DEFAULT 1,
user_daily_free_charge_sec INT NOT NULL DEFAULT 0,
daily_free_charge_reset_date TIMESTAMPTZ,
max_preview_duration_sec INT NOT NULL DEFAULT 30,
force_render_queue BOOLEAN NOT NULL DEFAULT FALSE,
remove_watermark_service BOOLEAN NOT NULL DEFAULT FALSE,
-- Telegram (legacy but kept)
telegram_id TEXT,
telegram_token TEXT,
telegram_token_expire_date TIMESTAMPTZ,
telegram_tell_me BOOLEAN NOT NULL DEFAULT FALSE,
telegram_reset_date TIMESTAMPTZ,
user_telegram_charge INT NOT NULL DEFAULT 0,
-- Comms preferences
email_tell_me BOOLEAN NOT NULL DEFAULT TRUE,
sms_tell_me BOOLEAN NOT NULL DEFAULT FALSE,
push_tell_me BOOLEAN NOT NULL DEFAULT TRUE,
-- Storage
storage_endpoint TEXT,
used_storage_bytes BIGINT NOT NULL DEFAULT 0,
-- Status
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
is_tenant_admin BOOLEAN NOT NULL DEFAULT FALSE, -- admin within a tenant
ban_account BOOLEAN NOT NULL DEFAULT FALSE,
ban_reason TEXT,
unblock_date TIMESTAMPTZ,
-- Activity
last_active_date TIMESTAMPTZ,
last_login_at TIMESTAMPTZ,
last_login_ip INET,
registered_with_mobile_app BOOLEAN NOT NULL DEFAULT FALSE,
register_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Misc
cid TEXT, -- legacy
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
-- Uniqueness scoped to tenant (so two tenants can have user@x.com independently)
CONSTRAINT uq_users_tenant_email UNIQUE (tenant_id, email),
CONSTRAINT uq_users_tenant_phone UNIQUE (tenant_id, phone_number),
CONSTRAINT uq_users_external UNIQUE (external_provider, external_provider_id)
);
CREATE INDEX idx_users_tenant ON users(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_email ON users(email) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_affiliate ON users(affiliate_owner_id) WHERE affiliate_owner_id IS NOT NULL;
CREATE INDEX idx_users_last_active ON users(last_active_date DESC);
CREATE INDEX idx_users_fullname_trgm ON users USING gin (full_name gin_trgm_ops);
CREATE TRIGGER tg_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- Now fix FK from tenant_api_keys.created_by_user_id
ALTER TABLE tenant_api_keys
ADD CONSTRAINT fk_api_keys_creator
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL;
-- ---------------------------------------------------------------------
-- user_sessions — JWT refresh tokens / device tracking
-- ---------------------------------------------------------------------
CREATE TABLE user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
refresh_token_hash TEXT NOT NULL,
device_id TEXT,
device_name TEXT,
user_agent TEXT,
ip_address INET,
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uq_sessions_token ON user_sessions(refresh_token_hash);
CREATE INDEX idx_sessions_user ON user_sessions(user_id) WHERE revoked_at IS NULL;
-- ---------------------------------------------------------------------
-- confirmation_tokens — email/phone verify, password reset, MFA
-- ---------------------------------------------------------------------
CREATE TYPE token_purpose AS ENUM (
'EmailVerification','PhoneVerification',
'PasswordReset','MfaSetup','Login','EmailChange'
);
CREATE TABLE confirmation_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
purpose token_purpose NOT NULL,
identifier CITEXT NOT NULL, -- email or phone being verified
next_identifier CITEXT, -- for email change
token_hash TEXT NOT NULL,
code TEXT, -- 6-digit OTP (hashed in token_hash)
is_consumed BOOLEAN NOT NULL DEFAULT FALSE,
consumed_at TIMESTAMPTZ,
try_count INT NOT NULL DEFAULT 0,
max_tries INT NOT NULL DEFAULT 5,
request_ip INET,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_conf_tokens_user ON confirmation_tokens(user_id, purpose) WHERE is_consumed = FALSE;
CREATE INDEX idx_conf_tokens_lookup ON confirmation_tokens(token_hash) WHERE is_consumed = FALSE;
-- ---------------------------------------------------------------------
-- push_subscriptions — PWA Web Push
-- ---------------------------------------------------------------------
CREATE TABLE push_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL,
p256dh_key TEXT NOT NULL,
auth_key TEXT NOT NULL,
user_agent TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_used_at TIMESTAMPTZ,
failure_count INT NOT NULL DEFAULT 0,
last_failure_at TIMESTAMPTZ,
last_failure_status INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, endpoint)
);
CREATE INDEX idx_push_subs_user_active ON push_subscriptions(user_id) WHERE is_active = TRUE;
-- ---------------------------------------------------------------------
-- mfa_factors — TOTP, SMS, recovery codes
-- ---------------------------------------------------------------------
CREATE TYPE mfa_factor_type AS ENUM ('TOTP','SMS','Email','RecoveryCode');
CREATE TABLE mfa_factors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
factor_type mfa_factor_type NOT NULL,
secret_encrypted TEXT,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
label TEXT,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_mfa_user ON mfa_factors(user_id) WHERE is_verified = TRUE;
@@ -0,0 +1,314 @@
-- =====================================================================
-- IDENTITY SCHEMA — Part 3: Plans, Subscriptions, Payments, Discounts
-- =====================================================================
SET search_path TO identity, public;
CREATE TYPE plan_scope AS ENUM ('User','Tenant');
CREATE TYPE billing_period AS ENUM ('Monthly','Quarterly','SemiAnnual','Annual','Lifetime','OneTime');
CREATE TYPE payment_gateway AS ENUM ('ZarinPal','IdPay','Bazaar','Stripe','Balance','Manual');
CREATE TYPE payment_status AS ENUM ('Pending','Succeeded','Failed','Refunded','Cancelled');
CREATE TYPE payment_action AS ENUM ('PlanPurchase','BalanceCharge','ProjectRender','UserProject','StorageUpgrade','Other');
CREATE TYPE discount_kind AS ENUM ('Percentage','FixedAmount','FreeMonths','RenderCredits');
-- ---------------------------------------------------------------------
-- plans — subscription tiers (FlatRender or tenant-defined)
-- ---------------------------------------------------------------------
CREATE TABLE plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
-- NULL tenant_id = global plan available to all tenants
scope plan_scope NOT NULL DEFAULT 'User',
code TEXT NOT NULL, -- 'pro_monthly', 'business_annual'
name TEXT NOT NULL,
description TEXT,
-- Pricing
price_minor BIGINT NOT NULL DEFAULT 0,
before_price_minor BIGINT, -- shown crossed-out
currency TEXT NOT NULL DEFAULT 'IRR',
discount_percentage NUMERIC(5,2) NOT NULL DEFAULT 0,
billing_period billing_period NOT NULL DEFAULT 'Monthly',
months_duration INT, -- for non-monthly
-- Quotas
seconds_charge INT NOT NULL DEFAULT 0, -- render seconds included
monthly_renders_quota INT, -- NULL = unlimited
storage_gb INT NOT NULL DEFAULT 1,
parallel_renders INT NOT NULL DEFAULT 1,
max_resolution TEXT NOT NULL DEFAULT 'FullHD',
min_video_length_sec INT NOT NULL DEFAULT 0,
render_speed_factor NUMERIC(4,2) NOT NULL DEFAULT 1.0,
-- UI
sort INT NOT NULL DEFAULT 0,
icon TEXT,
cover TEXT,
loyalty_mark TEXT,
color TEXT,
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
-- Feature flags carried by this plan
features JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Lifecycle
is_active BOOLEAN NOT NULL DEFAULT TRUE,
available_from TIMESTAMPTZ,
available_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uq_plans_tenant_code UNIQUE (tenant_id, code)
);
CREATE INDEX idx_plans_tenant_active ON plans(tenant_id) WHERE is_active = TRUE AND deleted_at IS NULL;
CREATE TRIGGER tg_plans_updated_at
BEFORE UPDATE ON plans
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- user_plans — active subscriptions
-- ---------------------------------------------------------------------
CREATE TABLE user_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
plan_id UUID NOT NULL REFERENCES plans(id),
-- Snapshot at purchase time
plan_code TEXT NOT NULL,
plan_name TEXT NOT NULL,
price_minor_paid BIGINT NOT NULL,
currency TEXT NOT NULL,
-- Quota state
initial_seconds_charge INT NOT NULL,
remain_charge_sec INT NOT NULL,
added_charge_from_past_plan INT NOT NULL DEFAULT 0,
monthly_renders_used INT NOT NULL DEFAULT 0,
monthly_renders_reset_at TIMESTAMPTZ,
-- Lifecycle
register_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
cancelled_at TIMESTAMPTZ,
cancel_reason TEXT,
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
-- Reference to payment
payment_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_user_plans_active ON user_plans(user_id) WHERE cancelled_at IS NULL;
CREATE INDEX idx_user_plans_expire ON user_plans(expires_at) WHERE cancelled_at IS NULL;
CREATE TRIGGER tg_user_plans_updated_at
BEFORE UPDATE ON user_plans
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- discounts — coupons / affiliate codes
-- ---------------------------------------------------------------------
CREATE TABLE discounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
code CITEXT NOT NULL,
kind discount_kind NOT NULL,
value NUMERIC(14,2) NOT NULL, -- percent or amount
-- Affiliate split
owner_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
owner_profit_percentage NUMERIC(5,2) NOT NULL DEFAULT 0,
only_owner BOOLEAN NOT NULL DEFAULT FALSE, -- usable only by owner
-- Constraints
max_use_count INT, -- NULL = unlimited
used_count INT NOT NULL DEFAULT 0,
min_purchase_minor BIGINT NOT NULL DEFAULT 0,
applies_to_plan_ids UUID[],
starts_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_discounts_tenant_code UNIQUE (tenant_id, code)
);
CREATE INDEX idx_discounts_active ON discounts(tenant_id) WHERE is_active = TRUE;
CREATE INDEX idx_discounts_owner ON discounts(owner_user_id) WHERE owner_user_id IS NOT NULL;
CREATE TRIGGER tg_discounts_updated_at
BEFORE UPDATE ON discounts
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- used_discounts — usage log per user
-- ---------------------------------------------------------------------
CREATE TABLE used_discounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
discount_id UUID NOT NULL REFERENCES discounts(id) ON DELETE RESTRICT,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
payment_id UUID,
code CITEXT NOT NULL,
amount_discounted_minor BIGINT NOT NULL,
use_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_used_discounts_user ON used_discounts(user_id, use_date DESC);
-- ---------------------------------------------------------------------
-- payments
-- ---------------------------------------------------------------------
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
gateway payment_gateway NOT NULL,
status payment_status NOT NULL DEFAULT 'Pending',
action payment_action NOT NULL,
-- Amounts
amount_minor BIGINT NOT NULL,
currency TEXT NOT NULL DEFAULT 'IRR',
balance_reducer_minor BIGINT NOT NULL DEFAULT 0, -- portion paid from balance
discount_value_minor BIGINT NOT NULL DEFAULT 0,
-- Gateway refs
gateway_token TEXT,
gateway_order_id TEXT,
gateway_track_id TEXT,
gateway_response JSONB,
card_last4 TEXT,
card_hash TEXT,
-- Description
title TEXT,
description TEXT,
-- Affiliate
owner_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
affiliate_profit_minor BIGINT NOT NULL DEFAULT 0,
after_pay_score NUMERIC(8,2) NOT NULL DEFAULT 0,
-- Polymorphic product reference
used_discount_id UUID REFERENCES used_discounts(id),
plan_id UUID REFERENCES plans(id),
user_project_id UUID, -- FK added later if user_projects keeps existing
render_job_id UUID,
product_id UUID,
-- Lifecycle
confirmed_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
failure_reason TEXT,
refunded_at TIMESTAMPTZ,
refund_amount_minor BIGINT,
refund_reason TEXT,
-- Checkout/receipt
checkouted BOOLEAN NOT NULL DEFAULT FALSE,
checkout_date TIMESTAMPTZ,
checkout_recipe TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_payments_user ON payments(user_id, created_at DESC);
CREATE INDEX idx_payments_tenant ON payments(tenant_id, created_at DESC);
CREATE INDEX idx_payments_status ON payments(status) WHERE status IN ('Pending','Failed');
CREATE INDEX idx_payments_track ON payments(gateway, gateway_track_id) WHERE gateway_track_id IS NOT NULL;
CREATE TRIGGER tg_payments_updated_at
BEFORE UPDATE ON payments
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- Now wire up the FKs that were dangling
ALTER TABLE used_discounts
ADD CONSTRAINT fk_used_disc_payment
FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE SET NULL;
ALTER TABLE user_plans
ADD CONSTRAINT fk_user_plans_payment
FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE SET NULL;
-- ---------------------------------------------------------------------
-- checkouts — receipts / affiliate payouts log
-- ---------------------------------------------------------------------
CREATE TABLE checkouts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
affiliate_owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
title TEXT,
description TEXT,
amount_minor BIGINT NOT NULL,
currency TEXT NOT NULL DEFAULT 'IRR',
card_number_last4 TEXT,
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
paid_at TIMESTAMPTZ,
recipe TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_checkouts_user ON checkouts(user_id, requested_at DESC);
-- ---------------------------------------------------------------------
-- user_projects — freelance custom-template requests
-- ---------------------------------------------------------------------
CREATE TYPE user_project_status AS ENUM (
'Draft','Submitted','Quoted','InProgress','Review','Completed','Cancelled'
);
CREATE TABLE user_projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
title TEXT,
description TEXT,
graphist_description TEXT,
attachment_path TEXT,
price_minor BIGINT,
user_suggested_price_minor BIGINT,
status user_project_status NOT NULL DEFAULT 'Draft',
user_max_requested_day_to_create INT,
user_graphist_max_requested_day_to_create INT,
created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
end_time TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_user_projects_user ON user_projects(user_id, status);
CREATE TRIGGER tg_user_projects_updated_at
BEFORE UPDATE ON user_projects
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
ALTER TABLE payments
ADD CONSTRAINT fk_payments_user_project
FOREIGN KEY (user_project_id) REFERENCES user_projects(id) ON DELETE SET NULL;
@@ -0,0 +1,149 @@
-- =====================================================================
-- IDENTITY SCHEMA — Part 4: Gamification (simplified)
-- =====================================================================
-- Quests, gifts, loyalty — leaner than V1 but still functional.
-- =====================================================================
SET search_path TO identity, public;
CREATE TYPE quest_type AS ENUM ('OneTime','Daily','Weekly','Onboarding','Milestone');
CREATE TYPE prize_type AS ENUM ('Balance','RenderSeconds','LoyaltyPoints','StorageGB','Plan','Discount');
CREATE TYPE gift_type AS ENUM ('Bonus','Referral','Compensation','Promotion','Achievement');
-- ---------------------------------------------------------------------
-- quests
-- ---------------------------------------------------------------------
CREATE TABLE quests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, -- NULL = global
title TEXT NOT NULL,
challenge TEXT,
why TEXT,
hint TEXT,
aphorism TEXT,
icon TEXT,
quest_type quest_type NOT NULL,
target_event TEXT NOT NULL, -- 'user.registered','project.created',...
target_count INT NOT NULL DEFAULT 1, -- how many times event must fire
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
prize_type prize_type NOT NULL,
prize_amount BIGINT NOT NULL, -- minor units or seconds/points
level_limit INT, -- minimum loyalty level
start_url TEXT,
post_action_name TEXT,
order_value INT NOT NULL DEFAULT 0,
starts_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_quests_active ON quests(quest_type, is_active);
CREATE TRIGGER tg_quests_updated_at
BEFORE UPDATE ON quests
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- user_quest_progress — incremental tracking
-- ---------------------------------------------------------------------
CREATE TABLE user_quest_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
quest_id UUID NOT NULL REFERENCES quests(id) ON DELETE CASCADE,
current_count INT NOT NULL DEFAULT 0,
text_value TEXT, -- for input-based quests
is_completed BOOLEAN NOT NULL DEFAULT FALSE,
completed_at TIMESTAMPTZ,
prize_claimed BOOLEAN NOT NULL DEFAULT FALSE,
prize_claimed_at TIMESTAMPTZ,
period_start DATE, -- for Daily/Weekly resets
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, quest_id, period_start)
);
CREATE INDEX idx_quest_prog_user_open ON user_quest_progress(user_id) WHERE is_completed = FALSE;
CREATE TRIGGER tg_quest_prog_updated_at
BEFORE UPDATE ON user_quest_progress
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- gifts — admin-issued bonuses
-- ---------------------------------------------------------------------
CREATE TABLE gifts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
icon TEXT,
gift_type gift_type NOT NULL,
prize_type prize_type NOT NULL,
value BIGINT NOT NULL,
unit TEXT, -- 'seconds','IRR','points',...
assigned_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ---------------------------------------------------------------------
-- earned_gifts — issued to user (consumed via used_gifts)
-- ---------------------------------------------------------------------
CREATE TABLE earned_gifts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
gift_id UUID NOT NULL REFERENCES gifts(id) ON DELETE RESTRICT,
notification_id UUID, -- FK added later
source TEXT, -- 'quest','admin','referral','plan'
source_ref UUID, -- e.g. quest_id
earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
is_used BOOLEAN NOT NULL DEFAULT FALSE,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_earned_gifts_user ON earned_gifts(user_id) WHERE is_used = FALSE;
-- ---------------------------------------------------------------------
-- used_gifts — claim log
-- ---------------------------------------------------------------------
CREATE TABLE used_gifts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
earned_gift_id UUID NOT NULL REFERENCES earned_gifts(id) ON DELETE RESTRICT,
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_used_gifts_user ON used_gifts(user_id, used_at DESC);
-- ---------------------------------------------------------------------
-- avatars — preset avatar library
-- ---------------------------------------------------------------------
CREATE TABLE avatars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT UNIQUE NOT NULL,
url TEXT NOT NULL,
description TEXT,
sort INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -0,0 +1,159 @@
-- =====================================================================
-- CONTENT SCHEMA — Part 1: Taxonomy & Assets (categories, tags, fonts, music)
-- =====================================================================
SET search_path TO content, public;
-- ---------------------------------------------------------------------
-- categories — hierarchical
-- ---------------------------------------------------------------------
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES categories(id) ON DELETE SET NULL,
name TEXT NOT NULL,
slug CITEXT NOT NULL UNIQUE,
description TEXT,
image_url TEXT,
icon TEXT,
-- SEO
meta_title TEXT,
meta_description TEXT,
meta_keywords TEXT,
bot_follow BOOLEAN NOT NULL DEFAULT TRUE,
sort INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_categories_parent ON categories(parent_id);
CREATE INDEX idx_categories_active ON categories(is_active) WHERE deleted_at IS NULL;
CREATE TRIGGER tg_categories_updated_at
BEFORE UPDATE ON categories FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- container_categories — many-to-many
-- ---------------------------------------------------------------------
-- Will be created after project_containers table
-- ---------------------------------------------------------------------
-- tags
-- ---------------------------------------------------------------------
CREATE TYPE choose_mode AS ENUM ('FIX','FLEXIBLE','MockUp','MusicVisualizer','VoiceOver');
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
latin_name TEXT,
slug CITEXT NOT NULL UNIQUE,
applies_to_mode choose_mode,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_tags_active ON tags(is_active) WHERE deleted_at IS NULL;
CREATE TRIGGER tg_tags_updated_at
BEFORE UPDATE ON tags FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- fonts
-- ---------------------------------------------------------------------
CREATE TABLE fonts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL, -- display name
original_name TEXT, -- as registered in AE
system_name TEXT, -- exact OS family name
family TEXT,
weight INT, -- 100-900
style TEXT, -- 'normal' | 'italic'
direction TEXT NOT NULL DEFAULT 'LTR', -- LTR/RTL/Auto
file_url TEXT, -- .ttf/.otf URL
sample_image_url TEXT,
is_premium BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
installed_on_nodes BOOLEAN NOT NULL DEFAULT FALSE,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_fonts_active ON fonts(is_active) WHERE deleted_at IS NULL;
CREATE TRIGGER tg_fonts_updated_at
BEFORE UPDATE ON fonts FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- music_tracks
-- ---------------------------------------------------------------------
CREATE TABLE music_tracks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
caption TEXT,
keywords TEXT,
url TEXT NOT NULL,
waveform_data JSONB, -- precomputed visualization
duration_sec NUMERIC(8,2) NOT NULL,
bpm INT,
genre TEXT,
mood TEXT,
is_premium BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_music_active ON music_tracks(is_active) WHERE deleted_at IS NULL;
CREATE INDEX idx_music_genre ON music_tracks(genre);
CREATE TRIGGER tg_music_updated_at
BEFORE UPDATE ON music_tracks FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- project_servers — render server configs (multi-region)
-- ---------------------------------------------------------------------
CREATE TABLE project_servers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
region TEXT NOT NULL, -- 'tehran','frankfurt',...
ip INET,
physical_path_output TEXT,
default_project_address TEXT,
render_output_location TEXT,
pre_need_folder_address TEXT,
minio_endpoint TEXT,
minio_bucket_templates TEXT,
minio_bucket_outputs TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_project_servers_region ON project_servers(region) WHERE is_active = TRUE;
CREATE TRIGGER tg_project_servers_updated_at
BEFORE UPDATE ON project_servers FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- admin_files — admin-uploaded resources
-- ---------------------------------------------------------------------
CREATE TABLE admin_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT,
url TEXT NOT NULL,
thumbnail_url TEXT,
file_type TEXT,
size_bytes BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -0,0 +1,143 @@
-- =====================================================================
-- CONTENT SCHEMA — Part 2: Project Containers & Projects (Templates)
-- =====================================================================
SET search_path TO content, public;
-- Tenants can mark projects as private (only their users see them)
-- or use the global FlatRender catalog.
CREATE TYPE resolution_kind AS ENUM ('HD','FullHD','TwoK','FourK');
-- ---------------------------------------------------------------------
-- project_containers — the "product" (template pack)
-- ---------------------------------------------------------------------
CREATE TABLE project_containers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID, -- NULL = global
-- FK to identity.tenants is enforced via service code (cross-schema FK kept loose)
slug CITEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
keywords TEXT,
news_text TEXT,
-- Media
image TEXT,
demo TEXT,
full_demo TEXT,
mini_demo TEXT,
demo_script_tag TEXT,
-- Modes & classifications
is_published BOOLEAN NOT NULL DEFAULT FALSE,
is_premium BOOLEAN NOT NULL DEFAULT FALSE,
is_mockup BOOLEAN NOT NULL DEFAULT FALSE,
primary_mode choose_mode NOT NULL DEFAULT 'FLEXIBLE',
-- Stats (denormalized for speed)
rate_avg NUMERIC(3,2),
rate_count INT NOT NULL DEFAULT 0,
view_count BIGINT NOT NULL DEFAULT 0,
use_count BIGINT NOT NULL DEFAULT 0,
sort INT NOT NULL DEFAULT 0,
sort_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_containers_published ON project_containers(is_published, sort_date DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_containers_tenant ON project_containers(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_containers_name_trgm ON project_containers USING gin (name gin_trgm_ops);
CREATE TRIGGER tg_containers_updated_at
BEFORE UPDATE ON project_containers FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- M2M: container ↔ categories
-- ---------------------------------------------------------------------
CREATE TABLE container_categories (
container_id UUID NOT NULL REFERENCES project_containers(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
sort INT NOT NULL DEFAULT 0,
PRIMARY KEY (container_id, category_id)
);
CREATE INDEX idx_cc_category ON container_categories(category_id);
-- ---------------------------------------------------------------------
-- M2M: container ↔ tags
-- ---------------------------------------------------------------------
CREATE TABLE container_tags (
container_id UUID NOT NULL REFERENCES project_containers(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (container_id, tag_id)
);
CREATE INDEX idx_ct_tag ON container_tags(tag_id);
-- ---------------------------------------------------------------------
-- projects — one aspect-ratio variant of a container
-- ---------------------------------------------------------------------
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
container_id UUID NOT NULL REFERENCES project_containers(id) ON DELETE CASCADE,
project_server_id UUID REFERENCES project_servers(id),
name TEXT NOT NULL,
description TEXT,
image TEXT,
full_demo TEXT,
demo_script_tag TEXT,
download_link TEXT,
-- AEP file storage
aep_minio_bucket TEXT,
aep_minio_key TEXT,
aep_file_url TEXT,
aep_file_md5 TEXT, -- for node cache check
aep_file_size_bytes BIGINT,
aep_uploaded_at TIMESTAMPTZ,
folder TEXT, -- legacy path on server
-- Geometry
original_width INT NOT NULL,
original_height INT NOT NULL,
aspect TEXT, -- '16:9','9:16','1:1','4:5',...
-- Timing
project_duration_sec NUMERIC(8,2) NOT NULL,
min_duration_sec NUMERIC(8,2),
max_duration_sec NUMERIC(8,2),
free_fps INT NOT NULL DEFAULT 30,
-- Mode
choose_mode choose_mode NOT NULL,
resolution resolution_kind NOT NULL DEFAULT 'FullHD',
vip_factor NUMERIC(4,2) NOT NULL DEFAULT 1.0,
render_aep_comp TEXT NOT NULL DEFAULT 'flatrender', -- main comp name
-- Misc (legacy artifacts to preserve)
shared_layer_image TEXT,
shared_colors_svg TEXT,
shared_color_presets_svg TEXT,
-- Status
is_published BOOLEAN NOT NULL DEFAULT FALSE,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_projects_container ON projects(container_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_published ON projects(is_published) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_aep_md5 ON projects(aep_file_md5) WHERE aep_file_md5 IS NOT NULL;
CREATE TRIGGER tg_projects_updated_at
BEFORE UPDATE ON projects FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
+368
View File
@@ -0,0 +1,368 @@
-- =====================================================================
-- CONTENT SCHEMA — Part 3: Scenes & Editable Elements
-- =====================================================================
SET search_path TO content, public;
CREATE TYPE scene_kind AS ENUM ('Normal','Config','DesignStart','DesignEnd');
CREATE TYPE content_element_type AS ENUM (
'Text','TextArea','Media','Audio','Voiceover',
'CheckBox','DropDown','Fill','Color','Number',
'Date','Toggle','Slider','Counter','Hidden'
);
CREATE TYPE justify_kind AS ENUM ('LEFT_JUSTIFY','CENTER_JUSTIFY','RIGHT_JUSTIFY','FULL_JUSTIFY');
CREATE TYPE ai_input_type AS ENUM ('None','TitleSuggest','BodySuggest','TranslateRtl','TranslateLtr','RemoveBG','UpscaleImage','TTS');
CREATE TYPE repeat_sort_strategy AS ENUM ('Manual','Alphabetical','Numerical','InsertOrder');
CREATE TYPE attr_value_kind AS ENUM ('fill','stroke','tracking','dropshadow');
-- ---------------------------------------------------------------------
-- scenes
-- ---------------------------------------------------------------------
CREATE TABLE scenes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
-- Identity (maps to AE comp name)
key TEXT NOT NULL, -- matches AE comp name
title TEXT NOT NULL,
localized_title JSONB, -- {"fa":"...","en":"..."}
-- Type
scene_type scene_kind NOT NULL DEFAULT 'Normal',
-- Media
image TEXT,
demo TEXT,
scene_color_svg TEXT, -- SVG color preview (legacy)
snapshot_url TEXT, -- pre-rendered representative frame
-- Animation flags
generate_kf BOOLEAN NOT NULL DEFAULT FALSE,
-- Timing
default_duration_sec NUMERIC(8,2),
min_duration_sec NUMERIC(8,2),
max_duration_sec NUMERIC(8,2),
overlap_at_end_sec NUMERIC(6,2) NOT NULL DEFAULT 0,
can_handle_duration BOOLEAN NOT NULL DEFAULT TRUE,
-- Customization
manual_color_selection BOOLEAN NOT NULL DEFAULT FALSE,
sort INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
UNIQUE (project_id, key)
);
CREATE INDEX idx_scenes_project ON scenes(project_id, sort) WHERE deleted_at IS NULL;
CREATE INDEX idx_scenes_type ON scenes(project_id, scene_type);
CREATE TRIGGER tg_scenes_updated_at
BEFORE UPDATE ON scenes FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- M2M: scenes ↔ categories
-- ---------------------------------------------------------------------
CREATE TABLE scene_categories (
scene_id UUID NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
PRIMARY KEY (scene_id, category_id)
);
-- ---------------------------------------------------------------------
-- repeater_items — repeating sub-blocks within a scene
-- ---------------------------------------------------------------------
CREATE TABLE repeater_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scene_id UUID NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
title TEXT NOT NULL,
repeat_box_key TEXT NOT NULL, -- AE layer name of container
repeat_item_key TEXT NOT NULL, -- AE layer name of item template
max_repeat_count INT NOT NULL DEFAULT 10,
user_can_change_sort BOOLEAN NOT NULL DEFAULT TRUE,
repeat_sort_strategy repeat_sort_strategy NOT NULL DEFAULT 'Manual',
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_repeaters_scene ON repeater_items(scene_id);
CREATE TRIGGER tg_repeaters_updated_at
BEFORE UPDATE ON repeater_items FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- scene_content_elements — every editable field in a scene
-- ---------------------------------------------------------------------
CREATE TABLE scene_content_elements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scene_id UUID NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
repeater_item_id UUID REFERENCES repeater_items(id) ON DELETE CASCADE,
-- Identity (maps to AE frl_/frd_ layer name)
key TEXT NOT NULL,
title TEXT NOT NULL,
localized_title JSONB,
hint TEXT,
type content_element_type NOT NULL,
default_value TEXT,
-- Text-specific
font_id UUID REFERENCES fonts(id),
font_face TEXT,
font_face_name TEXT,
font_size INT,
default_font_size INT,
default_font_face TEXT,
is_font_changeable BOOLEAN NOT NULL DEFAULT TRUE,
is_font_size_changeable BOOLEAN NOT NULL DEFAULT TRUE,
justify justify_kind NOT NULL DEFAULT 'CENTER_JUSTIFY',
can_justify BOOLEAN NOT NULL DEFAULT TRUE,
position_in_container INT NOT NULL DEFAULT 0, -- 0-8 (see JSX positionMode)
is_text_box BOOLEAN NOT NULL DEFAULT FALSE,
max_size INT, -- max char count
direction_layer_key TEXT, -- companion frd_ for RTL
direction_layer_value INT NOT NULL DEFAULT 0, -- 0=LTR, 1=RTL
-- Media-specific
video_support BOOLEAN NOT NULL DEFAULT FALSE,
min_duration_sec NUMERIC(6,2),
max_duration_sec NUMERIC(6,2),
width INT,
height INT,
thumbnail TEXT,
-- Dropdown / mapped list
mapped_list JSONB, -- [{"label","value"}, ...]
counter_mode TEXT,
-- AI
ai_input_type ai_input_type NOT NULL DEFAULT 'None',
-- Visibility / linking
is_hidden BOOLEAN NOT NULL DEFAULT FALSE,
is_focused BOOLEAN NOT NULL DEFAULT FALSE,
opacity_controller_key TEXT, -- ref another element's key
-- Design pattern variants (legacy DP1-4 system)
dp1_image TEXT, dp1_title TEXT,
dp2_image TEXT, dp2_title TEXT,
dp3_image TEXT, dp3_title TEXT,
dp4_image TEXT, dp4_title TEXT,
-- Repeater virtualization
virtual_count INT NOT NULL DEFAULT 1,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (scene_id, key)
);
CREATE INDEX idx_sce_scene ON scene_content_elements(scene_id, sort);
CREATE INDEX idx_sce_repeater ON scene_content_elements(repeater_item_id) WHERE repeater_item_id IS NOT NULL;
CREATE TRIGGER tg_sce_updated_at
BEFORE UPDATE ON scene_content_elements FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- scene_color_elements — color zones (frd_ data layers per scene)
-- ---------------------------------------------------------------------
CREATE TABLE scene_color_elements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scene_id UUID NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
element_key TEXT NOT NULL, -- matches frd_ layer name
title TEXT NOT NULL,
icon TEXT,
attr_value attr_value_kind NOT NULL DEFAULT 'fill',
default_color TEXT NOT NULL, -- '#RRGGBB' or 'r,g,b'
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (scene_id, element_key)
);
CREATE INDEX idx_sce_color_scene ON scene_color_elements(scene_id, sort);
CREATE TRIGGER tg_sce_color_updated_at
BEFORE UPDATE ON scene_color_elements FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- scene_color_presets — theme presets per scene
-- ---------------------------------------------------------------------
CREATE TABLE scene_color_presets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scene_id UUID NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
name TEXT,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE scene_color_preset_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
preset_id UUID NOT NULL REFERENCES scene_color_presets(id) ON DELETE CASCADE,
element_key TEXT NOT NULL,
value TEXT NOT NULL,
sort INT NOT NULL DEFAULT 0
);
CREATE INDEX idx_sc_preset_items_preset ON scene_color_preset_items(preset_id);
-- ---------------------------------------------------------------------
-- shared_colors — global colors across project (frshare comp)
-- ---------------------------------------------------------------------
CREATE TABLE shared_colors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
element_key TEXT NOT NULL,
title TEXT NOT NULL,
icon TEXT,
attr_value attr_value_kind NOT NULL DEFAULT 'fill',
default_color TEXT NOT NULL,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, element_key)
);
CREATE INDEX idx_shared_colors_project ON shared_colors(project_id, sort);
CREATE TRIGGER tg_shared_colors_updated_at
BEFORE UPDATE ON shared_colors FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- shared_color_presets — project-wide palette themes
-- ---------------------------------------------------------------------
CREATE TABLE shared_color_presets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE shared_color_preset_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
preset_id UUID NOT NULL REFERENCES shared_color_presets(id) ON DELETE CASCADE,
element_key TEXT NOT NULL,
value TEXT NOT NULL,
sort INT NOT NULL DEFAULT 0
);
CREATE INDEX idx_shared_preset_items_preset ON shared_color_preset_items(preset_id);
-- ---------------------------------------------------------------------
-- shared_layers — global text/media layers across all scenes
-- ---------------------------------------------------------------------
CREATE TABLE shared_layers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
key TEXT NOT NULL,
title TEXT NOT NULL,
localized_title JSONB,
hint TEXT,
type content_element_type NOT NULL,
default_value TEXT,
font_id UUID REFERENCES fonts(id),
font_face TEXT,
font_face_name TEXT,
font_size INT,
default_font_size INT,
default_font_face TEXT,
is_font_changeable BOOLEAN NOT NULL DEFAULT TRUE,
is_font_size_changeable BOOLEAN NOT NULL DEFAULT TRUE,
justify justify_kind NOT NULL DEFAULT 'CENTER_JUSTIFY',
can_justify BOOLEAN NOT NULL DEFAULT TRUE,
position_in_container INT NOT NULL DEFAULT 0,
is_text_box BOOLEAN NOT NULL DEFAULT FALSE,
max_size INT,
direction_layer_key TEXT,
direction_layer_value INT NOT NULL DEFAULT 0,
video_support BOOLEAN NOT NULL DEFAULT FALSE,
min_duration_sec NUMERIC(6,2),
max_duration_sec NUMERIC(6,2),
width INT,
height INT,
thumbnail TEXT,
mapped_list JSONB,
counter_mode TEXT,
ai_input_type ai_input_type NOT NULL DEFAULT 'None',
is_hidden BOOLEAN NOT NULL DEFAULT FALSE,
is_focused BOOLEAN NOT NULL DEFAULT FALSE,
dp1_image TEXT, dp1_title TEXT,
dp2_image TEXT, dp2_title TEXT,
dp3_image TEXT, dp3_title TEXT,
dp4_image TEXT, dp4_title TEXT,
virtual_count INT NOT NULL DEFAULT 1,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, key)
);
CREATE INDEX idx_shared_layers_project ON shared_layers(project_id, sort);
CREATE TRIGGER tg_shared_layers_updated_at
BEFORE UPDATE ON shared_layers FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- template_svg_previews — NEW: drop-an-image → traced SVG for live color preview
-- ---------------------------------------------------------------------
CREATE TABLE template_svg_previews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
scene_id UUID REFERENCES scenes(id) ON DELETE CASCADE,
-- Exactly one of project_id or scene_id should be set
source_image_url TEXT, -- the dropped image
svg_url TEXT NOT NULL, -- in MinIO
thumbnail_url TEXT,
-- Maps each SVG <path data-color-key="frd_bg"> to a color element
color_zones JSONB NOT NULL,
-- Example: [{"element_key":"frd_bg","detected_color":"#1A1A2E","bbox":[x,y,w,h]}]
width INT,
height INT,
generation_method TEXT, -- 'auto','manual','ai-assisted'
generated_by_ai BOOLEAN NOT NULL DEFAULT FALSE,
quality_score NUMERIC(3,2), -- 0-1 confidence
created_by_user_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK ((project_id IS NULL) <> (scene_id IS NULL)) -- exactly one
);
CREATE INDEX idx_svg_previews_project ON template_svg_previews(project_id);
CREATE INDEX idx_svg_previews_scene ON template_svg_previews(scene_id);
CREATE TRIGGER tg_svg_previews_updated_at
BEFORE UPDATE ON template_svg_previews FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
@@ -0,0 +1,160 @@
-- =====================================================================
-- CONTENT SCHEMA — Part 4: Characters & Preset Stories
-- =====================================================================
SET search_path TO content, public;
-- ---------------------------------------------------------------------
-- scene_characters — character elements per scene
-- ---------------------------------------------------------------------
CREATE TABLE scene_characters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scene_id UUID NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
key TEXT NOT NULL,
name TEXT NOT NULL,
icon TEXT,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (scene_id, key)
);
CREATE INDEX idx_scene_chars_scene ON scene_characters(scene_id);
CREATE TRIGGER tg_scene_chars_updated_at
BEFORE UPDATE ON scene_characters FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- scene_character_controllers — animation properties of a character
-- ---------------------------------------------------------------------
CREATE TABLE scene_character_controllers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scene_character_id UUID NOT NULL REFERENCES scene_characters(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key TEXT NOT NULL,
default_value TEXT,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_scene_char_ctrl_char ON scene_character_controllers(scene_character_id);
CREATE TRIGGER tg_scene_char_ctrl_updated_at
BEFORE UPDATE ON scene_character_controllers FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- scene_controller_options — discrete value options per controller
-- ---------------------------------------------------------------------
CREATE TABLE scene_controller_options (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
controller_id UUID NOT NULL REFERENCES scene_character_controllers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
icon TEXT,
value TEXT NOT NULL,
sort INT NOT NULL DEFAULT 0
);
CREATE INDEX idx_scene_ctrl_opt_ctrl ON scene_controller_options(controller_id);
-- ---------------------------------------------------------------------
-- project_character_controllers — project-wide character animation defs
-- ---------------------------------------------------------------------
CREATE TABLE project_character_controllers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key TEXT NOT NULL,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, key)
);
CREATE INDEX idx_proj_char_ctrl_project ON project_character_controllers(project_id);
CREATE TRIGGER tg_proj_char_ctrl_updated_at
BEFORE UPDATE ON project_character_controllers FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- project_character_controller_options
-- ---------------------------------------------------------------------
CREATE TABLE project_character_controller_options (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
controller_id UUID NOT NULL REFERENCES project_character_controllers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
icon TEXT,
value TEXT NOT NULL,
sort INT NOT NULL DEFAULT 0
);
-- ---------------------------------------------------------------------
-- project_character_presets — named bundles of controller values
-- ---------------------------------------------------------------------
CREATE TABLE project_character_presets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
key UUID NOT NULL DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
icon TEXT,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_char_presets_project ON project_character_presets(project_id);
CREATE TRIGGER tg_char_presets_updated_at
BEFORE UPDATE ON project_character_presets FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- preset_character_controllers — controller values inside a preset
-- ---------------------------------------------------------------------
CREATE TABLE preset_character_controllers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_preset_id UUID NOT NULL REFERENCES project_character_presets(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_preset_char_ctrl_preset ON preset_character_controllers(character_preset_id);
-- ---------------------------------------------------------------------
-- preset_stories — pre-made scene combinations
-- ---------------------------------------------------------------------
CREATE TABLE preset_stories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
demo TEXT,
music_id UUID REFERENCES music_tracks(id),
scenes_spa TEXT, -- legacy serialized SPA (kept for migration)
sort INT NOT NULL DEFAULT 0,
is_published BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_preset_stories_project ON preset_stories(project_id) WHERE deleted_at IS NULL;
CREATE TRIGGER tg_preset_stories_updated_at
BEFORE UPDATE ON preset_stories FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- preset_scenes — which scenes appear in a preset story and order
-- ---------------------------------------------------------------------
CREATE TABLE preset_scenes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
preset_story_id UUID NOT NULL REFERENCES preset_stories(id) ON DELETE CASCADE,
scene_id UUID NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
sort INT NOT NULL DEFAULT 0,
default_duration_sec NUMERIC(8,2),
UNIQUE (preset_story_id, sort)
);
CREATE INDEX idx_preset_scenes_story ON preset_scenes(preset_story_id, sort);
+249
View File
@@ -0,0 +1,249 @@
-- =====================================================================
-- CONTENT SCHEMA — Part 5: CMS (blogs, comments, slides, routes, settings)
-- =====================================================================
SET search_path TO content, public;
-- ---------------------------------------------------------------------
-- blogs / landings
-- ---------------------------------------------------------------------
CREATE TYPE blog_kind AS ENUM ('Blog','Landing');
CREATE TABLE blogs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID, -- NULL = global
kind blog_kind NOT NULL DEFAULT 'Blog',
slug CITEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
short_description TEXT,
content TEXT NOT NULL,
-- SEO
meta_title TEXT,
meta_description TEXT,
meta_keywords TEXT,
include_in_site_map BOOLEAN NOT NULL DEFAULT TRUE,
-- Media
image TEXT,
cover TEXT,
-- Author
author_user_id UUID, -- references identity.users (loose)
author_display_name TEXT,
is_published BOOLEAN NOT NULL DEFAULT FALSE,
publish_date TIMESTAMPTZ,
view_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_blogs_published ON blogs(is_published, publish_date DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_blogs_tenant ON blogs(tenant_id) WHERE deleted_at IS NULL;
CREATE TRIGGER tg_blogs_updated_at
BEFORE UPDATE ON blogs FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- comments — on blogs or project containers
-- ---------------------------------------------------------------------
CREATE TABLE comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID,
user_id UUID NOT NULL, -- references identity.users (loose)
blog_id UUID REFERENCES blogs(id) ON DELETE CASCADE,
container_id UUID REFERENCES project_containers(id) ON DELETE CASCADE,
parent_comment_id UUID REFERENCES comments(id) ON DELETE CASCADE,
content TEXT NOT NULL,
rate NUMERIC(3,2), -- 0-5
is_approved BOOLEAN NOT NULL DEFAULT FALSE,
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CHECK (
(blog_id IS NOT NULL)::INT + (container_id IS NOT NULL)::INT = 1
)
);
CREATE INDEX idx_comments_blog ON comments(blog_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_comments_container ON comments(container_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_comments_user ON comments(user_id);
CREATE INDEX idx_comments_pending ON comments(is_approved) WHERE is_approved = FALSE AND deleted_at IS NULL;
CREATE TRIGGER tg_comments_updated_at
BEFORE UPDATE ON comments FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- home_page_events — marketing banners / events
-- ---------------------------------------------------------------------
CREATE TABLE home_page_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID,
title TEXT,
subtitle TEXT,
description TEXT,
badge TEXT,
badge_class TEXT,
button_text TEXT,
button_url TEXT,
button_class TEXT,
color TEXT,
background_color TEXT,
text_color TEXT,
image TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort INT NOT NULL DEFAULT 0,
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_home_events_active ON home_page_events(tenant_id, is_active);
CREATE TRIGGER tg_home_events_updated_at
BEFORE UPDATE ON home_page_events FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- new_slides — carousel
-- ---------------------------------------------------------------------
CREATE TYPE slide_type AS ENUM ('Hero','Promo','Tutorial','Category','Custom');
CREATE TABLE new_slides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID,
keyword TEXT,
title TEXT,
image TEXT,
parameter TEXT,
slide_type slide_type NOT NULL DEFAULT 'Hero',
expire_date TIMESTAMPTZ,
sort INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_slides_active ON new_slides(tenant_id, is_active);
CREATE TRIGGER tg_slides_updated_at
BEFORE UPDATE ON new_slides FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- internal_routes / custom_routes — SEO + redirects
-- ---------------------------------------------------------------------
CREATE TABLE internal_routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID,
name TEXT,
image TEXT,
slug CITEXT NOT NULL,
priority INT NOT NULL DEFAULT 5,
last_date TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE custom_routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID,
target TEXT NOT NULL, -- source path
destination TEXT NOT NULL, -- redirect target
redirect_code INT NOT NULL DEFAULT 301,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_custom_routes_active ON custom_routes(tenant_id, is_active);
-- ---------------------------------------------------------------------
-- website_settings — key-value config (per tenant)
-- ---------------------------------------------------------------------
CREATE TABLE website_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID, -- NULL = global default
key TEXT NOT NULL,
value JSONB NOT NULL,
description TEXT,
is_secret BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, key)
);
CREATE INDEX idx_settings_tenant ON website_settings(tenant_id);
-- ---------------------------------------------------------------------
-- learn — help articles
-- ---------------------------------------------------------------------
CREATE TABLE learn (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID,
title TEXT NOT NULL,
body TEXT,
demo_url TEXT,
mode TEXT,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ---------------------------------------------------------------------
-- trainings — video tutorials
-- ---------------------------------------------------------------------
CREATE TABLE trainings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID,
title TEXT NOT NULL,
description TEXT,
video_url TEXT,
thumbnail_url TEXT,
sort INT NOT NULL DEFAULT 0,
is_published BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ---------------------------------------------------------------------
-- favorite_folders — user collections of templates
-- ---------------------------------------------------------------------
CREATE TABLE favorite_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- references identity.users
tenant_id UUID NOT NULL,
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_fav_folders_user ON favorite_folders(user_id);
CREATE TRIGGER tg_fav_folders_updated_at
BEFORE UPDATE ON favorite_folders FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- favorite_containers — saved template references in user's folders
-- ---------------------------------------------------------------------
CREATE TABLE favorite_containers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
tenant_id UUID NOT NULL,
container_id UUID NOT NULL REFERENCES project_containers(id) ON DELETE CASCADE,
folder_id UUID REFERENCES favorite_folders(id) ON DELETE SET NULL,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, container_id)
);
CREATE INDEX idx_fav_containers_user ON favorite_containers(user_id);
@@ -0,0 +1,335 @@
-- =====================================================================
-- STUDIO SCHEMA — Saved Projects (User's Project Instances)
-- =====================================================================
-- This is where user input lives: their text values, color choices,
-- media uploads, music, voiceover, etc. The render service reads from
-- here to build the JSX.
-- =====================================================================
SET search_path TO studio, public;
CREATE TYPE saved_project_type AS ENUM ('Draft','Active','Archived','Trash');
-- ---------------------------------------------------------------------
-- saved_projects — root of user's project instance
-- ---------------------------------------------------------------------
CREATE TABLE saved_projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL, -- references identity.tenants
user_id UUID NOT NULL, -- references identity.users
-- Source template snapshot (so deleting template doesn't break this)
original_project_id UUID NOT NULL, -- references content.projects
original_project_name TEXT NOT NULL,
original_container_id UUID,
original_container_slug CITEXT,
-- Identity
name TEXT NOT NULL,
image TEXT,
type saved_project_type NOT NULL DEFAULT 'Draft',
-- Snapshot of project metadata
frame_rate INT NOT NULL DEFAULT 30,
project_duration_sec NUMERIC(8,2) NOT NULL,
resolution TEXT NOT NULL, -- HD/FullHD/TwoK/FourK
choose_mode TEXT NOT NULL, -- FIX/FLEXIBLE/MockUp/MusicVisualizer
vip_factor NUMERIC(4,2) NOT NULL DEFAULT 1.0,
-- =====================================
-- Audio (NEW — voiceover + volumes)
-- =====================================
music_file_id UUID, -- references file_mgr.user_files
music_track_id UUID, -- references content.music_tracks (library)
music_volume NUMERIC(4,3) NOT NULL DEFAULT 0.7 CHECK (music_volume BETWEEN 0 AND 1),
voiceover_file_id UUID, -- references file_mgr.user_files
voiceover_volume NUMERIC(4,3) NOT NULL DEFAULT 1.0 CHECK (voiceover_volume BETWEEN 0 AND 1),
voiceover_recorded_in_browser BOOLEAN NOT NULL DEFAULT FALSE,
sfx_volume NUMERIC(4,3) NOT NULL DEFAULT 1.0 CHECK (sfx_volume BETWEEN 0 AND 1),
sfx_enabled BOOLEAN NOT NULL DEFAULT TRUE,
-- Music visualizer mode
audio_visualizer_music_url TEXT,
audio_visualizer_duration_sec NUMERIC(8,2),
-- =====================================
-- Customization options
-- =====================================
manual_color_picker BOOLEAN NOT NULL DEFAULT FALSE,
selected_preset_story_id UUID, -- references content.preset_stories
-- Auto-save / state
last_edit_step TEXT, -- track wizard step
edit_state JSONB NOT NULL DEFAULT '{}'::jsonb,
create_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_edit_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_saved_proj_user ON saved_projects(user_id, last_edit_date DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_saved_proj_tenant ON saved_projects(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_saved_proj_original ON saved_projects(original_project_id);
CREATE INDEX idx_saved_proj_name_trgm ON saved_projects USING gin (name gin_trgm_ops);
CREATE TRIGGER tg_saved_projects_updated_at
BEFORE UPDATE ON saved_projects FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- saved_scenes — user's chosen scenes (FLEXIBLE mode = multiple per project)
-- ---------------------------------------------------------------------
CREATE TABLE saved_scenes (
id BIGSERIAL PRIMARY KEY,
saved_project_id UUID NOT NULL REFERENCES saved_projects(id) ON DELETE CASCADE,
-- Snapshot from original scene
original_scene_id UUID, -- references content.scenes
key TEXT NOT NULL, -- AE comp name
title TEXT,
image TEXT,
demo TEXT,
scene_color_svg TEXT,
scene_type TEXT NOT NULL, -- Normal/Config/DesignStart/DesignEnd
-- Timing
sort INT NOT NULL,
scene_length_sec NUMERIC(8,2) NOT NULL,
min_duration_sec NUMERIC(8,2),
max_duration_sec NUMERIC(8,2),
overlap_at_end_sec NUMERIC(6,2) NOT NULL DEFAULT 0,
can_handle_duration BOOLEAN NOT NULL DEFAULT TRUE,
-- Customization
manual_color_selection BOOLEAN NOT NULL DEFAULT FALSE,
selected_color_preset_id UUID, -- ref to saved_scene_color_presets
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_saved_scenes_proj ON saved_scenes(saved_project_id, sort);
CREATE TRIGGER tg_saved_scenes_updated_at
BEFORE UPDATE ON saved_scenes FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- saved_scene_contents — user-filled content values per scene
-- ---------------------------------------------------------------------
CREATE TABLE saved_scene_contents (
id BIGSERIAL PRIMARY KEY,
saved_scene_id BIGINT NOT NULL REFERENCES saved_scenes(id) ON DELETE CASCADE,
-- Element identity
key TEXT NOT NULL,
title TEXT,
localized_title JSONB,
hint TEXT,
type TEXT NOT NULL, -- Text/Media/Audio/Voiceover/...
-- User value
value TEXT, -- text or file UUID
value_file_id UUID, -- if file: references file_mgr.user_files
inserted_file_type TEXT, -- Image/Video/Audio
file_url_cached TEXT, -- resolved CDN url at last save
file_url_cached_at TIMESTAMPTZ,
-- Text styling
font_face TEXT,
font_face_name TEXT,
font_size INT,
default_font_size INT,
default_font_face TEXT,
justify TEXT,
position_in_container INT NOT NULL DEFAULT 0,
direction_layer_value INT NOT NULL DEFAULT 0,
is_text_box BOOLEAN NOT NULL DEFAULT FALSE,
-- AI assistance
ai_input_type TEXT,
-- Design pattern choice
selected_dp INT, -- 1-4
-- Repeater
repeater_item_key TEXT,
repeater_index INT,
-- Selection state
is_focused BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT,
mapped_list JSONB,
thumbnail TEXT,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_saved_contents_scene ON saved_scene_contents(saved_scene_id, sort);
CREATE INDEX idx_saved_contents_filerefs ON saved_scene_contents(value_file_id) WHERE value_file_id IS NOT NULL;
CREATE TRIGGER tg_saved_contents_updated_at
BEFORE UPDATE ON saved_scene_contents FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- saved_scene_colors — color choices per scene
-- ---------------------------------------------------------------------
CREATE TABLE saved_scene_colors (
id BIGSERIAL PRIMARY KEY,
saved_scene_id BIGINT NOT NULL REFERENCES saved_scenes(id) ON DELETE CASCADE,
element_key TEXT NOT NULL,
title TEXT,
icon TEXT,
attr_value TEXT NOT NULL DEFAULT 'fill',
value TEXT NOT NULL, -- hex or rgb
is_selected BOOLEAN NOT NULL DEFAULT TRUE,
sort INT NOT NULL DEFAULT 0,
UNIQUE (saved_scene_id, element_key)
);
CREATE INDEX idx_saved_colors_scene ON saved_scene_colors(saved_scene_id);
-- ---------------------------------------------------------------------
-- saved_scene_color_presets + items
-- ---------------------------------------------------------------------
CREATE TABLE saved_scene_color_presets (
id BIGSERIAL PRIMARY KEY,
saved_scene_id BIGINT NOT NULL REFERENCES saved_scenes(id) ON DELETE CASCADE,
is_selected BOOLEAN NOT NULL DEFAULT FALSE,
sort INT NOT NULL DEFAULT 0
);
CREATE TABLE saved_scene_color_preset_items (
id BIGSERIAL PRIMARY KEY,
preset_id BIGINT NOT NULL REFERENCES saved_scene_color_presets(id) ON DELETE CASCADE,
element_key TEXT NOT NULL,
value TEXT NOT NULL,
sort INT NOT NULL DEFAULT 0
);
CREATE INDEX idx_saved_scp_items_preset ON saved_scene_color_preset_items(preset_id);
-- ---------------------------------------------------------------------
-- saved_scene_characters
-- ---------------------------------------------------------------------
CREATE TABLE saved_scene_characters (
id BIGSERIAL PRIMARY KEY,
saved_scene_id BIGINT NOT NULL REFERENCES saved_scenes(id) ON DELETE CASCADE,
key UUID NOT NULL,
name TEXT,
icon TEXT
);
CREATE INDEX idx_saved_chars_scene ON saved_scene_characters(saved_scene_id);
-- ---------------------------------------------------------------------
-- saved_scene_character_controllers
-- ---------------------------------------------------------------------
CREATE TABLE saved_scene_character_controllers (
id BIGSERIAL PRIMARY KEY,
saved_scene_character_id BIGINT NOT NULL REFERENCES saved_scene_characters(id) ON DELETE CASCADE,
name TEXT,
key TEXT NOT NULL,
value TEXT NOT NULL,
sort INT NOT NULL DEFAULT 0
);
CREATE INDEX idx_saved_char_ctrl_char ON saved_scene_character_controllers(saved_scene_character_id);
-- ---------------------------------------------------------------------
-- saved_shared_colors — project-level color choices
-- ---------------------------------------------------------------------
CREATE TABLE saved_shared_colors (
id BIGSERIAL PRIMARY KEY,
saved_project_id UUID NOT NULL REFERENCES saved_projects(id) ON DELETE CASCADE,
element_key TEXT NOT NULL,
title TEXT,
icon TEXT,
attr_value TEXT NOT NULL DEFAULT 'fill',
value TEXT NOT NULL,
is_selected BOOLEAN NOT NULL DEFAULT TRUE,
sort INT NOT NULL DEFAULT 0,
UNIQUE (saved_project_id, element_key)
);
CREATE INDEX idx_saved_shared_colors_proj ON saved_shared_colors(saved_project_id);
-- ---------------------------------------------------------------------
-- saved_shared_color_presets
-- ---------------------------------------------------------------------
CREATE TABLE saved_shared_color_presets (
id BIGSERIAL PRIMARY KEY,
saved_project_id UUID NOT NULL REFERENCES saved_projects(id) ON DELETE CASCADE,
name TEXT,
is_selected BOOLEAN NOT NULL DEFAULT FALSE,
sort INT NOT NULL DEFAULT 0
);
CREATE TABLE saved_shared_color_preset_items (
id BIGSERIAL PRIMARY KEY,
preset_id BIGINT NOT NULL REFERENCES saved_shared_color_presets(id) ON DELETE CASCADE,
element_key TEXT NOT NULL,
value TEXT NOT NULL,
sort INT NOT NULL DEFAULT 0
);
CREATE INDEX idx_saved_sscp_items_preset ON saved_shared_color_preset_items(preset_id);
-- ---------------------------------------------------------------------
-- saved_shared_layers — project-level layer values
-- ---------------------------------------------------------------------
CREATE TABLE saved_shared_layers (
id BIGSERIAL PRIMARY KEY,
saved_project_id UUID NOT NULL REFERENCES saved_projects(id) ON DELETE CASCADE,
key TEXT NOT NULL,
title TEXT,
localized_title JSONB,
hint TEXT,
type TEXT NOT NULL,
value TEXT,
value_file_id UUID,
file_url_cached TEXT,
file_url_cached_at TIMESTAMPTZ,
font_face TEXT,
font_face_name TEXT,
font_size INT,
default_font_size INT,
default_font_face TEXT,
justify TEXT,
position_in_container INT NOT NULL DEFAULT 0,
direction_layer_value INT NOT NULL DEFAULT 0,
is_text_box BOOLEAN NOT NULL DEFAULT FALSE,
ai_input_type TEXT,
mapped_list JSONB,
thumbnail TEXT,
width INT,
height INT,
min_duration_sec NUMERIC(6,2),
max_duration_sec NUMERIC(6,2),
is_focused BOOLEAN NOT NULL DEFAULT FALSE,
is_font_changeable BOOLEAN NOT NULL DEFAULT TRUE,
is_font_size_changeable BOOLEAN NOT NULL DEFAULT TRUE,
status TEXT,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (saved_project_id, key)
);
CREATE INDEX idx_saved_shared_layers_proj ON saved_shared_layers(saved_project_id);
CREATE TRIGGER tg_saved_shared_layers_updated_at
BEFORE UPDATE ON saved_shared_layers FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
+176
View File
@@ -0,0 +1,176 @@
-- =====================================================================
-- RENDER SCHEMA — Part 1: Farm Nodes & Health
-- =====================================================================
SET search_path TO render, public;
CREATE TYPE node_status AS ENUM ('Ready','Busy','Offline','Maintenance','Crashed','Updating','Disabled');
CREATE TYPE node_kind AS ENUM ('Shared','Dedicated','Spot');
CREATE TYPE render_type AS ENUM ('Free','Paid','Snapshot','Mockup');
CREATE TYPE ae_version AS ENUM ('2020','2021','2022','2023','2024','2025');
-- ---------------------------------------------------------------------
-- render_nodes — registry of farm machines
-- ---------------------------------------------------------------------
CREATE TABLE render_nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
region TEXT NOT NULL, -- 'tehran','frankfurt',...
-- Network
node_ip INET NOT NULL,
worker_port INT NOT NULL DEFAULT 5555,
public_endpoint TEXT,
-- Spec
ram_gb INT,
cpu_cores INT,
gpu_model TEXT,
storage_gb INT,
-- Software
current_ae_version ae_version NOT NULL,
available_ae_versions TEXT[] NOT NULL DEFAULT '{}',
node_agent_version TEXT,
last_update_at TIMESTAMPTZ,
last_update_error TEXT,
-- Ownership
node_kind node_kind NOT NULL DEFAULT 'Shared',
owner_user_id UUID, -- references identity.users (for Dedicated)
owner_tenant_id UUID, -- references identity.tenants
-- State
status node_status NOT NULL DEFAULT 'Offline',
current_job_id UUID, -- references render_jobs
current_frame_job_id UUID, -- references frame_jobs
job_started_at TIMESTAMPTZ,
render_type render_type, -- which queue it's serving now
-- Health (denormalized for hot reads)
last_heartbeat_at TIMESTAMPTZ,
last_cpu_pct INT,
last_ram_available_mb INT,
ae_running BOOLEAN NOT NULL DEFAULT FALSE,
-- Stats
lifetime_task_count BIGINT NOT NULL DEFAULT 0,
lifetime_crash_count INT NOT NULL DEFAULT 0,
consecutive_failures INT NOT NULL DEFAULT 0,
-- Scheduling
priority INT NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
accepts_new_jobs BOOLEAN NOT NULL DEFAULT TRUE,
-- Maintenance
last_maintenance_at TIMESTAMPTZ,
next_maintenance_at TIMESTAMPTZ,
maintenance_reason TEXT,
-- Local cache state (templates the node has downloaded)
cached_template_md5s TEXT[] NOT NULL DEFAULT '{}',
cache_used_gb INT NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_nodes_region_status ON render_nodes(region, status) WHERE is_active = TRUE;
CREATE INDEX idx_nodes_ready ON render_nodes(region, priority DESC)
WHERE status = 'Ready' AND accepts_new_jobs = TRUE AND is_active = TRUE;
CREATE INDEX idx_nodes_owner ON render_nodes(owner_user_id) WHERE node_kind = 'Dedicated';
CREATE INDEX idx_nodes_heartbeat ON render_nodes(last_heartbeat_at) WHERE is_active = TRUE;
CREATE UNIQUE INDEX uq_nodes_ip_port ON render_nodes(node_ip, worker_port);
CREATE TRIGGER tg_render_nodes_updated_at
BEFORE UPDATE ON render_nodes FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- node_health_logs — historical heartbeat data (partitioned monthly)
-- ---------------------------------------------------------------------
CREATE TABLE node_health_logs (
id BIGSERIAL,
node_id UUID NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status node_status NOT NULL,
cpu_pct INT,
ram_available_mb INT,
ae_running BOOLEAN,
current_job_id UUID,
current_frame INT,
-- Templates cached (size summary only)
cache_used_gb INT,
PRIMARY KEY (id, recorded_at)
) PARTITION BY RANGE (recorded_at);
CREATE INDEX idx_node_health_node ON node_health_logs(node_id, recorded_at DESC);
CREATE TABLE node_health_logs_y2026m01
PARTITION OF node_health_logs
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- ---------------------------------------------------------------------
-- node_crashes — every detected AE crash
-- ---------------------------------------------------------------------
CREATE TABLE node_crashes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
node_id UUID NOT NULL REFERENCES render_nodes(id) ON DELETE CASCADE,
render_job_id UUID, -- which job was running
frame_job_id UUID,
crashed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_known_frame INT,
crash_signal TEXT, -- exit code or signal
error_log TEXT, -- last N lines of AE log
log_file_url TEXT, -- MinIO upload of full log
-- Recovery
auto_recovered BOOLEAN NOT NULL DEFAULT FALSE,
recovery_action TEXT, -- 'reset_prefs','restart_ae','reassign_job'
recovered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_node_crashes_node ON node_crashes(node_id, crashed_at DESC);
CREATE INDEX idx_node_crashes_job ON node_crashes(render_job_id) WHERE render_job_id IS NOT NULL;
-- ---------------------------------------------------------------------
-- node_updates — software/AE update tracking
-- ---------------------------------------------------------------------
CREATE TABLE node_updates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
update_file_name TEXT NOT NULL,
update_number INT NOT NULL,
description TEXT,
target_ae_version ae_version,
in_update_queue BOOLEAN NOT NULL DEFAULT FALSE,
rolled_out_to_node_ids UUID[] NOT NULL DEFAULT '{}',
last_update_queue_date TIMESTAMPTZ,
create_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ---------------------------------------------------------------------
-- node_template_cache — what's currently cached on each node
-- ---------------------------------------------------------------------
CREATE TABLE node_template_cache (
id BIGSERIAL PRIMARY KEY,
node_id UUID NOT NULL REFERENCES render_nodes(id) ON DELETE CASCADE,
project_id UUID NOT NULL, -- references content.projects
aep_file_md5 TEXT NOT NULL,
file_size_bytes BIGINT NOT NULL,
cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
use_count INT NOT NULL DEFAULT 1,
local_path TEXT NOT NULL,
UNIQUE (node_id, aep_file_md5)
);
CREATE INDEX idx_node_cache_node_lru ON node_template_cache(node_id, last_used_at);
CREATE INDEX idx_node_cache_md5 ON node_template_cache(aep_file_md5);
+315
View File
@@ -0,0 +1,315 @@
-- =====================================================================
-- RENDER SCHEMA — Part 2: Jobs, Frames, Snapshots, Exports
-- =====================================================================
SET search_path TO render, public;
CREATE TYPE render_step AS ENUM (
'Queued','Preparing','TemplateCache','JsxGen','Music',
'Rendering','Validating','Repairing','Optimisation','Video',
'Mixing','Final','Uploading','Done','Failed','Cancelled'
);
CREATE TYPE price_kind AS ENUM ('Free','Preview','Cash','Plan','Snapshot','Reseller');
CREATE TYPE render_quality AS ENUM ('Low','Medium','High','Full','Lossless');
CREATE TYPE frame_job_status AS ENUM (
'Pending','Rendering','Validated','Repairing','Converting','Done','Failed'
);
CREATE TYPE render_priority_queue AS ENUM (
'snapshot','vip','paid','preview','mockup','voiceover'
);
-- ---------------------------------------------------------------------
-- render_jobs — top-level render task
-- ---------------------------------------------------------------------
CREATE TABLE render_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
-- References (loose cross-schema)
saved_project_id UUID NOT NULL,
original_project_id UUID NOT NULL,
project_name TEXT,
-- Job identity
title TEXT,
name TEXT,
external_job_id TEXT, -- RabbitMQ message ID
priority_queue render_priority_queue NOT NULL,
priority_score INT NOT NULL DEFAULT 50, -- 0-100, higher = sooner
-- Pipeline state
step render_step NOT NULL DEFAULT 'Queued',
render_progress INT NOT NULL DEFAULT 0 CHECK (render_progress BETWEEN 0 AND 100),
convert_progress INT NOT NULL DEFAULT 0,
image_preview_b64 TEXT, -- last frame thumbnail
-- Pricing
price_type price_kind NOT NULL,
paid_price_minor BIGINT NOT NULL DEFAULT 0,
discount_code TEXT,
support_flatrender BOOLEAN NOT NULL DEFAULT FALSE,
-- Output config
mode TEXT NOT NULL, -- FIX/FLEXIBLE/MockUp/MusicVisualizer
quality render_quality NOT NULL DEFAULT 'High',
resolution TEXT NOT NULL, -- FullHD/FourK
r_height INT NOT NULL,
frame_rate INT NOT NULL DEFAULT 30,
is_60_fps BOOLEAN NOT NULL DEFAULT FALSE,
duration_sec NUMERIC(8,2) NOT NULL,
export_duration_sec NUMERIC(8,2),
-- Audio (NEW)
has_music BOOLEAN NOT NULL DEFAULT FALSE,
has_sfx BOOLEAN NOT NULL DEFAULT FALSE,
has_voiceover BOOLEAN NOT NULL DEFAULT FALSE,
music_volume NUMERIC(4,3),
sfx_volume NUMERIC(4,3),
voiceover_volume NUMERIC(4,3),
-- Resource allocation
render_node_count INT NOT NULL DEFAULT 1,
current_active_nodes INT NOT NULL DEFAULT 0,
region TEXT, -- preferred region
tell_me_when_done BOOLEAN NOT NULL DEFAULT TRUE,
-- Retry / recovery
retry_count INT NOT NULL DEFAULT 0,
max_retries INT NOT NULL DEFAULT 3,
repair_attempts INT NOT NULL DEFAULT 0,
failed_message TEXT,
failed_at_step render_step,
-- File outputs (paths in MinIO; final URL goes in exports.path)
render_folder TEXT, -- temp working dir
output_folder TEXT, -- frames dir
physical_render_folder TEXT, -- absolute path for nodes
physical_output_folder TEXT,
target_replica_name TEXT,
-- Reference to result
export_id UUID,
-- Timing
task_start_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
start_render_date TIMESTAMPTZ,
done_task_date TIMESTAMPTZ,
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_render_jobs_user ON render_jobs(user_id, created_at DESC);
CREATE INDEX idx_render_jobs_tenant ON render_jobs(tenant_id, created_at DESC);
CREATE INDEX idx_render_jobs_step ON render_jobs(step) WHERE step NOT IN ('Done','Failed','Cancelled');
CREATE INDEX idx_render_jobs_queue ON render_jobs(priority_queue, priority_score DESC, queued_at)
WHERE step = 'Queued';
CREATE INDEX idx_render_jobs_in_flight ON render_jobs(started_at) WHERE step NOT IN ('Done','Failed','Cancelled','Queued');
CREATE TRIGGER tg_render_jobs_updated_at
BEFORE UPDATE ON render_jobs FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- frame_jobs — per-node frame-range assignments
-- ---------------------------------------------------------------------
CREATE TABLE frame_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
render_job_id UUID NOT NULL REFERENCES render_jobs(id) ON DELETE CASCADE,
node_id UUID NOT NULL REFERENCES render_nodes(id),
-- Range
start_frame INT NOT NULL,
end_frame INT NOT NULL,
collect_frame_count INT NOT NULL,
order_value INT NOT NULL DEFAULT 0,
folder_name TEXT NOT NULL, -- "O0", "O1"...
convert_url TEXT,
-- Status
status frame_job_status NOT NULL DEFAULT 'Pending',
frames_rendered INT NOT NULL DEFAULT 0,
frames_validated INT NOT NULL DEFAULT 0,
-- Errors
attempt INT NOT NULL DEFAULT 1,
last_error TEXT,
-- Outputs
output_mp4_url TEXT, -- chunk MP4 after ffmpeg
-- Timing
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
last_progress_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_frame_jobs_render ON frame_jobs(render_job_id, order_value);
CREATE INDEX idx_frame_jobs_node ON frame_jobs(node_id, status);
CREATE INDEX idx_frame_jobs_stalled ON frame_jobs(last_progress_at) WHERE status = 'Rendering';
CREATE TRIGGER tg_frame_jobs_updated_at
BEFORE UPDATE ON frame_jobs FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- frame_repair_jobs — missing/corrupt frame repair tracking
-- ---------------------------------------------------------------------
CREATE TABLE frame_repair_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
render_job_id UUID NOT NULL REFERENCES render_jobs(id) ON DELETE CASCADE,
node_id UUID REFERENCES render_nodes(id),
-- Range to repair
start_frame INT NOT NULL,
end_frame INT NOT NULL,
missing_frames INT[] NOT NULL DEFAULT '{}',
corrupt_frames INT[] NOT NULL DEFAULT '{}',
attempt INT NOT NULL DEFAULT 1,
status frame_job_status NOT NULL DEFAULT 'Pending',
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_frame_repair_job ON frame_repair_jobs(render_job_id, attempt);
-- ---------------------------------------------------------------------
-- snapshots — single-frame scene previews (new feature)
-- ---------------------------------------------------------------------
CREATE TABLE snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
saved_project_id UUID NOT NULL,
scene_key TEXT NOT NULL,
frame_number INT NOT NULL,
-- Cache key — same inputs = same output
inputs_hash TEXT NOT NULL,
-- Status
status TEXT NOT NULL DEFAULT 'Pending', -- Pending/Rendering/Done/Failed
render_node_id UUID REFERENCES render_nodes(id),
-- Output
image_url TEXT,
thumbnail_url TEXT,
width INT,
height INT,
size_bytes BIGINT,
-- Timing
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
duration_ms INT,
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days',
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_snapshots_cache ON snapshots(saved_project_id, scene_key, frame_number, inputs_hash)
WHERE status = 'Done';
CREATE INDEX idx_snapshots_user ON snapshots(user_id, requested_at DESC);
CREATE INDEX idx_snapshots_expire ON snapshots(expires_at) WHERE status = 'Done';
-- ---------------------------------------------------------------------
-- exports — final rendered output records
-- ---------------------------------------------------------------------
CREATE TYPE export_create_type AS ENUM ('Render','Upload','Snapshot','Reupload');
CREATE TYPE export_file_type AS ENUM ('Video','Image','Audio','GIF','PDF');
CREATE TABLE exports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
saved_project_id UUID NOT NULL,
project_id UUID NOT NULL,
render_job_id UUID REFERENCES render_jobs(id) ON DELETE SET NULL,
-- Output
image TEXT, -- thumbnail
path TEXT NOT NULL, -- main file URL
file_extension TEXT NOT NULL DEFAULT 'mp4',
file_type export_file_type NOT NULL DEFAULT 'Video',
render_quality render_quality NOT NULL,
create_type export_create_type NOT NULL DEFAULT 'Render',
size_bytes BIGINT NOT NULL,
duration_sec NUMERIC(8,2),
width INT,
height INT,
-- Lifecycle
produce_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
auto_delete_date TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '30 days',
delete_notified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_exports_user ON exports(user_id, produce_date DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_exports_tenant ON exports(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_exports_saved_project ON exports(saved_project_id);
CREATE INDEX idx_exports_auto_delete ON exports(auto_delete_date) WHERE deleted_at IS NULL;
-- Now wire FK back to render_jobs
ALTER TABLE render_jobs
ADD CONSTRAINT fk_render_jobs_export
FOREIGN KEY (export_id) REFERENCES exports(id) ON DELETE SET NULL;
-- ---------------------------------------------------------------------
-- export_files — mockup mode produces multiple images per export
-- ---------------------------------------------------------------------
CREATE TABLE export_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
export_id UUID NOT NULL REFERENCES exports(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
name TEXT,
thumbnail TEXT,
path TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
file_type export_file_type NOT NULL DEFAULT 'Image',
width INT,
height INT,
sort INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_export_files_export ON export_files(export_id, sort);
-- ---------------------------------------------------------------------
-- render_progress_events — WebSocket fan-out source (short-lived)
-- ---------------------------------------------------------------------
CREATE TABLE render_progress_events (
id BIGSERIAL PRIMARY KEY,
render_job_id UUID NOT NULL REFERENCES render_jobs(id) ON DELETE CASCADE,
step render_step NOT NULL,
progress INT NOT NULL,
current_frame INT,
total_frames INT,
eta_seconds INT,
preview_b64 TEXT,
message TEXT,
emitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_progress_events_job ON render_progress_events(render_job_id, emitted_at DESC);
-- Cleanup: keep last N per job via cron, drop > 7 days
+236
View File
@@ -0,0 +1,236 @@
-- =====================================================================
-- FILE_MGR SCHEMA — File Manager, Storage Quotas, Cleanup Scheduler
-- =====================================================================
SET search_path TO file_mgr, public;
CREATE TYPE file_kind AS ENUM ('Video','Image','Audio','Voiceover','Document','Other');
CREATE TYPE folder_kind AS ENUM ('System','User','Shared','Tenant');
CREATE TYPE upload_status AS ENUM ('Pending','Uploading','Processing','Ready','Failed','Quarantined');
CREATE TYPE cleanup_entity_type AS ENUM ('Export','TempRenderFolder','OrphanedFile','UnusedUpload','SnapshotExpired');
CREATE TYPE cleanup_status AS ENUM ('Scheduled','Notified','Processing','Done','Skipped','Failed');
-- ---------------------------------------------------------------------
-- user_folders — hierarchical folders
-- ---------------------------------------------------------------------
CREATE TABLE user_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
name TEXT NOT NULL,
folder_type folder_kind NOT NULL DEFAULT 'User',
parent_folder_id UUID REFERENCES user_folders(id) ON DELETE CASCADE,
-- Stats (denormalized for fast UI)
file_count INT NOT NULL DEFAULT 0,
total_size_bytes BIGINT NOT NULL DEFAULT 0,
sort INT NOT NULL DEFAULT 0,
is_shared BOOLEAN NOT NULL DEFAULT FALSE,
share_token TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_folders_user ON user_folders(user_id, parent_folder_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_folders_parent ON user_folders(parent_folder_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_folders_share ON user_folders(share_token) WHERE share_token IS NOT NULL;
CREATE TRIGGER tg_folders_updated_at
BEFORE UPDATE ON user_folders FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- user_files
-- ---------------------------------------------------------------------
CREATE TABLE user_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
user_folder_id UUID REFERENCES user_folders(id) ON DELETE SET NULL,
-- Identity
name TEXT NOT NULL,
original_filename TEXT,
file_extension TEXT,
mime_type TEXT,
file_type file_kind NOT NULL,
-- Storage
minio_bucket TEXT NOT NULL,
minio_key TEXT NOT NULL,
cdn_url TEXT,
file_address TEXT NOT NULL, -- canonical URL
size_bytes BIGINT NOT NULL,
md5_hash TEXT,
sha256_hash TEXT,
-- Media metadata
duration_sec NUMERIC(8,2),
width INT,
height INT,
fps NUMERIC(5,2),
bitrate_kbps INT,
codec TEXT,
has_audio BOOLEAN,
has_video BOOLEAN,
-- Thumbnails
thumbnail_url TEXT,
waveform_data JSONB, -- for audio files
-- Upload state
upload_status upload_status NOT NULL DEFAULT 'Ready',
upload_id TEXT, -- multipart upload ID if used
upload_progress INT NOT NULL DEFAULT 100,
processing_error TEXT,
-- Source / linkage
source TEXT, -- 'upload','export','snapshot','voiceover_record','stock'
export_id UUID, -- references render.exports
parent_file_id UUID REFERENCES user_files(id) ON DELETE SET NULL, -- derived files
-- Lifecycle
last_used_at TIMESTAMPTZ,
use_count INT NOT NULL DEFAULT 0,
-- Sharing
is_public BOOLEAN NOT NULL DEFAULT FALSE,
share_token TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_files_user_folder ON user_files(user_id, user_folder_id, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_files_tenant ON user_files(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_files_type ON user_files(user_id, file_type) WHERE deleted_at IS NULL;
CREATE INDEX idx_files_hash ON user_files(md5_hash) WHERE md5_hash IS NOT NULL;
CREATE INDEX idx_files_unused ON user_files(last_used_at) WHERE deleted_at IS NULL;
CREATE INDEX idx_files_name_trgm ON user_files USING gin (name gin_trgm_ops);
CREATE INDEX idx_files_share ON user_files(share_token) WHERE share_token IS NOT NULL;
CREATE TRIGGER tg_files_updated_at
BEFORE UPDATE ON user_files FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- storage_quotas — current usage per user
-- ---------------------------------------------------------------------
CREATE TABLE storage_quotas (
user_id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
plan_quota_bytes BIGINT NOT NULL DEFAULT 0, -- from plan
bonus_quota_bytes BIGINT NOT NULL DEFAULT 0, -- purchased extra
used_bytes BIGINT NOT NULL DEFAULT 0,
-- Cached counts
video_count INT NOT NULL DEFAULT 0,
image_count INT NOT NULL DEFAULT 0,
audio_count INT NOT NULL DEFAULT 0,
video_bytes BIGINT NOT NULL DEFAULT 0,
image_bytes BIGINT NOT NULL DEFAULT 0,
audio_bytes BIGINT NOT NULL DEFAULT 0,
-- Notifications
last_90pct_notified_at TIMESTAMPTZ,
last_100pct_notified_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_quotas_tenant ON storage_quotas(tenant_id);
CREATE TRIGGER tg_quotas_updated_at
BEFORE UPDATE ON storage_quotas FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- cleanup_schedules — track what's queued for auto-delete
-- ---------------------------------------------------------------------
CREATE TABLE cleanup_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID,
user_id UUID,
entity_type cleanup_entity_type NOT NULL,
entity_id UUID NOT NULL,
entity_path TEXT, -- filesystem path for temp folders
scheduled_delete_at TIMESTAMPTZ NOT NULL,
notify_user_at TIMESTAMPTZ, -- send "expires in 3 days" notice
user_notified BOOLEAN NOT NULL DEFAULT FALSE,
user_notified_at TIMESTAMPTZ,
status cleanup_status NOT NULL DEFAULT 'Scheduled',
processed_at TIMESTAMPTZ,
processing_error TEXT,
bytes_freed BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_cleanup_due ON cleanup_schedules(scheduled_delete_at) WHERE status = 'Scheduled';
CREATE INDEX idx_cleanup_notify_due ON cleanup_schedules(notify_user_at) WHERE user_notified = FALSE AND notify_user_at IS NOT NULL;
CREATE INDEX idx_cleanup_entity ON cleanup_schedules(entity_type, entity_id);
CREATE TRIGGER tg_cleanup_updated_at
BEFORE UPDATE ON cleanup_schedules FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- upload_sessions — multipart / chunked upload tracking
-- ---------------------------------------------------------------------
CREATE TABLE upload_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
minio_bucket TEXT NOT NULL,
minio_key TEXT NOT NULL,
minio_upload_id TEXT NOT NULL, -- S3/MinIO multipart ID
filename TEXT NOT NULL,
mime_type TEXT,
total_size_bytes BIGINT NOT NULL,
chunks_received INT NOT NULL DEFAULT 0,
bytes_received BIGINT NOT NULL DEFAULT 0,
chunk_size_bytes INT NOT NULL DEFAULT 5242880, -- 5MB default
target_folder_id UUID REFERENCES user_folders(id) ON DELETE SET NULL,
target_file_id UUID, -- created when complete
status upload_status NOT NULL DEFAULT 'Uploading',
error_message TEXT,
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours',
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_uploads_user ON upload_sessions(user_id, created_at DESC);
CREATE INDEX idx_uploads_expired ON upload_sessions(expires_at) WHERE status = 'Uploading';
CREATE TRIGGER tg_uploads_updated_at
BEFORE UPDATE ON upload_sessions FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- minio_buckets — bucket registry (per region, per purpose)
-- ---------------------------------------------------------------------
CREATE TABLE minio_buckets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
region TEXT NOT NULL,
endpoint TEXT NOT NULL,
purpose TEXT NOT NULL, -- 'templates','user-uploads','exports','snapshots','voiceovers'
is_public BOOLEAN NOT NULL DEFAULT FALSE,
cdn_base_url TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_buckets_region_purpose ON minio_buckets(region, purpose) WHERE is_active = TRUE;
+194
View File
@@ -0,0 +1,194 @@
-- =====================================================================
-- NOTIFICATION SCHEMA — In-app, Push, Email, SMS, Telegram
-- =====================================================================
SET search_path TO notification, public;
CREATE TYPE notification_kind AS ENUM (
'RenderCompleted','RenderFailed','RenderProgress',
'PlanExpiring','PlanExpired','PaymentSuccess','PaymentFailed',
'StorageWarning','StorageFull','ExportExpiring','ExportDeleted',
'GiftEarned','QuestCompleted','LevelUp',
'AccountSecurity','SystemAnnouncement','TenantInvite',
'Marketing','Other'
);
CREATE TYPE notification_priority AS ENUM ('Low','Normal','High','Urgent');
CREATE TYPE delivery_channel AS ENUM ('InApp','Push','Email','SMS','Telegram','Webhook');
CREATE TYPE delivery_status_kind AS ENUM (
'Pending','Sent','Delivered','Failed','Bounced','Suppressed'
);
-- ---------------------------------------------------------------------
-- notifications — in-app notification feed
-- ---------------------------------------------------------------------
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
notification_type notification_kind NOT NULL,
priority notification_priority NOT NULL DEFAULT 'Normal',
title TEXT NOT NULL,
message TEXT NOT NULL,
label TEXT,
signature TEXT,
icon TEXT,
image TEXT,
animation_demo TEXT,
design TEXT,
-- Link target
action_url TEXT,
action_text TEXT,
-- Linked entities (sparse)
render_job_id UUID,
export_id UUID,
payment_id UUID,
gift_id UUID,
earned_gift_id UUID,
-- State
is_emergency BOOLEAN NOT NULL DEFAULT FALSE,
seen BOOLEAN NOT NULL DEFAULT FALSE,
seen_at TIMESTAMPTZ,
clicked BOOLEAN NOT NULL DEFAULT FALSE,
clicked_at TIMESTAMPTZ,
gift_used BOOLEAN NOT NULL DEFAULT FALSE,
-- Lifecycle
expire_date TIMESTAMPTZ,
create_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_notifs_user_feed ON notifications(user_id, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_notifs_unread ON notifications(user_id) WHERE seen = FALSE AND deleted_at IS NULL;
CREATE INDEX idx_notifs_tenant_type ON notifications(tenant_id, notification_type);
CREATE TRIGGER tg_notifs_updated_at
BEFORE UPDATE ON notifications FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- readed_notifications — read receipts (kept for analytics)
-- ---------------------------------------------------------------------
CREATE TABLE readed_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
read_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, notification_id)
);
CREATE INDEX idx_readed_user ON readed_notifications(user_id, read_date DESC);
-- ---------------------------------------------------------------------
-- notification_deliveries — outbound across channels
-- ---------------------------------------------------------------------
CREATE TABLE notification_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
notification_id UUID REFERENCES notifications(id) ON DELETE SET NULL,
channel delivery_channel NOT NULL,
recipient TEXT NOT NULL, -- email/phone/push endpoint
subject TEXT,
body_text TEXT,
body_html TEXT,
template_id TEXT, -- reference to template engine
template_vars JSONB,
-- Provider tracking
provider TEXT, -- 'web-push','smtp','kavenegar','telegram','firebase'
provider_message_id TEXT,
provider_response JSONB,
status delivery_status_kind NOT NULL DEFAULT 'Pending',
error_message TEXT,
error_code TEXT,
-- Retry
attempt INT NOT NULL DEFAULT 1,
max_attempts INT NOT NULL DEFAULT 3,
next_retry_at TIMESTAMPTZ,
-- Timing
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_deliveries_user ON notification_deliveries(user_id, created_at DESC);
CREATE INDEX idx_deliveries_pending ON notification_deliveries(next_retry_at)
WHERE status IN ('Pending','Failed') AND next_retry_at IS NOT NULL;
CREATE INDEX idx_deliveries_channel ON notification_deliveries(channel, status);
CREATE TRIGGER tg_deliveries_updated_at
BEFORE UPDATE ON notification_deliveries FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- notification_preferences — per-user opt-in per channel per type
-- ---------------------------------------------------------------------
CREATE TABLE notification_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
notification_type notification_kind NOT NULL,
channel delivery_channel NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, notification_type, channel)
);
CREATE INDEX idx_notif_prefs_user ON notification_preferences(user_id);
CREATE TRIGGER tg_notif_prefs_updated_at
BEFORE UPDATE ON notification_preferences FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- notification_templates — reusable templates per tenant
-- ---------------------------------------------------------------------
CREATE TABLE notification_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID, -- NULL = default
code TEXT NOT NULL, -- 'render.completed.email'
channel delivery_channel NOT NULL,
locale TEXT NOT NULL DEFAULT 'fa',
subject TEXT,
body_text TEXT,
body_html TEXT,
push_title TEXT,
push_body TEXT,
push_icon TEXT,
variables_schema JSONB, -- expected variables
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, code, channel, locale)
);
CREATE INDEX idx_notif_tpl_lookup ON notification_templates(tenant_id, code, channel, locale) WHERE is_active = TRUE;
CREATE TRIGGER tg_notif_tpl_updated_at
BEFORE UPDATE ON notification_templates FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- Wire back: earned_gifts.notification_id
-- ---------------------------------------------------------------------
ALTER TABLE identity.earned_gifts
ADD CONSTRAINT fk_earned_gifts_notification
FOREIGN KEY (notification_id) REFERENCES notification.notifications(id) ON DELETE SET NULL;
@@ -0,0 +1,8 @@
-- =====================================================================
-- IDENTITY SCHEMA — Part 15: Add Tara and SnapPay payment gateways
-- =====================================================================
-- ALTER TYPE ... ADD VALUE cannot run inside a transaction block in older
-- Postgres; use IF NOT EXISTS to make it idempotent.
ALTER TYPE identity.payment_gateway ADD VALUE IF NOT EXISTS 'Tara';
ALTER TYPE identity.payment_gateway ADD VALUE IF NOT EXISTS 'SnapPay';
@@ -0,0 +1,35 @@
-- 16_fix_inet_to_text.sql
-- The C# domain models all IP/network columns as string / string[]. The original
-- schema declared them as native PostgreSQL INET / INET[], which fails at runtime
-- with: "column ... is of type inet but expression is of type text".
--
-- Rather than add per-property EF value converters across every service, we align
-- the schema with the (string-based) code: convert every inet/inet[] column to
-- text/text[]. This block is idempotent — it only touches columns still typed inet,
-- and alters partitioned parents (which cascade) while skipping partition children.
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT n.nspname AS sch, c.relname AS tbl, a.attname AS col, t.typname AS typ
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_type t ON t.oid = a.atttypid
WHERE n.nspname IN ('identity','content','studio','render','notification','file_mgr')
AND a.attnum > 0 AND NOT a.attisdropped
AND c.relkind IN ('r','p') -- ordinary + partitioned parents
AND NOT c.relispartition -- skip partition children (parent cascades)
AND t.typname IN ('inet','_inet')
LOOP
IF r.typ = '_inet' THEN
EXECUTE format('ALTER TABLE %I.%I ALTER COLUMN %I TYPE text[] USING %I::text[];',
r.sch, r.tbl, r.col, r.col);
ELSE
EXECUTE format('ALTER TABLE %I.%I ALTER COLUMN %I TYPE text USING %I::text;',
r.sch, r.tbl, r.col, r.col);
END IF;
RAISE NOTICE 'inet->text: %.%.% (%)', r.sch, r.tbl, r.col, r.typ;
END LOOP;
END $$;
+308
View File
@@ -0,0 +1,308 @@
# FlatRender V2 — full microservices stack
# Usage:
# cp .env.v2.example .env.v2
# docker compose -f docker-compose.v2.yml --env-file .env.v2 up -d
#
# Public port: 8080 → API Gateway
# MinIO UI: 9001 → http://localhost:9001
#
# Per-service ports are intentionally NOT published (internal Docker network).
# Add `ports: ["5010:8080"]` to a service to expose it for local debugging.
services:
# ── Shared infrastructure ───────────────────────────────────────────────────
postgres:
image: postgres:16-alpine
container_name: fr2-postgres
restart: unless-stopped
environment:
POSTGRES_DB: flatrender
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes:
- pgdata:/var/lib/postgresql/data
# migrations are run once by init-db.sh when the data volume is first created
- ./backend/db/migrations:/migrations:ro
- ./scripts/init-db.sh:/docker-entrypoint-initdb.d/00-init.sh:ro
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d flatrender"]
interval: 5s
timeout: 5s
retries: 15
start_period: 10s
minio:
image: minio/minio:latest
container_name: fr2-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
volumes:
- miniodata:/data
ports:
- "9000:9000"
- "9001:9001"
healthcheck:
test: ["CMD-SHELL", "mc ready local || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# ── Identity Service (.NET 10) ──────────────────────────────────────────────
# Config keys: ConnectionStrings:DefaultConnection Jwt:Secret
# ZarinPal:MerchantId SnapPay:ClientId Tara:ApiKey
# Stripe:SecretKey Stripe:WebhookSecret
identity-svc:
build:
context: ./services/identity
container_name: fr2-identity
restart: unless-stopped
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_HTTP_PORTS: "8080"
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=flatrender;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres};Search Path=identity,public;Pooling=true"
Jwt__Secret: "${JWT_SECRET}"
Jwt__Issuer: "flatrender-identity"
Jwt__Audience: "flatrender"
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
ZarinPal__CallbackUrl: "${ZARINPAL_CALLBACK_URL:-http://localhost:8080/v1/payments/callback/zarinpal}"
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
Stripe__SecretKey: "${STRIPE_SECRET_KEY:-}"
Stripe__WebhookSecret: "${STRIPE_WEBHOOK_SECRET:-}"
SnapPay__ClientId: "${SNAPPAY_CLIENT_ID:-}"
SnapPay__ClientSecret: "${SNAPPAY_CLIENT_SECRET:-}"
SnapPay__BaseUrl: "${SNAPPAY_BASE_URL:-https://api.snappay.ir}"
SnapPay__CallbackUrl: "${SNAPPAY_CALLBACK_URL:-http://localhost:8080/v1/payments/callback/snappay}"
Tara__ApiKey: "${TARA_API_KEY:-}"
Tara__BaseUrl: "${TARA_BASE_URL:-https://api.tara.ir}"
Tara__CallbackUrl: "${TARA_CALLBACK_URL:-http://localhost:8080/v1/payments/callback/tara}"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
# ── Content Service (.NET 10) ───────────────────────────────────────────────
# Config keys: ConnectionStrings:Postgres Jwt:Secret
content-svc:
build:
context: ./services/content
container_name: fr2-content
restart: unless-stopped
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_HTTP_PORTS: "8080"
ConnectionStrings__Postgres: "Host=postgres;Port=5432;Database=flatrender;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres};Search Path=content,public;Pooling=true"
Jwt__Secret: "${JWT_SECRET}"
Jwt__Issuer: "flatrender"
Jwt__Audience: "flatrender"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
# ── File Service (Go) ───────────────────────────────────────────────────────
file-svc:
build:
context: ./services/file
container_name: fr2-file
restart: unless-stopped
environment:
DATABASE_URL: "postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/flatrender?search_path=file_mgr,public"
JWT_SECRET: "${JWT_SECRET}"
MINIO_ENDPOINT: "minio:9000"
MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}"
MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}"
MINIO_USE_SSL: "false"
PORT: "8080"
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
# ── Studio Service (.NET 10) ────────────────────────────────────────────────
# Config keys: ConnectionStrings:Default Jwt:Key
studio-svc:
build:
context: ./services/studio
container_name: fr2-studio
restart: unless-stopped
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_HTTP_PORTS: "8080"
ConnectionStrings__Default: "Host=postgres;Port=5432;Database=flatrender;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres};Search Path=studio,public;Pooling=true"
Jwt__Key: "${JWT_SECRET}"
Jwt__Issuer: "flatrender"
Jwt__Audience: "flatrender"
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:3000}"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
# ── Render Orchestrator (Go) ────────────────────────────────────────────────
render-svc:
build:
context: ./services/render
container_name: fr2-render
restart: unless-stopped
environment:
DATABASE_URL: "postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/flatrender?search_path=render,public"
JWT_SECRET: "${JWT_SECRET}"
NODE_HMAC_SECRET: "${NODE_HMAC_SECRET:-node-secret-change-me}"
MINIO_ENDPOINT: "minio:9000"
MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}"
MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}"
MINIO_USE_SSL: "false"
MINIO_BUCKET: "${MINIO_BUCKET:-flatrender-exports}"
NOTIFICATION_URL: "http://notification-svc:8080"
SERVICE_TOKEN: "${SERVICE_TOKEN:-internal-service-secret}"
PORT: "8080"
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
# ── Notification Service (Go) ───────────────────────────────────────────────
notification-svc:
build:
context: ./services/notification
container_name: fr2-notification
restart: unless-stopped
environment:
DATABASE_URL: "postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/flatrender?search_path=notification,public"
JWT_SECRET: "${JWT_SECRET}"
SERVICE_TOKEN: "${SERVICE_TOKEN:-internal-service-secret}"
PORT: "8080"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
# ── API Gateway (Go) ────────────────────────────────────────────────────────
gateway:
build:
context: ./services/gateway
container_name: fr2-gateway
restart: unless-stopped
ports:
- "${GATEWAY_PORT:-8080}:8080"
environment:
JWT_SECRET: "${JWT_SECRET}"
IDENTITY_URL: "http://identity-svc:8080"
CONTENT_URL: "http://content-svc:8080"
FILE_URL: "http://file-svc:8080"
STUDIO_URL: "http://studio-svc:8080"
RENDER_URL: "http://render-svc:8080"
RENDER_WS_URL: "ws://render-svc:8080"
NOTIFICATION_URL: "http://notification-svc:8080"
PORT: "8080"
depends_on:
identity-svc:
condition: service_healthy
content-svc:
condition: service_healthy
file-svc:
condition: service_healthy
studio-svc:
condition: service_healthy
render-svc:
condition: service_healthy
notification-svc:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
# ── Frontend (Next.js) ──────────────────────────────────────────────────────
# NEXT_PUBLIC_* vars are baked in at build time — pass them as build args.
# Server-side secrets are injected at runtime via environment.
frontend:
build:
context: .
args:
NEXT_PUBLIC_SUPABASE_URL: "${NEXT_PUBLIC_SUPABASE_URL:-}"
NEXT_PUBLIC_SUPABASE_ANON_KEY: "${NEXT_PUBLIC_SUPABASE_ANON_KEY:-}"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: "${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-}"
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}"
# V2 gateway: browser-facing base (host port) baked in at build time.
NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL:-http://localhost:${GATEWAY_PORT:-8088}/v1}"
NEXT_PUBLIC_TENANT_SLUG: "${NEXT_PUBLIC_TENANT_SLUG:-flatrender}"
container_name: fr2-frontend
restart: unless-stopped
ports:
- "3000:3000"
environment:
NODE_ENV: production
PORT: "3000"
HOSTNAME: "0.0.0.0"
# Server-side: Next route handlers reach the gateway over the internal network.
API_GATEWAY_URL: "http://gateway:8080"
# Server-side secrets (not baked into image)
SUPABASE_SERVICE_ROLE_KEY: "${SUPABASE_SERVICE_ROLE_KEY:-}"
STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY:-}"
STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:-}"
depends_on:
gateway:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
pgdata:
miniodata:
+203 -58
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -15,6 +15,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
"@fontsource/vazirmatn": "^5.2.8",
"@hookform/resolvers": "^5.2.2",
"@nexrender/core": "^1.46.0",
"@radix-ui/react-accordion": "^1.2.12",
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# FlatRender V2 — run all schema migrations in order on first postgres init.
# Mounted at: /docker-entrypoint-initdb.d/00-init.sh
# Migrations dir mounted at: /migrations (read-only)
set -e
MIGRATIONS_DIR="/migrations"
echo "==> FlatRender: running schema migrations from $MIGRATIONS_DIR"
for f in $(ls "$MIGRATIONS_DIR"/*.sql | sort); do
echo " Applying: $(basename "$f")"
psql -v ON_ERROR_STOP=1 \
--username "$POSTGRES_USER" \
--dbname "$POSTGRES_DB" \
--file "$f"
done
echo "==> FlatRender: all migrations applied."
+19
View File
@@ -0,0 +1,19 @@
# Build output — rebuilt inside container
**/bin/
**/obj/
# Local dev secrets
**/appsettings.Development.json
**/appsettings.*.Local.json
**/*.user
# IDE / OS
.vs/
.idea/
.DS_Store
Thumbs.db
# Docker files
Dockerfile
.dockerignore
docker-compose*.yml
+24
View File
@@ -0,0 +1,24 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 8080
# The .NET base image ships neither wget nor curl, which the container healthcheck needs.
# Copy a single static busybox binary named `wget` (busybox dispatches on argv[0]).
# This stays fully offline — no apt/network — matching the vendored Go builds.
COPY --from=busybox:1.36 /bin/busybox /usr/bin/wget
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore is its own cached layer: it only re-runs when the .csproj (deps) changes,
# not on every source edit. Critical here — NuGet restore is the slow step.
COPY NuGet.Config .
COPY ["FlatRender.ContentSvc/FlatRender.ContentSvc.csproj", "FlatRender.ContentSvc/"]
RUN dotnet restore "FlatRender.ContentSvc/FlatRender.ContentSvc.csproj"
COPY . .
# Single publish compiles + packages; --no-restore reuses the cached restore above.
RUN dotnet publish "FlatRender.ContentSvc/FlatRender.ContentSvc.csproj" \
-c Release -o /app/publish --no-restore /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "FlatRender.ContentSvc.dll"]
@@ -0,0 +1,3 @@
<Solution>
<Project Path="FlatRender.ContentSvc/FlatRender.ContentSvc.csproj" />
</Solution>
@@ -0,0 +1,279 @@
using FlatRender.ContentSvc.Domain.Entities;
using FlatRender.ContentSvc.Domain.Enums;
using FlatRender.ContentSvc.Infrastructure.Data;
using FlatRender.ContentSvc.Models.Requests;
using FlatRender.ContentSvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.ContentSvc.Application.Services;
public class CmsService(ContentDbContext db)
{
// ── Blogs ─────────────────────────────────────────────────────────────────
public async Task<PagedResponse<BlogSummaryResponse>> GetBlogsAsync(BlogListRequest req)
{
var kind = Enum.TryParse<BlogKind>(req.Kind, true, out var k) ? k : BlogKind.Blog;
var q = db.Blogs.Where(x => x.Kind == kind);
if (!string.IsNullOrWhiteSpace(req.Search))
q = q.Where(x => EF.Functions.ILike(x.Title, $"%{req.Search}%"));
if (req.IsPublished.HasValue)
q = q.Where(x => x.IsPublished == req.IsPublished);
var total = await q.LongCountAsync();
var items = await q.OrderByDescending(x => x.PublishDate ?? x.CreatedAt)
.Skip((req.Page - 1) * req.PageSize).Take(req.PageSize).ToListAsync();
return new PagedResponse<BlogSummaryResponse>(
items.Select(MapBlogSummary),
new PaginationMeta(req.Page, req.PageSize, total, (int)Math.Ceiling((double)total / req.PageSize))
);
}
public async Task<BlogDetailResponse> GetBlogBySlugAsync(string slug)
{
var blog = await db.Blogs.FirstOrDefaultAsync(x => x.Slug == slug)
?? throw new KeyNotFoundException($"Blog '{slug}' not found");
await db.Blogs.Where(x => x.Id == blog.Id)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ViewCount, x => x.ViewCount + 1));
return MapBlogDetail(blog);
}
public async Task<BlogDetailResponse> CreateBlogAsync(BlogListRequest _, CreateBlogRequest req, Guid? authorId)
{
var kind = Enum.TryParse<BlogKind>(req.Kind, true, out var k) ? k : BlogKind.Blog;
var blog = new Blog
{
TenantId = null, Kind = kind, Slug = req.Slug, Title = req.Title,
ShortDescription = req.ShortDescription, Content = req.Content,
MetaTitle = req.MetaTitle, MetaDescription = req.MetaDescription, MetaKeywords = req.MetaKeywords,
IncludeInSiteMap = req.IncludeInSiteMap, Image = req.Image, Cover = req.Cover,
AuthorUserId = authorId, AuthorDisplayName = req.AuthorDisplayName,
IsPublished = req.IsPublished, PublishDate = req.PublishDate
};
db.Blogs.Add(blog);
await db.SaveChangesAsync();
return MapBlogDetail(blog);
}
public async Task<BlogDetailResponse> UpdateBlogAsync(Guid id, UpdateBlogRequest req)
{
var blog = await db.Blogs.FindAsync(id)
?? throw new KeyNotFoundException($"Blog {id} not found");
blog.Slug = req.Slug; blog.Title = req.Title; blog.ShortDescription = req.ShortDescription;
blog.Content = req.Content; blog.MetaTitle = req.MetaTitle; blog.MetaDescription = req.MetaDescription;
blog.MetaKeywords = req.MetaKeywords; blog.IncludeInSiteMap = req.IncludeInSiteMap;
blog.Image = req.Image; blog.Cover = req.Cover; blog.AuthorDisplayName = req.AuthorDisplayName;
blog.IsPublished = req.IsPublished; blog.PublishDate = req.PublishDate;
blog.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return MapBlogDetail(blog);
}
public async Task DeleteBlogAsync(Guid id)
{
var blog = await db.Blogs.FindAsync(id) ?? throw new KeyNotFoundException($"Blog {id} not found");
blog.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
// ── Comments ──────────────────────────────────────────────────────────────
public async Task<PagedResponse<CommentResponse>> GetCommentsAsync(
int page, int pageSize, Guid? blogId, Guid? containerId, bool? isApproved)
{
var q = db.Comments.AsQueryable();
if (blogId.HasValue) q = q.Where(x => x.BlogId == blogId);
if (containerId.HasValue) q = q.Where(x => x.ContainerId == containerId);
if (isApproved.HasValue) q = q.Where(x => x.IsApproved == isApproved);
var total = await q.LongCountAsync();
var items = await q.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
return new PagedResponse<CommentResponse>(
items.Select(MapComment),
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))
);
}
public async Task<CommentResponse> CreateCommentAsync(CreateCommentRequest req, Guid userId)
{
if (req.BlogId == null && req.ContainerId == null)
throw new ArgumentException("Either BlogId or ContainerId must be provided");
if (req.BlogId != null && req.ContainerId != null)
throw new ArgumentException("Only one of BlogId or ContainerId may be provided");
var comment = new Comment
{
UserId = userId, BlogId = req.BlogId, ContainerId = req.ContainerId,
ParentCommentId = req.ParentCommentId, Content = req.Content, Rate = req.Rate
};
db.Comments.Add(comment);
await db.SaveChangesAsync();
return MapComment(comment);
}
public async Task ApproveCommentAsync(Guid id, bool approve)
{
var comment = await db.Comments.FindAsync(id)
?? throw new KeyNotFoundException($"Comment {id} not found");
comment.IsApproved = approve;
comment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task DeleteCommentAsync(Guid id)
{
var comment = await db.Comments.FindAsync(id)
?? throw new KeyNotFoundException($"Comment {id} not found");
comment.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
// ── Slides ────────────────────────────────────────────────────────────────
public async Task<List<SlideResponse>> GetSlidesAsync(Guid? tenantId)
{
var q = db.NewSlides.Where(x => x.IsActive &&
(x.TenantId == null || x.TenantId == tenantId) &&
(x.ExpireDate == null || x.ExpireDate > DateTime.UtcNow));
return await q.OrderBy(x => x.Sort).Select(s => new SlideResponse(
s.Id, s.Keyword, s.Title, s.Image, s.Parameter, s.SlideType.ToString(),
s.ExpireDate, s.Sort, s.IsActive
)).ToListAsync();
}
public async Task<SlideResponse> CreateSlideAsync(CreateSlideRequest req)
{
if (!Enum.TryParse<SlideType>(req.SlideType, true, out var type))
throw new ArgumentException($"Invalid SlideType: {req.SlideType}");
var slide = new NewSlide
{
Keyword = req.Keyword, Title = req.Title, Image = req.Image,
Parameter = req.Parameter, SlideType = type, ExpireDate = req.ExpireDate,
Sort = req.Sort, IsActive = req.IsActive
};
db.NewSlides.Add(slide);
await db.SaveChangesAsync();
return new SlideResponse(slide.Id, slide.Keyword, slide.Title, slide.Image, slide.Parameter,
slide.SlideType.ToString(), slide.ExpireDate, slide.Sort, slide.IsActive);
}
public async Task DeleteSlideAsync(Guid id)
{
var slide = await db.NewSlides.FindAsync(id) ?? throw new KeyNotFoundException($"Slide {id} not found");
db.NewSlides.Remove(slide);
await db.SaveChangesAsync();
}
// ── Home Page Events ──────────────────────────────────────────────────────
public async Task<List<HomePageEventResponse>> GetHomePageEventsAsync(Guid? tenantId)
{
return await db.HomePageEvents
.Where(x => x.IsActive && (x.TenantId == null || x.TenantId == tenantId))
.OrderBy(x => x.Sort)
.Select(e => new HomePageEventResponse(
e.Id, e.Title, e.Subtitle, e.Description, e.Badge, e.BadgeClass,
e.ButtonText, e.ButtonUrl, e.ButtonClass, e.Color, e.BackgroundColor,
e.TextColor, e.Image, e.IsActive, e.Sort, e.StartsAt, e.EndsAt
)).ToListAsync();
}
// ── Website Settings ──────────────────────────────────────────────────────
public async Task<List<WebsiteSettingResponse>> GetSettingsAsync(Guid? tenantId, bool includeSecret = false)
{
var q = db.WebsiteSettings.Where(x => x.TenantId == tenantId);
if (!includeSecret) q = q.Where(x => !x.IsSecret);
return await q.Select(s => new WebsiteSettingResponse(s.Id, s.Key, s.Value, s.Description, s.IsSecret)).ToListAsync();
}
public async Task<WebsiteSettingResponse> UpsertSettingAsync(Guid? tenantId, UpsertWebsiteSettingRequest req)
{
var setting = await db.WebsiteSettings.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Key == req.Key);
if (setting == null)
{
setting = new WebsiteSetting { TenantId = tenantId, Key = req.Key };
db.WebsiteSettings.Add(setting);
}
setting.Value = req.Value;
setting.Description = req.Description;
setting.IsSecret = req.IsSecret;
setting.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new WebsiteSettingResponse(setting.Id, setting.Key, setting.Value, setting.Description, setting.IsSecret);
}
// ── Favorites ─────────────────────────────────────────────────────────────
public async Task<List<FavoriteFolderResponse>> GetFavoriteFoldersAsync(Guid userId)
{
return await db.FavoriteFolders
.Where(x => x.UserId == userId)
.Select(f => new FavoriteFolderResponse(
f.Id, f.Name, f.Description,
db.FavoriteContainers.Count(fc => fc.FolderId == f.Id),
f.CreatedAt
)).ToListAsync();
}
public async Task<FavoriteFolderResponse> CreateFavoriteFolderAsync(Guid userId, Guid tenantId, CreateFavoriteFolderRequest req)
{
var folder = new FavoriteFolder { UserId = userId, TenantId = tenantId, Name = req.Name, Description = req.Description };
db.FavoriteFolders.Add(folder);
await db.SaveChangesAsync();
return new FavoriteFolderResponse(folder.Id, folder.Name, folder.Description, 0, folder.CreatedAt);
}
public async Task DeleteFavoriteFolderAsync(Guid userId, Guid id)
{
var folder = await db.FavoriteFolders.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId)
?? throw new KeyNotFoundException($"Folder {id} not found");
db.FavoriteFolders.Remove(folder);
await db.SaveChangesAsync();
}
public async Task AddFavoriteContainerAsync(Guid userId, Guid tenantId, AddFavoriteContainerRequest req)
{
var exists = await db.FavoriteContainers.AnyAsync(x => x.UserId == userId && x.ContainerId == req.ContainerId);
if (exists) return;
db.FavoriteContainers.Add(new FavoriteContainer
{
UserId = userId, TenantId = tenantId, ContainerId = req.ContainerId,
FolderId = req.FolderId, Note = req.Note
});
await db.SaveChangesAsync();
}
public async Task RemoveFavoriteContainerAsync(Guid userId, Guid containerId)
{
var fav = await db.FavoriteContainers.FirstOrDefaultAsync(x => x.UserId == userId && x.ContainerId == containerId)
?? throw new KeyNotFoundException($"Favorite not found");
db.FavoriteContainers.Remove(fav);
await db.SaveChangesAsync();
}
// ── Mappers ───────────────────────────────────────────────────────────────
private static BlogSummaryResponse MapBlogSummary(Blog b) => new(
b.Id, b.Slug, b.Title, b.ShortDescription, b.Image, b.Cover,
b.AuthorDisplayName, b.IsPublished, b.PublishDate, b.ViewCount, b.CreatedAt
);
private static BlogDetailResponse MapBlogDetail(Blog b) => new(
b.Id, b.Slug, b.Title, b.ShortDescription, b.Content, b.MetaTitle, b.MetaDescription,
b.MetaKeywords, b.IncludeInSiteMap, b.Image, b.Cover, b.AuthorUserId, b.AuthorDisplayName,
b.IsPublished, b.PublishDate, b.ViewCount, b.CreatedAt, b.UpdatedAt
);
private static CommentResponse MapComment(Comment c) => new(
c.Id, c.UserId, c.BlogId, c.ContainerId, c.ParentCommentId,
c.Content, c.Rate, c.IsApproved, c.IsPinned, c.CreatedAt
);
}
@@ -0,0 +1,228 @@
using FlatRender.ContentSvc.Domain.Entities;
using FlatRender.ContentSvc.Infrastructure.Data;
using FlatRender.ContentSvc.Models.Requests;
using FlatRender.ContentSvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.ContentSvc.Application.Services;
public class TaxonomyService(ContentDbContext db)
{
public async Task<List<CategoryResponse>> GetCategoryTreeAsync()
{
var all = await db.Categories
.Where(x => x.ParentId == null)
.Include(x => x.Children)
.OrderBy(x => x.Sort)
.ToListAsync();
return all.Select(MapCategory).ToList();
}
public async Task<CategoryResponse> CreateCategoryAsync(CreateCategoryRequest req)
{
var cat = new Category
{
ParentId = req.ParentId,
Name = req.Name,
Slug = req.Slug,
Description = req.Description,
ImageUrl = req.ImageUrl,
Icon = req.Icon,
MetaTitle = req.MetaTitle,
MetaDescription = req.MetaDescription,
MetaKeywords = req.MetaKeywords,
BotFollow = req.BotFollow,
Sort = req.Sort,
IsActive = req.IsActive
};
db.Categories.Add(cat);
await db.SaveChangesAsync();
return MapCategory(cat);
}
public async Task<CategoryResponse> UpdateCategoryAsync(Guid id, UpdateCategoryRequest req)
{
var cat = await db.Categories.FindAsync(id)
?? throw new KeyNotFoundException($"Category {id} not found");
cat.ParentId = req.ParentId;
cat.Name = req.Name;
cat.Slug = req.Slug;
cat.Description = req.Description;
cat.ImageUrl = req.ImageUrl;
cat.Icon = req.Icon;
cat.MetaTitle = req.MetaTitle;
cat.MetaDescription = req.MetaDescription;
cat.MetaKeywords = req.MetaKeywords;
cat.BotFollow = req.BotFollow;
cat.Sort = req.Sort;
cat.IsActive = req.IsActive;
cat.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return MapCategory(cat);
}
public async Task DeleteCategoryAsync(Guid id)
{
var cat = await db.Categories.FindAsync(id)
?? throw new KeyNotFoundException($"Category {id} not found");
cat.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<PagedResponse<TagResponse>> GetTagsAsync(int page, int pageSize, string? search)
{
var q = db.Tags.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{search}%"));
var total = await q.LongCountAsync();
var items = await q.OrderBy(x => x.Name).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
return new PagedResponse<TagResponse>(
items.Select(t => new TagResponse(t.Id, t.Name, t.LatinName, t.Slug, t.AppliesToMode, t.IsActive)),
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))
);
}
public async Task<TagResponse> CreateTagAsync(CreateTagRequest req)
{
var tag = new Tag
{
Name = req.Name,
LatinName = req.LatinName,
Slug = req.Slug,
AppliesToMode = req.AppliesToMode,
IsActive = req.IsActive
};
db.Tags.Add(tag);
await db.SaveChangesAsync();
return new TagResponse(tag.Id, tag.Name, tag.LatinName, tag.Slug, tag.AppliesToMode, tag.IsActive);
}
public async Task<TagResponse> UpdateTagAsync(Guid id, UpdateTagRequest req)
{
var tag = await db.Tags.FindAsync(id)
?? throw new KeyNotFoundException($"Tag {id} not found");
tag.Name = req.Name;
tag.LatinName = req.LatinName;
tag.Slug = req.Slug;
tag.AppliesToMode = req.AppliesToMode;
tag.IsActive = req.IsActive;
tag.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new TagResponse(tag.Id, tag.Name, tag.LatinName, tag.Slug, tag.AppliesToMode, tag.IsActive);
}
public async Task DeleteTagAsync(Guid id)
{
var tag = await db.Tags.FindAsync(id)
?? throw new KeyNotFoundException($"Tag {id} not found");
tag.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<PagedResponse<FontResponse>> GetFontsAsync(int page, int pageSize, string? search, string? direction)
{
var q = db.Fonts.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{search}%"));
if (!string.IsNullOrWhiteSpace(direction))
q = q.Where(x => x.Direction == direction);
var total = await q.LongCountAsync();
var items = await q.OrderBy(x => x.Sort).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
return new PagedResponse<FontResponse>(
items.Select(MapFont),
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))
);
}
public async Task<FontResponse> CreateFontAsync(CreateFontRequest req)
{
var font = new Font
{
Name = req.Name, OriginalName = req.OriginalName, SystemName = req.SystemName,
Family = req.Family, Weight = req.Weight, Style = req.Style, Direction = req.Direction,
FileUrl = req.FileUrl, SampleImageUrl = req.SampleImageUrl, IsPremium = req.IsPremium,
IsActive = req.IsActive, InstalledOnNodes = req.InstalledOnNodes, Sort = req.Sort
};
db.Fonts.Add(font);
await db.SaveChangesAsync();
return MapFont(font);
}
public async Task<FontResponse> UpdateFontAsync(Guid id, UpdateFontRequest req)
{
var font = await db.Fonts.FindAsync(id)
?? throw new KeyNotFoundException($"Font {id} not found");
font.Name = req.Name; font.OriginalName = req.OriginalName; font.SystemName = req.SystemName;
font.Family = req.Family; font.Weight = req.Weight; font.Style = req.Style; font.Direction = req.Direction;
font.FileUrl = req.FileUrl; font.SampleImageUrl = req.SampleImageUrl; font.IsPremium = req.IsPremium;
font.IsActive = req.IsActive; font.InstalledOnNodes = req.InstalledOnNodes; font.Sort = req.Sort;
font.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return MapFont(font);
}
public async Task DeleteFontAsync(Guid id)
{
var font = await db.Fonts.FindAsync(id)
?? throw new KeyNotFoundException($"Font {id} not found");
font.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<PagedResponse<MusicTrackResponse>> GetMusicTracksAsync(int page, int pageSize, string? search)
{
var q = db.MusicTracks.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{search}%"));
var total = await q.LongCountAsync();
var items = await q.OrderBy(x => x.Sort).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
return new PagedResponse<MusicTrackResponse>(
items.Select(MapMusic),
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))
);
}
public async Task<MusicTrackResponse> CreateMusicTrackAsync(CreateMusicTrackRequest req)
{
var track = new MusicTrack
{
Name = req.Name, Caption = req.Caption, Keywords = req.Keywords, Url = req.Url,
WaveformData = req.WaveformData, DurationSec = req.DurationSec, Bpm = req.Bpm,
Genre = req.Genre, Mood = req.Mood, IsPremium = req.IsPremium, IsActive = req.IsActive, Sort = req.Sort
};
db.MusicTracks.Add(track);
await db.SaveChangesAsync();
return MapMusic(track);
}
public async Task DeleteMusicTrackAsync(Guid id)
{
var track = await db.MusicTracks.FindAsync(id)
?? throw new KeyNotFoundException($"MusicTrack {id} not found");
track.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
private static CategoryResponse MapCategory(Category c) => new(
c.Id, c.ParentId, c.Name, c.Slug, c.Description, c.ImageUrl, c.Icon,
c.IsActive, c.Sort, c.Children.Select(MapCategory).ToList()
);
private static FontResponse MapFont(Font f) => new(
f.Id, f.Name, f.OriginalName, f.SystemName, f.Family, f.Weight, f.Style,
f.Direction, f.FileUrl, f.SampleImageUrl, f.IsPremium, f.IsActive, f.InstalledOnNodes
);
private static MusicTrackResponse MapMusic(MusicTrack m) => new(
m.Id, m.Name, m.Caption, m.Url, m.WaveformData, m.DurationSec, m.Bpm, m.Genre, m.Mood, m.IsPremium
);
}
@@ -0,0 +1,294 @@
using FlatRender.ContentSvc.Domain.Entities;
using FlatRender.ContentSvc.Domain.Enums;
using FlatRender.ContentSvc.Infrastructure.Data;
using FlatRender.ContentSvc.Models.Requests;
using FlatRender.ContentSvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.ContentSvc.Application.Services;
public class TemplateService(ContentDbContext db)
{
public async Task<PagedResponse<ContainerSummaryResponse>> GetContainersAsync(ContainerListRequest req)
{
var q = db.ProjectContainers
.Include(x => x.ContainerCategories).ThenInclude(x => x.Category)
.Include(x => x.ContainerTags).ThenInclude(x => x.Tag)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Search))
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{req.Search}%") ||
EF.Functions.ILike(x.Slug, $"%{req.Search}%"));
if (req.CategoryId.HasValue)
q = q.Where(x => x.ContainerCategories.Any(cc => cc.CategoryId == req.CategoryId));
if (!string.IsNullOrWhiteSpace(req.TagSlug))
q = q.Where(x => x.ContainerTags.Any(ct => ct.Tag.Slug == req.TagSlug));
if (req.IsPublished.HasValue)
q = q.Where(x => x.IsPublished == req.IsPublished);
if (req.IsPremium.HasValue)
q = q.Where(x => x.IsPremium == req.IsPremium);
if (!string.IsNullOrWhiteSpace(req.Mode))
{
if (Enum.TryParse<ChooseMode>(req.Mode, true, out var mode))
q = q.Where(x => x.PrimaryMode == mode);
}
q = req.Sort switch
{
"sort_asc" => q.OrderBy(x => x.Sort),
"name_asc" => q.OrderBy(x => x.Name),
"view_count_desc" => q.OrderByDescending(x => x.ViewCount),
_ => q.OrderByDescending(x => x.SortDate)
};
var total = await q.LongCountAsync();
var items = await q.Skip((req.Page - 1) * req.PageSize).Take(req.PageSize).ToListAsync();
return new PagedResponse<ContainerSummaryResponse>(
items.Select(MapContainerSummary),
new PaginationMeta(req.Page, req.PageSize, total, (int)Math.Ceiling((double)total / req.PageSize))
);
}
public async Task<ContainerDetailResponse> GetContainerBySlugAsync(string slug)
{
var container = await db.ProjectContainers
.Include(x => x.ContainerCategories).ThenInclude(x => x.Category)
.Include(x => x.ContainerTags).ThenInclude(x => x.Tag)
.Include(x => x.Projects.Where(p => p.DeletedAt == null))
.FirstOrDefaultAsync(x => x.Slug == slug)
?? throw new KeyNotFoundException($"Container '{slug}' not found");
return MapContainerDetail(container);
}
public async Task<ContainerDetailResponse> GetContainerByIdAsync(Guid id)
{
var container = await db.ProjectContainers
.Include(x => x.ContainerCategories).ThenInclude(x => x.Category)
.Include(x => x.ContainerTags).ThenInclude(x => x.Tag)
.Include(x => x.Projects.Where(p => p.DeletedAt == null))
.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Container {id} not found");
return MapContainerDetail(container);
}
public async Task<ContainerDetailResponse> CreateContainerAsync(CreateContainerRequest req)
{
if (!Enum.TryParse<ChooseMode>(req.PrimaryMode, true, out var mode))
throw new ArgumentException($"Invalid PrimaryMode: {req.PrimaryMode}");
var container = new ProjectContainer
{
Slug = req.Slug, Name = req.Name, Description = req.Description, Keywords = req.Keywords,
NewsText = req.NewsText, Image = req.Image, Demo = req.Demo, FullDemo = req.FullDemo,
MiniDemo = req.MiniDemo, DemoScriptTag = req.DemoScriptTag, IsPublished = req.IsPublished,
IsPremium = req.IsPremium, IsMockup = req.IsMockup, PrimaryMode = mode, Sort = req.Sort,
SortDate = DateTime.UtcNow
};
db.ProjectContainers.Add(container);
await db.SaveChangesAsync();
await SyncContainerCategoriesAsync(container.Id, req.CategoryIds);
await SyncContainerTagsAsync(container.Id, req.TagIds);
return await GetContainerByIdAsync(container.Id);
}
public async Task<ContainerDetailResponse> UpdateContainerAsync(Guid id, UpdateContainerRequest req)
{
var container = await db.ProjectContainers.FindAsync(id)
?? throw new KeyNotFoundException($"Container {id} not found");
if (!Enum.TryParse<ChooseMode>(req.PrimaryMode, true, out var mode))
throw new ArgumentException($"Invalid PrimaryMode: {req.PrimaryMode}");
container.Slug = req.Slug; container.Name = req.Name; container.Description = req.Description;
container.Keywords = req.Keywords; container.NewsText = req.NewsText; container.Image = req.Image;
container.Demo = req.Demo; container.FullDemo = req.FullDemo; container.MiniDemo = req.MiniDemo;
container.DemoScriptTag = req.DemoScriptTag; container.IsPublished = req.IsPublished;
container.IsPremium = req.IsPremium; container.IsMockup = req.IsMockup;
container.PrimaryMode = mode; container.Sort = req.Sort; container.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await SyncContainerCategoriesAsync(id, req.CategoryIds);
await SyncContainerTagsAsync(id, req.TagIds);
return await GetContainerByIdAsync(id);
}
public async Task DeleteContainerAsync(Guid id)
{
var container = await db.ProjectContainers.FindAsync(id)
?? throw new KeyNotFoundException($"Container {id} not found");
container.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<ProjectDetailResponse> GetProjectDetailAsync(Guid id)
{
var project = await db.Projects
.Include(x => x.Scenes.Where(s => s.DeletedAt == null)).ThenInclude(x => x.RepeaterItems)
.Include(x => x.Scenes).ThenInclude(x => x.ContentElements)
.Include(x => x.Scenes).ThenInclude(x => x.ColorElements)
.Include(x => x.Scenes).ThenInclude(x => x.ColorPresets).ThenInclude(x => x.Items)
.Include(x => x.Scenes).ThenInclude(x => x.Characters).ThenInclude(x => x.Controllers).ThenInclude(x => x.Options)
.Include(x => x.SharedColors)
.Include(x => x.SharedLayers)
.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Project {id} not found");
return MapProjectDetail(project);
}
public async Task<ProjectDetailResponse> CreateProjectAsync(CreateProjectRequest req)
{
if (!Enum.TryParse<ChooseMode>(req.ChooseMode, true, out var chooseMode))
throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}");
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var resolution))
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
var project = new Project
{
ContainerId = req.ContainerId, ProjectServerId = req.ProjectServerId, Name = req.Name,
Description = req.Description, Image = req.Image, FullDemo = req.FullDemo,
DemoScriptTag = req.DemoScriptTag, DownloadLink = req.DownloadLink, Folder = req.Folder,
OriginalWidth = req.OriginalWidth, OriginalHeight = req.OriginalHeight, Aspect = req.Aspect,
ProjectDurationSec = req.ProjectDurationSec, MinDurationSec = req.MinDurationSec,
MaxDurationSec = req.MaxDurationSec, FreeFps = req.FreeFps, ChooseMode = chooseMode,
Resolution = resolution, VipFactor = req.VipFactor, RenderAepComp = req.RenderAepComp,
IsPublished = req.IsPublished, Sort = req.Sort
};
db.Projects.Add(project);
await db.SaveChangesAsync();
return await GetProjectDetailAsync(project.Id);
}
public async Task<ProjectDetailResponse> UpdateProjectAsync(Guid id, UpdateProjectRequest req)
{
var project = await db.Projects.FindAsync(id)
?? throw new KeyNotFoundException($"Project {id} not found");
if (!Enum.TryParse<ChooseMode>(req.ChooseMode, true, out var chooseMode))
throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}");
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var resolution))
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
project.Name = req.Name; project.Description = req.Description; project.Image = req.Image;
project.FullDemo = req.FullDemo; project.DemoScriptTag = req.DemoScriptTag;
project.DownloadLink = req.DownloadLink; project.Folder = req.Folder;
project.OriginalWidth = req.OriginalWidth; project.OriginalHeight = req.OriginalHeight;
project.Aspect = req.Aspect; project.ProjectDurationSec = req.ProjectDurationSec;
project.MinDurationSec = req.MinDurationSec; project.MaxDurationSec = req.MaxDurationSec;
project.FreeFps = req.FreeFps; project.ChooseMode = chooseMode; project.Resolution = resolution;
project.VipFactor = req.VipFactor; project.RenderAepComp = req.RenderAepComp;
project.SharedLayerImage = req.SharedLayerImage; project.SharedColorsSvg = req.SharedColorsSvg;
project.SharedColorPresetsSvg = req.SharedColorPresetsSvg;
project.IsPublished = req.IsPublished; project.Sort = req.Sort;
project.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return await GetProjectDetailAsync(project.Id);
}
public async Task DeleteProjectAsync(Guid id)
{
var project = await db.Projects.FindAsync(id)
?? throw new KeyNotFoundException($"Project {id} not found");
project.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task IncrementContainerViewAsync(Guid id)
{
await db.ProjectContainers
.Where(x => x.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ViewCount, x => x.ViewCount + 1));
}
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task SyncContainerCategoriesAsync(Guid containerId, List<Guid> categoryIds)
{
var existing = await db.ContainerCategories.Where(x => x.ContainerId == containerId).ToListAsync();
db.ContainerCategories.RemoveRange(existing);
for (int i = 0; i < categoryIds.Count; i++)
db.ContainerCategories.Add(new ContainerCategory { ContainerId = containerId, CategoryId = categoryIds[i], Sort = i });
await db.SaveChangesAsync();
}
private async Task SyncContainerTagsAsync(Guid containerId, List<Guid> tagIds)
{
var existing = await db.ContainerTags.Where(x => x.ContainerId == containerId).ToListAsync();
db.ContainerTags.RemoveRange(existing);
foreach (var tagId in tagIds)
db.ContainerTags.Add(new ContainerTag { ContainerId = containerId, TagId = tagId });
await db.SaveChangesAsync();
}
private static ContainerSummaryResponse MapContainerSummary(ProjectContainer c) => new(
c.Id, c.Slug, c.Name, c.Description, c.Image, c.Demo, c.MiniDemo,
c.IsPublished, c.IsPremium, c.IsMockup, c.PrimaryMode.ToString(),
c.RateAvg, c.RateCount, c.ViewCount, c.UseCount, c.Sort, c.SortDate,
c.ContainerCategories.Select(cc => cc.Category.Slug).ToList(),
c.ContainerTags.Select(ct => ct.Tag.Name).ToList()
);
private static ContainerDetailResponse MapContainerDetail(ProjectContainer c) => new(
c.Id, c.Slug, c.Name, c.Description, c.Keywords, c.NewsText,
c.Image, c.Demo, c.FullDemo, c.MiniDemo, c.DemoScriptTag,
c.IsPublished, c.IsPremium, c.IsMockup, c.PrimaryMode.ToString(),
c.RateAvg, c.RateCount, c.ViewCount, c.UseCount, c.Sort, c.SortDate,
c.Projects.Select(MapProject).ToList(),
c.ContainerCategories.Select(cc => MapCategoryFlat(cc.Category)).ToList(),
c.ContainerTags.Select(ct => new TagResponse(ct.Tag.Id, ct.Tag.Name, ct.Tag.LatinName, ct.Tag.Slug, ct.Tag.AppliesToMode, ct.Tag.IsActive)).ToList()
);
private static CategoryResponse MapCategoryFlat(Category c) => new(
c.Id, c.ParentId, c.Name, c.Slug, c.Description, c.ImageUrl, c.Icon,
c.IsActive, c.Sort, []
);
private static ProjectResponse MapProject(Project p) => new(
p.Id, p.ContainerId, p.Name, p.Image, p.FullDemo,
p.OriginalWidth, p.OriginalHeight, p.Aspect,
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(),
p.IsPublished, p.Sort
);
private static ProjectDetailResponse MapProjectDetail(Project p) => new(
p.Id, p.ContainerId, p.Name, p.Description, p.Image, p.FullDemo, p.DemoScriptTag, p.DownloadLink,
p.OriginalWidth, p.OriginalHeight, p.Aspect,
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(), p.VipFactor, p.RenderAepComp,
p.SharedLayerImage, p.IsPublished, p.Sort,
p.Scenes.Select(MapScene).ToList(),
p.SharedColors.Select(sc => new SharedColorResponse(sc.Id, sc.ElementKey, sc.Title, sc.Icon, sc.AttrValue.ToString(), sc.DefaultColor, sc.Sort)).ToList(),
p.SharedLayers.Select(MapSharedLayer).ToList()
);
private static SceneResponse MapScene(Scene s) => new(
s.Id, s.ProjectId, s.Key, s.Title, s.LocalizedTitle, s.SceneType.ToString(),
s.Image, s.Demo, s.SnapshotUrl, s.GenerateKf,
s.DefaultDurationSec, s.MinDurationSec, s.MaxDurationSec, s.OverlapAtEndSec,
s.CanHandleDuration, s.ManualColorSelection, s.Sort, s.IsActive
);
private static SharedLayerResponse MapSharedLayer(SharedLayer sl) => new(
sl.Id, sl.Key, sl.Title, sl.LocalizedTitle, sl.Hint, sl.Type.ToString(), sl.DefaultValue,
sl.FontId, sl.FontFace, sl.FontSize, sl.IsFontChangeable, sl.IsFontSizeChangeable,
sl.Justify.ToString(), sl.CanJustify, sl.PositionInContainer, sl.IsTextBox, sl.MaxSize,
sl.VideoSupport, sl.MinDurationSec, sl.MaxDurationSec, sl.Width, sl.Height,
sl.MappedList, sl.AiInputType.ToString(), sl.IsHidden, sl.IsFocused,
sl.VirtualCount, sl.Sort
);
}
@@ -0,0 +1,165 @@
using System.Security.Claims;
using FlatRender.ContentSvc.Application.Services;
using FlatRender.ContentSvc.Models.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.ContentSvc.Controllers;
[ApiController]
[Route("v1/blogs")]
public class BlogsController(CmsService svc) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> List([FromQuery] BlogListRequest req) =>
Ok(await svc.GetBlogsAsync(req));
[HttpGet("{slug}")]
public async Task<IActionResult> Get(string slug) =>
Ok(await svc.GetBlogBySlugAsync(slug));
[Authorize(Roles = "Admin")]
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateBlogRequest req)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) is { } s ? Guid.Parse(s) : (Guid?)null;
return Ok(await svc.CreateBlogAsync(new BlogListRequest(), req, userId));
}
[Authorize(Roles = "Admin")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateBlogRequest req) =>
Ok(await svc.UpdateBlogAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await svc.DeleteBlogAsync(id);
return NoContent();
}
}
[ApiController]
[Route("v1/comments")]
public class CommentsController(CmsService svc) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> List(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] Guid? blogId = null, [FromQuery] Guid? containerId = null,
[FromQuery] bool? isApproved = null) =>
Ok(await svc.GetCommentsAsync(page, pageSize, blogId, containerId, isApproved));
[Authorize]
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateCommentRequest req)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
return Ok(await svc.CreateCommentAsync(req, userId));
}
[Authorize(Roles = "Admin")]
[HttpPatch("{id:guid}/approve")]
public async Task<IActionResult> Approve(Guid id, [FromQuery] bool approve = true)
{
await svc.ApproveCommentAsync(id, approve);
return NoContent();
}
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await svc.DeleteCommentAsync(id);
return NoContent();
}
}
[ApiController]
[Route("v1/slides")]
public class SlidesController(CmsService svc) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get([FromQuery] Guid? tenantId = null) =>
Ok(await svc.GetSlidesAsync(tenantId));
[Authorize(Roles = "Admin")]
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateSlideRequest req) =>
Ok(await svc.CreateSlideAsync(req));
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await svc.DeleteSlideAsync(id);
return NoContent();
}
}
[ApiController]
[Route("v1/home-events")]
public class HomePageEventsController(CmsService svc) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get([FromQuery] Guid? tenantId = null) =>
Ok(await svc.GetHomePageEventsAsync(tenantId));
}
[ApiController]
[Route("v1/settings")]
public class WebsiteSettingsController(CmsService svc) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get([FromQuery] Guid? tenantId = null) =>
Ok(await svc.GetSettingsAsync(tenantId, includeSecret: false));
[Authorize(Roles = "Admin")]
[HttpGet("all")]
public async Task<IActionResult> GetAll([FromQuery] Guid? tenantId = null) =>
Ok(await svc.GetSettingsAsync(tenantId, includeSecret: true));
[Authorize(Roles = "Admin")]
[HttpPut]
public async Task<IActionResult> Upsert([FromQuery] Guid? tenantId, [FromBody] UpsertWebsiteSettingRequest req) =>
Ok(await svc.UpsertSettingAsync(tenantId, req));
}
[ApiController]
[Route("v1/favorites")]
[Authorize]
public class FavoritesController(CmsService svc) : ControllerBase
{
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
private Guid TenantId => Guid.Parse(User.FindFirstValue("tenant_id") ?? "00000000-0000-0000-0000-000000000001");
[HttpGet("folders")]
public async Task<IActionResult> GetFolders() =>
Ok(await svc.GetFavoriteFoldersAsync(UserId));
[HttpPost("folders")]
public async Task<IActionResult> CreateFolder([FromBody] CreateFavoriteFolderRequest req) =>
Ok(await svc.CreateFavoriteFolderAsync(UserId, TenantId, req));
[HttpDelete("folders/{id:guid}")]
public async Task<IActionResult> DeleteFolder(Guid id)
{
await svc.DeleteFavoriteFolderAsync(UserId, id);
return NoContent();
}
[HttpPost("containers")]
public async Task<IActionResult> AddContainer([FromBody] AddFavoriteContainerRequest req)
{
await svc.AddFavoriteContainerAsync(UserId, TenantId, req);
return NoContent();
}
[HttpDelete("containers/{containerId:guid}")]
public async Task<IActionResult> RemoveContainer(Guid containerId)
{
await svc.RemoveFavoriteContainerAsync(UserId, containerId);
return NoContent();
}
}
@@ -0,0 +1,108 @@
using FlatRender.ContentSvc.Application.Services;
using FlatRender.ContentSvc.Models.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.ContentSvc.Controllers;
[ApiController]
[Route("v1")]
public class TaxonomyController(TaxonomyService svc) : ControllerBase
{
// ── Categories ────────────────────────────────────────────────────────────
[HttpGet("categories")]
public async Task<IActionResult> GetCategories() =>
Ok(await svc.GetCategoryTreeAsync());
[Authorize(Roles = "Admin")]
[HttpPost("categories")]
public async Task<IActionResult> CreateCategory([FromBody] CreateCategoryRequest req) =>
Ok(await svc.CreateCategoryAsync(req));
[Authorize(Roles = "Admin")]
[HttpPut("categories/{id:guid}")]
public async Task<IActionResult> UpdateCategory(Guid id, [FromBody] UpdateCategoryRequest req) =>
Ok(await svc.UpdateCategoryAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("categories/{id:guid}")]
public async Task<IActionResult> DeleteCategory(Guid id)
{
await svc.DeleteCategoryAsync(id);
return NoContent();
}
// ── Tags ──────────────────────────────────────────────────────────────────
[HttpGet("tags")]
public async Task<IActionResult> GetTags(
[FromQuery] int page = 1, [FromQuery] int pageSize = 50,
[FromQuery] string? search = null) =>
Ok(await svc.GetTagsAsync(page, pageSize, search));
[Authorize(Roles = "Admin")]
[HttpPost("tags")]
public async Task<IActionResult> CreateTag([FromBody] CreateTagRequest req) =>
Ok(await svc.CreateTagAsync(req));
[Authorize(Roles = "Admin")]
[HttpPut("tags/{id:guid}")]
public async Task<IActionResult> UpdateTag(Guid id, [FromBody] UpdateTagRequest req) =>
Ok(await svc.UpdateTagAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("tags/{id:guid}")]
public async Task<IActionResult> DeleteTag(Guid id)
{
await svc.DeleteTagAsync(id);
return NoContent();
}
// ── Fonts ─────────────────────────────────────────────────────────────────
[HttpGet("fonts")]
public async Task<IActionResult> GetFonts(
[FromQuery] int page = 1, [FromQuery] int pageSize = 50,
[FromQuery] string? search = null, [FromQuery] string? direction = null) =>
Ok(await svc.GetFontsAsync(page, pageSize, search, direction));
[Authorize(Roles = "Admin")]
[HttpPost("fonts")]
public async Task<IActionResult> CreateFont([FromBody] CreateFontRequest req) =>
Ok(await svc.CreateFontAsync(req));
[Authorize(Roles = "Admin")]
[HttpPut("fonts/{id:guid}")]
public async Task<IActionResult> UpdateFont(Guid id, [FromBody] UpdateFontRequest req) =>
Ok(await svc.UpdateFontAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("fonts/{id:guid}")]
public async Task<IActionResult> DeleteFont(Guid id)
{
await svc.DeleteFontAsync(id);
return NoContent();
}
// ── Music Tracks ──────────────────────────────────────────────────────────
[HttpGet("music")]
public async Task<IActionResult> GetMusicTracks(
[FromQuery] int page = 1, [FromQuery] int pageSize = 50,
[FromQuery] string? search = null) =>
Ok(await svc.GetMusicTracksAsync(page, pageSize, search));
[Authorize(Roles = "Admin")]
[HttpPost("music")]
public async Task<IActionResult> CreateMusicTrack([FromBody] CreateMusicTrackRequest req) =>
Ok(await svc.CreateMusicTrackAsync(req));
[Authorize(Roles = "Admin")]
[HttpDelete("music/{id:guid}")]
public async Task<IActionResult> DeleteMusicTrack(Guid id)
{
await svc.DeleteMusicTrackAsync(id);
return NoContent();
}
}
@@ -0,0 +1,70 @@
using FlatRender.ContentSvc.Application.Services;
using FlatRender.ContentSvc.Models.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.ContentSvc.Controllers;
[ApiController]
[Route("v1/templates")]
public class TemplatesController(TemplateService svc) : ControllerBase
{
// ── Containers ────────────────────────────────────────────────────────────
[HttpGet]
public async Task<IActionResult> ListContainers([FromQuery] ContainerListRequest req) =>
Ok(await svc.GetContainersAsync(req));
[HttpGet("{slug}")]
public async Task<IActionResult> GetContainer(string slug)
{
await svc.IncrementContainerViewAsync(
(await svc.GetContainerBySlugAsync(slug)).Id);
return Ok(await svc.GetContainerBySlugAsync(slug));
}
[Authorize(Roles = "Admin")]
[HttpPost]
public async Task<IActionResult> CreateContainer([FromBody] CreateContainerRequest req) =>
Ok(await svc.CreateContainerAsync(req));
[Authorize(Roles = "Admin")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> UpdateContainer(Guid id, [FromBody] UpdateContainerRequest req) =>
Ok(await svc.UpdateContainerAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteContainer(Guid id)
{
await svc.DeleteContainerAsync(id);
return NoContent();
}
}
[ApiController]
[Route("v1/projects")]
public class ProjectsController(TemplateService svc) : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetProject(Guid id) =>
Ok(await svc.GetProjectDetailAsync(id));
[Authorize(Roles = "Admin")]
[HttpPost]
public async Task<IActionResult> CreateProject([FromBody] CreateProjectRequest req) =>
Ok(await svc.CreateProjectAsync(req));
[Authorize(Roles = "Admin")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> UpdateProject(Guid id, [FromBody] UpdateProjectRequest req) =>
Ok(await svc.UpdateProjectAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteProject(Guid id)
{
await svc.DeleteProjectAsync(id);
return NoContent();
}
}
@@ -0,0 +1,142 @@
namespace FlatRender.ContentSvc.Domain.Entities;
public class SceneCharacter
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid SceneId { get; set; }
public Scene Scene { get; set; } = default!;
public string Key { get; set; } = default!;
public string Name { get; set; } = default!;
public string? Icon { get; set; }
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<SceneCharacterController> Controllers { get; set; } = [];
}
public class SceneCharacterController
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid SceneCharacterId { get; set; }
public SceneCharacter Character { get; set; } = default!;
public string Name { get; set; } = default!;
public string Key { get; set; } = default!;
public string? DefaultValue { get; set; }
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<SceneControllerOption> Options { get; set; } = [];
}
public class SceneControllerOption
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ControllerId { get; set; }
public SceneCharacterController Controller { get; set; } = default!;
public string Name { get; set; } = default!;
public string? Icon { get; set; }
public string Value { get; set; } = default!;
public int Sort { get; set; }
}
public class ProjectCharacterController
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project Project { get; set; } = default!;
public string Name { get; set; } = default!;
public string Key { get; set; } = default!;
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<ProjectCharacterControllerOption> Options { get; set; } = [];
}
public class ProjectCharacterControllerOption
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ControllerId { get; set; }
public ProjectCharacterController Controller { get; set; } = default!;
public string Name { get; set; } = default!;
public string? Icon { get; set; }
public string Value { get; set; } = default!;
public int Sort { get; set; }
}
public class ProjectCharacterPreset
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project Project { get; set; } = default!;
public Guid Key { get; set; } = Guid.NewGuid();
public string Name { get; set; } = default!;
public string? Icon { get; set; }
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<PresetCharacterController> PresetControllers { get; set; } = [];
}
public class PresetCharacterController
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid CharacterPresetId { get; set; }
public ProjectCharacterPreset Preset { get; set; } = default!;
public string Name { get; set; } = default!;
public string Key { get; set; } = default!;
public string Value { get; set; } = default!;
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class PresetStory
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project Project { get; set; } = default!;
public string Name { get; set; } = default!;
public string? Description { get; set; }
public string? Demo { get; set; }
public Guid? MusicId { get; set; }
public MusicTrack? Music { get; set; }
public string? ScenesSpa { get; set; }
public int Sort { get; set; }
public bool IsPublished { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletedAt { get; set; }
public ICollection<PresetScene> Scenes { get; set; } = [];
}
public class PresetScene
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid PresetStoryId { get; set; }
public PresetStory Story { get; set; } = default!;
public Guid SceneId { get; set; }
public Scene Scene { get; set; } = default!;
public int Sort { get; set; }
public decimal? DefaultDurationSec { get; set; }
}
@@ -0,0 +1,205 @@
using FlatRender.ContentSvc.Domain.Enums;
namespace FlatRender.ContentSvc.Domain.Entities;
public class Blog
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? TenantId { get; set; }
public BlogKind Kind { get; set; } = BlogKind.Blog;
public string Slug { get; set; } = default!;
public string Title { get; set; } = default!;
public string? ShortDescription { get; set; }
public string Content { get; set; } = default!;
public string? MetaTitle { get; set; }
public string? MetaDescription { get; set; }
public string? MetaKeywords { get; set; }
public bool IncludeInSiteMap { get; set; } = true;
public string? Image { get; set; }
public string? Cover { get; set; }
public Guid? AuthorUserId { get; set; }
public string? AuthorDisplayName { get; set; }
public bool IsPublished { get; set; }
public DateTime? PublishDate { get; set; }
public long ViewCount { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletedAt { get; set; }
public ICollection<Comment> Comments { get; set; } = [];
}
public class Comment
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? TenantId { get; set; }
public Guid UserId { get; set; }
public Guid? BlogId { get; set; }
public Blog? Blog { get; set; }
public Guid? ContainerId { get; set; }
public ProjectContainer? Container { get; set; }
public Guid? ParentCommentId { get; set; }
public Comment? ParentComment { get; set; }
public string Content { get; set; } = default!;
public decimal? Rate { get; set; }
public bool IsApproved { get; set; }
public bool IsPinned { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletedAt { get; set; }
public ICollection<Comment> Replies { get; set; } = [];
}
public class HomePageEvent
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? TenantId { get; set; }
public string? Title { get; set; }
public string? Subtitle { get; set; }
public string? Description { get; set; }
public string? Badge { get; set; }
public string? BadgeClass { get; set; }
public string? ButtonText { get; set; }
public string? ButtonUrl { get; set; }
public string? ButtonClass { get; set; }
public string? Color { get; set; }
public string? BackgroundColor { get; set; }
public string? TextColor { get; set; }
public string? Image { get; set; }
public bool IsActive { get; set; } = true;
public int Sort { get; set; }
public DateTime? StartsAt { get; set; }
public DateTime? EndsAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class NewSlide
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? TenantId { get; set; }
public string? Keyword { get; set; }
public string? Title { get; set; }
public string? Image { get; set; }
public string? Parameter { get; set; }
public SlideType SlideType { get; set; } = SlideType.Hero;
public DateTime? ExpireDate { get; set; }
public int Sort { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class InternalRoute
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? TenantId { get; set; }
public string? Name { get; set; }
public string? Image { get; set; }
public string Slug { get; set; } = default!;
public int Priority { get; set; } = 5;
public DateTime? LastDate { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class CustomRoute
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? TenantId { get; set; }
public string Target { get; set; } = default!;
public string Destination { get; set; } = default!;
public int RedirectCode { get; set; } = 301;
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class WebsiteSetting
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? TenantId { get; set; }
public string Key { get; set; } = default!;
public string Value { get; set; } = "{}";
public string? Description { get; set; }
public bool IsSecret { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class LearnArticle
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? TenantId { get; set; }
public string Title { get; set; } = default!;
public string? Body { get; set; }
public string? DemoUrl { get; set; }
public string? Mode { get; set; }
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class Training
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? TenantId { get; set; }
public string Title { get; set; } = default!;
public string? Description { get; set; }
public string? VideoUrl { get; set; }
public string? ThumbnailUrl { get; set; }
public int Sort { get; set; }
public bool IsPublished { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class FavoriteFolder
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public Guid TenantId { get; set; }
public string Name { get; set; } = default!;
public string? Description { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<FavoriteContainer> Containers { get; set; } = [];
}
public class FavoriteContainer
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public Guid TenantId { get; set; }
public Guid ContainerId { get; set; }
public ProjectContainer Container { get; set; } = default!;
public Guid? FolderId { get; set; }
public FavoriteFolder? Folder { get; set; }
public string? Note { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,297 @@
using FlatRender.ContentSvc.Domain.Enums;
namespace FlatRender.ContentSvc.Domain.Entities;
public class Scene
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project Project { get; set; } = default!;
public string Key { get; set; } = default!;
public string Title { get; set; } = default!;
public string? LocalizedTitle { get; set; }
public SceneKind SceneType { get; set; } = SceneKind.Normal;
public string? Image { get; set; }
public string? Demo { get; set; }
public string? SceneColorSvg { get; set; }
public string? SnapshotUrl { get; set; }
public bool GenerateKf { get; set; }
public decimal? DefaultDurationSec { get; set; }
public decimal? MinDurationSec { get; set; }
public decimal? MaxDurationSec { get; set; }
public decimal OverlapAtEndSec { get; set; }
public bool CanHandleDuration { get; set; } = true;
public bool ManualColorSelection { get; set; }
public int Sort { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletedAt { get; set; }
public ICollection<RepeaterItem> RepeaterItems { get; set; } = [];
public ICollection<SceneContentElement> ContentElements { get; set; } = [];
public ICollection<SceneColorElement> ColorElements { get; set; } = [];
public ICollection<SceneColorPreset> ColorPresets { get; set; } = [];
public ICollection<SceneCharacter> Characters { get; set; } = [];
public ICollection<SceneCategory> SceneCategories { get; set; } = [];
}
public class RepeaterItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid SceneId { get; set; }
public Scene Scene { get; set; } = default!;
public string Title { get; set; } = default!;
public string RepeatBoxKey { get; set; } = default!;
public string RepeatItemKey { get; set; } = default!;
public int MaxRepeatCount { get; set; } = 10;
public bool UserCanChangeSort { get; set; } = true;
public RepeatSortStrategy RepeatSortStrategy { get; set; } = RepeatSortStrategy.Manual;
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<SceneContentElement> ContentElements { get; set; } = [];
}
public class SceneContentElement
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid SceneId { get; set; }
public Scene Scene { get; set; } = default!;
public Guid? RepeaterItemId { get; set; }
public string Key { get; set; } = default!;
public string Title { get; set; } = default!;
public string? LocalizedTitle { get; set; }
public string? Hint { get; set; }
public ContentElementType Type { get; set; }
public string? DefaultValue { get; set; }
// Text
public Guid? FontId { get; set; }
public string? FontFace { get; set; }
public string? FontFaceName { get; set; }
public int? FontSize { get; set; }
public int? DefaultFontSize { get; set; }
public string? DefaultFontFace { get; set; }
public bool IsFontChangeable { get; set; } = true;
public bool IsFontSizeChangeable { get; set; } = true;
public JustifyKind Justify { get; set; } = JustifyKind.CENTER_JUSTIFY;
public bool CanJustify { get; set; } = true;
public int PositionInContainer { get; set; }
public bool IsTextBox { get; set; }
public int? MaxSize { get; set; }
public string? DirectionLayerKey { get; set; }
public int DirectionLayerValue { get; set; }
// Media
public bool VideoSupport { get; set; }
public decimal? MinDurationSec { get; set; }
public decimal? MaxDurationSec { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
public string? Thumbnail { get; set; }
// Misc
public string? MappedList { get; set; }
public string? CounterMode { get; set; }
public AiInputType AiInputType { get; set; } = AiInputType.None;
public bool IsHidden { get; set; }
public bool IsFocused { get; set; }
public string? OpacityControllerKey { get; set; }
// Legacy design pattern variants
public string? Dp1Image { get; set; }
public string? Dp1Title { get; set; }
public string? Dp2Image { get; set; }
public string? Dp2Title { get; set; }
public string? Dp3Image { get; set; }
public string? Dp3Title { get; set; }
public string? Dp4Image { get; set; }
public string? Dp4Title { get; set; }
public int VirtualCount { get; set; } = 1;
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class SceneColorElement
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid SceneId { get; set; }
public Scene Scene { get; set; } = default!;
public string ElementKey { get; set; } = default!;
public string Title { get; set; } = default!;
public string? Icon { get; set; }
public AttrValueKind AttrValue { get; set; } = AttrValueKind.fill;
public string DefaultColor { get; set; } = default!;
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class SceneColorPreset
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid SceneId { get; set; }
public string? Name { get; set; }
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<SceneColorPresetItem> Items { get; set; } = [];
}
public class SceneColorPresetItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid PresetId { get; set; }
public SceneColorPreset Preset { get; set; } = default!;
public string ElementKey { get; set; } = default!;
public string Value { get; set; } = default!;
public int Sort { get; set; }
}
public class SharedColor
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project Project { get; set; } = default!;
public string ElementKey { get; set; } = default!;
public string Title { get; set; } = default!;
public string? Icon { get; set; }
public AttrValueKind AttrValue { get; set; } = AttrValueKind.fill;
public string DefaultColor { get; set; } = default!;
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class SharedLayer
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project Project { get; set; } = default!;
public string Key { get; set; } = default!;
public string Title { get; set; } = default!;
public string? LocalizedTitle { get; set; }
public string? Hint { get; set; }
public ContentElementType Type { get; set; }
public string? DefaultValue { get; set; }
public Guid? FontId { get; set; }
public string? FontFace { get; set; }
public string? FontFaceName { get; set; }
public int? FontSize { get; set; }
public int? DefaultFontSize { get; set; }
public string? DefaultFontFace { get; set; }
public bool IsFontChangeable { get; set; } = true;
public bool IsFontSizeChangeable { get; set; } = true;
public JustifyKind Justify { get; set; } = JustifyKind.CENTER_JUSTIFY;
public bool CanJustify { get; set; } = true;
public int PositionInContainer { get; set; }
public bool IsTextBox { get; set; }
public int? MaxSize { get; set; }
public string? DirectionLayerKey { get; set; }
public int DirectionLayerValue { get; set; }
public bool VideoSupport { get; set; }
public decimal? MinDurationSec { get; set; }
public decimal? MaxDurationSec { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
public string? Thumbnail { get; set; }
public string? MappedList { get; set; }
public string? CounterMode { get; set; }
public AiInputType AiInputType { get; set; } = AiInputType.None;
public bool IsHidden { get; set; }
public bool IsFocused { get; set; }
// Legacy design pattern variants
public string? Dp1Image { get; set; }
public string? Dp1Title { get; set; }
public string? Dp2Image { get; set; }
public string? Dp2Title { get; set; }
public string? Dp3Image { get; set; }
public string? Dp3Title { get; set; }
public string? Dp4Image { get; set; }
public string? Dp4Title { get; set; }
public int VirtualCount { get; set; } = 1;
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class SceneCategory
{
public Guid SceneId { get; set; }
public Scene Scene { get; set; } = default!;
public Guid CategoryId { get; set; }
public Category Category { get; set; } = default!;
}
public class SharedColorPreset
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project Project { get; set; } = default!;
public string? Name { get; set; }
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<SharedColorPresetItem> Items { get; set; } = [];
}
public class SharedColorPresetItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid PresetId { get; set; }
public SharedColorPreset Preset { get; set; } = default!;
public string ElementKey { get; set; } = default!;
public string Value { get; set; } = default!;
public int Sort { get; set; }
}
public class TemplateSvgPreview
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? ProjectId { get; set; }
public Guid? SceneId { get; set; }
public string? SourceImageUrl { get; set; }
public string SvgUrl { get; set; } = default!;
public string? ThumbnailUrl { get; set; }
public string ColorZones { get; set; } = "[]";
public int? Width { get; set; }
public int? Height { get; set; }
public string? GenerationMethod { get; set; }
public bool GeneratedByAi { get; set; }
public decimal? QualityScore { get; set; }
public Guid? CreatedByUserId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,111 @@
namespace FlatRender.ContentSvc.Domain.Entities;
public class Category
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? ParentId { get; set; }
public Category? Parent { get; set; }
public string Name { get; set; } = default!;
public string Slug { get; set; } = default!;
public string? Description { get; set; }
public string? ImageUrl { get; set; }
public string? Icon { get; set; }
public string? MetaTitle { get; set; }
public string? MetaDescription { get; set; }
public string? MetaKeywords { get; set; }
public bool BotFollow { get; set; } = true;
public int Sort { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletedAt { get; set; }
public ICollection<Category> Children { get; set; } = [];
}
public class Tag
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = default!;
public string? LatinName { get; set; }
public string Slug { get; set; } = default!;
public string? AppliesToMode { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletedAt { get; set; }
}
public class Font
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = default!;
public string? OriginalName { get; set; }
public string? SystemName { get; set; }
public string? Family { get; set; }
public int? Weight { get; set; }
public string? Style { get; set; }
public string Direction { get; set; } = "LTR";
public string? FileUrl { get; set; }
public string? SampleImageUrl { get; set; }
public bool IsPremium { get; set; }
public bool IsActive { get; set; } = true;
public bool InstalledOnNodes { get; set; }
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletedAt { get; set; }
}
public class MusicTrack
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = default!;
public string? Caption { get; set; }
public string? Keywords { get; set; }
public string Url { get; set; } = default!;
public string? WaveformData { get; set; }
public decimal DurationSec { get; set; }
public int? Bpm { get; set; }
public string? Genre { get; set; }
public string? Mood { get; set; }
public bool IsPremium { get; set; }
public bool IsActive { get; set; } = true;
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletedAt { get; set; }
}
public class ProjectServer
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = default!;
public string Region { get; set; } = default!;
public string? Ip { get; set; }
public string? PhysicalPathOutput { get; set; }
public string? DefaultProjectAddress { get; set; }
public string? RenderOutputLocation { get; set; }
public string? PreNeedFolderAddress { get; set; }
public string? MinioEndpoint { get; set; }
public string? MinioBucketTemplates { get; set; }
public string? MinioBucketOutputs { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class AdminFile
{
public Guid Id { get; set; } = Guid.NewGuid();
public string? Name { get; set; }
public string Url { get; set; } = default!;
public string? ThumbnailUrl { get; set; }
public string? FileType { get; set; }
public long? SizeBytes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,116 @@
using FlatRender.ContentSvc.Domain.Enums;
namespace FlatRender.ContentSvc.Domain.Entities;
public class ProjectContainer
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? TenantId { get; set; }
public string Slug { get; set; } = default!;
public string Name { get; set; } = default!;
public string? Description { get; set; }
public string? Keywords { get; set; }
public string? NewsText { get; set; }
public string? Image { get; set; }
public string? Demo { get; set; }
public string? FullDemo { get; set; }
public string? MiniDemo { get; set; }
public string? DemoScriptTag { get; set; }
public bool IsPublished { get; set; }
public bool IsPremium { get; set; }
public bool IsMockup { get; set; }
public ChooseMode PrimaryMode { get; set; } = ChooseMode.FLEXIBLE;
public decimal? RateAvg { get; set; }
public int RateCount { get; set; }
public long ViewCount { get; set; }
public long UseCount { get; set; }
public int Sort { get; set; }
public DateTime SortDate { get; set; } = DateTime.UtcNow;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletedAt { get; set; }
public ICollection<Project> Projects { get; set; } = [];
public ICollection<ContainerCategory> ContainerCategories { get; set; } = [];
public ICollection<ContainerTag> ContainerTags { get; set; } = [];
public ICollection<Comment> Comments { get; set; } = [];
}
public class ContainerCategory
{
public Guid ContainerId { get; set; }
public ProjectContainer Container { get; set; } = default!;
public Guid CategoryId { get; set; }
public Category Category { get; set; } = default!;
public int Sort { get; set; }
}
public class ContainerTag
{
public Guid ContainerId { get; set; }
public ProjectContainer Container { get; set; } = default!;
public Guid TagId { get; set; }
public Tag Tag { get; set; } = default!;
}
public class Project
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ContainerId { get; set; }
public ProjectContainer Container { get; set; } = default!;
public Guid? ProjectServerId { get; set; }
public string Name { get; set; } = default!;
public string? Description { get; set; }
public string? Image { get; set; }
public string? FullDemo { get; set; }
public string? DemoScriptTag { get; set; }
public string? DownloadLink { get; set; }
public string? AepMinioBucket { get; set; }
public string? AepMinioKey { get; set; }
public string? AepFileUrl { get; set; }
public string? AepFileMd5 { get; set; }
public long? AepFileSizeBytes { get; set; }
public DateTime? AepUploadedAt { get; set; }
public string? Folder { get; set; }
public int OriginalWidth { get; set; }
public int OriginalHeight { get; set; }
public string? Aspect { get; set; }
public decimal ProjectDurationSec { get; set; }
public decimal? MinDurationSec { get; set; }
public decimal? MaxDurationSec { get; set; }
public int FreeFps { get; set; } = 30;
public ChooseMode ChooseMode { get; set; }
public ResolutionKind Resolution { get; set; } = ResolutionKind.FullHD;
public decimal VipFactor { get; set; } = 1.0m;
public string RenderAepComp { get; set; } = "flatrender";
public string? SharedLayerImage { get; set; }
public string? SharedColorsSvg { get; set; }
public string? SharedColorPresetsSvg { get; set; }
public bool IsPublished { get; set; }
public int Sort { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletedAt { get; set; }
public ICollection<Scene> Scenes { get; set; } = [];
public ICollection<SharedColor> SharedColors { get; set; } = [];
public ICollection<SharedColorPreset> SharedColorPresets { get; set; } = [];
public ICollection<SharedLayer> SharedLayers { get; set; } = [];
public ICollection<ProjectCharacterController> CharacterControllers { get; set; } = [];
public ICollection<ProjectCharacterPreset> CharacterPresets { get; set; } = [];
public ICollection<PresetStory> PresetStories { get; set; } = [];
}
@@ -0,0 +1,18 @@
namespace FlatRender.ContentSvc.Domain.Enums;
public enum ChooseMode { FIX, FLEXIBLE, MockUp, MusicVisualizer, VoiceOver }
public enum ResolutionKind { HD, FullHD, TwoK, FourK }
public enum SceneKind { Normal, Config, DesignStart, DesignEnd }
public enum ContentElementType
{
Text, TextArea, Media, Audio, Voiceover,
CheckBox, DropDown, Fill, Color, Number,
Date, Toggle, Slider, Counter, Hidden
}
public enum JustifyKind { LEFT_JUSTIFY, CENTER_JUSTIFY, RIGHT_JUSTIFY, FULL_JUSTIFY }
public enum AiInputType { None, TitleSuggest, BodySuggest, TranslateRtl, TranslateLtr, RemoveBG, UpscaleImage, TTS }
public enum RepeatSortStrategy { Manual, Alphabetical, Numerical, InsertOrder }
public enum AttrValueKind { fill, stroke, tracking, dropshadow }
public enum BlogKind { Blog, Landing }
public enum SlideType { Hero, Promo, Tutorial, Category, Custom }
public enum ContainerFavoriteKind { Container }
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.*" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.*" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
@FlatRender.ContentSvc_HostAddress = http://localhost:5088
GET {{FlatRender.ContentSvc_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,903 @@
using FlatRender.ContentSvc.Domain.Entities;
using FlatRender.ContentSvc.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.ContentSvc.Infrastructure.Data;
public class ContentDbContext(DbContextOptions<ContentDbContext> options) : DbContext(options)
{
// Taxonomy
public DbSet<Category> Categories => Set<Category>();
public DbSet<Tag> Tags => Set<Tag>();
public DbSet<Font> Fonts => Set<Font>();
public DbSet<MusicTrack> MusicTracks => Set<MusicTrack>();
public DbSet<ProjectServer> ProjectServers => Set<ProjectServer>();
public DbSet<AdminFile> AdminFiles => Set<AdminFile>();
// Templates
public DbSet<ProjectContainer> ProjectContainers => Set<ProjectContainer>();
public DbSet<ContainerCategory> ContainerCategories => Set<ContainerCategory>();
public DbSet<ContainerTag> ContainerTags => Set<ContainerTag>();
public DbSet<Project> Projects => Set<Project>();
// Scenes
public DbSet<Scene> Scenes => Set<Scene>();
public DbSet<SceneCategory> SceneCategories => Set<SceneCategory>();
public DbSet<RepeaterItem> RepeaterItems => Set<RepeaterItem>();
public DbSet<SceneContentElement> SceneContentElements => Set<SceneContentElement>();
public DbSet<SceneColorElement> SceneColorElements => Set<SceneColorElement>();
public DbSet<SceneColorPreset> SceneColorPresets => Set<SceneColorPreset>();
public DbSet<SceneColorPresetItem> SceneColorPresetItems => Set<SceneColorPresetItem>();
public DbSet<SharedColor> SharedColors => Set<SharedColor>();
public DbSet<SharedColorPreset> SharedColorPresets => Set<SharedColorPreset>();
public DbSet<SharedColorPresetItem> SharedColorPresetItems => Set<SharedColorPresetItem>();
public DbSet<SharedLayer> SharedLayers => Set<SharedLayer>();
public DbSet<TemplateSvgPreview> TemplateSvgPreviews => Set<TemplateSvgPreview>();
// Characters
public DbSet<SceneCharacter> SceneCharacters => Set<SceneCharacter>();
public DbSet<SceneCharacterController> SceneCharacterControllers => Set<SceneCharacterController>();
public DbSet<SceneControllerOption> SceneControllerOptions => Set<SceneControllerOption>();
public DbSet<ProjectCharacterController> ProjectCharacterControllers => Set<ProjectCharacterController>();
public DbSet<ProjectCharacterControllerOption> ProjectCharacterControllerOptions => Set<ProjectCharacterControllerOption>();
public DbSet<ProjectCharacterPreset> ProjectCharacterPresets => Set<ProjectCharacterPreset>();
public DbSet<PresetCharacterController> PresetCharacterControllers => Set<PresetCharacterController>();
public DbSet<PresetStory> PresetStories => Set<PresetStory>();
public DbSet<PresetScene> PresetScenes => Set<PresetScene>();
// CMS
public DbSet<Blog> Blogs => Set<Blog>();
public DbSet<Comment> Comments => Set<Comment>();
public DbSet<HomePageEvent> HomePageEvents => Set<HomePageEvent>();
public DbSet<NewSlide> NewSlides => Set<NewSlide>();
public DbSet<InternalRoute> InternalRoutes => Set<InternalRoute>();
public DbSet<CustomRoute> CustomRoutes => Set<CustomRoute>();
public DbSet<WebsiteSetting> WebsiteSettings => Set<WebsiteSetting>();
public DbSet<LearnArticle> LearnArticles => Set<LearnArticle>();
public DbSet<Training> Trainings => Set<Training>();
public DbSet<FavoriteFolder> FavoriteFolders => Set<FavoriteFolder>();
public DbSet<FavoriteContainer> FavoriteContainers => Set<FavoriteContainer>();
protected override void OnModelCreating(ModelBuilder mb)
{
mb.HasDefaultSchema("content");
// Native PostgreSQL enums are registered on the EF provider via npgsql.MapEnum<T>()
// in Program.cs (EF Core 9+ approach), covering both model + runtime ADO mapping.
ConfigureTaxonomy(mb);
ConfigureTemplates(mb);
ConfigureScenes(mb);
ConfigureCharacters(mb);
ConfigureCms(mb);
}
private static void ConfigureTaxonomy(ModelBuilder mb)
{
mb.Entity<Category>(e =>
{
e.ToTable("categories");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.ParentId).HasColumnName("parent_id");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Slug).HasColumnName("slug").HasColumnType("citext").IsRequired();
e.Property(x => x.Description).HasColumnName("description");
e.Property(x => x.ImageUrl).HasColumnName("image_url");
e.Property(x => x.Icon).HasColumnName("icon");
e.Property(x => x.MetaTitle).HasColumnName("meta_title");
e.Property(x => x.MetaDescription).HasColumnName("meta_description");
e.Property(x => x.MetaKeywords).HasColumnName("meta_keywords");
e.Property(x => x.BotFollow).HasColumnName("bot_follow");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.Property(x => x.DeletedAt).HasColumnName("deleted_at");
e.HasOne(x => x.Parent).WithMany(x => x.Children).HasForeignKey(x => x.ParentId).OnDelete(DeleteBehavior.Restrict);
e.HasQueryFilter(x => x.DeletedAt == null);
});
mb.Entity<Tag>(e =>
{
e.ToTable("tags");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.LatinName).HasColumnName("latin_name");
e.Property(x => x.Slug).HasColumnName("slug").HasColumnType("citext").IsRequired();
e.Property(x => x.AppliesToMode).HasColumnName("applies_to_mode");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.Property(x => x.DeletedAt).HasColumnName("deleted_at");
e.HasQueryFilter(x => x.DeletedAt == null);
});
mb.Entity<Font>(e =>
{
e.ToTable("fonts");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.OriginalName).HasColumnName("original_name");
e.Property(x => x.SystemName).HasColumnName("system_name");
e.Property(x => x.Family).HasColumnName("family");
e.Property(x => x.Weight).HasColumnName("weight");
e.Property(x => x.Style).HasColumnName("style");
e.Property(x => x.Direction).HasColumnName("direction");
e.Property(x => x.FileUrl).HasColumnName("file_url");
e.Property(x => x.SampleImageUrl).HasColumnName("sample_image_url");
e.Property(x => x.IsPremium).HasColumnName("is_premium");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.InstalledOnNodes).HasColumnName("installed_on_nodes");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.Property(x => x.DeletedAt).HasColumnName("deleted_at");
e.HasQueryFilter(x => x.DeletedAt == null);
});
mb.Entity<MusicTrack>(e =>
{
e.ToTable("music_tracks");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Caption).HasColumnName("caption");
e.Property(x => x.Keywords).HasColumnName("keywords");
e.Property(x => x.Url).HasColumnName("url").IsRequired();
e.Property(x => x.WaveformData).HasColumnName("waveform_data").HasColumnType("jsonb");
e.Property(x => x.DurationSec).HasColumnName("duration_sec");
e.Property(x => x.Bpm).HasColumnName("bpm");
e.Property(x => x.Genre).HasColumnName("genre");
e.Property(x => x.Mood).HasColumnName("mood");
e.Property(x => x.IsPremium).HasColumnName("is_premium");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.Property(x => x.DeletedAt).HasColumnName("deleted_at");
e.HasQueryFilter(x => x.DeletedAt == null);
});
mb.Entity<ProjectServer>(e =>
{
e.ToTable("project_servers");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Region).HasColumnName("region").IsRequired();
e.Property(x => x.Ip).HasColumnName("ip");
e.Property(x => x.PhysicalPathOutput).HasColumnName("physical_path_output");
e.Property(x => x.DefaultProjectAddress).HasColumnName("default_project_address");
e.Property(x => x.RenderOutputLocation).HasColumnName("render_output_location");
e.Property(x => x.PreNeedFolderAddress).HasColumnName("pre_need_folder_address");
e.Property(x => x.MinioEndpoint).HasColumnName("minio_endpoint");
e.Property(x => x.MinioBucketTemplates).HasColumnName("minio_bucket_templates");
e.Property(x => x.MinioBucketOutputs).HasColumnName("minio_bucket_outputs");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
});
mb.Entity<AdminFile>(e =>
{
e.ToTable("admin_files");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.Name).HasColumnName("name");
e.Property(x => x.Url).HasColumnName("url").IsRequired();
e.Property(x => x.ThumbnailUrl).HasColumnName("thumbnail_url");
e.Property(x => x.FileType).HasColumnName("file_type");
e.Property(x => x.SizeBytes).HasColumnName("size_bytes");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
});
}
private static void ConfigureTemplates(ModelBuilder mb)
{
mb.Entity<ProjectContainer>(e =>
{
e.ToTable("project_containers");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Slug).HasColumnName("slug").HasColumnType("citext").IsRequired();
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Description).HasColumnName("description");
e.Property(x => x.Keywords).HasColumnName("keywords");
e.Property(x => x.NewsText).HasColumnName("news_text");
e.Property(x => x.Image).HasColumnName("image");
e.Property(x => x.Demo).HasColumnName("demo");
e.Property(x => x.FullDemo).HasColumnName("full_demo");
e.Property(x => x.MiniDemo).HasColumnName("mini_demo");
e.Property(x => x.DemoScriptTag).HasColumnName("demo_script_tag");
e.Property(x => x.IsPublished).HasColumnName("is_published");
e.Property(x => x.IsPremium).HasColumnName("is_premium");
e.Property(x => x.IsMockup).HasColumnName("is_mockup");
e.Property(x => x.PrimaryMode).HasColumnName("primary_mode");
e.Property(x => x.RateAvg).HasColumnName("rate_avg");
e.Property(x => x.RateCount).HasColumnName("rate_count");
e.Property(x => x.ViewCount).HasColumnName("view_count");
e.Property(x => x.UseCount).HasColumnName("use_count");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.SortDate).HasColumnName("sort_date");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.Property(x => x.DeletedAt).HasColumnName("deleted_at");
e.HasQueryFilter(x => x.DeletedAt == null);
});
mb.Entity<ContainerCategory>(e =>
{
e.ToTable("container_categories");
e.HasKey(x => new { x.ContainerId, x.CategoryId });
e.Property(x => x.ContainerId).HasColumnName("container_id");
e.Property(x => x.CategoryId).HasColumnName("category_id");
e.Property(x => x.Sort).HasColumnName("sort");
e.HasOne(x => x.Container).WithMany(x => x.ContainerCategories).HasForeignKey(x => x.ContainerId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Category).WithMany().HasForeignKey(x => x.CategoryId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<ContainerTag>(e =>
{
e.ToTable("container_tags");
e.HasKey(x => new { x.ContainerId, x.TagId });
e.Property(x => x.ContainerId).HasColumnName("container_id");
e.Property(x => x.TagId).HasColumnName("tag_id");
e.HasOne(x => x.Container).WithMany(x => x.ContainerTags).HasForeignKey(x => x.ContainerId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Tag).WithMany().HasForeignKey(x => x.TagId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<Project>(e =>
{
e.ToTable("projects");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.ContainerId).HasColumnName("container_id");
e.Property(x => x.ProjectServerId).HasColumnName("project_server_id");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Description).HasColumnName("description");
e.Property(x => x.Image).HasColumnName("image");
e.Property(x => x.FullDemo).HasColumnName("full_demo");
e.Property(x => x.DemoScriptTag).HasColumnName("demo_script_tag");
e.Property(x => x.DownloadLink).HasColumnName("download_link");
e.Property(x => x.AepMinioBucket).HasColumnName("aep_minio_bucket");
e.Property(x => x.AepMinioKey).HasColumnName("aep_minio_key");
e.Property(x => x.AepFileUrl).HasColumnName("aep_file_url");
e.Property(x => x.AepFileMd5).HasColumnName("aep_file_md5");
e.Property(x => x.AepFileSizeBytes).HasColumnName("aep_file_size_bytes");
e.Property(x => x.AepUploadedAt).HasColumnName("aep_uploaded_at");
e.Property(x => x.Folder).HasColumnName("folder");
e.Property(x => x.OriginalWidth).HasColumnName("original_width");
e.Property(x => x.OriginalHeight).HasColumnName("original_height");
e.Property(x => x.Aspect).HasColumnName("aspect");
e.Property(x => x.ProjectDurationSec).HasColumnName("project_duration_sec");
e.Property(x => x.MinDurationSec).HasColumnName("min_duration_sec");
e.Property(x => x.MaxDurationSec).HasColumnName("max_duration_sec");
e.Property(x => x.FreeFps).HasColumnName("free_fps");
e.Property(x => x.ChooseMode).HasColumnName("choose_mode");
e.Property(x => x.Resolution).HasColumnName("resolution");
e.Property(x => x.VipFactor).HasColumnName("vip_factor");
e.Property(x => x.RenderAepComp).HasColumnName("render_aep_comp");
e.Property(x => x.SharedLayerImage).HasColumnName("shared_layer_image");
e.Property(x => x.SharedColorsSvg).HasColumnName("shared_colors_svg");
e.Property(x => x.SharedColorPresetsSvg).HasColumnName("shared_color_presets_svg");
e.Property(x => x.IsPublished).HasColumnName("is_published");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.Property(x => x.DeletedAt).HasColumnName("deleted_at");
e.HasOne(x => x.Container).WithMany(x => x.Projects).HasForeignKey(x => x.ContainerId).OnDelete(DeleteBehavior.Cascade);
e.HasQueryFilter(x => x.DeletedAt == null);
});
}
private static void ConfigureScenes(ModelBuilder mb)
{
mb.Entity<Scene>(e =>
{
e.ToTable("scenes");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.ProjectId).HasColumnName("project_id");
e.Property(x => x.Key).HasColumnName("key").IsRequired();
e.Property(x => x.Title).HasColumnName("title").IsRequired();
e.Property(x => x.LocalizedTitle).HasColumnName("localized_title").HasColumnType("jsonb");
e.Property(x => x.SceneType).HasColumnName("scene_type");
e.Property(x => x.Image).HasColumnName("image");
e.Property(x => x.Demo).HasColumnName("demo");
e.Property(x => x.SceneColorSvg).HasColumnName("scene_color_svg");
e.Property(x => x.SnapshotUrl).HasColumnName("snapshot_url");
e.Property(x => x.GenerateKf).HasColumnName("generate_kf");
e.Property(x => x.DefaultDurationSec).HasColumnName("default_duration_sec");
e.Property(x => x.MinDurationSec).HasColumnName("min_duration_sec");
e.Property(x => x.MaxDurationSec).HasColumnName("max_duration_sec");
e.Property(x => x.OverlapAtEndSec).HasColumnName("overlap_at_end_sec");
e.Property(x => x.CanHandleDuration).HasColumnName("can_handle_duration");
e.Property(x => x.ManualColorSelection).HasColumnName("manual_color_selection");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.Property(x => x.DeletedAt).HasColumnName("deleted_at");
e.HasOne(x => x.Project).WithMany(x => x.Scenes).HasForeignKey(x => x.ProjectId).OnDelete(DeleteBehavior.Cascade);
e.HasQueryFilter(x => x.DeletedAt == null);
});
mb.Entity<SceneCategory>(e =>
{
e.ToTable("scene_categories");
e.HasKey(x => new { x.SceneId, x.CategoryId });
e.Property(x => x.SceneId).HasColumnName("scene_id");
e.Property(x => x.CategoryId).HasColumnName("category_id");
e.HasOne(x => x.Scene).WithMany(x => x.SceneCategories).HasForeignKey(x => x.SceneId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Category).WithMany().HasForeignKey(x => x.CategoryId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<RepeaterItem>(e =>
{
e.ToTable("repeater_items");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.SceneId).HasColumnName("scene_id");
e.Property(x => x.Title).HasColumnName("title").IsRequired();
e.Property(x => x.RepeatBoxKey).HasColumnName("repeat_box_key").IsRequired();
e.Property(x => x.RepeatItemKey).HasColumnName("repeat_item_key").IsRequired();
e.Property(x => x.MaxRepeatCount).HasColumnName("max_repeat_count");
e.Property(x => x.UserCanChangeSort).HasColumnName("user_can_change_sort");
e.Property(x => x.RepeatSortStrategy).HasColumnName("repeat_sort_strategy");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasOne(x => x.Scene).WithMany(x => x.RepeaterItems).HasForeignKey(x => x.SceneId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SceneContentElement>(e =>
{
e.ToTable("scene_content_elements");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.SceneId).HasColumnName("scene_id");
e.Property(x => x.RepeaterItemId).HasColumnName("repeater_item_id");
e.Property(x => x.Key).HasColumnName("key").IsRequired();
e.Property(x => x.Title).HasColumnName("title").IsRequired();
e.Property(x => x.LocalizedTitle).HasColumnName("localized_title").HasColumnType("jsonb");
e.Property(x => x.Hint).HasColumnName("hint");
e.Property(x => x.Type).HasColumnName("type");
e.Property(x => x.DefaultValue).HasColumnName("default_value");
e.Property(x => x.FontId).HasColumnName("font_id");
e.Property(x => x.FontFace).HasColumnName("font_face");
e.Property(x => x.FontFaceName).HasColumnName("font_face_name");
e.Property(x => x.FontSize).HasColumnName("font_size");
e.Property(x => x.DefaultFontSize).HasColumnName("default_font_size");
e.Property(x => x.DefaultFontFace).HasColumnName("default_font_face");
e.Property(x => x.IsFontChangeable).HasColumnName("is_font_changeable");
e.Property(x => x.IsFontSizeChangeable).HasColumnName("is_font_size_changeable");
e.Property(x => x.Justify).HasColumnName("justify");
e.Property(x => x.CanJustify).HasColumnName("can_justify");
e.Property(x => x.PositionInContainer).HasColumnName("position_in_container");
e.Property(x => x.IsTextBox).HasColumnName("is_text_box");
e.Property(x => x.MaxSize).HasColumnName("max_size");
e.Property(x => x.DirectionLayerKey).HasColumnName("direction_layer_key");
e.Property(x => x.DirectionLayerValue).HasColumnName("direction_layer_value");
e.Property(x => x.VideoSupport).HasColumnName("video_support");
e.Property(x => x.MinDurationSec).HasColumnName("min_duration_sec");
e.Property(x => x.MaxDurationSec).HasColumnName("max_duration_sec");
e.Property(x => x.Width).HasColumnName("width");
e.Property(x => x.Height).HasColumnName("height");
e.Property(x => x.Thumbnail).HasColumnName("thumbnail");
e.Property(x => x.MappedList).HasColumnName("mapped_list").HasColumnType("jsonb");
e.Property(x => x.CounterMode).HasColumnName("counter_mode");
e.Property(x => x.AiInputType).HasColumnName("ai_input_type");
e.Property(x => x.IsHidden).HasColumnName("is_hidden");
e.Property(x => x.IsFocused).HasColumnName("is_focused");
e.Property(x => x.OpacityControllerKey).HasColumnName("opacity_controller_key");
e.Property(x => x.Dp1Image).HasColumnName("dp1_image");
e.Property(x => x.Dp1Title).HasColumnName("dp1_title");
e.Property(x => x.Dp2Image).HasColumnName("dp2_image");
e.Property(x => x.Dp2Title).HasColumnName("dp2_title");
e.Property(x => x.Dp3Image).HasColumnName("dp3_image");
e.Property(x => x.Dp3Title).HasColumnName("dp3_title");
e.Property(x => x.Dp4Image).HasColumnName("dp4_image");
e.Property(x => x.Dp4Title).HasColumnName("dp4_title");
e.Property(x => x.VirtualCount).HasColumnName("virtual_count");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasOne(x => x.Scene).WithMany(x => x.ContentElements).HasForeignKey(x => x.SceneId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SceneColorElement>(e =>
{
e.ToTable("scene_color_elements");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.SceneId).HasColumnName("scene_id");
e.Property(x => x.ElementKey).HasColumnName("element_key").IsRequired();
e.Property(x => x.Title).HasColumnName("title").IsRequired();
e.Property(x => x.Icon).HasColumnName("icon");
e.Property(x => x.AttrValue).HasColumnName("attr_value");
e.Property(x => x.DefaultColor).HasColumnName("default_color").IsRequired();
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasOne(x => x.Scene).WithMany(x => x.ColorElements).HasForeignKey(x => x.SceneId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SceneColorPreset>(e =>
{
e.ToTable("scene_color_presets");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.SceneId).HasColumnName("scene_id");
e.Property(x => x.Name).HasColumnName("name");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.HasOne<Scene>().WithMany(x => x.ColorPresets).HasForeignKey(x => x.SceneId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SceneColorPresetItem>(e =>
{
e.ToTable("scene_color_preset_items");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.PresetId).HasColumnName("preset_id");
e.Property(x => x.ElementKey).HasColumnName("element_key").IsRequired();
e.Property(x => x.Value).HasColumnName("value").IsRequired();
e.Property(x => x.Sort).HasColumnName("sort");
e.HasOne(x => x.Preset).WithMany(x => x.Items).HasForeignKey(x => x.PresetId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SharedColor>(e =>
{
e.ToTable("shared_colors");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.ProjectId).HasColumnName("project_id");
e.Property(x => x.ElementKey).HasColumnName("element_key").IsRequired();
e.Property(x => x.Title).HasColumnName("title").IsRequired();
e.Property(x => x.Icon).HasColumnName("icon");
e.Property(x => x.AttrValue).HasColumnName("attr_value");
e.Property(x => x.DefaultColor).HasColumnName("default_color").IsRequired();
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasOne(x => x.Project).WithMany(x => x.SharedColors).HasForeignKey(x => x.ProjectId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SharedColorPreset>(e =>
{
e.ToTable("shared_color_presets");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.ProjectId).HasColumnName("project_id");
e.Property(x => x.Name).HasColumnName("name");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.HasOne(x => x.Project).WithMany(x => x.SharedColorPresets).HasForeignKey(x => x.ProjectId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SharedColorPresetItem>(e =>
{
e.ToTable("shared_color_preset_items");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.PresetId).HasColumnName("preset_id");
e.Property(x => x.ElementKey).HasColumnName("element_key").IsRequired();
e.Property(x => x.Value).HasColumnName("value").IsRequired();
e.Property(x => x.Sort).HasColumnName("sort");
e.HasOne(x => x.Preset).WithMany(x => x.Items).HasForeignKey(x => x.PresetId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SharedLayer>(e =>
{
e.ToTable("shared_layers");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.ProjectId).HasColumnName("project_id");
e.Property(x => x.Key).HasColumnName("key").IsRequired();
e.Property(x => x.Title).HasColumnName("title").IsRequired();
e.Property(x => x.LocalizedTitle).HasColumnName("localized_title").HasColumnType("jsonb");
e.Property(x => x.Hint).HasColumnName("hint");
e.Property(x => x.Type).HasColumnName("type");
e.Property(x => x.DefaultValue).HasColumnName("default_value");
e.Property(x => x.FontId).HasColumnName("font_id");
e.Property(x => x.FontFace).HasColumnName("font_face");
e.Property(x => x.FontFaceName).HasColumnName("font_face_name");
e.Property(x => x.FontSize).HasColumnName("font_size");
e.Property(x => x.DefaultFontSize).HasColumnName("default_font_size");
e.Property(x => x.DefaultFontFace).HasColumnName("default_font_face");
e.Property(x => x.IsFontChangeable).HasColumnName("is_font_changeable");
e.Property(x => x.IsFontSizeChangeable).HasColumnName("is_font_size_changeable");
e.Property(x => x.Justify).HasColumnName("justify");
e.Property(x => x.CanJustify).HasColumnName("can_justify");
e.Property(x => x.PositionInContainer).HasColumnName("position_in_container");
e.Property(x => x.IsTextBox).HasColumnName("is_text_box");
e.Property(x => x.MaxSize).HasColumnName("max_size");
e.Property(x => x.DirectionLayerKey).HasColumnName("direction_layer_key");
e.Property(x => x.DirectionLayerValue).HasColumnName("direction_layer_value");
e.Property(x => x.VideoSupport).HasColumnName("video_support");
e.Property(x => x.MinDurationSec).HasColumnName("min_duration_sec");
e.Property(x => x.MaxDurationSec).HasColumnName("max_duration_sec");
e.Property(x => x.Width).HasColumnName("width");
e.Property(x => x.Height).HasColumnName("height");
e.Property(x => x.Thumbnail).HasColumnName("thumbnail");
e.Property(x => x.MappedList).HasColumnName("mapped_list").HasColumnType("jsonb");
e.Property(x => x.CounterMode).HasColumnName("counter_mode");
e.Property(x => x.AiInputType).HasColumnName("ai_input_type");
e.Property(x => x.IsHidden).HasColumnName("is_hidden");
e.Property(x => x.IsFocused).HasColumnName("is_focused");
e.Property(x => x.Dp1Image).HasColumnName("dp1_image");
e.Property(x => x.Dp1Title).HasColumnName("dp1_title");
e.Property(x => x.Dp2Image).HasColumnName("dp2_image");
e.Property(x => x.Dp2Title).HasColumnName("dp2_title");
e.Property(x => x.Dp3Image).HasColumnName("dp3_image");
e.Property(x => x.Dp3Title).HasColumnName("dp3_title");
e.Property(x => x.Dp4Image).HasColumnName("dp4_image");
e.Property(x => x.Dp4Title).HasColumnName("dp4_title");
e.Property(x => x.VirtualCount).HasColumnName("virtual_count");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasOne(x => x.Project).WithMany(x => x.SharedLayers).HasForeignKey(x => x.ProjectId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<TemplateSvgPreview>(e =>
{
e.ToTable("template_svg_previews");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.ProjectId).HasColumnName("project_id");
e.Property(x => x.SceneId).HasColumnName("scene_id");
e.Property(x => x.SourceImageUrl).HasColumnName("source_image_url");
e.Property(x => x.SvgUrl).HasColumnName("svg_url").IsRequired();
e.Property(x => x.ThumbnailUrl).HasColumnName("thumbnail_url");
e.Property(x => x.ColorZones).HasColumnName("color_zones").HasColumnType("jsonb").IsRequired();
e.Property(x => x.Width).HasColumnName("width");
e.Property(x => x.Height).HasColumnName("height");
e.Property(x => x.GenerationMethod).HasColumnName("generation_method");
e.Property(x => x.GeneratedByAi).HasColumnName("generated_by_ai");
e.Property(x => x.QualityScore).HasColumnName("quality_score");
e.Property(x => x.CreatedByUserId).HasColumnName("created_by_user_id");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
});
}
private static void ConfigureCharacters(ModelBuilder mb)
{
mb.Entity<SceneCharacter>(e =>
{
e.ToTable("scene_characters");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.SceneId).HasColumnName("scene_id");
e.Property(x => x.Key).HasColumnName("key").IsRequired();
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Icon).HasColumnName("icon");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasOne(x => x.Scene).WithMany(x => x.Characters).HasForeignKey(x => x.SceneId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SceneCharacterController>(e =>
{
e.ToTable("scene_character_controllers");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.SceneCharacterId).HasColumnName("scene_character_id");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Key).HasColumnName("key").IsRequired();
e.Property(x => x.DefaultValue).HasColumnName("default_value");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasOne(x => x.Character).WithMany(x => x.Controllers).HasForeignKey(x => x.SceneCharacterId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<SceneControllerOption>(e =>
{
e.ToTable("scene_controller_options");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.ControllerId).HasColumnName("controller_id");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Icon).HasColumnName("icon");
e.Property(x => x.Value).HasColumnName("value").IsRequired();
e.Property(x => x.Sort).HasColumnName("sort");
e.HasOne(x => x.Controller).WithMany(x => x.Options).HasForeignKey(x => x.ControllerId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<ProjectCharacterController>(e =>
{
e.ToTable("project_character_controllers");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.ProjectId).HasColumnName("project_id");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Key).HasColumnName("key").IsRequired();
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasOne(x => x.Project).WithMany(x => x.CharacterControllers).HasForeignKey(x => x.ProjectId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<ProjectCharacterControllerOption>(e =>
{
e.ToTable("project_character_controller_options");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.ControllerId).HasColumnName("controller_id");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Icon).HasColumnName("icon");
e.Property(x => x.Value).HasColumnName("value").IsRequired();
e.Property(x => x.Sort).HasColumnName("sort");
e.HasOne(x => x.Controller).WithMany(x => x.Options).HasForeignKey(x => x.ControllerId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<ProjectCharacterPreset>(e =>
{
e.ToTable("project_character_presets");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.ProjectId).HasColumnName("project_id");
e.Property(x => x.Key).HasColumnName("key");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Icon).HasColumnName("icon");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasOne(x => x.Project).WithMany(x => x.CharacterPresets).HasForeignKey(x => x.ProjectId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<PresetCharacterController>(e =>
{
e.ToTable("preset_character_controllers");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.CharacterPresetId).HasColumnName("character_preset_id");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Key).HasColumnName("key").IsRequired();
e.Property(x => x.Value).HasColumnName("value").IsRequired();
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.HasOne(x => x.Preset).WithMany(x => x.PresetControllers).HasForeignKey(x => x.CharacterPresetId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<PresetStory>(e =>
{
e.ToTable("preset_stories");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.ProjectId).HasColumnName("project_id");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Description).HasColumnName("description");
e.Property(x => x.Demo).HasColumnName("demo");
e.Property(x => x.MusicId).HasColumnName("music_id");
e.Property(x => x.ScenesSpa).HasColumnName("scenes_spa");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.IsPublished).HasColumnName("is_published");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.Property(x => x.DeletedAt).HasColumnName("deleted_at");
e.HasOne(x => x.Project).WithMany(x => x.PresetStories).HasForeignKey(x => x.ProjectId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Music).WithMany().HasForeignKey(x => x.MusicId).OnDelete(DeleteBehavior.SetNull);
e.HasQueryFilter(x => x.DeletedAt == null);
});
mb.Entity<PresetScene>(e =>
{
e.ToTable("preset_scenes");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.PresetStoryId).HasColumnName("preset_story_id");
e.Property(x => x.SceneId).HasColumnName("scene_id");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.DefaultDurationSec).HasColumnName("default_duration_sec");
e.HasOne(x => x.Story).WithMany(x => x.Scenes).HasForeignKey(x => x.PresetStoryId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Scene).WithMany().HasForeignKey(x => x.SceneId).OnDelete(DeleteBehavior.Cascade);
});
}
private static void ConfigureCms(ModelBuilder mb)
{
mb.Entity<Blog>(e =>
{
e.ToTable("blogs");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Kind).HasColumnName("kind");
e.Property(x => x.Slug).HasColumnName("slug").HasColumnType("citext").IsRequired();
e.Property(x => x.Title).HasColumnName("title").IsRequired();
e.Property(x => x.ShortDescription).HasColumnName("short_description");
e.Property(x => x.Content).HasColumnName("content").IsRequired();
e.Property(x => x.MetaTitle).HasColumnName("meta_title");
e.Property(x => x.MetaDescription).HasColumnName("meta_description");
e.Property(x => x.MetaKeywords).HasColumnName("meta_keywords");
e.Property(x => x.IncludeInSiteMap).HasColumnName("include_in_site_map");
e.Property(x => x.Image).HasColumnName("image");
e.Property(x => x.Cover).HasColumnName("cover");
e.Property(x => x.AuthorUserId).HasColumnName("author_user_id");
e.Property(x => x.AuthorDisplayName).HasColumnName("author_display_name");
e.Property(x => x.IsPublished).HasColumnName("is_published");
e.Property(x => x.PublishDate).HasColumnName("publish_date");
e.Property(x => x.ViewCount).HasColumnName("view_count");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.Property(x => x.DeletedAt).HasColumnName("deleted_at");
e.HasQueryFilter(x => x.DeletedAt == null);
});
mb.Entity<Comment>(e =>
{
e.ToTable("comments");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.UserId).HasColumnName("user_id");
e.Property(x => x.BlogId).HasColumnName("blog_id");
e.Property(x => x.ContainerId).HasColumnName("container_id");
e.Property(x => x.ParentCommentId).HasColumnName("parent_comment_id");
e.Property(x => x.Content).HasColumnName("content").IsRequired();
e.Property(x => x.Rate).HasColumnName("rate");
e.Property(x => x.IsApproved).HasColumnName("is_approved");
e.Property(x => x.IsPinned).HasColumnName("is_pinned");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.Property(x => x.DeletedAt).HasColumnName("deleted_at");
e.HasOne(x => x.Blog).WithMany(x => x.Comments).HasForeignKey(x => x.BlogId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Container).WithMany(x => x.Comments).HasForeignKey(x => x.ContainerId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.ParentComment).WithMany(x => x.Replies).HasForeignKey(x => x.ParentCommentId).OnDelete(DeleteBehavior.Cascade);
e.HasQueryFilter(x => x.DeletedAt == null);
});
mb.Entity<HomePageEvent>(e =>
{
e.ToTable("home_page_events");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Title).HasColumnName("title");
e.Property(x => x.Subtitle).HasColumnName("subtitle");
e.Property(x => x.Description).HasColumnName("description");
e.Property(x => x.Badge).HasColumnName("badge");
e.Property(x => x.BadgeClass).HasColumnName("badge_class");
e.Property(x => x.ButtonText).HasColumnName("button_text");
e.Property(x => x.ButtonUrl).HasColumnName("button_url");
e.Property(x => x.ButtonClass).HasColumnName("button_class");
e.Property(x => x.Color).HasColumnName("color");
e.Property(x => x.BackgroundColor).HasColumnName("background_color");
e.Property(x => x.TextColor).HasColumnName("text_color");
e.Property(x => x.Image).HasColumnName("image");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.StartsAt).HasColumnName("starts_at");
e.Property(x => x.EndsAt).HasColumnName("ends_at");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
});
mb.Entity<NewSlide>(e =>
{
e.ToTable("new_slides");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Keyword).HasColumnName("keyword");
e.Property(x => x.Title).HasColumnName("title");
e.Property(x => x.Image).HasColumnName("image");
e.Property(x => x.Parameter).HasColumnName("parameter");
e.Property(x => x.SlideType).HasColumnName("slide_type");
e.Property(x => x.ExpireDate).HasColumnName("expire_date");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
});
mb.Entity<InternalRoute>(e =>
{
e.ToTable("internal_routes");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Name).HasColumnName("name");
e.Property(x => x.Image).HasColumnName("image");
e.Property(x => x.Slug).HasColumnName("slug").HasColumnType("citext").IsRequired();
e.Property(x => x.Priority).HasColumnName("priority");
e.Property(x => x.LastDate).HasColumnName("last_date");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
});
mb.Entity<CustomRoute>(e =>
{
e.ToTable("custom_routes");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Target).HasColumnName("target").IsRequired();
e.Property(x => x.Destination).HasColumnName("destination").IsRequired();
e.Property(x => x.RedirectCode).HasColumnName("redirect_code");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
});
mb.Entity<WebsiteSetting>(e =>
{
e.ToTable("website_settings");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Key).HasColumnName("key").IsRequired();
e.Property(x => x.Value).HasColumnName("value").HasColumnType("jsonb").IsRequired();
e.Property(x => x.Description).HasColumnName("description");
e.Property(x => x.IsSecret).HasColumnName("is_secret");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
});
mb.Entity<LearnArticle>(e =>
{
e.ToTable("learn");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Title).HasColumnName("title").IsRequired();
e.Property(x => x.Body).HasColumnName("body");
e.Property(x => x.DemoUrl).HasColumnName("demo_url");
e.Property(x => x.Mode).HasColumnName("mode");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
});
mb.Entity<Training>(e =>
{
e.ToTable("trainings");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Title).HasColumnName("title").IsRequired();
e.Property(x => x.Description).HasColumnName("description");
e.Property(x => x.VideoUrl).HasColumnName("video_url");
e.Property(x => x.ThumbnailUrl).HasColumnName("thumbnail_url");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.IsPublished).HasColumnName("is_published");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
});
mb.Entity<FavoriteFolder>(e =>
{
e.ToTable("favorite_folders");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.UserId).HasColumnName("user_id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Description).HasColumnName("description");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
});
mb.Entity<FavoriteContainer>(e =>
{
e.ToTable("favorite_containers");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.UserId).HasColumnName("user_id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.ContainerId).HasColumnName("container_id");
e.Property(x => x.FolderId).HasColumnName("folder_id");
e.Property(x => x.Note).HasColumnName("note");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.HasOne(x => x.Container).WithMany().HasForeignKey(x => x.ContainerId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Folder).WithMany(x => x.Containers).HasForeignKey(x => x.FolderId).OnDelete(DeleteBehavior.SetNull);
});
}
}
@@ -0,0 +1,18 @@
using Npgsql;
namespace FlatRender.ContentSvc.Infrastructure.Data;
/// <summary>
/// Npgsql name translator that returns CLR names verbatim. The database enum labels
/// match the C# enum member names exactly (e.g. 'FIX', 'MockUp', 'fill', 'LEFT_JUSTIFY'),
/// so no snake_case translation may be applied to enum values. PG type names are passed
/// explicitly wherever this translator is used, so type-name translation is moot.
/// </summary>
public sealed class PreserveCaseNameTranslator : INpgsqlNameTranslator
{
public static readonly PreserveCaseNameTranslator Instance = new();
public string TranslateTypeName(string clrName) => clrName;
public string TranslateMemberName(string clrName) => clrName;
}
@@ -0,0 +1,44 @@
using System.Text.Json;
using FlatRender.ContentSvc.Models.Responses;
namespace FlatRender.ContentSvc.Middleware;
public class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
public async Task InvokeAsync(HttpContext ctx)
{
try
{
await next(ctx);
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled exception");
await WriteError(ctx, ex);
}
}
private static Task WriteError(HttpContext ctx, Exception ex)
{
var (status, code) = ex switch
{
KeyNotFoundException => (404, "not_found"),
UnauthorizedAccessException => (401, "unauthorized"),
InvalidOperationException => (400, "invalid_operation"),
ArgumentException => (400, "bad_request"),
NotImplementedException => (501, "not_implemented"),
_ => (500, "internal_error")
};
ctx.Response.StatusCode = status;
ctx.Response.ContentType = "application/json";
var error = new { error = new ApiError(code, ex.Message, ctx.TraceIdentifier) };
return ctx.Response.WriteAsync(JsonSerializer.Serialize(error, JsonOptions));
}
}
@@ -0,0 +1,300 @@
namespace FlatRender.ContentSvc.Models.Requests;
// ── Taxonomy ─────────────────────────────────────────────────────────────────
public record CreateCategoryRequest(
Guid? ParentId,
string Name,
string Slug,
string? Description,
string? ImageUrl,
string? Icon,
string? MetaTitle,
string? MetaDescription,
string? MetaKeywords,
bool BotFollow,
int Sort,
bool IsActive
);
public record UpdateCategoryRequest(
Guid? ParentId,
string Name,
string Slug,
string? Description,
string? ImageUrl,
string? Icon,
string? MetaTitle,
string? MetaDescription,
string? MetaKeywords,
bool BotFollow,
int Sort,
bool IsActive
);
public record CreateTagRequest(
string Name,
string? LatinName,
string Slug,
string? AppliesToMode,
bool IsActive
);
public record UpdateTagRequest(
string Name,
string? LatinName,
string Slug,
string? AppliesToMode,
bool IsActive
);
public record CreateFontRequest(
string Name,
string? OriginalName,
string? SystemName,
string? Family,
int? Weight,
string? Style,
string Direction,
string? FileUrl,
string? SampleImageUrl,
bool IsPremium,
bool IsActive,
bool InstalledOnNodes,
int Sort
);
public record UpdateFontRequest(
string Name,
string? OriginalName,
string? SystemName,
string? Family,
int? Weight,
string? Style,
string Direction,
string? FileUrl,
string? SampleImageUrl,
bool IsPremium,
bool IsActive,
bool InstalledOnNodes,
int Sort
);
public record CreateMusicTrackRequest(
string Name,
string? Caption,
string? Keywords,
string Url,
string? WaveformData,
decimal DurationSec,
int? Bpm,
string? Genre,
string? Mood,
bool IsPremium,
bool IsActive,
int Sort
);
public record UpdateMusicTrackRequest(
string Name,
string? Caption,
string? Keywords,
string Url,
string? WaveformData,
decimal DurationSec,
int? Bpm,
string? Genre,
string? Mood,
bool IsPremium,
bool IsActive,
int Sort
);
// ── Templates ────────────────────────────────────────────────────────────────
public record CreateContainerRequest(
string Slug,
string Name,
string? Description,
string? Keywords,
string? NewsText,
string? Image,
string? Demo,
string? FullDemo,
string? MiniDemo,
string? DemoScriptTag,
bool IsPublished,
bool IsPremium,
bool IsMockup,
string PrimaryMode,
int Sort,
List<Guid> CategoryIds,
List<Guid> TagIds
);
public record UpdateContainerRequest(
string Slug,
string Name,
string? Description,
string? Keywords,
string? NewsText,
string? Image,
string? Demo,
string? FullDemo,
string? MiniDemo,
string? DemoScriptTag,
bool IsPublished,
bool IsPremium,
bool IsMockup,
string PrimaryMode,
int Sort,
List<Guid> CategoryIds,
List<Guid> TagIds
);
public record ContainerListRequest(
int Page = 1,
int PageSize = 20,
string? Search = null,
Guid? CategoryId = null,
string? TagSlug = null,
bool? IsPublished = null,
bool? IsPremium = null,
string? Mode = null,
string? Sort = "sort_date_desc"
);
public record CreateProjectRequest(
Guid ContainerId,
Guid? ProjectServerId,
string Name,
string? Description,
string? Image,
string? FullDemo,
string? DemoScriptTag,
string? DownloadLink,
string? Folder,
int OriginalWidth,
int OriginalHeight,
string? Aspect,
decimal ProjectDurationSec,
decimal? MinDurationSec,
decimal? MaxDurationSec,
int FreeFps,
string ChooseMode,
string Resolution,
decimal VipFactor,
string RenderAepComp,
bool IsPublished,
int Sort
);
public record UpdateProjectRequest(
string Name,
string? Description,
string? Image,
string? FullDemo,
string? DemoScriptTag,
string? DownloadLink,
string? Folder,
int OriginalWidth,
int OriginalHeight,
string? Aspect,
decimal ProjectDurationSec,
decimal? MinDurationSec,
decimal? MaxDurationSec,
int FreeFps,
string ChooseMode,
string Resolution,
decimal VipFactor,
string RenderAepComp,
string? SharedLayerImage,
string? SharedColorsSvg,
string? SharedColorPresetsSvg,
bool IsPublished,
int Sort
);
// ── CMS ──────────────────────────────────────────────────────────────────────
public record CreateBlogRequest(
string Slug,
string Title,
string? ShortDescription,
string Content,
string? MetaTitle,
string? MetaDescription,
string? MetaKeywords,
bool IncludeInSiteMap,
string? Image,
string? Cover,
string? AuthorDisplayName,
bool IsPublished,
DateTime? PublishDate,
string Kind = "Blog"
);
public record UpdateBlogRequest(
string Slug,
string Title,
string? ShortDescription,
string Content,
string? MetaTitle,
string? MetaDescription,
string? MetaKeywords,
bool IncludeInSiteMap,
string? Image,
string? Cover,
string? AuthorDisplayName,
bool IsPublished,
DateTime? PublishDate
);
public record BlogListRequest(
int Page = 1,
int PageSize = 20,
string? Search = null,
bool? IsPublished = null,
string Kind = "Blog"
);
public record CreateCommentRequest(
Guid? BlogId,
Guid? ContainerId,
Guid? ParentCommentId,
string Content,
decimal? Rate
);
public record CreateSlideRequest(
string? Keyword,
string? Title,
string? Image,
string? Parameter,
string SlideType,
DateTime? ExpireDate,
int Sort,
bool IsActive
);
public record UpdateSlideRequest(
string? Keyword,
string? Title,
string? Image,
string? Parameter,
string SlideType,
DateTime? ExpireDate,
int Sort,
bool IsActive
);
public record UpsertWebsiteSettingRequest(
string Key,
string Value,
string? Description,
bool IsSecret
);
public record CreateFavoriteFolderRequest(string Name, string? Description);
public record UpdateFavoriteFolderRequest(string Name, string? Description);
public record AddFavoriteContainerRequest(Guid ContainerId, Guid? FolderId, string? Note);
@@ -0,0 +1,452 @@
using FlatRender.ContentSvc.Domain.Enums;
namespace FlatRender.ContentSvc.Models.Responses;
// ── Pagination ───────────────────────────────────────────────────────────────
public record PagedResponse<T>(IEnumerable<T> Items, PaginationMeta Meta);
public record PaginationMeta(int Page, int PageSize, long Total, int TotalPages);
public record ApiError(string Code, string Message, string? TraceId = null);
// ── Taxonomy ─────────────────────────────────────────────────────────────────
public record CategoryResponse(
Guid Id,
Guid? ParentId,
string Name,
string Slug,
string? Description,
string? ImageUrl,
string? Icon,
bool IsActive,
int Sort,
List<CategoryResponse> Children
);
public record TagResponse(
Guid Id,
string Name,
string? LatinName,
string Slug,
string? AppliesToMode,
bool IsActive
);
public record FontResponse(
Guid Id,
string Name,
string? OriginalName,
string? SystemName,
string? Family,
int? Weight,
string? Style,
string Direction,
string? FileUrl,
string? SampleImageUrl,
bool IsPremium,
bool IsActive,
bool InstalledOnNodes
);
public record MusicTrackResponse(
Guid Id,
string Name,
string? Caption,
string Url,
string? WaveformData,
decimal DurationSec,
int? Bpm,
string? Genre,
string? Mood,
bool IsPremium
);
// ── Templates ────────────────────────────────────────────────────────────────
public record ContainerSummaryResponse(
Guid Id,
string Slug,
string Name,
string? Description,
string? Image,
string? Demo,
string? MiniDemo,
bool IsPublished,
bool IsPremium,
bool IsMockup,
string PrimaryMode,
decimal? RateAvg,
int RateCount,
long ViewCount,
long UseCount,
int Sort,
DateTime SortDate,
List<string> CategorySlugs,
List<string> Tags
);
public record ContainerDetailResponse(
Guid Id,
string Slug,
string Name,
string? Description,
string? Keywords,
string? NewsText,
string? Image,
string? Demo,
string? FullDemo,
string? MiniDemo,
string? DemoScriptTag,
bool IsPublished,
bool IsPremium,
bool IsMockup,
string PrimaryMode,
decimal? RateAvg,
int RateCount,
long ViewCount,
long UseCount,
int Sort,
DateTime SortDate,
List<ProjectResponse> Projects,
List<CategoryResponse> Categories,
List<TagResponse> Tags
);
public record ProjectResponse(
Guid Id,
Guid ContainerId,
string Name,
string? Image,
string? FullDemo,
int OriginalWidth,
int OriginalHeight,
string? Aspect,
decimal ProjectDurationSec,
decimal? MinDurationSec,
decimal? MaxDurationSec,
int FreeFps,
string ChooseMode,
string Resolution,
bool IsPublished,
int Sort
);
public record ProjectDetailResponse(
Guid Id,
Guid ContainerId,
string Name,
string? Description,
string? Image,
string? FullDemo,
string? DemoScriptTag,
string? DownloadLink,
int OriginalWidth,
int OriginalHeight,
string? Aspect,
decimal ProjectDurationSec,
decimal? MinDurationSec,
decimal? MaxDurationSec,
int FreeFps,
string ChooseMode,
string Resolution,
decimal VipFactor,
string RenderAepComp,
string? SharedLayerImage,
bool IsPublished,
int Sort,
List<SceneResponse> Scenes,
List<SharedColorResponse> SharedColors,
List<SharedLayerResponse> SharedLayers
);
// ── Scenes ───────────────────────────────────────────────────────────────────
public record SceneResponse(
Guid Id,
Guid ProjectId,
string Key,
string Title,
string? LocalizedTitle,
string SceneType,
string? Image,
string? Demo,
string? SnapshotUrl,
bool GenerateKf,
decimal? DefaultDurationSec,
decimal? MinDurationSec,
decimal? MaxDurationSec,
decimal OverlapAtEndSec,
bool CanHandleDuration,
bool ManualColorSelection,
int Sort,
bool IsActive
);
public record SceneDetailResponse(
Guid Id,
Guid ProjectId,
string Key,
string Title,
string? LocalizedTitle,
string SceneType,
string? Image,
string? Demo,
string? SnapshotUrl,
bool GenerateKf,
decimal? DefaultDurationSec,
decimal? MinDurationSec,
decimal? MaxDurationSec,
decimal OverlapAtEndSec,
bool CanHandleDuration,
bool ManualColorSelection,
int Sort,
bool IsActive,
List<RepeaterItemResponse> RepeaterItems,
List<ContentElementResponse> ContentElements,
List<ColorElementResponse> ColorElements,
List<ColorPresetResponse> ColorPresets,
List<CharacterResponse> Characters
);
public record RepeaterItemResponse(
Guid Id,
string Title,
string RepeatBoxKey,
string RepeatItemKey,
int MaxRepeatCount,
bool UserCanChangeSort,
string RepeatSortStrategy,
int Sort,
List<ContentElementResponse> ContentElements
);
public record ContentElementResponse(
Guid Id,
Guid? RepeaterItemId,
string Key,
string Title,
string? LocalizedTitle,
string? Hint,
string Type,
string? DefaultValue,
Guid? FontId,
string? FontFace,
string? FontFaceName,
int? FontSize,
int? DefaultFontSize,
string? DefaultFontFace,
bool IsFontChangeable,
bool IsFontSizeChangeable,
string Justify,
bool CanJustify,
int PositionInContainer,
bool IsTextBox,
int? MaxSize,
string? DirectionLayerKey,
int DirectionLayerValue,
bool VideoSupport,
decimal? MinDurationSec,
decimal? MaxDurationSec,
int? Width,
int? Height,
string? Thumbnail,
string? MappedList,
string? CounterMode,
string AiInputType,
bool IsHidden,
bool IsFocused,
string? OpacityControllerKey,
int VirtualCount,
int Sort
);
public record ColorElementResponse(
Guid Id,
string ElementKey,
string Title,
string? Icon,
string AttrValue,
string DefaultColor,
int Sort
);
public record ColorPresetResponse(
Guid Id,
string? Name,
int Sort,
List<ColorPresetItemResponse> Items
);
public record ColorPresetItemResponse(
Guid Id,
string ElementKey,
string Value,
int Sort
);
public record SharedColorResponse(
Guid Id,
string ElementKey,
string Title,
string? Icon,
string AttrValue,
string DefaultColor,
int Sort
);
public record SharedLayerResponse(
Guid Id,
string Key,
string Title,
string? LocalizedTitle,
string? Hint,
string Type,
string? DefaultValue,
Guid? FontId,
string? FontFace,
int? FontSize,
bool IsFontChangeable,
bool IsFontSizeChangeable,
string Justify,
bool CanJustify,
int PositionInContainer,
bool IsTextBox,
int? MaxSize,
bool VideoSupport,
decimal? MinDurationSec,
decimal? MaxDurationSec,
int? Width,
int? Height,
string? MappedList,
string AiInputType,
bool IsHidden,
bool IsFocused,
int VirtualCount,
int Sort
);
// ── Characters ───────────────────────────────────────────────────────────────
public record CharacterResponse(
Guid Id,
string Key,
string Name,
string? Icon,
int Sort,
List<CharacterControllerResponse> Controllers
);
public record CharacterControllerResponse(
Guid Id,
string Name,
string Key,
string? DefaultValue,
int Sort,
List<ControllerOptionResponse> Options
);
public record ControllerOptionResponse(
Guid Id,
string Name,
string? Icon,
string Value,
int Sort
);
// ── CMS ──────────────────────────────────────────────────────────────────────
public record BlogSummaryResponse(
Guid Id,
string Slug,
string Title,
string? ShortDescription,
string? Image,
string? Cover,
string? AuthorDisplayName,
bool IsPublished,
DateTime? PublishDate,
long ViewCount,
DateTime CreatedAt
);
public record BlogDetailResponse(
Guid Id,
string Slug,
string Title,
string? ShortDescription,
string Content,
string? MetaTitle,
string? MetaDescription,
string? MetaKeywords,
bool IncludeInSiteMap,
string? Image,
string? Cover,
Guid? AuthorUserId,
string? AuthorDisplayName,
bool IsPublished,
DateTime? PublishDate,
long ViewCount,
DateTime CreatedAt,
DateTime UpdatedAt
);
public record CommentResponse(
Guid Id,
Guid UserId,
Guid? BlogId,
Guid? ContainerId,
Guid? ParentCommentId,
string Content,
decimal? Rate,
bool IsApproved,
bool IsPinned,
DateTime CreatedAt
);
public record SlideResponse(
Guid Id,
string? Keyword,
string? Title,
string? Image,
string? Parameter,
string SlideType,
DateTime? ExpireDate,
int Sort,
bool IsActive
);
public record HomePageEventResponse(
Guid Id,
string? Title,
string? Subtitle,
string? Description,
string? Badge,
string? BadgeClass,
string? ButtonText,
string? ButtonUrl,
string? ButtonClass,
string? Color,
string? BackgroundColor,
string? TextColor,
string? Image,
bool IsActive,
int Sort,
DateTime? StartsAt,
DateTime? EndsAt
);
public record WebsiteSettingResponse(
Guid Id,
string Key,
string Value,
string? Description,
bool IsSecret
);
public record FavoriteFolderResponse(
Guid Id,
string Name,
string? Description,
int ContainerCount,
DateTime CreatedAt
);
@@ -0,0 +1,127 @@
using System.Text;
using FlatRender.ContentSvc.Application.Services;
using FlatRender.ContentSvc.Domain.Enums;
using FlatRender.ContentSvc.Infrastructure.Data;
using FlatRender.ContentSvc.Middleware;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Npgsql;
var builder = WebApplication.CreateBuilder(args);
// ── Database ──────────────────────────────────────────────────────────────────
// Native PostgreSQL enums are mapped on the EF provider so Npgsql can read/write
// them at runtime (HasPostgresEnum in the model alone is not enough on Npgsql 8+).
// PG labels match the C# enum member names exactly, so preserve case verbatim.
var enumTr = PreserveCaseNameTranslator.Instance;
builder.Services.AddDbContext<ContentDbContext>(options =>
options.UseNpgsql(
builder.Configuration.GetConnectionString("Postgres"),
npgsql =>
{
npgsql.MapEnum<ChooseMode>("choose_mode", "content", enumTr);
npgsql.MapEnum<ResolutionKind>("resolution_kind", "content", enumTr);
npgsql.MapEnum<SceneKind>("scene_kind", "content", enumTr);
npgsql.MapEnum<ContentElementType>("content_element_type", "content", enumTr);
npgsql.MapEnum<JustifyKind>("justify_kind", "content", enumTr);
npgsql.MapEnum<AiInputType>("ai_input_type", "content", enumTr);
npgsql.MapEnum<RepeatSortStrategy>("repeat_sort_strategy", "content", enumTr);
npgsql.MapEnum<AttrValueKind>("attr_value_kind", "content", enumTr);
npgsql.MapEnum<BlogKind>("blog_kind", "content", enumTr);
npgsql.MapEnum<SlideType>("slide_type", "content", enumTr);
})
.UseSnakeCaseNamingConvention());
// ── JWT Auth ──────────────────────────────────────────────────────────────────
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
};
});
builder.Services.AddAuthorization();
// ── Application Services ──────────────────────────────────────────────────────
builder.Services.AddScoped<TaxonomyService>();
builder.Services.AddScoped<TemplateService>();
builder.Services.AddScoped<CmsService>();
// ── HTTP ──────────────────────────────────────────────────────────────────────
builder.Services.AddRouting(opts =>
{
opts.LowercaseUrls = true;
opts.AppendTrailingSlash = false; // prevent 301 redirects from gateway calls
});
builder.Services.AddControllers()
.AddJsonOptions(opts =>
{
opts.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "FlatRender Content API", Version = "v1" });
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "JWT Bearer token"
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } },
Array.Empty<string>()
}
});
});
builder.Services.AddHealthChecks()
.AddCheck("db", () => HealthCheckResult.Healthy());
builder.Services.AddCors(opts => opts.AddDefaultPolicy(p =>
p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
// ── Build ─────────────────────────────────────────────────────────────────────
var app = builder.Build();
app.UseMiddleware<ExceptionMiddleware>();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ContentDbContext>();
db.Database.Migrate();
}
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health", new HealthCheckOptions { AllowCachingResponses = false });
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5088",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7250;http://localhost:5088",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,19 @@
{
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5432;Database=flatrender;Username=postgres;Password=postgres;Search Path=content,public"
},
"Jwt": {
"Secret": "your-256-bit-secret-key-change-in-production",
"Issuer": "flatrender",
"Audience": "flatrender",
"AccessTokenExpiryMinutes": 15,
"RefreshTokenExpiryDays": 30
},
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
"Microsoft.EntityFrameworkCore": "Information"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org" value="http://171.22.25.73:8081/repository/nuget-group/index.json" protocolVersion="3" allowInsecureConnections="true" />
</packageSources>
</configuration>
+30
View File
@@ -0,0 +1,30 @@
version: "3.9"
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: flatrender
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pg_content_data:/var/lib/postgresql/data
- ../../backend/db/migrations:/docker-entrypoint-initdb.d:ro
content-svc:
build: .
ports:
- "5011:8080"
environment:
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__Postgres: "Host=postgres;Port=5432;Database=flatrender;Username=postgres;Password=postgres;Search Path=content,public"
Jwt__Secret: "dev-secret-32-chars-minimum-here!!"
Jwt__Issuer: "flatrender"
Jwt__Audience: "flatrender"
depends_on:
- postgres
volumes:
pg_content_data:
+16
View File
@@ -0,0 +1,16 @@
# Build output
file-svc
# Local env
.env
.env.*
# IDE / OS
.idea/
.DS_Store
Thumbs.db
# Docker files
Dockerfile
.dockerignore
docker-compose*.yml
+11
View File
@@ -0,0 +1,11 @@
FROM golang:1.25-alpine AS build
WORKDIR /src
# Dependencies are vendored — build fully offline (proxy.golang.org is geo-blocked from some regions)
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -mod=vendor -o /file-svc ./cmd/server
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=build /file-svc /file-svc
EXPOSE 8080
ENTRYPOINT ["/file-svc"]
+110
View File
@@ -0,0 +1,110 @@
package main
import (
"context"
"log"
"net/http"
"os"
"github.com/flatrender/file-svc/internal/db"
"github.com/flatrender/file-svc/internal/handlers"
"github.com/flatrender/file-svc/internal/middleware"
"github.com/flatrender/file-svc/internal/storage"
"github.com/gin-gonic/gin"
)
func main() {
// ── Config from env ───────────────────────────────────────────────────────
dsn := mustEnv("DATABASE_URL")
jwtSecret := mustEnv("JWT_SECRET")
minioEndpoint := envOr("MINIO_ENDPOINT", "localhost:9000")
minioAccessKey := envOr("MINIO_ACCESS_KEY", "minioadmin")
minioSecretKey := envOr("MINIO_SECRET_KEY", "minioadmin")
minioUseSSL := os.Getenv("MINIO_USE_SSL") == "true"
uploadBucket := envOr("MINIO_UPLOAD_BUCKET", "user-uploads")
port := envOr("PORT", "8080")
// ── Database ──────────────────────────────────────────────────────────────
store, err := db.New(dsn)
if err != nil {
log.Fatalf("db: %v", err)
}
defer store.Close()
if err := store.Ping(context.Background()); err != nil {
log.Fatalf("db ping: %v", err)
}
log.Println("db: connected")
// ── MinIO ─────────────────────────────────────────────────────────────────
minioClient, err := storage.NewMinioClient(storage.Config{
Endpoint: minioEndpoint,
AccessKeyID: minioAccessKey,
SecretAccessKey: minioSecretKey,
UseSSL: minioUseSSL,
})
if err != nil {
log.Fatalf("minio: %v", err)
}
log.Println("minio: connected")
// ── Handlers ──────────────────────────────────────────────────────────────
fileHandler := handlers.NewFileHandler(store, minioClient, uploadBucket)
// ── Router ────────────────────────────────────────────────────────────────
if os.Getenv("GIN_MODE") == "" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
r.GET("/health", func(c *gin.Context) {
if err := store.Ping(c.Request.Context()); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unhealthy", "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "healthy"})
})
v1 := r.Group("/v1", middleware.Auth(jwtSecret))
{
// Files
v1.GET("/files", fileHandler.ListFiles)
v1.GET("/files/:id", fileHandler.GetFile)
v1.POST("/files/presigned-upload", fileHandler.PresignedUpload)
v1.POST("/files/:id/confirm", fileHandler.ConfirmUpload)
v1.DELETE("/files/:id", fileHandler.DeleteFile)
v1.GET("/files/:id/download", fileHandler.GetDownloadURL)
// Folders
v1.GET("/folders", fileHandler.ListFolders)
v1.POST("/folders", fileHandler.CreateFolder)
v1.DELETE("/folders/:id", fileHandler.DeleteFolder)
// Quota
v1.GET("/quota", fileHandler.GetQuota)
}
log.Printf("file-svc listening on :%s", port)
if err := r.Run(":" + port); err != nil {
log.Fatalf("server: %v", err)
}
}
func mustEnv(key string) string {
v := os.Getenv(key)
if v == "" {
log.Fatalf("required env var %s not set", key)
}
return v
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
+32
View File
@@ -0,0 +1,32 @@
version: "3.9"
services:
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
file-svc:
build: .
ports:
- "5012:8080"
environment:
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/flatrender?search_path=file_mgr,public"
JWT_SECRET: "dev-secret-32-chars-minimum-here!!"
MINIO_ENDPOINT: "minio:9000"
MINIO_ACCESS_KEY: "minioadmin"
MINIO_SECRET_KEY: "minioadmin"
MINIO_UPLOAD_BUCKET: "user-uploads"
GIN_MODE: "debug"
depends_on:
- minio
volumes:
minio_data:
+51
View File
@@ -0,0 +1,51 @@
module github.com/flatrender/file-svc
go 1.25
require (
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.1
github.com/minio/minio-go/v7 v7.0.83
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+123
View File
@@ -0,0 +1,123 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.83 h1:W4Kokksvlz3OKf3OqIlzDNKd4MERlC2oN8YptwJ0+GA=
github.com/minio/minio-go/v7 v7.0.83/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+314
View File
@@ -0,0 +1,314 @@
package db
import (
"context"
"fmt"
"math"
"time"
"github.com/flatrender/file-svc/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct {
pool *pgxpool.Pool
}
func New(connStr string) (*Store, error) {
cfg, err := pgxpool.ParseConfig(connStr)
if err != nil {
return nil, fmt.Errorf("db config: %w", err)
}
cfg.MaxConns = 20
cfg.MinConns = 2
pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
if err != nil {
return nil, fmt.Errorf("db connect: %w", err)
}
return &Store{pool: pool}, nil
}
func (s *Store) Close() { s.pool.Close() }
func (s *Store) Ping(ctx context.Context) error {
return s.pool.Ping(ctx)
}
// ── Folders ───────────────────────────────────────────────────────────────────
func (s *Store) GetFolders(ctx context.Context, userID uuid.UUID, parentID *uuid.UUID) ([]models.UserFolder, error) {
var rows pgx.Rows
var err error
if parentID == nil {
rows, err = s.pool.Query(ctx,
`SELECT id, tenant_id, user_id, name, folder_type, parent_folder_id,
file_count, total_size_bytes, sort, is_shared, share_token, created_at, updated_at
FROM file_mgr.user_folders
WHERE user_id = $1 AND parent_folder_id IS NULL AND deleted_at IS NULL
ORDER BY sort, name`, userID)
} else {
rows, err = s.pool.Query(ctx,
`SELECT id, tenant_id, user_id, name, folder_type, parent_folder_id,
file_count, total_size_bytes, sort, is_shared, share_token, created_at, updated_at
FROM file_mgr.user_folders
WHERE user_id = $1 AND parent_folder_id = $2 AND deleted_at IS NULL
ORDER BY sort, name`, userID, parentID)
}
if err != nil {
return nil, err
}
defer rows.Close()
var folders []models.UserFolder
for rows.Next() {
var f models.UserFolder
if err := rows.Scan(&f.ID, &f.TenantID, &f.UserID, &f.Name, &f.FolderType, &f.ParentFolderID,
&f.FileCount, &f.TotalSizeBytes, &f.Sort, &f.IsShared, &f.ShareToken, &f.CreatedAt, &f.UpdatedAt); err != nil {
return nil, err
}
folders = append(folders, f)
}
return folders, rows.Err()
}
func (s *Store) CreateFolder(ctx context.Context, tenantID, userID uuid.UUID, req models.CreateFolderRequest) (*models.UserFolder, error) {
var f models.UserFolder
err := s.pool.QueryRow(ctx,
`INSERT INTO file_mgr.user_folders (tenant_id, user_id, name, folder_type, parent_folder_id)
VALUES ($1, $2, $3, 'User', $4)
RETURNING id, tenant_id, user_id, name, folder_type, parent_folder_id,
file_count, total_size_bytes, sort, is_shared, share_token, created_at, updated_at`,
tenantID, userID, req.Name, req.ParentFolderID,
).Scan(&f.ID, &f.TenantID, &f.UserID, &f.Name, &f.FolderType, &f.ParentFolderID,
&f.FileCount, &f.TotalSizeBytes, &f.Sort, &f.IsShared, &f.ShareToken, &f.CreatedAt, &f.UpdatedAt)
if err != nil {
return nil, err
}
return &f, nil
}
func (s *Store) DeleteFolder(ctx context.Context, id, userID uuid.UUID) error {
ct, err := s.pool.Exec(ctx,
`UPDATE file_mgr.user_folders SET deleted_at = NOW() WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`,
id, userID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
// ── Files ─────────────────────────────────────────────────────────────────────
func (s *Store) ListFiles(ctx context.Context, userID uuid.UUID, req models.FileListRequest) ([]models.UserFile, int64, error) {
offset := (req.Page - 1) * req.PageSize
baseQ := `FROM file_mgr.user_files WHERE user_id = $1 AND deleted_at IS NULL`
args := []any{userID}
argN := 2
if req.FolderID != nil {
baseQ += fmt.Sprintf(" AND user_folder_id = $%d", argN)
args = append(args, req.FolderID)
argN++
}
if req.FileType != nil {
baseQ += fmt.Sprintf(" AND file_type = $%d", argN)
args = append(args, req.FileType)
argN++
}
if req.Search != nil {
baseQ += fmt.Sprintf(" AND name ILIKE $%d", argN)
args = append(args, "%"+*req.Search+"%")
argN++
}
var total int64
if err := s.pool.QueryRow(ctx, "SELECT COUNT(*) "+baseQ, args...).Scan(&total); err != nil {
return nil, 0, err
}
args = append(args, req.PageSize, offset)
rows, err := s.pool.Query(ctx,
`SELECT id, tenant_id, user_id, user_folder_id, name, original_filename, file_extension, mime_type,
file_type, minio_bucket, minio_key, cdn_url, file_address, size_bytes, md5_hash,
thumbnail_url, upload_status, upload_progress, source, last_used_at, use_count,
is_public, created_at, updated_at
`+baseQ+fmt.Sprintf(` ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, argN, argN+1),
args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var files []models.UserFile
for rows.Next() {
var f models.UserFile
if err := rows.Scan(&f.ID, &f.TenantID, &f.UserID, &f.UserFolderID, &f.Name, &f.OriginalFilename,
&f.FileExtension, &f.MimeType, &f.FileType, &f.MinioBucket, &f.MinioKey, &f.CdnURL,
&f.FileAddress, &f.SizeBytes, &f.Md5Hash, &f.ThumbnailURL, &f.UploadStatus, &f.UploadProgress,
&f.Source, &f.LastUsedAt, &f.UseCount, &f.IsPublic, &f.CreatedAt, &f.UpdatedAt); err != nil {
return nil, 0, err
}
files = append(files, f)
}
return files, total, rows.Err()
}
func (s *Store) GetFile(ctx context.Context, id, userID uuid.UUID) (*models.UserFile, error) {
var f models.UserFile
err := s.pool.QueryRow(ctx,
`SELECT id, tenant_id, user_id, user_folder_id, name, original_filename, file_extension, mime_type,
file_type, minio_bucket, minio_key, cdn_url, file_address, size_bytes, md5_hash,
sha256_hash, duration_sec, width, height, fps, bitrate_kbps, codec, has_audio, has_video,
thumbnail_url, waveform_data, upload_status, upload_progress, source, export_id, parent_file_id,
last_used_at, use_count, is_public, share_token, metadata, created_at, updated_at
FROM file_mgr.user_files
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`, id, userID,
).Scan(&f.ID, &f.TenantID, &f.UserID, &f.UserFolderID, &f.Name, &f.OriginalFilename,
&f.FileExtension, &f.MimeType, &f.FileType, &f.MinioBucket, &f.MinioKey, &f.CdnURL,
&f.FileAddress, &f.SizeBytes, &f.Md5Hash, &f.Sha256Hash, &f.DurationSec, &f.Width, &f.Height,
&f.Fps, &f.BitrateKbps, &f.Codec, &f.HasAudio, &f.HasVideo, &f.ThumbnailURL, &f.WaveformData,
&f.UploadStatus, &f.UploadProgress, &f.Source, &f.ExportID, &f.ParentFileID,
&f.LastUsedAt, &f.UseCount, &f.IsPublic, &f.ShareToken, &f.Metadata, &f.CreatedAt, &f.UpdatedAt)
if err != nil {
return nil, err
}
return &f, nil
}
func (s *Store) CreateFileRecord(ctx context.Context, f *models.UserFile) error {
return s.pool.QueryRow(ctx,
`INSERT INTO file_mgr.user_files
(tenant_id, user_id, user_folder_id, name, original_filename, file_extension, mime_type,
file_type, minio_bucket, minio_key, file_address, size_bytes, upload_status, source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'Pending',$13)
RETURNING id, created_at, updated_at`,
f.TenantID, f.UserID, f.UserFolderID, f.Name, f.OriginalFilename, f.FileExtension,
f.MimeType, f.FileType, f.MinioBucket, f.MinioKey, f.FileAddress, f.SizeBytes, f.Source,
).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt)
}
func (s *Store) MarkFileReady(ctx context.Context, id uuid.UUID, cdnURL *string) error {
_, err := s.pool.Exec(ctx,
`UPDATE file_mgr.user_files
SET upload_status = 'Ready', upload_progress = 100, cdn_url = $2, updated_at = NOW()
WHERE id = $1`,
id, cdnURL)
return err
}
func (s *Store) DeleteFile(ctx context.Context, id, userID uuid.UUID) (*models.UserFile, error) {
var f models.UserFile
err := s.pool.QueryRow(ctx,
`UPDATE file_mgr.user_files SET deleted_at = NOW()
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
RETURNING id, minio_bucket, minio_key, size_bytes, user_folder_id`,
id, userID,
).Scan(&f.ID, &f.MinioBucket, &f.MinioKey, &f.SizeBytes, &f.UserFolderID)
if err != nil {
return nil, err
}
return &f, nil
}
// ── Storage Quota ─────────────────────────────────────────────────────────────
func (s *Store) GetQuota(ctx context.Context, userID uuid.UUID) (*models.StorageQuota, error) {
var q models.StorageQuota
err := s.pool.QueryRow(ctx,
`SELECT user_id, tenant_id, plan_quota_bytes, bonus_quota_bytes, used_bytes,
video_count, image_count, audio_count, video_bytes, image_bytes, audio_bytes,
last_90pct_notified_at, last_100pct_notified_at, updated_at
FROM file_mgr.storage_quotas WHERE user_id = $1`, userID,
).Scan(&q.UserID, &q.TenantID, &q.PlanQuotaBytes, &q.BonusQuotaBytes, &q.UsedBytes,
&q.VideoCount, &q.ImageCount, &q.AudioCount, &q.VideoBytes, &q.ImageBytes, &q.AudioBytes,
&q.Last90PctNotifiedAt, &q.Last100PctNotifiedAt, &q.UpdatedAt)
if err != nil {
return nil, err
}
return &q, nil
}
func (s *Store) EnsureQuota(ctx context.Context, userID, tenantID uuid.UUID) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO file_mgr.storage_quotas (user_id, tenant_id)
VALUES ($1, $2)
ON CONFLICT (user_id) DO NOTHING`, userID, tenantID)
return err
}
func (s *Store) AddUsedBytes(ctx context.Context, userID uuid.UUID, delta int64) error {
_, err := s.pool.Exec(ctx,
`UPDATE file_mgr.storage_quotas SET used_bytes = used_bytes + $2, updated_at = NOW()
WHERE user_id = $1`, userID, delta)
return err
}
// ── Upload Sessions ───────────────────────────────────────────────────────────
func (s *Store) CreateUploadSession(ctx context.Context, sess *models.UploadSession) error {
return s.pool.QueryRow(ctx,
`INSERT INTO file_mgr.upload_sessions
(tenant_id, user_id, minio_bucket, minio_key, minio_upload_id, filename, mime_type,
total_size_bytes, chunk_size_bytes, target_folder_id, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'Uploading')
RETURNING id, expires_at, created_at, updated_at`,
sess.TenantID, sess.UserID, sess.MinioBucket, sess.MinioKey, sess.MinioUploadID,
sess.Filename, sess.MimeType, sess.TotalSizeBytes, sess.ChunkSizeBytes, sess.TargetFolderID,
).Scan(&sess.ID, &sess.ExpiresAt, &sess.CreatedAt, &sess.UpdatedAt)
}
func (s *Store) GetUploadSession(ctx context.Context, id uuid.UUID) (*models.UploadSession, error) {
var sess models.UploadSession
err := s.pool.QueryRow(ctx,
`SELECT id, tenant_id, user_id, minio_bucket, minio_key, minio_upload_id, filename, mime_type,
total_size_bytes, chunks_received, bytes_received, chunk_size_bytes,
target_folder_id, target_file_id, status, error_message, expires_at, completed_at, created_at, updated_at
FROM file_mgr.upload_sessions WHERE id = $1`, id,
).Scan(&sess.ID, &sess.TenantID, &sess.UserID, &sess.MinioBucket, &sess.MinioKey, &sess.MinioUploadID,
&sess.Filename, &sess.MimeType, &sess.TotalSizeBytes, &sess.ChunksReceived, &sess.BytesReceived,
&sess.ChunkSizeBytes, &sess.TargetFolderID, &sess.TargetFileID, &sess.Status, &sess.ErrorMessage,
&sess.ExpiresAt, &sess.CompletedAt, &sess.CreatedAt, &sess.UpdatedAt)
if err != nil {
return nil, err
}
return &sess, nil
}
func (s *Store) CompleteUploadSession(ctx context.Context, id, fileID uuid.UUID) error {
now := time.Now()
_, err := s.pool.Exec(ctx,
`UPDATE file_mgr.upload_sessions
SET status = 'Ready', target_file_id = $2, completed_at = $3, updated_at = $3
WHERE id = $1`, id, fileID, now)
return err
}
// ── MinIO Buckets ─────────────────────────────────────────────────────────────
func (s *Store) GetBucketByPurpose(ctx context.Context, purpose string) (*models.MinioBucket, error) {
var b models.MinioBucket
err := s.pool.QueryRow(ctx,
`SELECT id, name, region, endpoint, purpose, is_public, cdn_base_url, is_active, created_at
FROM file_mgr.minio_buckets WHERE purpose = $1 AND is_active = TRUE LIMIT 1`, purpose,
).Scan(&b.ID, &b.Name, &b.Region, &b.Endpoint, &b.Purpose, &b.IsPublic, &b.CdnBaseURL, &b.IsActive, &b.CreatedAt)
if err != nil {
return nil, err
}
return &b, nil
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func TotalPages(total int64, pageSize int) int {
if pageSize == 0 {
return 0
}
return int(math.Ceil(float64(total) / float64(pageSize)))
}
+334
View File
@@ -0,0 +1,334 @@
package handlers
import (
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/flatrender/file-svc/internal/db"
"github.com/flatrender/file-svc/internal/middleware"
"github.com/flatrender/file-svc/internal/models"
"github.com/flatrender/file-svc/internal/storage"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type FileHandler struct {
store *db.Store
minio *storage.MinioClient
bucket string // default upload bucket name
}
func NewFileHandler(store *db.Store, minio *storage.MinioClient, bucket string) *FileHandler {
return &FileHandler{store: store, minio: minio, bucket: bucket}
}
// GET /v1/files
func (h *FileHandler) ListFiles(c *gin.Context) {
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
var req models.FileListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: err.Error()}})
return
}
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 || req.PageSize > 100 {
req.PageSize = 20
}
files, total, err := h.store.ListFiles(c.Request.Context(), userID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
return
}
if files == nil {
files = []models.UserFile{}
}
c.JSON(http.StatusOK, models.PagedResponse[models.UserFile]{
Items: files,
Meta: models.PaginationMeta{Page: req.Page, PageSize: req.PageSize, Total: total, TotalPages: db.TotalPages(total, req.PageSize)},
})
}
// GET /v1/files/:id
func (h *FileHandler) GetFile(c *gin.Context) {
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}})
return
}
file, err := h.store.GetFile(c.Request.Context(), id, userID)
if err == pgx.ErrNoRows {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "file not found"}})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
return
}
c.JSON(http.StatusOK, file)
}
// POST /v1/files/presigned-upload
func (h *FileHandler) PresignedUpload(c *gin.Context) {
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
tenantID := c.MustGet(middleware.KeyTenantID).(uuid.UUID)
var req models.PresignedUploadRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: err.Error()}})
return
}
if err := h.store.EnsureQuota(c.Request.Context(), userID, tenantID); err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
return
}
ext := strings.ToLower(filepath.Ext(req.Filename))
key := fmt.Sprintf("uploads/%s/%s%s", userID, uuid.New(), ext)
uploadURL, err := h.minio.PresignedPutURL(c.Request.Context(), h.bucket, key, 15*time.Minute)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "storage_error", Message: err.Error()}})
return
}
fileKind := guessFileKind(ext, req.MimeType)
file := &models.UserFile{
TenantID: tenantID,
UserID: userID,
UserFolderID: req.TargetFolderID,
Name: req.Filename,
OriginalFilename: &req.Filename,
FileExtension: &ext,
MimeType: req.MimeType,
FileType: fileKind,
MinioBucket: h.bucket,
MinioKey: key,
FileAddress: fmt.Sprintf("minio://%s/%s", h.bucket, key),
SizeBytes: req.SizeBytes,
UploadStatus: models.UploadStatusPending,
Source: strPtr("upload"),
}
if err := h.store.CreateFileRecord(c.Request.Context(), file); err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
return
}
c.JSON(http.StatusOK, models.PresignedUploadResponse{
UploadURL: uploadURL,
FileID: file.ID,
ExpiresAt: time.Now().Add(15 * time.Minute),
})
}
// POST /v1/files/:id/confirm
func (h *FileHandler) ConfirmUpload(c *gin.Context) {
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}})
return
}
file, err := h.store.GetFile(c.Request.Context(), id, userID)
if err == pgx.ErrNoRows {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "file not found"}})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
return
}
if err := h.store.MarkFileReady(c.Request.Context(), id, nil); err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
return
}
if err := h.store.AddUsedBytes(c.Request.Context(), userID, file.SizeBytes); err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ready"})
}
// DELETE /v1/files/:id
func (h *FileHandler) DeleteFile(c *gin.Context) {
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}})
return
}
file, err := h.store.DeleteFile(c.Request.Context(), id, userID)
if err == pgx.ErrNoRows {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "file not found"}})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
return
}
_ = h.minio.DeleteObject(c.Request.Context(), file.MinioBucket, file.MinioKey)
_ = h.store.AddUsedBytes(c.Request.Context(), userID, -file.SizeBytes)
c.Status(http.StatusNoContent)
}
// GET /v1/files/:id/download
func (h *FileHandler) GetDownloadURL(c *gin.Context) {
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}})
return
}
file, err := h.store.GetFile(c.Request.Context(), id, userID)
if err == pgx.ErrNoRows {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "file not found"}})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
return
}
dlURL, err := h.minio.PresignedGetURL(c.Request.Context(), file.MinioBucket, file.MinioKey, time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "storage_error", Message: err.Error()}})
return
}
c.JSON(http.StatusOK, gin.H{"url": dlURL, "expires_in": 3600})
}
// GET /v1/quota
func (h *FileHandler) GetQuota(c *gin.Context) {
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
tenantID := c.MustGet(middleware.KeyTenantID).(uuid.UUID)
if err := h.store.EnsureQuota(c.Request.Context(), userID, tenantID); err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
return
}
quota, err := h.store.GetQuota(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
return
}
c.JSON(http.StatusOK, quota)
}
// ── Folders ───────────────────────────────────────────────────────────────────
// GET /v1/folders
func (h *FileHandler) ListFolders(c *gin.Context) {
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
var parentID *uuid.UUID
if p := c.Query("parent_id"); p != "" {
id, err := uuid.Parse(p)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid parent_id"}})
return
}
parentID = &id
}
folders, err := h.store.GetFolders(c.Request.Context(), userID, parentID)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
return
}
if folders == nil {
folders = []models.UserFolder{}
}
c.JSON(http.StatusOK, folders)
}
// POST /v1/folders
func (h *FileHandler) CreateFolder(c *gin.Context) {
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
tenantID := c.MustGet(middleware.KeyTenantID).(uuid.UUID)
var req models.CreateFolderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: err.Error()}})
return
}
folder, err := h.store.CreateFolder(c.Request.Context(), tenantID, userID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
return
}
c.JSON(http.StatusCreated, folder)
}
// DELETE /v1/folders/:id
func (h *FileHandler) DeleteFolder(c *gin.Context) {
userID := c.MustGet(middleware.KeyUserID).(uuid.UUID)
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}})
return
}
if err := h.store.DeleteFolder(c.Request.Context(), id, userID); err == pgx.ErrNoRows {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "folder not found"}})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}})
return
}
c.Status(http.StatusNoContent)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func guessFileKind(ext string, mime *string) models.FileKind {
if mime != nil {
switch {
case strings.HasPrefix(*mime, "video/"):
return models.FileKindVideo
case strings.HasPrefix(*mime, "image/"):
return models.FileKindImage
case strings.HasPrefix(*mime, "audio/"):
return models.FileKindAudio
}
}
switch ext {
case ".mp4", ".mov", ".avi", ".webm", ".mkv":
return models.FileKindVideo
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg":
return models.FileKindImage
case ".mp3", ".wav", ".ogg", ".aac", ".flac":
return models.FileKindAudio
}
return models.FileKindOther
}
func strPtr(s string) *string { return &s }
+79
View File
@@ -0,0 +1,79 @@
package middleware
import (
"net/http"
"strings"
"github.com/flatrender/file-svc/internal/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
const (
KeyUserID = "user_id"
KeyTenantID = "tenant_id"
KeyIsAdmin = "is_admin"
)
func Auth(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
c.AbortWithStatusJSON(http.StatusUnauthorized, models.ErrorResponse{
Error: models.APIError{Code: "unauthorized", Message: "missing bearer token"},
})
return
}
tokenStr := strings.TrimPrefix(header, "Bearer ")
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, models.ErrorResponse{
Error: models.APIError{Code: "unauthorized", Message: "invalid token"},
})
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, models.ErrorResponse{
Error: models.APIError{Code: "unauthorized", Message: "invalid claims"},
})
return
}
userID, err := uuid.Parse(claims["sub"].(string))
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, models.ErrorResponse{
Error: models.APIError{Code: "unauthorized", Message: "invalid sub claim"},
})
return
}
tenantID, _ := uuid.Parse(claims["tenant_id"].(string))
isAdmin, _ := claims["is_admin"].(bool)
c.Set(KeyUserID, userID)
c.Set(KeyTenantID, tenantID)
c.Set(KeyIsAdmin, isAdmin)
c.Next()
}
}
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
if isAdmin, _ := c.Get(KeyIsAdmin); isAdmin != true {
c.AbortWithStatusJSON(http.StatusForbidden, models.ErrorResponse{
Error: models.APIError{Code: "forbidden", Message: "admin only"},
})
return
}
c.Next()
}
}
+247
View File
@@ -0,0 +1,247 @@
package models
import (
"time"
"github.com/google/uuid"
)
// ── Enums ─────────────────────────────────────────────────────────────────────
type FileKind string
const (
FileKindVideo FileKind = "Video"
FileKindImage FileKind = "Image"
FileKindAudio FileKind = "Audio"
FileKindVoiceover FileKind = "Voiceover"
FileKindDocument FileKind = "Document"
FileKindOther FileKind = "Other"
)
type FolderKind string
const (
FolderKindSystem FolderKind = "System"
FolderKindUser FolderKind = "User"
FolderKindShared FolderKind = "Shared"
FolderKindTenant FolderKind = "Tenant"
)
type UploadStatus string
const (
UploadStatusPending UploadStatus = "Pending"
UploadStatusUploading UploadStatus = "Uploading"
UploadStatusProcessing UploadStatus = "Processing"
UploadStatusReady UploadStatus = "Ready"
UploadStatusFailed UploadStatus = "Failed"
UploadStatusQuarantined UploadStatus = "Quarantined"
)
type CleanupEntityType string
const (
CleanupEntityExport CleanupEntityType = "Export"
CleanupEntityTempRenderFolder CleanupEntityType = "TempRenderFolder"
CleanupEntityOrphanedFile CleanupEntityType = "OrphanedFile"
CleanupEntityUnusedUpload CleanupEntityType = "UnusedUpload"
CleanupEntitySnapshotExpired CleanupEntityType = "SnapshotExpired"
)
type CleanupStatus string
const (
CleanupStatusScheduled CleanupStatus = "Scheduled"
CleanupStatusNotified CleanupStatus = "Notified"
CleanupStatusProcessing CleanupStatus = "Processing"
CleanupStatusDone CleanupStatus = "Done"
CleanupStatusSkipped CleanupStatus = "Skipped"
CleanupStatusFailed CleanupStatus = "Failed"
)
// ── Domain ────────────────────────────────────────────────────────────────────
type UserFolder struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
FolderType FolderKind `json:"folder_type"`
ParentFolderID *uuid.UUID `json:"parent_folder_id,omitempty"`
FileCount int `json:"file_count"`
TotalSizeBytes int64 `json:"total_size_bytes"`
Sort int `json:"sort"`
IsShared bool `json:"is_shared"`
ShareToken *string `json:"share_token,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
type UserFile struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
UserID uuid.UUID `json:"user_id"`
UserFolderID *uuid.UUID `json:"user_folder_id,omitempty"`
Name string `json:"name"`
OriginalFilename *string `json:"original_filename,omitempty"`
FileExtension *string `json:"file_extension,omitempty"`
MimeType *string `json:"mime_type,omitempty"`
FileType FileKind `json:"file_type"`
MinioBucket string `json:"minio_bucket"`
MinioKey string `json:"minio_key"`
CdnURL *string `json:"cdn_url,omitempty"`
FileAddress string `json:"file_address"`
SizeBytes int64 `json:"size_bytes"`
Md5Hash *string `json:"md5_hash,omitempty"`
Sha256Hash *string `json:"sha256_hash,omitempty"`
DurationSec *float64 `json:"duration_sec,omitempty"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
Fps *float64 `json:"fps,omitempty"`
BitrateKbps *int `json:"bitrate_kbps,omitempty"`
Codec *string `json:"codec,omitempty"`
HasAudio *bool `json:"has_audio,omitempty"`
HasVideo *bool `json:"has_video,omitempty"`
ThumbnailURL *string `json:"thumbnail_url,omitempty"`
WaveformData *string `json:"waveform_data,omitempty"`
UploadStatus UploadStatus `json:"upload_status"`
UploadProgress int `json:"upload_progress"`
Source *string `json:"source,omitempty"`
ExportID *uuid.UUID `json:"export_id,omitempty"`
ParentFileID *uuid.UUID `json:"parent_file_id,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
UseCount int `json:"use_count"`
IsPublic bool `json:"is_public"`
ShareToken *string `json:"share_token,omitempty"`
Metadata string `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
type StorageQuota struct {
UserID uuid.UUID `json:"user_id"`
TenantID uuid.UUID `json:"tenant_id"`
PlanQuotaBytes int64 `json:"plan_quota_bytes"`
BonusQuotaBytes int64 `json:"bonus_quota_bytes"`
UsedBytes int64 `json:"used_bytes"`
VideoCount int `json:"video_count"`
ImageCount int `json:"image_count"`
AudioCount int `json:"audio_count"`
VideoBytes int64 `json:"video_bytes"`
ImageBytes int64 `json:"image_bytes"`
AudioBytes int64 `json:"audio_bytes"`
Last90PctNotifiedAt *time.Time `json:"last_90pct_notified_at,omitempty"`
Last100PctNotifiedAt *time.Time `json:"last_100pct_notified_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type UploadSession struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
UserID uuid.UUID `json:"user_id"`
MinioBucket string `json:"minio_bucket"`
MinioKey string `json:"minio_key"`
MinioUploadID string `json:"minio_upload_id"`
Filename string `json:"filename"`
MimeType *string `json:"mime_type,omitempty"`
TotalSizeBytes int64 `json:"total_size_bytes"`
ChunksReceived int `json:"chunks_received"`
BytesReceived int64 `json:"bytes_received"`
ChunkSizeBytes int `json:"chunk_size_bytes"`
TargetFolderID *uuid.UUID `json:"target_folder_id,omitempty"`
TargetFileID *uuid.UUID `json:"target_file_id,omitempty"`
Status UploadStatus `json:"status"`
ErrorMessage *string `json:"error_message,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type MinioBucket struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
Purpose string `json:"purpose"`
IsPublic bool `json:"is_public"`
CdnBaseURL *string `json:"cdn_base_url,omitempty"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
}
// ── Request / Response ────────────────────────────────────────────────────────
type CreateFolderRequest struct {
Name string `json:"name" binding:"required"`
ParentFolderID *uuid.UUID `json:"parent_folder_id"`
}
type MoveFolderRequest struct {
ParentFolderID *uuid.UUID `json:"parent_folder_id"`
}
type RenameFolderRequest struct {
Name string `json:"name" binding:"required"`
}
type InitiateUploadRequest struct {
Filename string `json:"filename" binding:"required"`
MimeType *string `json:"mime_type"`
TotalSizeBytes int64 `json:"total_size_bytes" binding:"required,min=1"`
ChunkSizeBytes int `json:"chunk_size_bytes"`
TargetFolderID *uuid.UUID `json:"target_folder_id"`
}
type PresignedUploadRequest struct {
Filename string `json:"filename" binding:"required"`
MimeType *string `json:"mime_type"`
SizeBytes int64 `json:"size_bytes" binding:"required,min=1"`
TargetFolderID *uuid.UUID `json:"target_folder_id"`
}
type PresignedUploadResponse struct {
UploadURL string `json:"upload_url"`
FileID uuid.UUID `json:"file_id"`
ExpiresAt time.Time `json:"expires_at"`
}
type FileListRequest struct {
Page int `form:"page,default=1"`
PageSize int `form:"page_size,default=20"`
FolderID *uuid.UUID `form:"folder_id"`
FileType *FileKind `form:"file_type"`
Search *string `form:"search"`
}
type PagedResponse[T any] struct {
Items []T `json:"items"`
Meta PaginationMeta `json:"meta"`
}
type PaginationMeta struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
TotalPages int `json:"total_pages"`
}
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type ErrorResponse struct {
Error APIError `json:"error"`
}
// ── JWT Claims ────────────────────────────────────────────────────────────────
type Claims struct {
Sub string `json:"sub"`
TenantID string `json:"tenant_id"`
IsAdmin bool `json:"is_admin"`
}
+75
View File
@@ -0,0 +1,75 @@
package storage
import (
"context"
"fmt"
"io"
"net/url"
"time"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type MinioClient struct {
client *minio.Client
endpoint string
}
type Config struct {
Endpoint string
AccessKeyID string
SecretAccessKey string
UseSSL bool
}
func NewMinioClient(cfg Config) (*MinioClient, error) {
client, err := minio.New(cfg.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKeyID, cfg.SecretAccessKey, ""),
Secure: cfg.UseSSL,
})
if err != nil {
return nil, fmt.Errorf("minio client: %w", err)
}
return &MinioClient{client: client, endpoint: cfg.Endpoint}, nil
}
func (m *MinioClient) PresignedPutURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) {
u, err := m.client.PresignedPutObject(ctx, bucket, key, expiry)
if err != nil {
return "", err
}
return u.String(), nil
}
func (m *MinioClient) PresignedGetURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) {
reqParams := url.Values{}
u, err := m.client.PresignedGetObject(ctx, bucket, key, expiry, reqParams)
if err != nil {
return "", err
}
return u.String(), nil
}
func (m *MinioClient) GenerateKey(folder string) string {
return fmt.Sprintf("%s/%s", folder, uuid.New().String())
}
func (m *MinioClient) DeleteObject(ctx context.Context, bucket, key string) error {
return m.client.RemoveObject(ctx, bucket, key, minio.RemoveObjectOptions{})
}
func (m *MinioClient) GetObject(ctx context.Context, bucket, key string) (io.ReadCloser, error) {
obj, err := m.client.GetObject(ctx, bucket, key, minio.GetObjectOptions{})
if err != nil {
return nil, err
}
return obj, nil
}
func (m *MinioClient) InitiateMultipartUpload(ctx context.Context, bucket, key string) (string, error) {
// MinIO SDK doesn't directly expose multipart — use presigned PUT per chunk instead.
// Return a generated upload ID for session tracking.
return uuid.New().String(), nil
}
@@ -0,0 +1,52 @@
*.o
*.swp
*.swm
*.swn
*.a
*.so
_obj
_test
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.exe~
*.test
*.prof
*.rar
*.zip
*.gz
*.psd
*.bmd
*.cfg
*.pptx
*.log
*nohup.out
*settings.pyc
*.sublime-project
*.sublime-workspace
.DS_Store
/.idea/
/.vscode/
/output/
/vendor/
/Gopkg.lock
/Gopkg.toml
coverage.html
coverage.out
coverage.xml
junit.xml
*.profile
*.svg
*.out
ast/test.out
ast/bench.sh
!testdata/*.json.gz
fuzz/testdata
*__debug_bin
@@ -0,0 +1,6 @@
[submodule "cloudwego"]
path = tools/asm2asm
url = https://github.com/cloudwego/asm2asm.git
[submodule "tools/simde"]
path = tools/simde
url = https://github.com/simd-everywhere/simde.git
+24
View File
@@ -0,0 +1,24 @@
header:
license:
spdx-id: Apache-2.0
copyright-owner: ByteDance Inc.
paths:
- '**/*.go'
- '**/*.s'
paths-ignore:
- 'ast/asm.s' # empty file
- 'decoder/asm.s' # empty file
- 'encoder/asm.s' # empty file
- 'internal/caching/asm.s' # empty file
- 'internal/jit/asm.s' # empty file
- 'internal/native/avx/native_amd64.s' # auto-generated by asm2asm
- 'internal/native/avx/native_subr_amd64.go' # auto-generated by asm2asm
- 'internal/native/avx2/native_amd64.s' # auto-generated by asm2asm
- 'internal/native/avx2/native_subr_amd64.go' # auto-generated by asm2asm
- 'internal/resolver/asm.s' # empty file
- 'internal/rt/asm.s' # empty file
- 'internal/loader/asm.s' # empty file
comment: on-failure
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
wudi.daniel@bytedance.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+63
View File
@@ -0,0 +1,63 @@
# How to Contribute
## Your First Pull Request
We use GitHub for our codebase. You can start by reading [How To Pull Request](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests).
## Without Semantic Versioning
We keep the stable code in branch `main` like `golang.org/x`. Development base on branch `develop`. We promise the **Forward Compatibility** by adding new package directory with suffix `v2/v3` when code has break changes.
## Branch Organization
We use [git-flow](https://nvie.com/posts/a-successful-git-branching-model/) as our branch organization, as known as [FDD](https://en.wikipedia.org/wiki/Feature-driven_development)
## Bugs
### 1. How to Find Known Issues
We are using [Github Issues](https://github.com/bytedance/sonic/issues) for our public bugs. We keep a close eye on this and try to make it clear when we have an internal fix in progress. Before filing a new task, try to make sure your problem doesnt already exist.
### 2. Reporting New Issues
Providing a reduced test code is a recommended way for reporting issues. Then can be placed in:
- Just in issues
- [Golang Playground](https://play.golang.org/)
### 3. Security Bugs
Please do not report the safe disclosure of bugs to public issues. Contact us by [Support Email](mailto:sonic@bytedance.com)
## How to Get in Touch
- [Email](mailto:wudi.daniel@bytedance.com)
## Submit a Pull Request
Before you submit your Pull Request (PR) consider the following guidelines:
1. Search [GitHub](https://github.com/bytedance/sonic/pulls) for an open or closed PR that relates to your submission. You don't want to duplicate existing efforts.
2. Be sure that an issue describes the problem you're fixing, or documents the design for the feature you'd like to add. Discussing the design upfront helps to ensure that we're ready to accept your work.
3. [Fork](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo) the bytedance/sonic repo.
4. In your forked repository, make your changes in a new git branch:
```
git checkout -b bugfix/security_bug develop
```
5. Create your patch, including appropriate test cases.
6. Follow our [Style Guides](#code-style-guides).
7. Commit your changes using a descriptive commit message that follows [AngularJS Git Commit Message Conventions](https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/edit).
Adherence to these conventions is necessary because release notes will be automatically generated from these messages.
8. Push your branch to GitHub:
```
git push origin bugfix/security_bug
```
9. In GitHub, send a pull request to `sonic:main`
Note: you must use one of `optimize/feature/bugfix/doc/ci/test/refactor` following a slash(`/`) as the branch prefix.
Your pr title and commit message should follow https://www.conventionalcommits.org/.
## Contribution Prerequisites
- Our development environment keeps up with [Go Official](https://golang.org/project/).
- You need fully checking with lint tools before submit your pull request. [gofmt](https://golang.org/pkg/cmd/gofmt/) & [golangci-lint](https://github.com/golangci/golangci-lint)
- You are familiar with [Github](https://github.com)
- Maybe you need familiar with [Actions](https://github.com/features/actions)(our default workflow tool).
## Code Style Guides
See [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments).
Good resources:
- [Effective Go](https://golang.org/doc/effective_go)
- [Pingcap General advice](https://pingcap.github.io/style-guide/general.html)
- [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md)
View File
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+471
View File
@@ -0,0 +1,471 @@
# Sonic
English | [中文](README_ZH_CN.md)
A blazingly fast JSON serializing &amp; deserializing library, accelerated by JIT (just-in-time compiling) and SIMD (single-instruction-multiple-data).
## Requirement
- Go 1.16~1.22
- Linux / MacOS / Windows(need go1.17 above)
- Amd64 ARCH
## Features
- Runtime object binding without code generation
- Complete APIs for JSON value manipulation
- Fast, fast, fast!
## APIs
see [go.dev](https://pkg.go.dev/github.com/bytedance/sonic)
## Benchmarks
For **all sizes** of json and **all scenarios** of usage, **Sonic performs best**.
- [Medium](https://github.com/bytedance/sonic/blob/main/decoder/testdata_test.go#L19) (13KB, 300+ key, 6 layers)
```powershell
goversion: 1.17.1
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
BenchmarkEncoder_Generic_Sonic-16 32393 ns/op 402.40 MB/s 11965 B/op 4 allocs/op
BenchmarkEncoder_Generic_Sonic_Fast-16 21668 ns/op 601.57 MB/s 10940 B/op 4 allocs/op
BenchmarkEncoder_Generic_JsonIter-16 42168 ns/op 309.12 MB/s 14345 B/op 115 allocs/op
BenchmarkEncoder_Generic_GoJson-16 65189 ns/op 199.96 MB/s 23261 B/op 16 allocs/op
BenchmarkEncoder_Generic_StdLib-16 106322 ns/op 122.60 MB/s 49136 B/op 789 allocs/op
BenchmarkEncoder_Binding_Sonic-16 6269 ns/op 2079.26 MB/s 14173 B/op 4 allocs/op
BenchmarkEncoder_Binding_Sonic_Fast-16 5281 ns/op 2468.16 MB/s 12322 B/op 4 allocs/op
BenchmarkEncoder_Binding_JsonIter-16 20056 ns/op 649.93 MB/s 9488 B/op 2 allocs/op
BenchmarkEncoder_Binding_GoJson-16 8311 ns/op 1568.32 MB/s 9481 B/op 1 allocs/op
BenchmarkEncoder_Binding_StdLib-16 16448 ns/op 792.52 MB/s 9479 B/op 1 allocs/op
BenchmarkEncoder_Parallel_Generic_Sonic-16 6681 ns/op 1950.93 MB/s 12738 B/op 4 allocs/op
BenchmarkEncoder_Parallel_Generic_Sonic_Fast-16 4179 ns/op 3118.99 MB/s 10757 B/op 4 allocs/op
BenchmarkEncoder_Parallel_Generic_JsonIter-16 9861 ns/op 1321.84 MB/s 14362 B/op 115 allocs/op
BenchmarkEncoder_Parallel_Generic_GoJson-16 18850 ns/op 691.52 MB/s 23278 B/op 16 allocs/op
BenchmarkEncoder_Parallel_Generic_StdLib-16 45902 ns/op 283.97 MB/s 49174 B/op 789 allocs/op
BenchmarkEncoder_Parallel_Binding_Sonic-16 1480 ns/op 8810.09 MB/s 13049 B/op 4 allocs/op
BenchmarkEncoder_Parallel_Binding_Sonic_Fast-16 1209 ns/op 10785.23 MB/s 11546 B/op 4 allocs/op
BenchmarkEncoder_Parallel_Binding_JsonIter-16 6170 ns/op 2112.58 MB/s 9504 B/op 2 allocs/op
BenchmarkEncoder_Parallel_Binding_GoJson-16 3321 ns/op 3925.52 MB/s 9496 B/op 1 allocs/op
BenchmarkEncoder_Parallel_Binding_StdLib-16 3739 ns/op 3486.49 MB/s 9480 B/op 1 allocs/op
BenchmarkDecoder_Generic_Sonic-16 66812 ns/op 195.10 MB/s 57602 B/op 723 allocs/op
BenchmarkDecoder_Generic_Sonic_Fast-16 54523 ns/op 239.07 MB/s 49786 B/op 313 allocs/op
BenchmarkDecoder_Generic_StdLib-16 124260 ns/op 104.90 MB/s 50869 B/op 772 allocs/op
BenchmarkDecoder_Generic_JsonIter-16 91274 ns/op 142.81 MB/s 55782 B/op 1068 allocs/op
BenchmarkDecoder_Generic_GoJson-16 88569 ns/op 147.17 MB/s 66367 B/op 973 allocs/op
BenchmarkDecoder_Binding_Sonic-16 32557 ns/op 400.38 MB/s 28302 B/op 137 allocs/op
BenchmarkDecoder_Binding_Sonic_Fast-16 28649 ns/op 455.00 MB/s 24999 B/op 34 allocs/op
BenchmarkDecoder_Binding_StdLib-16 111437 ns/op 116.97 MB/s 10576 B/op 208 allocs/op
BenchmarkDecoder_Binding_JsonIter-16 35090 ns/op 371.48 MB/s 14673 B/op 385 allocs/op
BenchmarkDecoder_Binding_GoJson-16 28738 ns/op 453.59 MB/s 22039 B/op 49 allocs/op
BenchmarkDecoder_Parallel_Generic_Sonic-16 12321 ns/op 1057.91 MB/s 57233 B/op 723 allocs/op
BenchmarkDecoder_Parallel_Generic_Sonic_Fast-16 10644 ns/op 1224.64 MB/s 49362 B/op 313 allocs/op
BenchmarkDecoder_Parallel_Generic_StdLib-16 57587 ns/op 226.35 MB/s 50874 B/op 772 allocs/op
BenchmarkDecoder_Parallel_Generic_JsonIter-16 38666 ns/op 337.12 MB/s 55789 B/op 1068 allocs/op
BenchmarkDecoder_Parallel_Generic_GoJson-16 30259 ns/op 430.79 MB/s 66370 B/op 974 allocs/op
BenchmarkDecoder_Parallel_Binding_Sonic-16 5965 ns/op 2185.28 MB/s 27747 B/op 137 allocs/op
BenchmarkDecoder_Parallel_Binding_Sonic_Fast-16 5170 ns/op 2521.31 MB/s 24715 B/op 34 allocs/op
BenchmarkDecoder_Parallel_Binding_StdLib-16 27582 ns/op 472.58 MB/s 10576 B/op 208 allocs/op
BenchmarkDecoder_Parallel_Binding_JsonIter-16 13571 ns/op 960.51 MB/s 14685 B/op 385 allocs/op
BenchmarkDecoder_Parallel_Binding_GoJson-16 10031 ns/op 1299.51 MB/s 22111 B/op 49 allocs/op
BenchmarkGetOne_Sonic-16 3276 ns/op 3975.78 MB/s 24 B/op 1 allocs/op
BenchmarkGetOne_Gjson-16 9431 ns/op 1380.81 MB/s 0 B/op 0 allocs/op
BenchmarkGetOne_Jsoniter-16 51178 ns/op 254.46 MB/s 27936 B/op 647 allocs/op
BenchmarkGetOne_Parallel_Sonic-16 216.7 ns/op 60098.95 MB/s 24 B/op 1 allocs/op
BenchmarkGetOne_Parallel_Gjson-16 1076 ns/op 12098.62 MB/s 0 B/op 0 allocs/op
BenchmarkGetOne_Parallel_Jsoniter-16 17741 ns/op 734.06 MB/s 27945 B/op 647 allocs/op
BenchmarkSetOne_Sonic-16 9571 ns/op 1360.61 MB/s 1584 B/op 17 allocs/op
BenchmarkSetOne_Sjson-16 36456 ns/op 357.22 MB/s 52180 B/op 9 allocs/op
BenchmarkSetOne_Jsoniter-16 79475 ns/op 163.86 MB/s 45862 B/op 964 allocs/op
BenchmarkSetOne_Parallel_Sonic-16 850.9 ns/op 15305.31 MB/s 1584 B/op 17 allocs/op
BenchmarkSetOne_Parallel_Sjson-16 18194 ns/op 715.77 MB/s 52247 B/op 9 allocs/op
BenchmarkSetOne_Parallel_Jsoniter-16 33560 ns/op 388.05 MB/s 45892 B/op 964 allocs/op
BenchmarkLoadNode/LoadAll()-16 11384 ns/op 1143.93 MB/s 6307 B/op 25 allocs/op
BenchmarkLoadNode_Parallel/LoadAll()-16 5493 ns/op 2370.68 MB/s 7145 B/op 25 allocs/op
BenchmarkLoadNode/Interface()-16 17722 ns/op 734.85 MB/s 13323 B/op 88 allocs/op
BenchmarkLoadNode_Parallel/Interface()-16 10330 ns/op 1260.70 MB/s 15178 B/op 88 allocs/op
```
- [Small](https://github.com/bytedance/sonic/blob/main/testdata/small.go) (400B, 11 keys, 3 layers)
![small benchmarks](./docs/imgs/bench-small.png)
- [Large](https://github.com/bytedance/sonic/blob/main/testdata/twitter.json) (635KB, 10000+ key, 6 layers)
![large benchmarks](./docs/imgs/bench-large.png)
See [bench.sh](https://github.com/bytedance/sonic/blob/main/scripts/bench.sh) for benchmark codes.
## How it works
See [INTRODUCTION.md](./docs/INTRODUCTION.md).
## Usage
### Marshal/Unmarshal
Default behaviors are mostly consistent with `encoding/json`, except HTML escaping form (see [Escape HTML](https://github.com/bytedance/sonic/blob/main/README.md#escape-html)) and `SortKeys` feature (optional support see [Sort Keys](https://github.com/bytedance/sonic/blob/main/README.md#sort-keys)) that is **NOT** in conformity to [RFC8259](https://datatracker.ietf.org/doc/html/rfc8259).
```go
import "github.com/bytedance/sonic"
var data YourSchema
// Marshal
output, err := sonic.Marshal(&data)
// Unmarshal
err := sonic.Unmarshal(output, &data)
```
### Streaming IO
Sonic supports decoding json from `io.Reader` or encoding objects into `io.Writer`, aims at handling multiple values as well as reducing memory consumption.
- encoder
```go
var o1 = map[string]interface{}{
"a": "b",
}
var o2 = 1
var w = bytes.NewBuffer(nil)
var enc = sonic.ConfigDefault.NewEncoder(w)
enc.Encode(o1)
enc.Encode(o2)
fmt.Println(w.String())
// Output:
// {"a":"b"}
// 1
```
- decoder
```go
var o = map[string]interface{}{}
var r = strings.NewReader(`{"a":"b"}{"1":"2"}`)
var dec = sonic.ConfigDefault.NewDecoder(r)
dec.Decode(&o)
dec.Decode(&o)
fmt.Printf("%+v", o)
// Output:
// map[1:2 a:b]
```
### Use Number/Use Int64
```go
import "github.com/bytedance/sonic/decoder"
var input = `1`
var data interface{}
// default float64
dc := decoder.NewDecoder(input)
dc.Decode(&data) // data == float64(1)
// use json.Number
dc = decoder.NewDecoder(input)
dc.UseNumber()
dc.Decode(&data) // data == json.Number("1")
// use int64
dc = decoder.NewDecoder(input)
dc.UseInt64()
dc.Decode(&data) // data == int64(1)
root, err := sonic.GetFromString(input)
// Get json.Number
jn := root.Number()
jm := root.InterfaceUseNumber().(json.Number) // jn == jm
// Get float64
fn := root.Float64()
fm := root.Interface().(float64) // jn == jm
```
### Sort Keys
On account of the performance loss from sorting (roughly 10%), sonic doesn't enable this feature by default. If your component depends on it to work (like [zstd](https://github.com/facebook/zstd)), Use it like this:
```go
import "github.com/bytedance/sonic"
import "github.com/bytedance/sonic/encoder"
// Binding map only
m := map[string]interface{}{}
v, err := encoder.Encode(m, encoder.SortMapKeys)
// Or ast.Node.SortKeys() before marshal
var root := sonic.Get(JSON)
err := root.SortKeys()
```
### Escape HTML
On account of the performance loss (roughly 15%), sonic doesn't enable this feature by default. You can use `encoder.EscapeHTML` option to open this feature (align with `encoding/json.HTMLEscape`).
```go
import "github.com/bytedance/sonic"
v := map[string]string{"&&":"<>"}
ret, err := Encode(v, EscapeHTML) // ret == `{"\u0026\u0026":{"X":"\u003c\u003e"}}`
```
### Compact Format
Sonic encodes primitive objects (struct/map...) as compact-format JSON by default, except marshaling `json.RawMessage` or `json.Marshaler`: sonic ensures validating their output JSON but **DONOT** compacting them for performance concerns. We provide the option `encoder.CompactMarshaler` to add compacting process.
### Print Error
If there invalid syntax in input JSON, sonic will return `decoder.SyntaxError`, which supports pretty-printing of error position
```go
import "github.com/bytedance/sonic"
import "github.com/bytedance/sonic/decoder"
var data interface{}
err := sonic.UnmarshalString("[[[}]]", &data)
if err != nil {
/* One line by default */
println(e.Error()) // "Syntax error at index 3: invalid char\n\n\t[[[}]]\n\t...^..\n"
/* Pretty print */
if e, ok := err.(decoder.SyntaxError); ok {
/*Syntax error at index 3: invalid char
[[[}]]
...^..
*/
print(e.Description())
} else if me, ok := err.(*decoder.MismatchTypeError); ok {
// decoder.MismatchTypeError is new to Sonic v1.6.0
print(me.Description())
}
}
```
#### Mismatched Types [Sonic v1.6.0]
If there a **mismatch-typed** value for a given key, sonic will report `decoder.MismatchTypeError` (if there are many, report the last one), but still skip wrong the value and keep decoding next JSON.
```go
import "github.com/bytedance/sonic"
import "github.com/bytedance/sonic/decoder"
var data = struct{
A int
B int
}{}
err := UnmarshalString(`{"A":"1","B":1}`, &data)
println(err.Error()) // Mismatch type int with value string "at index 5: mismatched type with value\n\n\t{\"A\":\"1\",\"B\":1}\n\t.....^.........\n"
fmt.Printf("%+v", data) // {A:0 B:1}
```
### Ast.Node
Sonic/ast.Node is a completely self-contained AST for JSON. It implements serialization and deserialization both and provides robust APIs for obtaining and modification of generic data.
#### Get/Index
Search partial JSON by given paths, which must be non-negative integer or string, or nil
```go
import "github.com/bytedance/sonic"
input := []byte(`{"key1":[{},{"key2":{"key3":[1,2,3]}}]}`)
// no path, returns entire json
root, err := sonic.Get(input)
raw := root.Raw() // == string(input)
// multiple paths
root, err := sonic.Get(input, "key1", 1, "key2")
sub := root.Get("key3").Index(2).Int64() // == 3
```
**Tip**: since `Index()` uses offset to locate data, which is much faster than scanning like `Get()`, we suggest you use it as much as possible. And sonic also provides another API `IndexOrGet()` to underlying use offset as well as ensure the key is matched.
#### Set/Unset
Modify the json content by Set()/Unset()
```go
import "github.com/bytedance/sonic"
// Set
exist, err := root.Set("key4", NewBool(true)) // exist == false
alias1 := root.Get("key4")
println(alias1.Valid()) // true
alias2 := root.Index(1)
println(alias1 == alias2) // true
// Unset
exist, err := root.UnsetByIndex(1) // exist == true
println(root.Get("key4").Check()) // "value not exist"
```
#### Serialize
To encode `ast.Node` as json, use `MarshalJson()` or `json.Marshal()` (MUST pass the node's pointer)
```go
import (
"encoding/json"
"github.com/bytedance/sonic"
)
buf, err := root.MarshalJson()
println(string(buf)) // {"key1":[{},{"key2":{"key3":[1,2,3]}}]}
exp, err := json.Marshal(&root) // WARN: use pointer
println(string(buf) == string(exp)) // true
```
#### APIs
- validation: `Check()`, `Error()`, `Valid()`, `Exist()`
- searching: `Index()`, `Get()`, `IndexPair()`, `IndexOrGet()`, `GetByPath()`
- go-type casting: `Int64()`, `Float64()`, `String()`, `Number()`, `Bool()`, `Map[UseNumber|UseNode]()`, `Array[UseNumber|UseNode]()`, `Interface[UseNumber|UseNode]()`
- go-type packing: `NewRaw()`, `NewNumber()`, `NewNull()`, `NewBool()`, `NewString()`, `NewObject()`, `NewArray()`
- iteration: `Values()`, `Properties()`, `ForEach()`, `SortKeys()`
- modification: `Set()`, `SetByIndex()`, `Add()`
### Ast.Visitor
Sonic provides an advanced API for fully parsing JSON into non-standard types (neither `struct` not `map[string]interface{}`) without using any intermediate representation (`ast.Node` or `interface{}`). For example, you might have the following types which are like `interface{}` but actually not `interface{}`:
```go
type UserNode interface {}
// the following types implement the UserNode interface.
type (
UserNull struct{}
UserBool struct{ Value bool }
UserInt64 struct{ Value int64 }
UserFloat64 struct{ Value float64 }
UserString struct{ Value string }
UserObject struct{ Value map[string]UserNode }
UserArray struct{ Value []UserNode }
)
```
Sonic provides the following API to return **the preorder traversal of a JSON AST**. The `ast.Visitor` is a SAX style interface which is used in some C++ JSON library. You should implement `ast.Visitor` by yourself and pass it to `ast.Preorder()` method. In your visitor you can make your custom types to represent JSON values. There may be an O(n) space container (such as stack) in your visitor to record the object / array hierarchy.
```go
func Preorder(str string, visitor Visitor, opts *VisitorOptions) error
type Visitor interface {
OnNull() error
OnBool(v bool) error
OnString(v string) error
OnInt64(v int64, n json.Number) error
OnFloat64(v float64, n json.Number) error
OnObjectBegin(capacity int) error
OnObjectKey(key string) error
OnObjectEnd() error
OnArrayBegin(capacity int) error
OnArrayEnd() error
}
```
See [ast/visitor.go](https://github.com/bytedance/sonic/blob/main/ast/visitor.go) for detailed usage. We also implement a demo visitor for `UserNode` in [ast/visitor_test.go](https://github.com/bytedance/sonic/blob/main/ast/visitor_test.go).
## Compatibility
Sonic **DOES NOT** ensure to support all environments, due to the difficulty of developing high-performance codes. For developers who use sonic to build their applications in different environments, we have the following suggestions:
- Developing on **Mac M1**: Make sure you have Rosetta 2 installed on your machine, and set `GOARCH=amd64` when building your application. Rosetta 2 can automatically translate x86 binaries to arm64 binaries and run x86 applications on Mac M1.
- Developing on **Linux arm64**: You can install qemu and use the `qemu-x86_64 -cpu max` command to convert x86 binaries to amr64 binaries for applications built with sonic. The qemu can achieve a similar transfer effect to Rosetta 2 on Mac M1.
For developers who want to use sonic on Linux arm64 without qemu, or those who want to handle JSON strictly consistent with `encoding/json`, we provide some compatible APIs as `sonic.API`
- `ConfigDefault`: the sonic's default config (`EscapeHTML=false`,`SortKeys=false`...) to run on sonic-supporting environment. It will fall back to `encoding/json` with the corresponding config, and some options like `SortKeys=false` will be invalid.
- `ConfigStd`: the std-compatible config (`EscapeHTML=true`,`SortKeys=true`...) to run on sonic-supporting environment. It will fall back to `encoding/json`.
- `ConfigFastest`: the fastest config (`NoQuoteTextMarshaler=true`) to run on sonic-supporting environment. It will fall back to `encoding/json` with the corresponding config, and some options will be invalid.
## Tips
### Pretouch
Since Sonic uses [golang-asm](https://github.com/twitchyliquid64/golang-asm) as a JIT assembler, which is NOT very suitable for runtime compiling, first-hit running of a huge schema may cause request-timeout or even process-OOM. For better stability, we advise **using `Pretouch()` for huge-schema or compact-memory applications** before `Marshal()/Unmarshal()`.
```go
import (
"reflect"
"github.com/bytedance/sonic"
"github.com/bytedance/sonic/option"
)
func init() {
var v HugeStruct
// For most large types (nesting depth <= option.DefaultMaxInlineDepth)
err := sonic.Pretouch(reflect.TypeOf(v))
// with more CompileOption...
err := sonic.Pretouch(reflect.TypeOf(v),
// If the type is too deep nesting (nesting depth > option.DefaultMaxInlineDepth),
// you can set compile recursive loops in Pretouch for better stability in JIT.
option.WithCompileRecursiveDepth(loop),
// For a large nested struct, try to set a smaller depth to reduce compiling time.
option.WithCompileMaxInlineDepth(depth),
)
}
```
### Copy string
When decoding **string values without any escaped characters**, sonic references them from the origin JSON buffer instead of mallocing a new buffer to copy. This helps a lot for CPU performance but may leave the whole JSON buffer in memory as long as the decoded objects are being used. In practice, we found the extra memory introduced by referring JSON buffer is usually 20% ~ 80% of decoded objects. Once an application holds these objects for a long time (for example, cache the decoded objects for reusing), its in-use memory on the server may go up. - `Config.CopyString`/`decoder.CopyString()`: We provide the option for `Decode()` / `Unmarshal()` users to choose not to reference the JSON buffer, which may cause a decline in CPU performance to some degree.
- `GetFromStringNoCopy()`: For memory safety, `sonic.Get()` / `sonic.GetFromString()` now copies return JSON. If users want to get json more quickly and not care about memory usage, you can use `GetFromStringNoCopy()` to return a JSON directly referenced from source.
### Pass string or []byte?
For alignment to `encoding/json`, we provide API to pass `[]byte` as an argument, but the string-to-bytes copy is conducted at the same time considering safety, which may lose performance when the origin JSON is huge. Therefore, you can use `UnmarshalString()` and `GetFromString()` to pass a string, as long as your origin data is a string or **nocopy-cast** is safe for your []byte. We also provide API `MarshalString()` for convenient **nocopy-cast** of encoded JSON []byte, which is safe since sonic's output bytes is always duplicated and unique.
### Accelerate `encoding.TextMarshaler`
To ensure data security, sonic.Encoder quotes and escapes string values from `encoding.TextMarshaler` interfaces by default, which may degrade performance much if most of your data is in form of them. We provide `encoder.NoQuoteTextMarshaler` to skip these operations, which means you **MUST** ensure their output string escaped and quoted following [RFC8259](https://datatracker.ietf.org/doc/html/rfc8259).
### Better performance for generic data
In **fully-parsed** scenario, `Unmarshal()` performs better than `Get()`+`Node.Interface()`. But if you only have a part of the schema for specific json, you can combine `Get()` and `Unmarshal()` together:
```go
import "github.com/bytedance/sonic"
node, err := sonic.GetFromString(_TwitterJson, "statuses", 3, "user")
var user User // your partial schema...
err = sonic.UnmarshalString(node.Raw(), &user)
```
Even if you don't have any schema, use `ast.Node` as the container of generic values instead of `map` or `interface`:
```go
import "github.com/bytedance/sonic"
root, err := sonic.GetFromString(_TwitterJson)
user := root.GetByPath("statuses", 3, "user") // === root.Get("status").Index(3).Get("user")
err = user.Check()
// err = user.LoadAll() // only call this when you want to use 'user' concurrently...
go someFunc(user)
```
Why? Because `ast.Node` stores its children using `array`:
- `Array`'s performance is **much better** than `Map` when Inserting (Deserialize) and Scanning (Serialize) data;
- **Hashing** (`map[x]`) is not as efficient as **Indexing** (`array[x]`), which `ast.Node` can conduct on **both array and object**;
- Using `Interface()`/`Map()` means Sonic must parse all the underlying values, while `ast.Node` can parse them **on demand**.
**CAUTION:** `ast.Node` **DOESN'T** ensure concurrent security directly, due to its **lazy-load** design. However, you can call `Node.Load()`/`Node.LoadAll()` to achieve that, which may bring performance reduction while it still works faster than converting to `map` or `interface{}`
### Ast.Node or Ast.Visitor?
For generic data, `ast.Node` should be enough for your needs in most cases.
However, `ast.Node` is designed for partially processing JSON string. It has some special designs such as lazy-load which might not be suitable for directly parsing the whole JSON string like `Unmarshal()`. Although `ast.Node` is better then `map` or `interface{}`, it's also a kind of intermediate representation after all if your final types are customized and you have to convert the above types to your custom types after parsing.
For better performance, in previous case the `ast.Visitor` will be the better choice. It performs JSON decoding like `Unmarshal()` and you can directly use your final types to represents a JSON AST without any intermediate representations.
But `ast.Visitor` is not a very handy API. You might need to write a lot of code to implement your visitor and carefully maintain the tree hierarchy during decoding. Please read the comments in [ast/visitor.go](https://github.com/bytedance/sonic/blob/main/ast/visitor.go) carefully if you decide to use this API.
## Community
Sonic is a subproject of [CloudWeGo](https://www.cloudwego.io/). We are committed to building a cloud native ecosystem.
+469
View File
@@ -0,0 +1,469 @@
# Sonic
[English](README.md) | 中文
一个速度奇快的 JSON 序列化/反序列化库,由 JIT (即时编译)和 SIMD (单指令流多数据流)加速。
## 依赖
- Go 1.16~1.22
- Linux / MacOS / Windows(需要 Go1.17 以上)
- Amd64 架构
## 接口
详见 [go.dev](https://pkg.go.dev/github.com/bytedance/sonic)
## 特色
- 运行时对象绑定,无需代码生成
- 完备的 JSON 操作 API
- 快,更快,还要更快!
## 基准测试
对于**所有大小**的 json 和**所有使用场景** **Sonic 表现均为最佳**
- [中型](https://github.com/bytedance/sonic/blob/main/decoder/testdata_test.go#L19) (13kB, 300+ 键, 6 层)
```powershell
goversion: 1.17.1
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
BenchmarkEncoder_Generic_Sonic-16 32393 ns/op 402.40 MB/s 11965 B/op 4 allocs/op
BenchmarkEncoder_Generic_Sonic_Fast-16 21668 ns/op 601.57 MB/s 10940 B/op 4 allocs/op
BenchmarkEncoder_Generic_JsonIter-16 42168 ns/op 309.12 MB/s 14345 B/op 115 allocs/op
BenchmarkEncoder_Generic_GoJson-16 65189 ns/op 199.96 MB/s 23261 B/op 16 allocs/op
BenchmarkEncoder_Generic_StdLib-16 106322 ns/op 122.60 MB/s 49136 B/op 789 allocs/op
BenchmarkEncoder_Binding_Sonic-16 6269 ns/op 2079.26 MB/s 14173 B/op 4 allocs/op
BenchmarkEncoder_Binding_Sonic_Fast-16 5281 ns/op 2468.16 MB/s 12322 B/op 4 allocs/op
BenchmarkEncoder_Binding_JsonIter-16 20056 ns/op 649.93 MB/s 9488 B/op 2 allocs/op
BenchmarkEncoder_Binding_GoJson-16 8311 ns/op 1568.32 MB/s 9481 B/op 1 allocs/op
BenchmarkEncoder_Binding_StdLib-16 16448 ns/op 792.52 MB/s 9479 B/op 1 allocs/op
BenchmarkEncoder_Parallel_Generic_Sonic-16 6681 ns/op 1950.93 MB/s 12738 B/op 4 allocs/op
BenchmarkEncoder_Parallel_Generic_Sonic_Fast-16 4179 ns/op 3118.99 MB/s 10757 B/op 4 allocs/op
BenchmarkEncoder_Parallel_Generic_JsonIter-16 9861 ns/op 1321.84 MB/s 14362 B/op 115 allocs/op
BenchmarkEncoder_Parallel_Generic_GoJson-16 18850 ns/op 691.52 MB/s 23278 B/op 16 allocs/op
BenchmarkEncoder_Parallel_Generic_StdLib-16 45902 ns/op 283.97 MB/s 49174 B/op 789 allocs/op
BenchmarkEncoder_Parallel_Binding_Sonic-16 1480 ns/op 8810.09 MB/s 13049 B/op 4 allocs/op
BenchmarkEncoder_Parallel_Binding_Sonic_Fast-16 1209 ns/op 10785.23 MB/s 11546 B/op 4 allocs/op
BenchmarkEncoder_Parallel_Binding_JsonIter-16 6170 ns/op 2112.58 MB/s 9504 B/op 2 allocs/op
BenchmarkEncoder_Parallel_Binding_GoJson-16 3321 ns/op 3925.52 MB/s 9496 B/op 1 allocs/op
BenchmarkEncoder_Parallel_Binding_StdLib-16 3739 ns/op 3486.49 MB/s 9480 B/op 1 allocs/op
BenchmarkDecoder_Generic_Sonic-16 66812 ns/op 195.10 MB/s 57602 B/op 723 allocs/op
BenchmarkDecoder_Generic_Sonic_Fast-16 54523 ns/op 239.07 MB/s 49786 B/op 313 allocs/op
BenchmarkDecoder_Generic_StdLib-16 124260 ns/op 104.90 MB/s 50869 B/op 772 allocs/op
BenchmarkDecoder_Generic_JsonIter-16 91274 ns/op 142.81 MB/s 55782 B/op 1068 allocs/op
BenchmarkDecoder_Generic_GoJson-16 88569 ns/op 147.17 MB/s 66367 B/op 973 allocs/op
BenchmarkDecoder_Binding_Sonic-16 32557 ns/op 400.38 MB/s 28302 B/op 137 allocs/op
BenchmarkDecoder_Binding_Sonic_Fast-16 28649 ns/op 455.00 MB/s 24999 B/op 34 allocs/op
BenchmarkDecoder_Binding_StdLib-16 111437 ns/op 116.97 MB/s 10576 B/op 208 allocs/op
BenchmarkDecoder_Binding_JsonIter-16 35090 ns/op 371.48 MB/s 14673 B/op 385 allocs/op
BenchmarkDecoder_Binding_GoJson-16 28738 ns/op 453.59 MB/s 22039 B/op 49 allocs/op
BenchmarkDecoder_Parallel_Generic_Sonic-16 12321 ns/op 1057.91 MB/s 57233 B/op 723 allocs/op
BenchmarkDecoder_Parallel_Generic_Sonic_Fast-16 10644 ns/op 1224.64 MB/s 49362 B/op 313 allocs/op
BenchmarkDecoder_Parallel_Generic_StdLib-16 57587 ns/op 226.35 MB/s 50874 B/op 772 allocs/op
BenchmarkDecoder_Parallel_Generic_JsonIter-16 38666 ns/op 337.12 MB/s 55789 B/op 1068 allocs/op
BenchmarkDecoder_Parallel_Generic_GoJson-16 30259 ns/op 430.79 MB/s 66370 B/op 974 allocs/op
BenchmarkDecoder_Parallel_Binding_Sonic-16 5965 ns/op 2185.28 MB/s 27747 B/op 137 allocs/op
BenchmarkDecoder_Parallel_Binding_Sonic_Fast-16 5170 ns/op 2521.31 MB/s 24715 B/op 34 allocs/op
BenchmarkDecoder_Parallel_Binding_StdLib-16 27582 ns/op 472.58 MB/s 10576 B/op 208 allocs/op
BenchmarkDecoder_Parallel_Binding_JsonIter-16 13571 ns/op 960.51 MB/s 14685 B/op 385 allocs/op
BenchmarkDecoder_Parallel_Binding_GoJson-16 10031 ns/op 1299.51 MB/s 22111 B/op 49 allocs/op
BenchmarkGetOne_Sonic-16 3276 ns/op 3975.78 MB/s 24 B/op 1 allocs/op
BenchmarkGetOne_Gjson-16 9431 ns/op 1380.81 MB/s 0 B/op 0 allocs/op
BenchmarkGetOne_Jsoniter-16 51178 ns/op 254.46 MB/s 27936 B/op 647 allocs/op
BenchmarkGetOne_Parallel_Sonic-16 216.7 ns/op 60098.95 MB/s 24 B/op 1 allocs/op
BenchmarkGetOne_Parallel_Gjson-16 1076 ns/op 12098.62 MB/s 0 B/op 0 allocs/op
BenchmarkGetOne_Parallel_Jsoniter-16 17741 ns/op 734.06 MB/s 27945 B/op 647 allocs/op
BenchmarkSetOne_Sonic-16 9571 ns/op 1360.61 MB/s 1584 B/op 17 allocs/op
BenchmarkSetOne_Sjson-16 36456 ns/op 357.22 MB/s 52180 B/op 9 allocs/op
BenchmarkSetOne_Jsoniter-16 79475 ns/op 163.86 MB/s 45862 B/op 964 allocs/op
BenchmarkSetOne_Parallel_Sonic-16 850.9 ns/op 15305.31 MB/s 1584 B/op 17 allocs/op
BenchmarkSetOne_Parallel_Sjson-16 18194 ns/op 715.77 MB/s 52247 B/op 9 allocs/op
BenchmarkSetOne_Parallel_Jsoniter-16 33560 ns/op 388.05 MB/s 45892 B/op 964 allocs/op
BenchmarkLoadNode/LoadAll()-16 11384 ns/op 1143.93 MB/s 6307 B/op 25 allocs/op
BenchmarkLoadNode_Parallel/LoadAll()-16 5493 ns/op 2370.68 MB/s 7145 B/op 25 allocs/op
BenchmarkLoadNode/Interface()-16 17722 ns/op 734.85 MB/s 13323 B/op 88 allocs/op
BenchmarkLoadNode_Parallel/Interface()-16 10330 ns/op 1260.70 MB/s 15178 B/op 88 allocs/op
```
- [小型](https://github.com/bytedance/sonic/blob/main/testdata/small.go) (400B, 11 个键, 3 层)
![small benchmarks](./docs/imgs/bench-small.png)
- [大型](https://github.com/bytedance/sonic/blob/main/testdata/twitter.json) (635kB, 10000+ 个键, 6 层)
![large benchmarks](./docs/imgs/bench-large.png)
要查看基准测试代码,请参阅 [bench.sh](https://github.com/bytedance/sonic/blob/main/scripts/bench.sh) 。
## 工作原理
请参阅 [INTRODUCTION_ZH_CN.md](./docs/INTRODUCTION_ZH_CN.md).
## 使用方式
### 序列化/反序列化
默认的行为基本上与 `encoding/json` 相一致,除了 HTML 转义形式(参见 [Escape HTML](https://github.com/bytedance/sonic/blob/main/README.md#escape-html)) 和 `SortKeys` 功能(参见 [Sort Keys](https://github.com/bytedance/sonic/blob/main/README.md#sort-keys)**没有**遵循 [RFC8259](https://datatracker.ietf.org/doc/html/rfc8259) 。
```go
import "github.com/bytedance/sonic"
var data YourSchema
// Marshal
output, err := sonic.Marshal(&data)
// Unmarshal
err := sonic.Unmarshal(output, &data)
```
### 流式输入输出
Sonic 支持解码 `io.Reader` 中输入的 json,或将对象编码为 json 后输出至 `io.Writer`,以处理多个值并减少内存消耗。
- 编码器
```go
var o1 = map[string]interface{}{
"a": "b",
}
var o2 = 1
var w = bytes.NewBuffer(nil)
var enc = sonic.ConfigDefault.NewEncoder(w)
enc.Encode(o1)
enc.Encode(o2)
fmt.Println(w.String())
// Output:
// {"a":"b"}
// 1
```
- 解码器
```go
var o = map[string]interface{}{}
var r = strings.NewReader(`{"a":"b"}{"1":"2"}`)
var dec = sonic.ConfigDefault.NewDecoder(r)
dec.Decode(&o)
dec.Decode(&o)
fmt.Printf("%+v", o)
// Output:
// map[1:2 a:b]
```
### 使用 `Number` / `int64`
```go
import "github.com/bytedance/sonic/decoder"
var input = `1`
var data interface{}
// default float64
dc := decoder.NewDecoder(input)
dc.Decode(&data) // data == float64(1)
// use json.Number
dc = decoder.NewDecoder(input)
dc.UseNumber()
dc.Decode(&data) // data == json.Number("1")
// use int64
dc = decoder.NewDecoder(input)
dc.UseInt64()
dc.Decode(&data) // data == int64(1)
root, err := sonic.GetFromString(input)
// Get json.Number
jn := root.Number()
jm := root.InterfaceUseNumber().(json.Number) // jn == jm
// Get float64
fn := root.Float64()
fm := root.Interface().(float64) // jn == jm
```
### 对键排序
考虑到排序带来的性能损失(约 10% ), sonic 默认不会启用这个功能。如果你的组件依赖这个行为(如 [zstd](https://github.com/facebook/zstd)) ,可以仿照下面的例子:
```go
import "github.com/bytedance/sonic"
import "github.com/bytedance/sonic/encoder"
// Binding map only
m := map[string]interface{}{}
v, err := encoder.Encode(m, encoder.SortMapKeys)
// Or ast.Node.SortKeys() before marshal
var root := sonic.Get(JSON)
err := root.SortKeys()
```
### HTML 转义
考虑到性能损失(约15%), sonic 默认不会启用这个功能。你可以使用 `encoder.EscapeHTML` 选项来开启(与 `encoding/json.HTMLEscape` 行为一致)。
```go
import "github.com/bytedance/sonic"
v := map[string]string{"&&":"<>"}
ret, err := Encode(v, EscapeHTML) // ret == `{"\u0026\u0026":{"X":"\u003c\u003e"}}`
```
### 紧凑格式
Sonic 默认将基本类型( `struct` `map` 等)编码为紧凑格式的 JSON ,除非使用 `json.RawMessage` or `json.Marshaler` 进行编码: sonic 确保输出的 JSON 合法,但出于性能考虑,**不会**加工成紧凑格式。我们提供选项 `encoder.CompactMarshaler` 来添加此过程,
### 打印错误
如果输入的 JSON 存在无效的语法,sonic 将返回 `decoder.SyntaxError`,该错误支持错误位置的美化输出。
```go
import "github.com/bytedance/sonic"
import "github.com/bytedance/sonic/decoder"
var data interface{}
err := sonic.UnmarshalString("[[[}]]", &data)
if err != nil {
/* One line by default */
println(e.Error()) // "Syntax error at index 3: invalid char\n\n\t[[[}]]\n\t...^..\n"
/* Pretty print */
if e, ok := err.(decoder.SyntaxError); ok {
/*Syntax error at index 3: invalid char
[[[}]]
...^..
*/
print(e.Description())
} else if me, ok := err.(*decoder.MismatchTypeError); ok {
// decoder.MismatchTypeError is new to Sonic v1.6.0
print(me.Description())
}
}
```
#### 类型不匹配 [Sonic v1.6.0]
如果给定键中存在**类型不匹配**的值, sonic 会抛出 `decoder.MismatchTypeError` (如果有多个,只会报告最后一个),但仍会跳过错误的值并解码下一个 JSON 。
```go
import "github.com/bytedance/sonic"
import "github.com/bytedance/sonic/decoder"
var data = struct{
A int
B int
}{}
err := UnmarshalString(`{"A":"1","B":1}`, &data)
println(err.Error()) // Mismatch type int with value string "at index 5: mismatched type with value\n\n\t{\"A\":\"1\",\"B\":1}\n\t.....^.........\n"
fmt.Printf("%+v", data) // {A:0 B:1}
```
### `Ast.Node`
Sonic/ast.Node 是完全独立的 JSON 抽象语法树库。它实现了序列化和反序列化,并提供了获取和修改通用数据的鲁棒的 API。
#### 查找/索引
通过给定的路径搜索 JSON 片段,路径必须为非负整数,字符串或 `nil` 。
```go
import "github.com/bytedance/sonic"
input := []byte(`{"key1":[{},{"key2":{"key3":[1,2,3]}}]}`)
// no path, returns entire json
root, err := sonic.Get(input)
raw := root.Raw() // == string(input)
// multiple paths
root, err := sonic.Get(input, "key1", 1, "key2")
sub := root.Get("key3").Index(2).Int64() // == 3
```
**注意**:由于 `Index()` 使用偏移量来定位数据,比使用扫描的 `Get()` 要快的多,建议尽可能的使用 `Index` 。 Sonic 也提供了另一个 API, `IndexOrGet()` ,以偏移量为基础并且也确保键的匹配。
#### 修改
使用 `Set()` / `Unset()` 修改 json 的内容
```go
import "github.com/bytedance/sonic"
// Set
exist, err := root.Set("key4", NewBool(true)) // exist == false
alias1 := root.Get("key4")
println(alias1.Valid()) // true
alias2 := root.Index(1)
println(alias1 == alias2) // true
// Unset
exist, err := root.UnsetByIndex(1) // exist == true
println(root.Get("key4").Check()) // "value not exist"
```
#### 序列化
要将 `ast.Node` 编码为 json ,使用 `MarshalJson()` 或者 `json.Marshal()` (必须传递指向节点的指针)
```go
import (
"encoding/json"
"github.com/bytedance/sonic"
)
buf, err := root.MarshalJson()
println(string(buf)) // {"key1":[{},{"key2":{"key3":[1,2,3]}}]}
exp, err := json.Marshal(&root) // WARN: use pointer
println(string(buf) == string(exp)) // true
```
#### APIs
- 合法性检查: `Check()`, `Error()`, `Valid()`, `Exist()`
- 索引: `Index()`, `Get()`, `IndexPair()`, `IndexOrGet()`, `GetByPath()`
- 转换至 go 内置类型: `Int64()`, `Float64()`, `String()`, `Number()`, `Bool()`, `Map[UseNumber|UseNode]()`, `Array[UseNumber|UseNode]()`, `Interface[UseNumber|UseNode]()`
- go 类型打包: `NewRaw()`, `NewNumber()`, `NewNull()`, `NewBool()`, `NewString()`, `NewObject()`, `NewArray()`
- 迭代: `Values()`, `Properties()`, `ForEach()`, `SortKeys()`
- 修改: `Set()`, `SetByIndex()`, `Add()`
### `Ast.Visitor`
Sonic 提供了一个高级的 API 用于直接全量解析 JSON 到非标准容器里 (既不是 `struct` 也不是 `map[string]interface{}`) 且不需要借助任何中间表示 (`ast.Node` 或 `interface{}`)。举个例子,你可能定义了下述的类型,它们看起来像 `interface{}`,但实际上并不是:
```go
type UserNode interface {}
// the following types implement the UserNode interface.
type (
UserNull struct{}
UserBool struct{ Value bool }
UserInt64 struct{ Value int64 }
UserFloat64 struct{ Value float64 }
UserString struct{ Value string }
UserObject struct{ Value map[string]UserNode }
UserArray struct{ Value []UserNode }
)
```
Sonic 提供了下述的 API 来返回 **“对 JSON AST 的前序遍历”**。`ast.Visitor` 是一个 SAX 风格的接口,这在某些 C++ 的 JSON 解析库中被使用到。你需要自己实现一个 `ast.Visitor`,将它传递给 `ast.Preorder()` 方法。在你的实现中你可以使用自定义的类型来表示 JSON 的值。在你的 `ast.Visitor` 中,可能需要有一个 O(n) 空间复杂度的容器(比如说栈)来记录 object / array 的层级。
```go
func Preorder(str string, visitor Visitor, opts *VisitorOptions) error
type Visitor interface {
OnNull() error
OnBool(v bool) error
OnString(v string) error
OnInt64(v int64, n json.Number) error
OnFloat64(v float64, n json.Number) error
OnObjectBegin(capacity int) error
OnObjectKey(key string) error
OnObjectEnd() error
OnArrayBegin(capacity int) error
OnArrayEnd() error
}
```
详细用法参看 [ast/visitor.go](https://github.com/bytedance/sonic/blob/main/ast/visitor.go),我们还为 `UserNode` 实现了一个示例 `ast.Visitor`,你可以在 [ast/visitor_test.go](https://github.com/bytedance/sonic/blob/main/ast/visitor_test.go) 中找到它。
## 兼容性
由于开发高性能代码的困难性, Sonic **不**保证对所有环境的支持。对于在不同环境中使用 Sonic 构建应用程序的开发者,我们有以下建议:
- 在 **Mac M1** 上开发:确保在您的计算机上安装了 Rosetta 2,并在构建时设置 `GOARCH=amd64` 。 Rosetta 2 可以自动将 x86 二进制文件转换为 arm64 二进制文件,并在 Mac M1 上运行 x86 应用程序。
- 在 **Linux arm64** 上开发:您可以安装 qemu 并使用 `qemu-x86_64 -cpu max` 命令来将 x86 二进制文件转换为 arm64 二进制文件。qemu可以实现与Mac M1上的Rosetta 2类似的转换效果。
对于希望在不使用 qemu 下使用 sonic 的开发者,或者希望处理 JSON 时与 `encoding/JSON` 严格保持一致的开发者,我们在 `sonic.API` 中提供了一些兼容性 API
- `ConfigDefault`: 在支持 sonic 的环境下 sonic 的默认配置(`EscapeHTML=false``SortKeys=false`等)。行为与具有相应配置的 `encoding/json` 一致,一些选项,如 `SortKeys=false` 将无效。
- `ConfigStd`: 在支持 sonic 的环境下与标准库兼容的配置(`EscapeHTML=true``SortKeys=true`等)。行为与 `encoding/json` 一致。
- `ConfigFastest`: 在支持 sonic 的环境下运行最快的配置(`NoQuoteTextMarshaler=true`)。行为与具有相应配置的 `encoding/json` 一致,某些选项将无效。
## 注意事项
### 预热
由于 Sonic 使用 [golang-asm](https://github.com/twitchyliquid64/golang-asm) 作为 JIT 汇编器,这个库并不适用于运行时编译,第一次运行一个大型模式可能会导致请求超时甚至进程内存溢出。为了更好地稳定性,我们建议在运行大型模式或在内存有限的应用中,在使用 `Marshal()/Unmarshal()` 前运行 `Pretouch()`。
```go
import (
"reflect"
"github.com/bytedance/sonic"
"github.com/bytedance/sonic/option"
)
func init() {
var v HugeStruct
// For most large types (nesting depth <= option.DefaultMaxInlineDepth)
err := sonic.Pretouch(reflect.TypeOf(v))
// with more CompileOption...
err := sonic.Pretouch(reflect.TypeOf(v),
// If the type is too deep nesting (nesting depth > option.DefaultMaxInlineDepth),
// you can set compile recursive loops in Pretouch for better stability in JIT.
option.WithCompileRecursiveDepth(loop),
// For a large nested struct, try to set a smaller depth to reduce compiling time.
option.WithCompileMaxInlineDepth(depth),
)
}
```
### 拷贝字符串
当解码 **没有转义字符的字符串**时, sonic 会从原始的 JSON 缓冲区内引用而不是复制到新的一个缓冲区中。这对 CPU 的性能方面很有帮助,但是可能因此在解码后对象仍在使用的时候将整个 JSON 缓冲区保留在内存中。实践中我们发现,通过引用 JSON 缓冲区引入的额外内存通常是解码后对象的 20% 至 80% ,一旦应用长期保留这些对象(如缓存以备重用),服务器所使用的内存可能会增加。我们提供了选项 `decoder.CopyString()` 供用户选择,不引用 JSON 缓冲区。这可能在一定程度上降低 CPU 性能。
### 传递字符串还是字节数组?
为了和 `encoding/json` 保持一致,我们提供了传递 `[]byte` 作为参数的 API ,但考虑到安全性,字符串到字节的复制是同时进行的,这在原始 JSON 非常大时可能会导致性能损失。因此,你可以使用 `UnmarshalString()` 和 `GetFromString()` 来传递字符串,只要你的原始数据是字符串,或**零拷贝类型转换**对于你的字节数组是安全的。我们也提供了 `MarshalString()` 的 API ,以便对编码的 JSON 字节数组进行**零拷贝类型转换**,因为 sonic 输出的字节始终是重复并且唯一的,所以这样是安全的。
### 加速 `encoding.TextMarshaler`
为了保证数据安全性, `sonic.Encoder` 默认会对来自 `encoding.TextMarshaler` 接口的字符串进行引用和转义,如果大部分数据都是这种形式那可能会导致很大的性能损失。我们提供了 `encoder.NoQuoteTextMarshaler` 选项来跳过这些操作,但你**必须**保证他们的输出字符串依照 [RFC8259](https://datatracker.ietf.org/doc/html/rfc8259) 进行了转义和引用。
### 泛型的性能优化
在 **完全解析**的场景下, `Unmarshal()` 表现得比 `Get()`+`Node.Interface()` 更好。但是如果你只有特定 JSON 的部分模式,你可以将 `Get()` 和 `Unmarshal()` 结合使用:
```go
import "github.com/bytedance/sonic"
node, err := sonic.GetFromString(_TwitterJson, "statuses", 3, "user")
var user User // your partial schema...
err = sonic.UnmarshalString(node.Raw(), &user)
```
甚至如果你没有任何模式,可以用 `ast.Node` 代替 `map` 或 `interface` 作为泛型的容器:
```go
import "github.com/bytedance/sonic"
root, err := sonic.GetFromString(_TwitterJson)
user := root.GetByPath("statuses", 3, "user") // === root.Get("status").Index(3).Get("user")
err = user.Check()
// err = user.LoadAll() // only call this when you want to use 'user' concurrently...
go someFunc(user)
```
为什么?因为 `ast.Node` 使用 `array` 来存储其子节点:
- 在插入(反序列化)和扫描(序列化)数据时,`Array` 的性能比 `Map` **好得多**
- **哈希**`map[x]`)的效率不如**索引**`array[x]`)高效,而 `ast.Node` 可以在数组和对象上使用索引;
- 使用 `Interface()` / `Map()` 意味着 sonic 必须解析所有的底层值,而 `ast.Node` 可以**按需解析**它们。
**注意**:由于 `ast.Node` 的惰性加载设计,其**不能**直接保证并发安全性,但你可以调用 `Node.Load()` / `Node.LoadAll()` 来实现并发安全。尽管可能会带来性能损失,但仍比转换成 `map` 或 `interface{}` 更为高效。
### 使用 `ast.Node` 还是 `ast.Visitor`
对于泛型数据的解析,`ast.Node` 在大多数场景上应该能够满足你的需求。
然而,`ast.Node` 是一种针对部分解析 JSON 而设计的泛型容器,它包含一些特殊设计,比如惰性加载,如果你希望像 `Unmarshal()` 那样直接解析整个 JSON,这些设计可能并不合适。尽管 `ast.Node` 相较于 `map` 或 `interface{}` 来说是更好的一种泛型容器,但它毕竟也是一种中间表示,如果你的最终类型是自定义的,你还得在解析完成后将上述类型转化成你自定义的类型。
在上述场景中,如果想要有更极致的性能,`ast.Visitor` 会是更好的选择。它采用和 `Unmarshal()` 类似的形式解析 JSON,并且你可以直接使用你的最终类型去表示 JSON AST,而不需要经过额外的任何中间表示。
但是,`ast.Visitor` 并不是一个很易用的 API。你可能需要写大量的代码去实现自己的 `ast.Visitor`,并且需要在解析过程中仔细维护树的层级。如果你决定要使用这个 API,请先仔细阅读 [ast/visitor.go](https://github.com/bytedance/sonic/blob/main/ast/visitor.go) 中的注释。
## 社区
Sonic 是 [CloudWeGo](https://www.cloudwego.io/) 下的一个子项目。我们致力于构建云原生生态系统。
+214
View File
@@ -0,0 +1,214 @@
/*
* Copyright 2021 ByteDance Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sonic
import (
`io`
`github.com/bytedance/sonic/ast`
`github.com/bytedance/sonic/internal/rt`
)
// Config is a combination of sonic/encoder.Options and sonic/decoder.Options
type Config struct {
// EscapeHTML indicates encoder to escape all HTML characters
// after serializing into JSON (see https://pkg.go.dev/encoding/json#HTMLEscape).
// WARNING: This hurts performance A LOT, USE WITH CARE.
EscapeHTML bool
// SortMapKeys indicates encoder that the keys of a map needs to be sorted
// before serializing into JSON.
// WARNING: This hurts performance A LOT, USE WITH CARE.
SortMapKeys bool
// CompactMarshaler indicates encoder that the output JSON from json.Marshaler
// is always compact and needs no validation
CompactMarshaler bool
// NoQuoteTextMarshaler indicates encoder that the output text from encoding.TextMarshaler
// is always escaped string and needs no quoting
NoQuoteTextMarshaler bool
// NoNullSliceOrMap indicates encoder that all empty Array or Object are encoded as '[]' or '{}',
// instead of 'null'
NoNullSliceOrMap bool
// UseInt64 indicates decoder to unmarshal an integer into an interface{} as an
// int64 instead of as a float64.
UseInt64 bool
// UseNumber indicates decoder to unmarshal a number into an interface{} as a
// json.Number instead of as a float64.
UseNumber bool
// UseUnicodeErrors indicates decoder to return an error when encounter invalid
// UTF-8 escape sequences.
UseUnicodeErrors bool
// DisallowUnknownFields indicates decoder to return an error when the destination
// is a struct and the input contains object keys which do not match any
// non-ignored, exported fields in the destination.
DisallowUnknownFields bool
// CopyString indicates decoder to decode string values by copying instead of referring.
CopyString bool
// ValidateString indicates decoder and encoder to valid string values: decoder will return errors
// when unescaped control chars(\u0000-\u001f) in the string value of JSON.
ValidateString bool
// NoValidateJSONMarshaler indicates that the encoder should not validate the output string
// after encoding the JSONMarshaler to JSON.
NoValidateJSONMarshaler bool
// NoEncoderNewline indicates that the encoder should not add a newline after every message
NoEncoderNewline bool
}
var (
// ConfigDefault is the default config of APIs, aiming at efficiency and safety.
ConfigDefault = Config{}.Froze()
// ConfigStd is the standard config of APIs, aiming at being compatible with encoding/json.
ConfigStd = Config{
EscapeHTML : true,
SortMapKeys: true,
CompactMarshaler: true,
CopyString : true,
ValidateString : true,
}.Froze()
// ConfigFastest is the fastest config of APIs, aiming at speed.
ConfigFastest = Config{
NoQuoteTextMarshaler: true,
NoValidateJSONMarshaler: true,
}.Froze()
)
// API is a binding of specific config.
// This interface is inspired by github.com/json-iterator/go,
// and has same behaviors under equavilent config.
type API interface {
// MarshalToString returns the JSON encoding string of v
MarshalToString(v interface{}) (string, error)
// Marshal returns the JSON encoding bytes of v.
Marshal(v interface{}) ([]byte, error)
// MarshalIndent returns the JSON encoding bytes with indent and prefix.
MarshalIndent(v interface{}, prefix, indent string) ([]byte, error)
// UnmarshalFromString parses the JSON-encoded bytes and stores the result in the value pointed to by v.
UnmarshalFromString(str string, v interface{}) error
// Unmarshal parses the JSON-encoded string and stores the result in the value pointed to by v.
Unmarshal(data []byte, v interface{}) error
// NewEncoder create a Encoder holding writer
NewEncoder(writer io.Writer) Encoder
// NewDecoder create a Decoder holding reader
NewDecoder(reader io.Reader) Decoder
// Valid validates the JSON-encoded bytes and reports if it is valid
Valid(data []byte) bool
}
// Encoder encodes JSON into io.Writer
type Encoder interface {
// Encode writes the JSON encoding of v to the stream, followed by a newline character.
Encode(val interface{}) error
// SetEscapeHTML specifies whether problematic HTML characters
// should be escaped inside JSON quoted strings.
// The default behavior NOT ESCAPE
SetEscapeHTML(on bool)
// SetIndent instructs the encoder to format each subsequent encoded value
// as if indented by the package-level function Indent(dst, src, prefix, indent).
// Calling SetIndent("", "") disables indentation
SetIndent(prefix, indent string)
}
// Decoder decodes JSON from io.Read
type Decoder interface {
// Decode reads the next JSON-encoded value from its input and stores it in the value pointed to by v.
Decode(val interface{}) error
// Buffered returns a reader of the data remaining in the Decoder's buffer.
// The reader is valid until the next call to Decode.
Buffered() io.Reader
// DisallowUnknownFields causes the Decoder to return an error when the destination is a struct
// and the input contains object keys which do not match any non-ignored, exported fields in the destination.
DisallowUnknownFields()
// More reports whether there is another element in the current array or object being parsed.
More() bool
// UseNumber causes the Decoder to unmarshal a number into an interface{} as a Number instead of as a float64.
UseNumber()
}
// Marshal returns the JSON encoding bytes of v.
func Marshal(val interface{}) ([]byte, error) {
return ConfigDefault.Marshal(val)
}
// MarshalString returns the JSON encoding string of v.
func MarshalString(val interface{}) (string, error) {
return ConfigDefault.MarshalToString(val)
}
// Unmarshal parses the JSON-encoded data and stores the result in the value pointed to by v.
// NOTICE: This API copies given buffer by default,
// if you want to pass JSON more efficiently, use UnmarshalString instead.
func Unmarshal(buf []byte, val interface{}) error {
return ConfigDefault.Unmarshal(buf, val)
}
// UnmarshalString is like Unmarshal, except buf is a string.
func UnmarshalString(buf string, val interface{}) error {
return ConfigDefault.UnmarshalFromString(buf, val)
}
// Get searches and locates the given path from src json,
// and returns a ast.Node representing the partially json.
//
// Each path arg must be integer or string:
// - Integer is target index(>=0), means searching current node as array.
// - String is target key, means searching current node as object.
//
//
// Notice: It expects the src json is **Well-formed** and **Immutable** when calling,
// otherwise it may return unexpected result.
// Considering memory safety, the returned JSON is **Copied** from the input
func Get(src []byte, path ...interface{}) (ast.Node, error) {
return GetCopyFromString(rt.Mem2Str(src), path...)
}
// GetFromString is same with Get except src is string.
//
// WARNING: The returned JSON is **Referenced** from the input.
// Caching or long-time holding the returned node may cause OOM.
// If your src is big, consider use GetFromStringCopy().
func GetFromString(src string, path ...interface{}) (ast.Node, error) {
return ast.NewSearcher(src).GetByPath(path...)
}
// GetCopyFromString is same with Get except src is string
func GetCopyFromString(src string, path ...interface{}) (ast.Node, error) {
return ast.NewSearcher(src).GetByPathCopy(path...)
}
// Valid reports whether data is a valid JSON encoding.
func Valid(data []byte) bool {
return ConfigDefault.Valid(data)
}
// Valid reports whether data is a valid JSON encoding.
func ValidString(data string) bool {
return ConfigDefault.Valid(rt.Str2Mem(data))
}
+135
View File
@@ -0,0 +1,135 @@
//go:build (amd64 && go1.16 && !go1.23) || (arm64 && go1.20 && !go1.23)
// +build amd64,go1.16,!go1.23 arm64,go1.20,!go1.23
/*
* Copyright 2022 ByteDance Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package ast
import (
`runtime`
`unsafe`
`github.com/bytedance/sonic/encoder`
`github.com/bytedance/sonic/internal/native`
`github.com/bytedance/sonic/internal/native/types`
`github.com/bytedance/sonic/internal/rt`
uq `github.com/bytedance/sonic/unquote`
`github.com/bytedance/sonic/utf8`
)
var typeByte = rt.UnpackEface(byte(0)).Type
//go:nocheckptr
func quote(buf *[]byte, val string) {
*buf = append(*buf, '"')
if len(val) == 0 {
*buf = append(*buf, '"')
return
}
sp := rt.IndexChar(val, 0)
nb := len(val)
b := (*rt.GoSlice)(unsafe.Pointer(buf))
// input buffer
for nb > 0 {
// output buffer
dp := unsafe.Pointer(uintptr(b.Ptr) + uintptr(b.Len))
dn := b.Cap - b.Len
// call native.Quote, dn is byte count it outputs
ret := native.Quote(sp, nb, dp, &dn, 0)
// update *buf length
b.Len += dn
// no need more output
if ret >= 0 {
break
}
// double buf size
*b = growslice(typeByte, *b, b.Cap*2)
// ret is the complement of consumed input
ret = ^ret
// update input buffer
nb -= ret
sp = unsafe.Pointer(uintptr(sp) + uintptr(ret))
}
runtime.KeepAlive(buf)
runtime.KeepAlive(sp)
*buf = append(*buf, '"')
}
func unquote(src string) (string, types.ParsingError) {
return uq.String(src)
}
func (self *Parser) decodeValue() (val types.JsonState) {
sv := (*rt.GoString)(unsafe.Pointer(&self.s))
flag := types.F_USE_NUMBER
if self.dbuf != nil {
flag = 0
val.Dbuf = self.dbuf
val.Dcap = types.MaxDigitNums
}
self.p = native.Value(sv.Ptr, sv.Len, self.p, &val, uint64(flag))
return
}
func (self *Parser) skip() (int, types.ParsingError) {
fsm := types.NewStateMachine()
start := native.SkipOne(&self.s, &self.p, fsm, 0)
types.FreeStateMachine(fsm)
if start < 0 {
return self.p, types.ParsingError(-start)
}
return start, 0
}
func (self *Node) encodeInterface(buf *[]byte) error {
//WARN: NOT compatible with json.Encoder
return encoder.EncodeInto(buf, self.packAny(), encoder.NoEncoderNewline)
}
func (self *Parser) skipFast() (int, types.ParsingError) {
start := native.SkipOneFast(&self.s, &self.p)
if start < 0 {
return self.p, types.ParsingError(-start)
}
return start, 0
}
func (self *Parser) getByPath(validate bool, path ...interface{}) (int, types.ParsingError) {
var fsm *types.StateMachine
if validate {
fsm = types.NewStateMachine()
}
start := native.GetByPath(&self.s, &self.p, &path, fsm)
if validate {
types.FreeStateMachine(fsm)
}
runtime.KeepAlive(path)
if start < 0 {
return self.p, types.ParsingError(-start)
}
return start, 0
}
func validate_utf8(str string) bool {
return utf8.ValidateString(str)
}
+114
View File
@@ -0,0 +1,114 @@
// +build !amd64,!arm64 go1.23 !go1.16 arm64,!go1.20
/*
* Copyright 2022 ByteDance Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package ast
import (
`encoding/json`
`unicode/utf8`
`github.com/bytedance/sonic/internal/native/types`
`github.com/bytedance/sonic/internal/rt`
)
func init() {
println("WARNING:(ast) sonic only supports Go1.16~1.22, but your environment is not suitable")
}
func quote(buf *[]byte, val string) {
quoteString(buf, val)
}
// unquote unescapes a internal JSON string (it doesn't count quotas at the begining and end)
func unquote(src string) (string, types.ParsingError) {
sp := rt.IndexChar(src, -1)
out, ok := unquoteBytes(rt.BytesFrom(sp, len(src)+2, len(src)+2))
if !ok {
return "", types.ERR_INVALID_ESCAPE
}
return rt.Mem2Str(out), 0
}
func (self *Parser) decodeValue() (val types.JsonState) {
e, v := decodeValue(self.s, self.p, self.dbuf == nil)
if e < 0 {
return v
}
self.p = e
return v
}
func (self *Parser) skip() (int, types.ParsingError) {
e, s := skipValue(self.s, self.p)
if e < 0 {
return self.p, types.ParsingError(-e)
}
self.p = e
return s, 0
}
func (self *Parser) skipFast() (int, types.ParsingError) {
e, s := skipValueFast(self.s, self.p)
if e < 0 {
return self.p, types.ParsingError(-e)
}
self.p = e
return s, 0
}
func (self *Node) encodeInterface(buf *[]byte) error {
out, err := json.Marshal(self.packAny())
if err != nil {
return err
}
*buf = append(*buf, out...)
return nil
}
func (self *Parser) getByPath(validate bool, path ...interface{}) (int, types.ParsingError) {
for _, p := range path {
if idx, ok := p.(int); ok && idx >= 0 {
if err := self.searchIndex(idx); err != 0 {
return self.p, err
}
} else if key, ok := p.(string); ok {
if err := self.searchKey(key); err != 0 {
return self.p, err
}
} else {
panic("path must be either int(>=0) or string")
}
}
var start int
var e types.ParsingError
if validate {
start, e = self.skip()
} else {
start, e = self.skipFast()
}
if e != 0 {
return self.p, e
}
return start, 0
}
func validate_utf8(str string) bool {
return utf8.ValidString(str)
}
View File
+31
View File
@@ -0,0 +1,31 @@
// +build amd64,go1.16
/**
* Copyright 2023 ByteDance Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package ast
import (
`github.com/cloudwego/base64x`
)
func decodeBase64(src string) ([]byte, error) {
return base64x.StdEncoding.DecodeString(src)
}
func encodeBase64(src []byte) string {
return base64x.StdEncoding.EncodeToString(src)
}
+31
View File
@@ -0,0 +1,31 @@
// +build !amd64 !go1.16
/*
* Copyright 2022 ByteDance Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package ast
import (
`encoding/base64`
)
func decodeBase64(src string) ([]byte, error) {
return base64.StdEncoding.DecodeString(src)
}
func encodeBase64(src []byte) string {
return base64.StdEncoding.EncodeToString(src)
}
+409
View File
@@ -0,0 +1,409 @@
/**
* Copyright 2023 ByteDance Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package ast
import (
`sort`
`unsafe`
)
type nodeChunk [_DEFAULT_NODE_CAP]Node
type linkedNodes struct {
head nodeChunk
tail []*nodeChunk
size int
}
func (self *linkedNodes) Cap() int {
if self == nil {
return 0
}
return (len(self.tail)+1)*_DEFAULT_NODE_CAP
}
func (self *linkedNodes) Len() int {
if self == nil {
return 0
}
return self.size
}
func (self *linkedNodes) At(i int) (*Node) {
if self == nil {
return nil
}
if i >= 0 && i<self.size && i < _DEFAULT_NODE_CAP {
return &self.head[i]
} else if i >= _DEFAULT_NODE_CAP && i<self.size {
a, b := i/_DEFAULT_NODE_CAP-1, i%_DEFAULT_NODE_CAP
if a < len(self.tail) {
return &self.tail[a][b]
}
}
return nil
}
func (self *linkedNodes) MoveOne(source int, target int) {
if source == target {
return
}
if source < 0 || source >= self.size || target < 0 || target >= self.size {
return
}
// reserve source
n := *self.At(source)
if source < target {
// move every element (source,target] one step back
for i:=source; i<target; i++ {
*self.At(i) = *self.At(i+1)
}
} else {
// move every element [target,source) one step forward
for i:=source; i>target; i-- {
*self.At(i) = *self.At(i-1)
}
}
// set target
*self.At(target) = n
}
func (self *linkedNodes) Pop() {
if self == nil || self.size == 0 {
return
}
self.Set(self.size-1, Node{})
self.size--
}
func (self *linkedPairs) Pop() {
if self == nil || self.size == 0 {
return
}
self.Set(self.size-1, Pair{})
self.size--
}
func (self *linkedNodes) Push(v Node) {
self.Set(self.size, v)
}
func (self *linkedNodes) Set(i int, v Node) {
if i < _DEFAULT_NODE_CAP {
self.head[i] = v
if self.size <= i {
self.size = i+1
}
return
}
a, b := i/_DEFAULT_NODE_CAP-1, i%_DEFAULT_NODE_CAP
if a < 0 {
self.head[b] = v
} else {
self.growTailLength(a+1)
var n = &self.tail[a]
if *n == nil {
*n = new(nodeChunk)
}
(*n)[b] = v
}
if self.size <= i {
self.size = i+1
}
}
func (self *linkedNodes) growTailLength(l int) {
if l <= len(self.tail) {
return
}
c := cap(self.tail)
for c < l {
c += 1 + c>>_APPEND_GROW_SHIFT
}
if c == cap(self.tail) {
self.tail = self.tail[:l]
return
}
tmp := make([]*nodeChunk, l, c)
copy(tmp, self.tail)
self.tail = tmp
}
func (self *linkedNodes) ToSlice(con []Node) {
if len(con) < self.size {
return
}
i := (self.size-1)
a, b := i/_DEFAULT_NODE_CAP-1, i%_DEFAULT_NODE_CAP
if a < 0 {
copy(con, self.head[:b+1])
return
} else {
copy(con, self.head[:])
con = con[_DEFAULT_NODE_CAP:]
}
for i:=0; i<a; i++ {
copy(con, self.tail[i][:])
con = con[_DEFAULT_NODE_CAP:]
}
copy(con, self.tail[a][:b+1])
}
func (self *linkedNodes) FromSlice(con []Node) {
self.size = len(con)
i := self.size-1
a, b := i/_DEFAULT_NODE_CAP-1, i%_DEFAULT_NODE_CAP
if a < 0 {
copy(self.head[:b+1], con)
return
} else {
copy(self.head[:], con)
con = con[_DEFAULT_NODE_CAP:]
}
if cap(self.tail) <= a {
c := (a+1) + (a+1)>>_APPEND_GROW_SHIFT
self.tail = make([]*nodeChunk, a+1, c)
}
self.tail = self.tail[:a+1]
for i:=0; i<a; i++ {
self.tail[i] = new(nodeChunk)
copy(self.tail[i][:], con)
con = con[_DEFAULT_NODE_CAP:]
}
self.tail[a] = new(nodeChunk)
copy(self.tail[a][:b+1], con)
}
type pairChunk [_DEFAULT_NODE_CAP]Pair
type linkedPairs struct {
head pairChunk
tail []*pairChunk
size int
}
func (self *linkedPairs) Cap() int {
if self == nil {
return 0
}
return (len(self.tail)+1)*_DEFAULT_NODE_CAP
}
func (self *linkedPairs) Len() int {
if self == nil {
return 0
}
return self.size
}
func (self *linkedPairs) At(i int) *Pair {
if self == nil {
return nil
}
if i >= 0 && i < _DEFAULT_NODE_CAP && i<self.size {
return &self.head[i]
} else if i >= _DEFAULT_NODE_CAP && i<self.size {
a, b := i/_DEFAULT_NODE_CAP-1, i%_DEFAULT_NODE_CAP
if a < len(self.tail) {
return &self.tail[a][b]
}
}
return nil
}
func (self *linkedPairs) Push(v Pair) {
self.Set(self.size, v)
}
func (self *linkedPairs) Set(i int, v Pair) {
if i < _DEFAULT_NODE_CAP {
self.head[i] = v
if self.size <= i {
self.size = i+1
}
return
}
a, b := i/_DEFAULT_NODE_CAP-1, i%_DEFAULT_NODE_CAP
if a < 0 {
self.head[b] = v
} else {
self.growTailLength(a+1)
var n = &self.tail[a]
if *n == nil {
*n = new(pairChunk)
}
(*n)[b] = v
}
if self.size <= i {
self.size = i+1
}
}
func (self *linkedPairs) growTailLength(l int) {
if l <= len(self.tail) {
return
}
c := cap(self.tail)
for c < l {
c += 1 + c>>_APPEND_GROW_SHIFT
}
if c == cap(self.tail) {
self.tail = self.tail[:l]
return
}
tmp := make([]*pairChunk, l, c)
copy(tmp, self.tail)
self.tail = tmp
}
// linear search
func (self *linkedPairs) Get(key string) (*Pair, int) {
for i:=0; i<self.size; i++ {
if n := self.At(i); n.Key == key {
return n, i
}
}
return nil, -1
}
func (self *linkedPairs) ToSlice(con []Pair) {
if len(con) < self.size {
return
}
i := self.size-1
a, b := i/_DEFAULT_NODE_CAP-1, i%_DEFAULT_NODE_CAP
if a < 0 {
copy(con, self.head[:b+1])
return
} else {
copy(con, self.head[:])
con = con[_DEFAULT_NODE_CAP:]
}
for i:=0; i<a; i++ {
copy(con, self.tail[i][:])
con = con[_DEFAULT_NODE_CAP:]
}
copy(con, self.tail[a][:b+1])
}
func (self *linkedPairs) ToMap(con map[string]Node) {
for i:=0; i<self.size; i++ {
n := self.At(i)
con[n.Key] = n.Value
}
}
func (self *linkedPairs) FromSlice(con []Pair) {
self.size = len(con)
i := self.size-1
a, b := i/_DEFAULT_NODE_CAP-1, i%_DEFAULT_NODE_CAP
if a < 0 {
copy(self.head[:b+1], con)
return
} else {
copy(self.head[:], con)
con = con[_DEFAULT_NODE_CAP:]
}
if cap(self.tail) <= a {
c := (a+1) + (a+1)>>_APPEND_GROW_SHIFT
self.tail = make([]*pairChunk, a+1, c)
}
self.tail = self.tail[:a+1]
for i:=0; i<a; i++ {
self.tail[i] = new(pairChunk)
copy(self.tail[i][:], con)
con = con[_DEFAULT_NODE_CAP:]
}
self.tail[a] = new(pairChunk)
copy(self.tail[a][:b+1], con)
}
func (self *linkedPairs) Less(i, j int) bool {
return lessFrom(self.At(i).Key, self.At(j).Key, 0)
}
func (self *linkedPairs) Swap(i, j int) {
a, b := self.At(i), self.At(j)
*a, *b = *b, *a
}
func (self *linkedPairs) Sort() {
sort.Stable(self)
}
// Compare two strings from the pos d.
func lessFrom(a, b string, d int) bool {
l := len(a)
if l > len(b) {
l = len(b)
}
for i := d; i < l; i++ {
if a[i] == b[i] {
continue
}
return a[i] < b[i]
}
return len(a) < len(b)
}
type parseObjectStack struct {
parser Parser
v linkedPairs
}
type parseArrayStack struct {
parser Parser
v linkedNodes
}
func newLazyArray(p *Parser) Node {
s := new(parseArrayStack)
s.parser = *p
return Node{
t: _V_ARRAY_LAZY,
p: unsafe.Pointer(s),
}
}
func newLazyObject(p *Parser) Node {
s := new(parseObjectStack)
s.parser = *p
return Node{
t: _V_OBJECT_LAZY,
p: unsafe.Pointer(s),
}
}
func (self *Node) getParserAndArrayStack() (*Parser, *parseArrayStack) {
stack := (*parseArrayStack)(self.p)
return &stack.parser, stack
}
func (self *Node) getParserAndObjectStack() (*Parser, *parseObjectStack) {
stack := (*parseObjectStack)(self.p)
return &stack.parser, stack
}
+618
View File
@@ -0,0 +1,618 @@
/*
* Copyright 2022 ByteDance Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package ast
import (
`encoding/base64`
`runtime`
`strconv`
`unsafe`
`github.com/bytedance/sonic/internal/native/types`
`github.com/bytedance/sonic/internal/rt`
)
const _blankCharsMask = (1 << ' ') | (1 << '\t') | (1 << '\r') | (1 << '\n')
const (
bytesNull = "null"
bytesTrue = "true"
bytesFalse = "false"
bytesObject = "{}"
bytesArray = "[]"
)
func isSpace(c byte) bool {
return (int(1<<c) & _blankCharsMask) != 0
}
//go:nocheckptr
func skipBlank(src string, pos int) int {
se := uintptr(rt.IndexChar(src, len(src)))
sp := uintptr(rt.IndexChar(src, pos))
for sp < se {
if !isSpace(*(*byte)(unsafe.Pointer(sp))) {
break
}
sp += 1
}
if sp >= se {
return -int(types.ERR_EOF)
}
runtime.KeepAlive(src)
return int(sp - uintptr(rt.IndexChar(src, 0)))
}
func decodeNull(src string, pos int) (ret int) {
ret = pos + 4
if ret > len(src) {
return -int(types.ERR_EOF)
}
if src[pos:ret] == bytesNull {
return ret
} else {
return -int(types.ERR_INVALID_CHAR)
}
}
func decodeTrue(src string, pos int) (ret int) {
ret = pos + 4
if ret > len(src) {
return -int(types.ERR_EOF)
}
if src[pos:ret] == bytesTrue {
return ret
} else {
return -int(types.ERR_INVALID_CHAR)
}
}
func decodeFalse(src string, pos int) (ret int) {
ret = pos + 5
if ret > len(src) {
return -int(types.ERR_EOF)
}
if src[pos:ret] == bytesFalse {
return ret
}
return -int(types.ERR_INVALID_CHAR)
}
//go:nocheckptr
func decodeString(src string, pos int) (ret int, v string) {
ret, ep := skipString(src, pos)
if ep == -1 {
(*rt.GoString)(unsafe.Pointer(&v)).Ptr = rt.IndexChar(src, pos+1)
(*rt.GoString)(unsafe.Pointer(&v)).Len = ret - pos - 2
return ret, v
}
vv, ok := unquoteBytes(rt.Str2Mem(src[pos:ret]))
if !ok {
return -int(types.ERR_INVALID_CHAR), ""
}
runtime.KeepAlive(src)
return ret, rt.Mem2Str(vv)
}
func decodeBinary(src string, pos int) (ret int, v []byte) {
var vv string
ret, vv = decodeString(src, pos)
if ret < 0 {
return ret, nil
}
var err error
v, err = base64.StdEncoding.DecodeString(vv)
if err != nil {
return -int(types.ERR_INVALID_CHAR), nil
}
return ret, v
}
func isDigit(c byte) bool {
return c >= '0' && c <= '9'
}
//go:nocheckptr
func decodeInt64(src string, pos int) (ret int, v int64, err error) {
sp := uintptr(rt.IndexChar(src, pos))
ss := uintptr(sp)
se := uintptr(rt.IndexChar(src, len(src)))
if uintptr(sp) >= se {
return -int(types.ERR_EOF), 0, nil
}
if c := *(*byte)(unsafe.Pointer(sp)); c == '-' {
sp += 1
}
if sp == se {
return -int(types.ERR_EOF), 0, nil
}
for ; sp < se; sp += uintptr(1) {
if !isDigit(*(*byte)(unsafe.Pointer(sp))) {
break
}
}
if sp < se {
if c := *(*byte)(unsafe.Pointer(sp)); c == '.' || c == 'e' || c == 'E' {
return -int(types.ERR_INVALID_NUMBER_FMT), 0, nil
}
}
var vv string
ret = int(uintptr(sp) - uintptr((*rt.GoString)(unsafe.Pointer(&src)).Ptr))
(*rt.GoString)(unsafe.Pointer(&vv)).Ptr = unsafe.Pointer(ss)
(*rt.GoString)(unsafe.Pointer(&vv)).Len = ret - pos
v, err = strconv.ParseInt(vv, 10, 64)
if err != nil {
//NOTICE: allow overflow here
if err.(*strconv.NumError).Err == strconv.ErrRange {
return ret, 0, err
}
return -int(types.ERR_INVALID_CHAR), 0, err
}
runtime.KeepAlive(src)
return ret, v, nil
}
func isNumberChars(c byte) bool {
return (c >= '0' && c <= '9') || c == '+' || c == '-' || c == 'e' || c == 'E' || c == '.'
}
//go:nocheckptr
func decodeFloat64(src string, pos int) (ret int, v float64, err error) {
sp := uintptr(rt.IndexChar(src, pos))
ss := uintptr(sp)
se := uintptr(rt.IndexChar(src, len(src)))
if uintptr(sp) >= se {
return -int(types.ERR_EOF), 0, nil
}
if c := *(*byte)(unsafe.Pointer(sp)); c == '-' {
sp += 1
}
if sp == se {
return -int(types.ERR_EOF), 0, nil
}
for ; sp < se; sp += uintptr(1) {
if !isNumberChars(*(*byte)(unsafe.Pointer(sp))) {
break
}
}
var vv string
ret = int(uintptr(sp) - uintptr((*rt.GoString)(unsafe.Pointer(&src)).Ptr))
(*rt.GoString)(unsafe.Pointer(&vv)).Ptr = unsafe.Pointer(ss)
(*rt.GoString)(unsafe.Pointer(&vv)).Len = ret - pos
v, err = strconv.ParseFloat(vv, 64)
if err != nil {
//NOTICE: allow overflow here
if err.(*strconv.NumError).Err == strconv.ErrRange {
return ret, 0, err
}
return -int(types.ERR_INVALID_CHAR), 0, err
}
runtime.KeepAlive(src)
return ret, v, nil
}
func decodeValue(src string, pos int, skipnum bool) (ret int, v types.JsonState) {
pos = skipBlank(src, pos)
if pos < 0 {
return pos, types.JsonState{Vt: types.ValueType(pos)}
}
switch c := src[pos]; c {
case 'n':
ret = decodeNull(src, pos)
if ret < 0 {
return ret, types.JsonState{Vt: types.ValueType(ret)}
}
return ret, types.JsonState{Vt: types.V_NULL}
case '"':
var ep int
ret, ep = skipString(src, pos)
if ret < 0 {
return ret, types.JsonState{Vt: types.ValueType(ret)}
}
return ret, types.JsonState{Vt: types.V_STRING, Iv: int64(pos + 1), Ep: ep}
case '{':
return pos + 1, types.JsonState{Vt: types.V_OBJECT}
case '[':
return pos + 1, types.JsonState{Vt: types.V_ARRAY}
case 't':
ret = decodeTrue(src, pos)
if ret < 0 {
return ret, types.JsonState{Vt: types.ValueType(ret)}
}
return ret, types.JsonState{Vt: types.V_TRUE}
case 'f':
ret = decodeFalse(src, pos)
if ret < 0 {
return ret, types.JsonState{Vt: types.ValueType(ret)}
}
return ret, types.JsonState{Vt: types.V_FALSE}
case '-', '+', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
if skipnum {
ret = skipNumber(src, pos)
if ret >= 0 {
return ret, types.JsonState{Vt: types.V_DOUBLE, Iv: 0, Ep: pos}
} else {
return ret, types.JsonState{Vt: types.ValueType(ret)}
}
} else {
var iv int64
ret, iv, _ = decodeInt64(src, pos)
if ret >= 0 {
return ret, types.JsonState{Vt: types.V_INTEGER, Iv: iv, Ep: pos}
} else if ret != -int(types.ERR_INVALID_NUMBER_FMT) {
return ret, types.JsonState{Vt: types.ValueType(ret)}
}
var fv float64
ret, fv, _ = decodeFloat64(src, pos)
if ret >= 0 {
return ret, types.JsonState{Vt: types.V_DOUBLE, Dv: fv, Ep: pos}
} else {
return ret, types.JsonState{Vt: types.ValueType(ret)}
}
}
default:
return -int(types.ERR_INVALID_CHAR), types.JsonState{Vt:-types.ValueType(types.ERR_INVALID_CHAR)}
}
}
//go:nocheckptr
func skipNumber(src string, pos int) (ret int) {
sp := uintptr(rt.IndexChar(src, pos))
se := uintptr(rt.IndexChar(src, len(src)))
if uintptr(sp) >= se {
return -int(types.ERR_EOF)
}
if c := *(*byte)(unsafe.Pointer(sp)); c == '-' {
sp += 1
}
ss := sp
var pointer bool
var exponent bool
var lastIsDigit bool
var nextNeedDigit = true
for ; sp < se; sp += uintptr(1) {
c := *(*byte)(unsafe.Pointer(sp))
if isDigit(c) {
lastIsDigit = true
nextNeedDigit = false
continue
} else if nextNeedDigit {
return -int(types.ERR_INVALID_CHAR)
} else if c == '.' {
if !lastIsDigit || pointer || exponent || sp == ss {
return -int(types.ERR_INVALID_CHAR)
}
pointer = true
lastIsDigit = false
nextNeedDigit = true
continue
} else if c == 'e' || c == 'E' {
if !lastIsDigit || exponent {
return -int(types.ERR_INVALID_CHAR)
}
if sp == se-1 {
return -int(types.ERR_EOF)
}
exponent = true
lastIsDigit = false
nextNeedDigit = false
continue
} else if c == '-' || c == '+' {
if prev := *(*byte)(unsafe.Pointer(sp - 1)); prev != 'e' && prev != 'E' {
return -int(types.ERR_INVALID_CHAR)
}
lastIsDigit = false
nextNeedDigit = true
continue
} else {
break
}
}
if nextNeedDigit {
return -int(types.ERR_EOF)
}
runtime.KeepAlive(src)
return int(uintptr(sp) - uintptr((*rt.GoString)(unsafe.Pointer(&src)).Ptr))
}
//go:nocheckptr
func skipString(src string, pos int) (ret int, ep int) {
if pos+1 >= len(src) {
return -int(types.ERR_EOF), -1
}
sp := uintptr(rt.IndexChar(src, pos))
se := uintptr(rt.IndexChar(src, len(src)))
// not start with quote
if *(*byte)(unsafe.Pointer(sp)) != '"' {
return -int(types.ERR_INVALID_CHAR), -1
}
sp += 1
ep = -1
for sp < se {
c := *(*byte)(unsafe.Pointer(sp))
if c == '\\' {
if ep == -1 {
ep = int(uintptr(sp) - uintptr((*rt.GoString)(unsafe.Pointer(&src)).Ptr))
}
sp += 2
continue
}
sp += 1
if c == '"' {
return int(uintptr(sp) - uintptr((*rt.GoString)(unsafe.Pointer(&src)).Ptr)), ep
}
}
runtime.KeepAlive(src)
// not found the closed quote until EOF
return -int(types.ERR_EOF), -1
}
//go:nocheckptr
func skipPair(src string, pos int, lchar byte, rchar byte) (ret int) {
if pos+1 >= len(src) {
return -int(types.ERR_EOF)
}
sp := uintptr(rt.IndexChar(src, pos))
se := uintptr(rt.IndexChar(src, len(src)))
if *(*byte)(unsafe.Pointer(sp)) != lchar {
return -int(types.ERR_INVALID_CHAR)
}
sp += 1
nbrace := 1
inquote := false
for sp < se {
c := *(*byte)(unsafe.Pointer(sp))
if c == '\\' {
sp += 2
continue
} else if c == '"' {
inquote = !inquote
} else if c == lchar {
if !inquote {
nbrace += 1
}
} else if c == rchar {
if !inquote {
nbrace -= 1
if nbrace == 0 {
sp += 1
break
}
}
}
sp += 1
}
if nbrace != 0 {
return -int(types.ERR_INVALID_CHAR)
}
runtime.KeepAlive(src)
return int(uintptr(sp) - uintptr((*rt.GoString)(unsafe.Pointer(&src)).Ptr))
}
func skipValueFast(src string, pos int) (ret int, start int) {
pos = skipBlank(src, pos)
if pos < 0 {
return pos, -1
}
switch c := src[pos]; c {
case 'n':
ret = decodeNull(src, pos)
case '"':
ret, _ = skipString(src, pos)
case '{':
ret = skipPair(src, pos, '{', '}')
case '[':
ret = skipPair(src, pos, '[', ']')
case 't':
ret = decodeTrue(src, pos)
case 'f':
ret = decodeFalse(src, pos)
case '-', '+', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
ret = skipNumber(src, pos)
default:
ret = -int(types.ERR_INVALID_CHAR)
}
return ret, pos
}
func skipValue(src string, pos int) (ret int, start int) {
pos = skipBlank(src, pos)
if pos < 0 {
return pos, -1
}
switch c := src[pos]; c {
case 'n':
ret = decodeNull(src, pos)
case '"':
ret, _ = skipString(src, pos)
case '{':
ret, _ = skipObject(src, pos)
case '[':
ret, _ = skipArray(src, pos)
case 't':
ret = decodeTrue(src, pos)
case 'f':
ret = decodeFalse(src, pos)
case '-', '+', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
ret = skipNumber(src, pos)
default:
ret = -int(types.ERR_INVALID_CHAR)
}
return ret, pos
}
func skipObject(src string, pos int) (ret int, start int) {
start = skipBlank(src, pos)
if start < 0 {
return start, -1
}
if src[start] != '{' {
return -int(types.ERR_INVALID_CHAR), -1
}
pos = start + 1
pos = skipBlank(src, pos)
if pos < 0 {
return pos, -1
}
if src[pos] == '}' {
return pos + 1, start
}
for {
pos, _ = skipString(src, pos)
if pos < 0 {
return pos, -1
}
pos = skipBlank(src, pos)
if pos < 0 {
return pos, -1
}
if src[pos] != ':' {
return -int(types.ERR_INVALID_CHAR), -1
}
pos++
pos, _ = skipValue(src, pos)
if pos < 0 {
return pos, -1
}
pos = skipBlank(src, pos)
if pos < 0 {
return pos, -1
}
if src[pos] == '}' {
return pos + 1, start
}
if src[pos] != ',' {
return -int(types.ERR_INVALID_CHAR), -1
}
pos++
pos = skipBlank(src, pos)
if pos < 0 {
return pos, -1
}
}
}
func skipArray(src string, pos int) (ret int, start int) {
start = skipBlank(src, pos)
if start < 0 {
return start, -1
}
if src[start] != '[' {
return -int(types.ERR_INVALID_CHAR), -1
}
pos = start + 1
pos = skipBlank(src, pos)
if pos < 0 {
return pos, -1
}
if src[pos] == ']' {
return pos + 1, start
}
for {
pos, _ = skipValue(src, pos)
if pos < 0 {
return pos, -1
}
pos = skipBlank(src, pos)
if pos < 0 {
return pos, -1
}
if src[pos] == ']' {
return pos + 1, start
}
if src[pos] != ',' {
return -int(types.ERR_INVALID_CHAR), -1
}
pos++
}
}
// DecodeString decodes a JSON string from pos and return golang string.
// - needEsc indicates if to unescaped escaping chars
// - hasEsc tells if the returned string has escaping chars
// - validStr enables validating UTF8 charset
//
func _DecodeString(src string, pos int, needEsc bool, validStr bool) (v string, ret int, hasEsc bool) {
p := NewParserObj(src)
p.p = pos
switch val := p.decodeValue(); val.Vt {
case types.V_STRING:
str := p.s[val.Iv : p.p-1]
if validStr && !validate_utf8(str) {
return "", -int(types.ERR_INVALID_UTF8), false
}
/* fast path: no escape sequence */
if val.Ep == -1 {
return str, p.p, false
} else if !needEsc {
return str, p.p, true
}
/* unquote the string */
out, err := unquote(str)
/* check for errors */
if err != 0 {
return "", -int(err), true
} else {
return out, p.p, true
}
default:
return "", -int(_ERR_UNSUPPORT_TYPE), false
}
}

Some files were not shown because too many files have changed in this diff Show More