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
@@ -8,9 +8,11 @@ import (
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
amqp "github.com/rabbitmq/amqp091-go"
"go.uber.org/zap"
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/messaging/rabbitmq"
tenantapp "github.com/geo-platform/tenant-api/internal/tenant/app"
@@ -20,10 +22,12 @@ import (
var errKolAssistConsumerClosed = errors.New("kol assist consumer channel closed")
type KolAssistWorker struct {
pool *pgxpool.Pool
rabbitMQ *rabbitmq.Client
repo repository.KolAssistRepository
llm llm.Client
logger *zap.Logger
cache sharedcache.Cache
retryInterval time.Duration
processTimeout time.Duration
consumerPrefix string
@@ -31,10 +35,12 @@ type KolAssistWorker struct {
}
func NewKolAssistWorker(
pool *pgxpool.Pool,
rabbitMQClient *rabbitmq.Client,
repo repository.KolAssistRepository,
llmClient llm.Client,
logger *zap.Logger,
cache sharedcache.Cache,
workerConcurrency int,
processTimeout time.Duration,
) *KolAssistWorker {
@@ -46,10 +52,12 @@ func NewKolAssistWorker(
}
return &KolAssistWorker{
pool: pool,
rabbitMQ: rabbitMQClient,
repo: repo,
llm: llmClient,
logger: logger,
cache: cache,
retryInterval: 5 * time.Second,
processTimeout: processTimeout,
consumerPrefix: fmt.Sprintf("kol-assist-%d", os.Getpid()),
@@ -73,7 +81,19 @@ func (w *KolAssistWorker) Process(ctx context.Context, job tenantapp.KolAssistJo
if err != nil {
return fmt.Errorf("load kol assist task: %w", err)
}
if task == nil || task.Status == "completed" || task.Status == "failed" {
if task == nil {
return nil
}
switch task.Status {
case "completed":
w.completeAIPoints(ctx, job, "")
return nil
case "failed":
errorMessage := "kol assist task failed"
if task.ErrorMessage != nil && *task.ErrorMessage != "" {
errorMessage = *task.ErrorMessage
}
w.refundAIPoints(ctx, job, errorMessage)
return nil
}
@@ -83,6 +103,7 @@ func (w *KolAssistWorker) Process(ctx context.Context, job tenantapp.KolAssistJo
if err := w.processTask(ctx, job, task); err != nil {
_ = w.repo.MarkFailed(context.Background(), job.TenantID, job.TaskID, err.Error())
w.refundAIPoints(context.Background(), job, err.Error())
if w.logger != nil {
w.logger.Warn("kol assist task failed",
zap.Error(err),
@@ -215,5 +236,30 @@ func (w *KolAssistWorker) processTask(ctx context.Context, job tenantapp.KolAssi
if err := w.repo.MarkCompleted(ctx, job.TenantID, job.TaskID, resultJSON); err != nil {
return fmt.Errorf("mark kol assist completed: %w", err)
}
w.completeAIPoints(ctx, job, result.Model)
return nil
}
func (w *KolAssistWorker) completeAIPoints(ctx context.Context, job tenantapp.KolAssistJob, model string) {
resourceType := "kol_assist_task"
resourceUID := job.TaskID
if err := tenantapp.CompletePendingAIPointsByResource(ctx, w.pool, job.TenantID, resourceType, nil, &resourceUID, model); err != nil && w.logger != nil {
w.logger.Warn("kol assist ai points confirmation failed",
zap.Error(err),
zap.String("task_id", job.TaskID),
zap.Int64("tenant_id", job.TenantID),
)
}
}
func (w *KolAssistWorker) refundAIPoints(ctx context.Context, job tenantapp.KolAssistJob, errorMessage string) {
resourceType := "kol_assist_task"
resourceUID := job.TaskID
if err := tenantapp.RefundPendingAIPointsByResource(ctx, w.pool, w.cache, job.TenantID, resourceType, nil, &resourceUID, errorMessage); err != nil && w.logger != nil {
w.logger.Warn("kol assist ai points refund failed",
zap.Error(err),
zap.String("task_id", job.TaskID),
zap.Int64("tenant_id", job.TenantID),
)
}
}