feat(server): meter AI point usage across tenant AI features

Adds an ai_point_usage_logs ledger and a reserve/refund/complete pipeline
that charges AI points for article selection optimize, template analyze
/title/outline, and KOL prompt generate/optimize. Pending reservations
are reconciled when the kol-assist worker and template-assist tasks
finish or fail, so points refund automatically on errors. Workspace now
exposes the AI quota status and a paginated usage ledger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 11:33:47 +08:00
parent 794a2d89db
commit e307a048d1
18 changed files with 1270 additions and 27 deletions
@@ -0,0 +1,416 @@
package app
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
const (
AIUsageTypeArticleSelectionOptimize = "article_selection_optimize"
AIUsageTypeTemplateAnalyze = "template_analyze"
AIUsageTypeTemplateTitleGenerate = "template_title_generate"
AIUsageTypeTemplateOutlineGenerate = "template_outline_generate"
AIUsageTypeKolPromptGenerate = "kol_prompt_generate"
AIUsageTypeKolPromptOptimize = "kol_prompt_optimize"
aiPointsQuotaType = "ai_points"
)
type AIPointReserveInput struct {
TenantID int64
OperatorID int64
UsageType string
ResourceType *string
ResourceID *int64
ResourceUID *string
MeteredText string
FixedPoints int
Metadata map[string]any
}
type AIPointReservation struct {
ReservationID int64
UsageLogID int64
Points int
RequestChars int
BaseChars int
}
func ReserveAIPoints(
ctx context.Context,
pool *pgxpool.Pool,
cache sharedcache.Cache,
input AIPointReserveInput,
) (*AIPointReservation, error) {
if pool == nil {
return nil, response.ErrServiceUnavailable(50344, "ai_points_unavailable", "ai points store is unavailable")
}
input.UsageType = strings.TrimSpace(input.UsageType)
if input.UsageType == "" {
return nil, response.ErrBadRequest(40075, "ai_points_usage_type_required", "usage type is required")
}
tx, err := pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50120, "ai_points_tx_failed", "failed to begin ai points transaction")
}
defer rollbackGenerationTx(tx)
if err := lockTenantAIPoints(ctx, tx, input.TenantID); err != nil {
return nil, response.ErrInternal(50121, "ai_points_lock_failed", "failed to lock ai points quota")
}
now := time.Now().UTC()
quotaRepo := repository.NewQuotaRepository(tx)
status, err := quotaRepo.GetAIQuotaStatus(ctx, input.TenantID, now)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrForbidden(40381, "ai_points_insufficient", "AI points are not available for this plan")
}
return nil, response.ErrInternal(50122, "ai_points_query_failed", "failed to query ai points")
}
requestChars := countAIPointChars(input.MeteredText)
points := calculateAIPointCost(requestChars, status.BaseChars)
if input.FixedPoints > 0 {
points = input.FixedPoints
}
if status.Total <= 0 || status.Balance < points {
return nil, response.ErrForbidden(40381, "ai_points_insufficient", "AI points are insufficient, please upgrade your plan")
}
resourceType := "ai_usage"
if input.ResourceType != nil && strings.TrimSpace(*input.ResourceType) != "" {
resourceType = strings.TrimSpace(*input.ResourceType)
}
reservationID, err := quotaRepo.CreateQuotaReservation(ctx, repository.QuotaReservationInput{
TenantID: input.TenantID,
OperatorID: input.OperatorID,
QuotaType: aiPointsQuotaType,
ResourceType: resourceType,
ResourceID: input.ResourceID,
ReservedAmount: points,
ExpireAt: now.Add(time.Hour),
})
if err != nil {
return nil, response.ErrInternal(50123, "ai_points_reserve_failed", "failed to reserve ai points")
}
metadata := input.Metadata
if metadata == nil {
metadata = map[string]any{}
}
metadataJSON, err := json.Marshal(metadata)
if err != nil {
return nil, response.ErrInternal(50124, "ai_points_metadata_failed", "failed to encode ai points metadata")
}
usageID, err := repository.NewAIPointUsageRepository(tx).Create(ctx, repository.CreateAIPointUsageLogInput{
TenantID: input.TenantID,
OperatorID: input.OperatorID,
QuotaReservationID: reservationID,
UsageType: input.UsageType,
ResourceType: &resourceType,
ResourceID: input.ResourceID,
ResourceUID: input.ResourceUID,
RequestChars: requestChars,
BaseChars: status.BaseChars,
Points: points,
MetadataJSON: metadataJSON,
})
if err != nil {
return nil, response.ErrInternal(50125, "ai_points_audit_failed", "failed to create ai points audit log")
}
reason := "ai_points_reserve"
referenceType := "ai_point_usage"
balanceAfter := status.Balance - points
if _, err := quotaRepo.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
TenantID: input.TenantID,
OperatorID: input.OperatorID,
QuotaType: aiPointsQuotaType,
Delta: -points,
BalanceAfter: balanceAfter,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &usageID,
}); err != nil {
return nil, response.ErrInternal(50126, "ai_points_ledger_failed", "failed to write ai points ledger")
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50127, "ai_points_commit_failed", "failed to commit ai points reservation")
}
deleteCacheKey(ctx, cache, workspaceQuotaSummaryCacheKey(input.TenantID))
return &AIPointReservation{
ReservationID: reservationID,
UsageLogID: usageID,
Points: points,
RequestChars: requestChars,
BaseChars: status.BaseChars,
}, nil
}
func CompleteAIPoints(
ctx context.Context,
pool *pgxpool.Pool,
tenantID int64,
reservation AIPointReservation,
model string,
) error {
if pool == nil || reservation.ReservationID <= 0 || reservation.UsageLogID <= 0 {
return nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer rollbackGenerationTx(tx)
pending, err := lockPendingAIPointUsage(ctx, tx, tenantID, reservation.UsageLogID)
if err != nil || !pending {
return err
}
if err := confirmAIPointReservation(ctx, tx, tenantID, reservation.ReservationID); err != nil {
return err
}
var modelPtr *string
if strings.TrimSpace(model) != "" {
normalized := strings.TrimSpace(model)
modelPtr = &normalized
}
if err := repository.NewAIPointUsageRepository(tx).MarkCompleted(ctx, tenantID, reservation.UsageLogID, modelPtr); err != nil {
return err
}
return tx.Commit(ctx)
}
func RefundAIPoints(
ctx context.Context,
pool *pgxpool.Pool,
cache sharedcache.Cache,
tenantID int64,
operatorID int64,
reservation AIPointReservation,
errorMessage string,
) error {
if pool == nil || reservation.ReservationID <= 0 || reservation.UsageLogID <= 0 || reservation.Points <= 0 {
return nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer rollbackGenerationTx(tx)
if err := lockTenantAIPoints(ctx, tx, tenantID); err != nil {
return err
}
pending, err := lockPendingAIPointUsage(ctx, tx, tenantID, reservation.UsageLogID)
if err != nil || !pending {
return err
}
status, err := repository.NewQuotaRepository(tx).GetAIQuotaStatus(ctx, tenantID, time.Now().UTC())
if err != nil {
return err
}
quotaRepo := repository.NewQuotaRepository(tx)
reason := "ai_points_refund"
referenceType := "ai_point_usage"
usageID := reservation.UsageLogID
if _, err := quotaRepo.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
TenantID: tenantID,
OperatorID: operatorID,
QuotaType: aiPointsQuotaType,
Delta: reservation.Points,
BalanceAfter: status.Balance + reservation.Points,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &usageID,
}); err != nil {
return err
}
if err := refundAIPointReservation(ctx, tx, tenantID, reservation.ReservationID); err != nil {
return err
}
if err := repository.NewAIPointUsageRepository(tx).MarkRefunded(ctx, tenantID, reservation.UsageLogID, strings.TrimSpace(errorMessage)); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
deleteCacheKey(ctx, cache, workspaceQuotaSummaryCacheKey(tenantID))
return nil
}
func CompletePendingAIPointsByResource(
ctx context.Context,
pool *pgxpool.Pool,
tenantID int64,
resourceType string,
resourceID *int64,
resourceUID *string,
model string,
) error {
reservation, err := findPendingAIPointReservation(ctx, pool, tenantID, resourceType, resourceID, resourceUID)
if err != nil || reservation == nil {
return err
}
return CompleteAIPoints(ctx, pool, tenantID, *reservation, model)
}
func RefundPendingAIPointsByResource(
ctx context.Context,
pool *pgxpool.Pool,
cache sharedcache.Cache,
tenantID int64,
resourceType string,
resourceID *int64,
resourceUID *string,
errorMessage string,
) error {
reservation, operatorID, err := findPendingAIPointReservationWithOperator(ctx, pool, tenantID, resourceType, resourceID, resourceUID)
if err != nil || reservation == nil {
return err
}
return RefundAIPoints(ctx, pool, cache, tenantID, operatorID, *reservation, errorMessage)
}
func findPendingAIPointReservation(
ctx context.Context,
pool *pgxpool.Pool,
tenantID int64,
resourceType string,
resourceID *int64,
resourceUID *string,
) (*AIPointReservation, error) {
reservation, _, err := findPendingAIPointReservationWithOperator(ctx, pool, tenantID, resourceType, resourceID, resourceUID)
return reservation, err
}
func findPendingAIPointReservationWithOperator(
ctx context.Context,
pool *pgxpool.Pool,
tenantID int64,
resourceType string,
resourceID *int64,
resourceUID *string,
) (*AIPointReservation, int64, error) {
if pool == nil {
return nil, 0, nil
}
log, err := repository.NewAIPointUsageRepository(pool).FindPendingByResource(ctx, tenantID, resourceType, resourceID, resourceUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, 0, nil
}
return nil, 0, err
}
if log == nil || log.QuotaReservationID == nil {
return nil, 0, nil
}
operatorID := int64(0)
if log.OperatorID != nil {
operatorID = *log.OperatorID
}
return &AIPointReservation{
ReservationID: *log.QuotaReservationID,
UsageLogID: log.ID,
Points: log.Points,
RequestChars: log.RequestChars,
BaseChars: log.BaseChars,
}, operatorID, nil
}
func countAIPointChars(text string) int {
return utf8.RuneCountInString(strings.TrimSpace(text))
}
func calculateAIPointCost(chars, baseChars int) int {
if baseChars <= 0 {
baseChars = 1000
}
if chars <= 0 {
return 1
}
return chars/baseChars + 1
}
func lockTenantAIPoints(ctx context.Context, tx pgx.Tx, tenantID int64) error {
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(20260428, hashint8($1)::int)`, tenantID)
return err
}
func lockPendingAIPointUsage(ctx context.Context, tx pgx.Tx, tenantID, usageLogID int64) (bool, error) {
var status string
err := tx.QueryRow(ctx, `
SELECT status
FROM ai_point_usage_logs
WHERE id = $1 AND tenant_id = $2
FOR UPDATE
`, usageLogID, tenantID).Scan(&status)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, err
}
return status == "pending", nil
}
func confirmAIPointReservation(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64) error {
tag, err := tx.Exec(ctx, `
UPDATE quota_reservations
SET status = 'confirmed',
consumed_amount = reserved_amount,
updated_at = NOW()
WHERE id = $1
AND tenant_id = $2
AND quota_type = $3
AND status = 'pending'
`, reservationID, tenantID, aiPointsQuotaType)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("ai points reservation is not pending")
}
return nil
}
func refundAIPointReservation(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64) error {
tag, err := tx.Exec(ctx, `
UPDATE quota_reservations
SET status = 'refunded',
refunded_amount = reserved_amount,
updated_at = NOW()
WHERE id = $1
AND tenant_id = $2
AND quota_type = $3
AND status = 'pending'
`, reservationID, tenantID, aiPointsQuotaType)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("ai points reservation is not pending")
}
return nil
}
@@ -0,0 +1,33 @@
package app
import "testing"
func TestCalculateAIPointCost(t *testing.T) {
t.Parallel()
tests := []struct {
name string
chars int
baseChars int
want int
}{
{name: "empty still charges base usage", chars: 0, baseChars: 1000, want: 1},
{name: "single char", chars: 1, baseChars: 1000, want: 1},
{name: "first base boundary", chars: 1000, baseChars: 1000, want: 2},
{name: "over first base", chars: 1001, baseChars: 1000, want: 2},
{name: "second base boundary", chars: 2000, baseChars: 1000, want: 3},
{name: "over second base", chars: 2001, baseChars: 1000, want: 3},
{name: "default base chars", chars: 1000, baseChars: 0, want: 2},
{name: "custom base chars", chars: 1500, baseChars: 500, want: 4},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := calculateAIPointCost(tt.chars, tt.baseChars); got != tt.want {
t.Fatalf("calculateAIPointCost(%d, %d) = %d, want %d", tt.chars, tt.baseChars, got, tt.want)
}
})
}
}
@@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/llm"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
@@ -38,6 +39,7 @@ type ArticleSelectionOptimizeService struct {
pool *pgxpool.Pool
llm llm.Client
defaultMaxOutTokens int64
cache sharedcache.Cache
}
func NewArticleSelectionOptimizeService(
@@ -52,6 +54,11 @@ func NewArticleSelectionOptimizeService(
}
}
func (s *ArticleSelectionOptimizeService) WithCache(c sharedcache.Cache) *ArticleSelectionOptimizeService {
s.cache = c
return s
}
func (s *ArticleSelectionOptimizeService) ValidateRequest(
ctx context.Context,
articleID int64,
@@ -70,9 +77,29 @@ func (s *ArticleSelectionOptimizeService) ValidateRequest(
func (s *ArticleSelectionOptimizeService) OptimizeSelection(
ctx context.Context,
articleID int64,
req ArticleSelectionOptimizeRequest,
onDelta func(string),
) (*ArticleSelectionOptimizeResult, error) {
actor := auth.MustActor(ctx)
resourceType := "article"
reservation, err := ReserveAIPoints(ctx, s.pool, s.cache, AIPointReserveInput{
TenantID: actor.TenantID,
OperatorID: actor.UserID,
UsageType: AIUsageTypeArticleSelectionOptimize,
ResourceType: &resourceType,
ResourceID: &articleID,
MeteredText: req.SelectedText,
Metadata: map[string]any{
"article_id": articleID,
"instruction": strings.TrimSpace(req.Instruction),
"title_length": countAIPointChars(req.Title),
},
})
if err != nil {
return nil, err
}
result, err := s.llm.Generate(
ctx,
llm.GenerateRequest{
@@ -83,14 +110,27 @@ func (s *ArticleSelectionOptimizeService) OptimizeSelection(
onDelta,
)
if err != nil {
refundCtx, cancel := newGenerationCleanupContext()
_ = RefundAIPoints(refundCtx, s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, err.Error())
cancel()
return nil, response.ErrInternal(50019, "article_selection_optimize_failed", err.Error())
}
content := strings.TrimSpace(result.Content)
if content == "" {
refundCtx, cancel := newGenerationCleanupContext()
_ = RefundAIPoints(refundCtx, s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, "optimized content is empty")
cancel()
return nil, response.ErrInternal(50019, "article_selection_optimize_failed", "optimized content is empty")
}
completeCtx, cancel := newGenerationCleanupContext()
if err := CompleteAIPoints(completeCtx, s.pool, actor.TenantID, *reservation, result.Model); err != nil {
cancel()
return nil, response.ErrInternal(50019, "article_selection_optimize_failed", "failed to confirm ai points usage")
}
cancel()
return &ArticleSelectionOptimizeResult{
Content: content,
Model: result.Model,
@@ -47,26 +47,42 @@ func (s *KolAssistService) Run(
req.Description = s.enrichGenerateDescription(ctx, req.Description)
}
reservation, err := s.reserveStreamingAIPoints(ctx, actor, req)
if err != nil {
return nil, "", err
}
generateReq, err := BuildKolAssistGenerateRequest(req)
if err != nil {
s.refundReservedAIPoints(context.Background(), actor, reservation, err.Error())
return nil, "", response.ErrInternal(50087, "kol_assist_prompt_build_failed", err.Error())
}
streamAccumulator := newKolAssistContentStream(onDelta)
generated, err := s.llm.Generate(ctx, generateReq, streamAccumulator.ConsumeRawDelta)
if err != nil {
s.refundReservedAIPoints(context.Background(), actor, reservation, err.Error())
return nil, "", err
}
result, err := ParseKolAssistResponse(generated.Content)
if err != nil {
s.refundReservedAIPoints(context.Background(), actor, reservation, err.Error())
return nil, "", response.ErrInternal(50086, "kol_assist_result_invalid", err.Error())
}
result, err = FinalizeKolAssistResult(req, result)
if err != nil {
s.refundReservedAIPoints(context.Background(), actor, reservation, err.Error())
return nil, "", response.ErrInternal(50086, "kol_assist_result_invalid", err.Error())
}
completeCtx, cancel := newGenerationCleanupContext()
if err := CompleteAIPoints(completeCtx, s.pool, actor.TenantID, *reservation, generated.Model); err != nil {
cancel()
return nil, "", response.ErrInternal(50086, "kol_assist_result_invalid", "failed to confirm ai points usage")
}
cancel()
return result, generated.Model, nil
}
@@ -6,8 +6,10 @@ import (
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"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/messaging/rabbitmq"
@@ -48,15 +50,18 @@ type KolAssistTaskResponse struct {
}
type KolAssistService struct {
pool *pgxpool.Pool
profileSvc *KolProfileService
repo repository.KolAssistRepository
llm llm.Client
messaging *rabbitmq.Client
logger *zap.Logger
urlResolver *kolAssistURLResolver
cache sharedcache.Cache
}
func NewKolAssistService(
pool *pgxpool.Pool,
profileSvc *KolProfileService,
repo repository.KolAssistRepository,
llmClient llm.Client,
@@ -65,6 +70,7 @@ func NewKolAssistService(
logger *zap.Logger,
) *KolAssistService {
return &KolAssistService{
pool: pool,
profileSvc: profileSvc,
repo: repo,
llm: llmClient,
@@ -74,6 +80,11 @@ func NewKolAssistService(
}
}
func (s *KolAssistService) WithCache(c sharedcache.Cache) *KolAssistService {
s.cache = c
return s
}
func (s *KolAssistService) Submit(ctx context.Context, actor auth.Actor, req AssistRequest) (string, error) {
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
if err != nil {
@@ -94,6 +105,11 @@ func (s *KolAssistService) Submit(ctx context.Context, actor auth.Actor, req Ass
}
taskID := uuid.NewString()
reservation, err := s.reserveAIPoints(ctx, actor, taskID, req)
if err != nil {
return "", err
}
if err := s.repo.Create(ctx, repository.CreateKolAssistInput{
ID: taskID,
TenantID: profile.TenantID,
@@ -103,6 +119,7 @@ func (s *KolAssistService) Submit(ctx context.Context, actor auth.Actor, req Ass
TaskType: taskType,
RequestJSON: requestJSON,
}); err != nil {
s.refundReservedAIPoints(context.Background(), actor, reservation, err.Error())
return "", response.ErrInternal(50084, "kol_assist_create_failed", err.Error())
}
@@ -111,6 +128,7 @@ func (s *KolAssistService) Submit(ctx context.Context, actor auth.Actor, req Ass
TenantID: profile.TenantID,
}); err != nil {
_ = s.repo.MarkFailed(context.Background(), profile.TenantID, taskID, err.Error())
s.refundReservedAIPoints(context.Background(), actor, reservation, err.Error())
return "", response.ErrServiceUnavailable(50342, "kol_assist_queue_unavailable", "assist queue is busy, please retry")
}
@@ -184,3 +202,75 @@ func normalizeAssistRequest(req AssistRequest) (AssistRequest, string, error) {
return req, "ai_" + req.Mode, nil
}
func (s *KolAssistService) reserveAIPoints(
ctx context.Context,
actor auth.Actor,
taskID string,
req AssistRequest,
) (*AIPointReservation, error) {
usageType := AIUsageTypeKolPromptGenerate
if req.Mode == kolAssistModeOptimize {
usageType = AIUsageTypeKolPromptOptimize
}
resourceType := "kol_assist_task"
resourceUID := taskID
var resourceID *int64
if req.PromptID != nil && *req.PromptID > 0 {
resourceID = req.PromptID
}
return ReserveAIPoints(ctx, s.pool, s.cache, AIPointReserveInput{
TenantID: actor.TenantID,
OperatorID: actor.UserID,
UsageType: usageType,
ResourceType: &resourceType,
ResourceID: resourceID,
ResourceUID: &resourceUID,
MeteredText: buildKolAssistAIPointMeteredText(req),
Metadata: map[string]any{
"task_id": taskID,
"mode": req.Mode,
"prompt_id": req.PromptID,
},
})
}
func (s *KolAssistService) reserveStreamingAIPoints(ctx context.Context, actor auth.Actor, req AssistRequest) (*AIPointReservation, error) {
usageType := AIUsageTypeKolPromptGenerate
if req.Mode == kolAssistModeOptimize {
usageType = AIUsageTypeKolPromptOptimize
}
resourceType := "kol_prompt"
var resourceID *int64
if req.PromptID != nil && *req.PromptID > 0 {
resourceID = req.PromptID
}
return ReserveAIPoints(ctx, s.pool, s.cache, AIPointReserveInput{
TenantID: actor.TenantID,
OperatorID: actor.UserID,
UsageType: usageType,
ResourceType: &resourceType,
ResourceID: resourceID,
MeteredText: buildKolAssistAIPointMeteredText(req),
Metadata: map[string]any{
"mode": req.Mode,
"prompt_id": req.PromptID,
"stream": true,
},
})
}
func (s *KolAssistService) refundReservedAIPoints(ctx context.Context, actor auth.Actor, reservation *AIPointReservation, errorMessage string) {
if reservation == nil {
return
}
_ = RefundAIPoints(ctx, s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, errorMessage)
}
func buildKolAssistAIPointMeteredText(req AssistRequest) string {
parts := []string{req.Description}
if req.Mode == kolAssistModeOptimize {
parts = append(parts, req.CurrentContent)
}
return strings.TrimSpace(strings.Join(parts, "\n"))
}
+135 -11
View File
@@ -144,6 +144,11 @@ func (s *TemplateService) CreateAnalyzeTask(ctx context.Context, templateID int6
}
taskID := uuid.NewString()
reservation, err := s.reserveTemplateAssistAIPoints(ctx, actor.TenantID, actor.UserID, templateID, taskID, templateAssistTaskTypeAnalyze, buildAnalyzeAIPointMeteredText(req))
if err != nil {
return nil, err
}
repo := repository.NewTemplateAssistTaskRepository(s.pool)
if err := repo.CreateTask(ctx, repository.CreateTemplateAssistTaskInput{
ID: taskID,
@@ -153,6 +158,7 @@ func (s *TemplateService) CreateAnalyzeTask(ctx context.Context, templateID int6
TemplateID: templateID,
RequestJSON: payload,
}); err != nil {
s.refundReservedAIPoints(context.Background(), actor.TenantID, actor.UserID, reservation, err.Error())
return nil, response.ErrInternal(50021, "task_create_failed", err.Error())
}
@@ -168,6 +174,7 @@ func (s *TemplateService) CreateAnalyzeTask(ctx context.Context, templateID int6
}
if err := publishTemplateAssistTask(ctx, s.rabbitMQ, job); err != nil {
_ = repo.FailTask(context.Background(), taskID, templateAssistTaskTypeAnalyze, actor.TenantID, err.Error(), time.Now())
s.refundReservedAIPoints(context.Background(), actor.TenantID, actor.UserID, reservation, err.Error())
return nil, response.ErrServiceUnavailable(50312, "analyze_queue_unavailable", "analyze queue is busy, please retry")
}
@@ -225,6 +232,11 @@ func (s *TemplateService) CreateTitleTask(ctx context.Context, templateID int64,
}
taskID := uuid.NewString()
reservation, err := s.reserveTemplateAssistAIPoints(ctx, actor.TenantID, actor.UserID, templateID, taskID, templateAssistTaskTypeTitle, buildTitleAIPointMeteredText(req))
if err != nil {
return nil, err
}
repo := repository.NewTemplateAssistTaskRepository(s.pool)
if err := repo.CreateTask(ctx, repository.CreateTemplateAssistTaskInput{
ID: taskID,
@@ -234,6 +246,7 @@ func (s *TemplateService) CreateTitleTask(ctx context.Context, templateID int64,
TemplateID: templateID,
RequestJSON: payload,
}); err != nil {
s.refundReservedAIPoints(context.Background(), actor.TenantID, actor.UserID, reservation, err.Error())
return nil, response.ErrInternal(50025, "task_create_failed", err.Error())
}
@@ -249,6 +262,7 @@ func (s *TemplateService) CreateTitleTask(ctx context.Context, templateID int64,
}
if err := publishTemplateAssistTask(ctx, s.rabbitMQ, job); err != nil {
_ = repo.FailTask(context.Background(), taskID, templateAssistTaskTypeTitle, actor.TenantID, err.Error(), time.Now())
s.refundReservedAIPoints(context.Background(), actor.TenantID, actor.UserID, reservation, err.Error())
return nil, response.ErrServiceUnavailable(50313, "title_queue_unavailable", "title queue is busy, please retry")
}
@@ -306,6 +320,11 @@ func (s *TemplateService) CreateOutlineTask(ctx context.Context, templateID int6
}
taskID := uuid.NewString()
reservation, err := s.reserveTemplateAssistAIPoints(ctx, actor.TenantID, actor.UserID, templateID, taskID, templateAssistTaskTypeOutline, buildOutlineAIPointMeteredText(req))
if err != nil {
return nil, err
}
repo := repository.NewTemplateAssistTaskRepository(s.pool)
if err := repo.CreateTask(ctx, repository.CreateTemplateAssistTaskInput{
ID: taskID,
@@ -315,6 +334,7 @@ func (s *TemplateService) CreateOutlineTask(ctx context.Context, templateID int6
TemplateID: templateID,
RequestJSON: payload,
}); err != nil {
s.refundReservedAIPoints(context.Background(), actor.TenantID, actor.UserID, reservation, err.Error())
return nil, response.ErrInternal(50029, "task_create_failed", err.Error())
}
@@ -330,6 +350,7 @@ func (s *TemplateService) CreateOutlineTask(ctx context.Context, templateID int6
}
if err := publishTemplateAssistTask(ctx, s.rabbitMQ, job); err != nil {
_ = repo.FailTask(context.Background(), taskID, templateAssistTaskTypeOutline, actor.TenantID, err.Error(), time.Now())
s.refundReservedAIPoints(context.Background(), actor.TenantID, actor.UserID, reservation, err.Error())
return nil, response.ErrServiceUnavailable(50314, "outline_queue_unavailable", "outline queue is busy, please retry")
}
@@ -401,7 +422,24 @@ func (s *TemplateService) IsAssistTaskTerminal(ctx context.Context, job AssistJo
if err != nil {
return false, err
}
return record.Status == "completed" || record.Status == "failed", nil
switch record.Status {
case "completed":
s.completeTemplateAssistAIPoints(ctx, job.TenantID, job.TaskID, "")
return true, nil
case "failed":
if isTemplateAssistAIPointTask(job.TaskType) {
resourceType := "template_assist_task"
resourceUID := job.TaskID
errorMessage := "template assist task failed"
if record.ErrorMessage != nil && strings.TrimSpace(*record.ErrorMessage) != "" {
errorMessage = strings.TrimSpace(*record.ErrorMessage)
}
_ = RefundPendingAIPointsByResource(ctx, s.pool, s.cache, job.TenantID, resourceType, nil, &resourceUID, errorMessage)
}
return true, nil
default:
return false, nil
}
}
func (s *TemplateService) executeAnalyzeTask(ctx context.Context, repo repository.TemplateAssistTaskRepository, job assistJob) {
@@ -433,7 +471,9 @@ func (s *TemplateService) executeAnalyzeTask(ctx context.Context, repo repositor
if err := repo.CompleteTask(ctx, job.TaskID, job.TaskType, job.TenantID, resultJSON, time.Now()); err != nil {
s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err)
return
}
s.completeTemplateAssistAIPoints(ctx, job.TenantID, job.TaskID, result.Model)
}
func (s *TemplateService) executeTitleTask(ctx context.Context, repo repository.TemplateAssistTaskRepository, job assistJob) {
@@ -470,7 +510,9 @@ func (s *TemplateService) executeTitleTask(ctx context.Context, repo repository.
if err := repo.CompleteTask(ctx, job.TaskID, job.TaskType, job.TenantID, resultJSON, time.Now()); err != nil {
s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err)
return
}
s.completeTemplateAssistAIPoints(ctx, job.TenantID, job.TaskID, result.Model)
}
func (s *TemplateService) executeOutlineTask(ctx context.Context, repo repository.TemplateAssistTaskRepository, job assistJob) {
@@ -512,12 +554,75 @@ func (s *TemplateService) executeOutlineTask(ctx context.Context, repo repositor
if err := repo.CompleteTask(ctx, job.TaskID, job.TaskType, job.TenantID, resultJSON, time.Now()); err != nil {
s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err)
return
}
s.completeTemplateAssistAIPoints(ctx, job.TenantID, job.TaskID, result.Model)
}
func (s *TemplateService) failAssist(ctx context.Context, taskID, taskType string, tenantID int64, err error) {
repo := repository.NewTemplateAssistTaskRepository(s.pool)
_ = repo.FailTask(ctx, taskID, taskType, tenantID, err.Error(), time.Now())
if isTemplateAssistAIPointTask(taskType) {
resourceType := "template_assist_task"
resourceUID := taskID
_ = RefundPendingAIPointsByResource(ctx, s.pool, s.cache, tenantID, resourceType, nil, &resourceUID, err.Error())
}
}
func (s *TemplateService) reserveTemplateAssistAIPoints(
ctx context.Context,
tenantID int64,
operatorID int64,
templateID int64,
taskID string,
taskType string,
meteredText string,
) (*AIPointReservation, error) {
usageType := AIUsageTypeTemplateTitleGenerate
fixedPoints := 0
switch taskType {
case templateAssistTaskTypeAnalyze:
usageType = AIUsageTypeTemplateAnalyze
fixedPoints = 1
case templateAssistTaskTypeOutline:
usageType = AIUsageTypeTemplateOutlineGenerate
}
resourceType := "template_assist_task"
resourceUID := taskID
return ReserveAIPoints(ctx, s.pool, s.cache, AIPointReserveInput{
TenantID: tenantID,
OperatorID: operatorID,
UsageType: usageType,
ResourceType: &resourceType,
ResourceID: &templateID,
ResourceUID: &resourceUID,
MeteredText: meteredText,
FixedPoints: fixedPoints,
Metadata: map[string]any{
"template_id": templateID,
"task_id": taskID,
"task_type": taskType,
},
})
}
func (s *TemplateService) refundReservedAIPoints(ctx context.Context, tenantID, operatorID int64, reservation *AIPointReservation, errorMessage string) {
if reservation == nil {
return
}
_ = RefundAIPoints(ctx, s.pool, s.cache, tenantID, operatorID, *reservation, errorMessage)
}
func (s *TemplateService) completeTemplateAssistAIPoints(ctx context.Context, tenantID int64, taskID string, model string) {
resourceType := "template_assist_task"
resourceUID := taskID
_ = CompletePendingAIPointsByResource(ctx, s.pool, tenantID, resourceType, nil, &resourceUID, model)
}
func isTemplateAssistAIPointTask(taskType string) bool {
return taskType == templateAssistTaskTypeAnalyze ||
taskType == templateAssistTaskTypeTitle ||
taskType == templateAssistTaskTypeOutline
}
func normalizeAnalyzeTaskRequest(req AnalyzeTaskRequest) AnalyzeTaskRequest {
@@ -661,16 +766,7 @@ func hasOutlineContext(req OutlineTaskRequest) bool {
}
func buildAnalyzePrompt(templateKey, templateName string, req AnalyzeTaskRequest, analyzePromptTemplate *string) string {
contextPayload := map[string]interface{}{
"template_key": templateKey,
"template_name": templateName,
"locale": req.Locale,
"brand_name": req.BrandName,
"official_website": req.Website,
"input_params": req.InputParams,
"existing_keywords": req.ExistingKeywords,
"existing_competitors": req.ExistingCompetitors,
}
contextPayload := analyzePromptParams(templateKey, templateName, req)
if analyzePromptTemplate != nil && strings.TrimSpace(*analyzePromptTemplate) != "" {
basePrompt := strings.TrimSpace(renderPromptTemplate(analyzePromptTemplate, contextPayload))
@@ -684,6 +780,11 @@ func buildAnalyzePrompt(templateKey, templateName string, req AnalyzeTaskRequest
return prompts.AnalyzeFallbackPrompt(string(contextJSON))
}
func buildAnalyzeAIPointMeteredText(req AnalyzeTaskRequest) string {
payload, _ := json.Marshal(analyzePromptParams("", "", req))
return string(payload)
}
func buildTitlePrompt(templateKey, templateName string, req TitleTaskRequest, titlePromptTemplate *string) string {
contextPayload := titlePromptParams(templateKey, templateName, req)
if titlePromptTemplate != nil && strings.TrimSpace(*titlePromptTemplate) != "" {
@@ -723,6 +824,29 @@ func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest
return prompts.OutlineFallbackPrompt(string(contextJSON))
}
func buildTitleAIPointMeteredText(req TitleTaskRequest) string {
payload, _ := json.Marshal(titlePromptParams("", "", req))
return string(payload)
}
func buildOutlineAIPointMeteredText(req OutlineTaskRequest) string {
payload, _ := json.Marshal(outlinePromptParams("", "", req))
return string(payload)
}
func analyzePromptParams(templateKey, templateName string, req AnalyzeTaskRequest) map[string]interface{} {
return map[string]interface{}{
"template_key": templateKey,
"template_name": templateName,
"locale": req.Locale,
"brand_name": req.BrandName,
"official_website": req.Website,
"input_params": req.InputParams,
"existing_keywords": req.ExistingKeywords,
"existing_competitors": req.ExistingCompetitors,
}
}
func titlePromptParams(templateKey, templateName string, req TitleTaskRequest) map[string]interface{} {
params := map[string]interface{}{
"template_key": templateKey,
@@ -20,6 +20,7 @@ type WorkspaceService struct {
repo repository.WorkspaceRepository
templates repository.TemplateRepository
quota repository.QuotaRepository
aiPointUsage repository.AIPointUsageRepository
supportedPlatformCount int
cache sharedcache.Cache
cacheGroup singleflight.Group
@@ -29,11 +30,13 @@ func NewWorkspaceService(
repo repository.WorkspaceRepository,
templates repository.TemplateRepository,
quota repository.QuotaRepository,
aiPointUsage repository.AIPointUsageRepository,
) *WorkspaceService {
return &WorkspaceService{
repo: repo,
templates: templates,
quota: quota,
aiPointUsage: aiPointUsage,
supportedPlatformCount: 5,
}
}
@@ -105,18 +108,80 @@ func (s *WorkspaceService) QuotaSummary(ctx context.Context) (*domain.QuotaSumma
}
return nil, response.ErrInternal(50010, "query_failed", "failed to get quota balance")
}
aiStatus, err := s.quota.GetAIQuotaStatus(loadCtx, actor.TenantID, time.Now().UTC())
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
aiStatus = &repository.AIQuotaStatus{}
} else {
return nil, response.ErrInternal(50010, "query_failed", "failed to get ai points balance")
}
}
return &domain.QuotaSummary{
PlanCode: status.PlanCode,
PlanName: status.PlanName,
TotalQuota: status.Total,
UsedQuota: status.Used,
Balance: status.Balance,
ResetAt: status.ResetAt,
PlanCode: status.PlanCode,
PlanName: status.PlanName,
TotalQuota: status.Total,
UsedQuota: status.Used,
Balance: status.Balance,
ResetAt: status.ResetAt,
AIPointsTotal: aiStatus.Total,
AIPointsUsed: aiStatus.Used,
AIPointsBalance: aiStatus.Balance,
AIPointBaseChars: aiStatus.BaseChars,
AIPointsResetAt: aiStatus.ResetAt,
}, nil
})
}
func (s *WorkspaceService) AIPointUsageLogs(ctx context.Context, page, pageSize int) (*domain.AIPointUsageListResponse, error) {
actor := auth.MustActor(ctx)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
offset := (page - 1) * pageSize
rows, err := s.aiPointUsage.List(ctx, repository.AIPointUsageListParams{
TenantID: actor.TenantID,
Limit: pageSize,
Offset: offset,
})
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to list ai point usage")
}
total, err := s.aiPointUsage.Count(ctx, actor.TenantID)
if err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to count ai point usage")
}
items := make([]domain.AIPointUsageLog, 0, len(rows))
for _, row := range rows {
items = append(items, domain.AIPointUsageLog{
ID: row.ID,
UsageType: row.UsageType,
ResourceType: row.ResourceType,
ResourceID: row.ResourceID,
ResourceUID: row.ResourceUID,
RequestChars: row.RequestChars,
BaseChars: row.BaseChars,
Points: row.Points,
Status: row.Status,
Model: row.Model,
ErrorMessage: row.ErrorMessage,
CompletedAt: row.CompletedAt,
CreatedAt: row.CreatedAt,
})
}
return &domain.AIPointUsageListResponse{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
type TemplateCard struct {
ID int64 `json:"id"`
Scope string `json:"scope"`