feat: add tenant and user management with migrations, handlers, and tests
- Implemented tenant and user management features including: - Tenant creation and management with associated migrations. - User creation and management with associated migrations. - Tenant membership management with associated migrations. - Platform user roles management with associated migrations. - Quota management with associated migrations. - Article and template management with associated migrations. - Added HTTP handlers for templates and workspaces. - Created tests for protected and public routes. - Introduced a script to check tenant scope in SQL queries. - Documented task plan for backend completion and frontend foundation.
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
type TemplateService struct {
|
||||
pool *pgxpool.Pool
|
||||
templates repository.TemplateRepository
|
||||
llm llm.Client
|
||||
streams *stream.GenerationHub
|
||||
streamEnabled bool
|
||||
jobs chan generationJob
|
||||
}
|
||||
|
||||
type generationJob struct {
|
||||
TenantID int64
|
||||
ArticleID int64
|
||||
TaskID int64
|
||||
ReservationID int64
|
||||
TemplateName string
|
||||
PromptTemplate *string
|
||||
Params map[string]interface{}
|
||||
InitialTitle string
|
||||
}
|
||||
|
||||
func NewTemplateService(
|
||||
pool *pgxpool.Pool,
|
||||
templates repository.TemplateRepository,
|
||||
llmClient llm.Client,
|
||||
streams *stream.GenerationHub,
|
||||
cfg config.GenerationConfig,
|
||||
) *TemplateService {
|
||||
queueSize := cfg.QueueSize
|
||||
if queueSize <= 0 {
|
||||
queueSize = 128
|
||||
}
|
||||
|
||||
workerCount := cfg.WorkerConcurrency
|
||||
if workerCount <= 0 {
|
||||
workerCount = 1
|
||||
}
|
||||
|
||||
svc := &TemplateService{
|
||||
pool: pool,
|
||||
templates: templates,
|
||||
llm: llmClient,
|
||||
streams: streams,
|
||||
streamEnabled: cfg.StreamEnabled,
|
||||
jobs: make(chan generationJob, queueSize),
|
||||
}
|
||||
|
||||
for i := 0; i < workerCount; i++ {
|
||||
go svc.runGenerationWorker()
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
type TemplateListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Scope string `json:"scope"`
|
||||
OriginType string `json:"origin_type"`
|
||||
TemplateKey string `json:"template_key"`
|
||||
TemplateName string `json:"template_name"`
|
||||
SchemaJSON map[string]interface{} `json:"schema_json"`
|
||||
PromptVisibility string `json:"prompt_visibility"`
|
||||
CardConfigJSON map[string]interface{} `json:"card_config"`
|
||||
Status string `json:"status"`
|
||||
VersionNo int `json:"version_no"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *TemplateService) List(ctx context.Context) ([]TemplateListItem, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
rows, err := s.templates.ListTemplates(ctx, actor.TenantID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to list templates")
|
||||
}
|
||||
|
||||
items := make([]TemplateListItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
item := TemplateListItem{
|
||||
ID: row.ID,
|
||||
Scope: row.Scope,
|
||||
OriginType: row.OriginType,
|
||||
TemplateKey: row.TemplateKey,
|
||||
TemplateName: row.TemplateName,
|
||||
PromptVisibility: row.PromptVisibility,
|
||||
CardConfigJSON: map[string]interface{}{},
|
||||
Status: row.Status,
|
||||
VersionNo: row.VersionNo,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
_ = json.Unmarshal(row.SchemaJSON, &item.SchemaJSON)
|
||||
_ = json.Unmarshal(row.CardConfigJSON, &item.CardConfigJSON)
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
type TemplateDetail struct {
|
||||
TemplateListItem
|
||||
PromptTemplate *string `json:"prompt_template,omitempty"`
|
||||
}
|
||||
|
||||
func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
record, err := s.templates.GetTemplateByID(ctx, id, actor.TenantID)
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40410, "template_not_found", "template not found")
|
||||
}
|
||||
|
||||
detail := &TemplateDetail{
|
||||
TemplateListItem: TemplateListItem{
|
||||
ID: record.ID,
|
||||
Scope: record.Scope,
|
||||
OriginType: record.OriginType,
|
||||
TemplateKey: record.TemplateKey,
|
||||
TemplateName: record.TemplateName,
|
||||
PromptVisibility: record.PromptVisibility,
|
||||
CardConfigJSON: map[string]interface{}{},
|
||||
Status: record.Status,
|
||||
VersionNo: record.VersionNo,
|
||||
CreatedAt: record.CreatedAt,
|
||||
},
|
||||
}
|
||||
_ = json.Unmarshal(record.SchemaJSON, &detail.SchemaJSON)
|
||||
_ = json.Unmarshal(record.CardConfigJSON, &detail.CardConfigJSON)
|
||||
|
||||
if detail.PromptVisibility != "sealed" {
|
||||
detail.PromptTemplate = record.PromptTemplate
|
||||
}
|
||||
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (s *TemplateService) Schema(ctx context.Context, id int64) (map[string]interface{}, error) {
|
||||
record, err := s.templates.GetTemplateSchema(ctx, id)
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40410, "template_not_found", "template not found")
|
||||
}
|
||||
|
||||
schema := map[string]interface{}{}
|
||||
_ = json.Unmarshal(record.SchemaJSON, &schema)
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
type GenerateRequest struct {
|
||||
InputParams map[string]interface{} `json:"input_params" binding:"required"`
|
||||
}
|
||||
|
||||
type GenerateResponse struct {
|
||||
ArticleID int64 `json:"article_id"`
|
||||
TaskID int64 `json:"task_id"`
|
||||
}
|
||||
|
||||
func (s *TemplateService) Generate(ctx context.Context, templateID int64, req GenerateRequest) (*GenerateResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
|
||||
templateRecord, err := s.templates.GetTemplateByID(ctx, templateID, actor.TenantID)
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40410, "template_not_found", "template not found")
|
||||
}
|
||||
if err := s.llm.Validate(); err != nil {
|
||||
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
|
||||
}
|
||||
|
||||
quotaRepo := repository.NewQuotaRepository(s.pool)
|
||||
balance, err := quotaRepo.GetCurrentBalance(ctx, actor.TenantID, "article_generation")
|
||||
if err != nil || balance < 1 {
|
||||
return nil, response.ErrForbidden(40301, "quota_insufficient", "generation quota insufficient, please upgrade your plan")
|
||||
}
|
||||
|
||||
inputJSON, _ := json.Marshal(req.InputParams)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback(ctx)
|
||||
}()
|
||||
|
||||
quotaTx := repository.NewQuotaRepository(tx)
|
||||
articleTx := repository.NewArticleRepository(tx)
|
||||
auditTx := repository.NewAuditRepository(tx)
|
||||
|
||||
newBalance := balance - 1
|
||||
reason := "generation_reserve"
|
||||
referenceType := "generation_task"
|
||||
_, err = quotaTx.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
|
||||
TenantID: actor.TenantID,
|
||||
QuotaType: "article_generation",
|
||||
Delta: -1,
|
||||
BalanceAfter: newBalance,
|
||||
Reason: &reason,
|
||||
ReferenceType: &referenceType,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deduct quota: %w", err)
|
||||
}
|
||||
|
||||
reservationID, err := quotaTx.CreateQuotaReservation(ctx, repository.QuotaReservationInput{
|
||||
TenantID: actor.TenantID,
|
||||
QuotaType: "article_generation",
|
||||
ResourceType: "article",
|
||||
ResourceID: 0,
|
||||
ReservedAmount: 1,
|
||||
ExpireAt: time.Now().Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create reservation: %w", err)
|
||||
}
|
||||
|
||||
articleID, err := articleTx.CreateArticle(ctx, repository.CreateArticleInput{
|
||||
TenantID: actor.TenantID,
|
||||
SourceType: "template",
|
||||
TemplateID: &templateID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create article: %w", err)
|
||||
}
|
||||
if err := articleTx.UpdateArticleGenerateStatus(ctx, articleID, actor.TenantID, "generating"); err != nil {
|
||||
return nil, fmt.Errorf("mark article generating: %w", err)
|
||||
}
|
||||
|
||||
if err := quotaTx.UpdateQuotaReservationResource(ctx, reservationID, actor.TenantID, articleID); err != nil {
|
||||
return nil, fmt.Errorf("update reservation: %w", err)
|
||||
}
|
||||
|
||||
taskID, err := auditTx.CreateGenerationTask(ctx, repository.GenerationTaskInput{
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: &articleID,
|
||||
QuotaReservationID: &reservationID,
|
||||
TaskType: "template",
|
||||
InputParamsJSON: inputJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create task: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
initialTitle := resolveArticleTitle(req.InputParams, "")
|
||||
job := generationJob{
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: articleID,
|
||||
TaskID: taskID,
|
||||
ReservationID: reservationID,
|
||||
TemplateName: templateRecord.TemplateName,
|
||||
PromptTemplate: templateRecord.PromptTemplate,
|
||||
Params: cloneInputParams(req.InputParams),
|
||||
InitialTitle: initialTitle,
|
||||
}
|
||||
|
||||
if err := s.enqueueGeneration(job); err != nil {
|
||||
s.failGeneration(context.Background(), job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, job.InitialTitle, err)
|
||||
return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is busy, please retry")
|
||||
}
|
||||
|
||||
if s.streamEnabled {
|
||||
s.streams.Start(articleID, taskID, initialTitle)
|
||||
}
|
||||
|
||||
return &GenerateResponse{ArticleID: articleID, TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
func (s *TemplateService) enqueueGeneration(job generationJob) error {
|
||||
select {
|
||||
case s.jobs <- job:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("generation queue is full")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TemplateService) runGenerationWorker() {
|
||||
for job := range s.jobs {
|
||||
s.executeGeneration(context.Background(), job)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TemplateService) executeGeneration(ctx context.Context, job generationJob) {
|
||||
now := time.Now()
|
||||
title := strings.TrimSpace(job.InitialTitle)
|
||||
if title == "" {
|
||||
title = "Generated Article"
|
||||
}
|
||||
|
||||
auditRepo := repository.NewAuditRepository(s.pool)
|
||||
_ = auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
||||
ID: job.TaskID,
|
||||
TenantID: job.TenantID,
|
||||
Status: "running",
|
||||
StartedAt: &now,
|
||||
})
|
||||
|
||||
prompt := buildGenerationPrompt(job.TemplateName, job.PromptTemplate, job.Params)
|
||||
result, err := s.llm.Generate(ctx, llm.GenerateRequest{Prompt: prompt}, func(delta string) {
|
||||
if s.streamEnabled && delta != "" {
|
||||
s.streams.AppendDelta(job.ArticleID, job.TaskID, title, delta)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
s.failGeneration(ctx, job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, title, err)
|
||||
return
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(result.Content)
|
||||
if content == "" {
|
||||
s.failGeneration(ctx, job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, title, fmt.Errorf("empty model output"))
|
||||
return
|
||||
}
|
||||
|
||||
title = resolveArticleTitle(job.Params, content)
|
||||
wordCount := estimateWordCount(content)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
s.failGeneration(ctx, job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, title, err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback(ctx)
|
||||
}()
|
||||
|
||||
articleRepo := repository.NewArticleRepository(tx)
|
||||
quotaRepo := repository.NewQuotaRepository(tx)
|
||||
auditTx := repository.NewAuditRepository(tx)
|
||||
|
||||
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
||||
ArticleID: job.ArticleID,
|
||||
VersionNo: 1,
|
||||
Title: title,
|
||||
HTMLContent: "",
|
||||
MarkdownContent: content,
|
||||
WordCount: wordCount,
|
||||
SourceLabel: result.Model,
|
||||
})
|
||||
if err != nil {
|
||||
s.failGeneration(ctx, job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, title, err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = articleRepo.UpdateArticleCurrentVersion(ctx, job.ArticleID, job.TenantID, versionID)
|
||||
_ = articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "completed")
|
||||
|
||||
completed := time.Now()
|
||||
_ = auditTx.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
||||
ID: job.TaskID,
|
||||
TenantID: job.TenantID,
|
||||
Status: "completed",
|
||||
StartedAt: &now,
|
||||
CompletedAt: &completed,
|
||||
})
|
||||
|
||||
_ = quotaRepo.ConfirmReservation(ctx, job.ReservationID, job.TenantID)
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
s.failGeneration(ctx, job.TenantID, job.ArticleID, job.TaskID, job.ReservationID, title, err)
|
||||
return
|
||||
}
|
||||
|
||||
if s.streamEnabled {
|
||||
s.streams.Complete(job.ArticleID, job.TaskID, title, content)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TemplateService) failGeneration(ctx context.Context, tenantID, articleID, taskID, reservationID int64, title string, genErr error) {
|
||||
errMsg := genErr.Error()
|
||||
now := time.Now()
|
||||
|
||||
auditRepo := repository.NewAuditRepository(s.pool)
|
||||
articleRepo := repository.NewArticleRepository(s.pool)
|
||||
quotaRepo := repository.NewQuotaRepository(s.pool)
|
||||
|
||||
_ = auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
||||
ID: taskID,
|
||||
TenantID: tenantID,
|
||||
Status: "failed",
|
||||
ErrorMessage: &errMsg,
|
||||
CompletedAt: &now,
|
||||
})
|
||||
_ = articleRepo.UpdateArticleGenerateStatus(ctx, articleID, tenantID, "failed")
|
||||
|
||||
balance, balanceErr := quotaRepo.GetCurrentBalance(ctx, tenantID, "article_generation")
|
||||
if balanceErr == nil {
|
||||
reason := "generation_refund"
|
||||
referenceType := "generation_task"
|
||||
taskReferenceID := taskID
|
||||
_, _ = quotaRepo.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
|
||||
TenantID: tenantID,
|
||||
QuotaType: "article_generation",
|
||||
Delta: 1,
|
||||
BalanceAfter: balance + 1,
|
||||
Reason: &reason,
|
||||
ReferenceType: &referenceType,
|
||||
ReferenceID: &taskReferenceID,
|
||||
})
|
||||
}
|
||||
|
||||
_ = quotaRepo.RefundReservation(ctx, reservationID, tenantID)
|
||||
|
||||
if s.streamEnabled {
|
||||
s.streams.Fail(articleID, taskID, title, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func cloneInputParams(params map[string]interface{}) map[string]interface{} {
|
||||
if len(params) == 0 {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
cloned := make(map[string]interface{}, len(params))
|
||||
for key, value := range params {
|
||||
cloned[key] = value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
Reference in New Issue
Block a user