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
@@ -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,