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:
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user