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
@@ -19,6 +19,16 @@ type ArticleQuotaStatus struct {
ResetAt *time.Time
}
type AIQuotaStatus struct {
PlanCode string
PlanName string
Total int
Used int
Balance int
BaseChars int
ResetAt *time.Time
}
type QuotaLedgerInput struct {
TenantID int64
OperatorID int64
@@ -43,6 +53,7 @@ type QuotaReservationInput struct {
type QuotaRepository interface {
GetCurrentBalance(ctx context.Context, tenantID int64, quotaType string) (int, error)
GetArticleQuotaStatus(ctx context.Context, tenantID int64, now time.Time) (*ArticleQuotaStatus, error)
GetAIQuotaStatus(ctx context.Context, tenantID int64, now time.Time) (*AIQuotaStatus, error)
InsertQuotaLedger(ctx context.Context, input QuotaLedgerInput) (int64, error)
CreateQuotaReservation(ctx context.Context, input QuotaReservationInput) (int64, error)
UpdateQuotaReservationResource(ctx context.Context, reservationID, tenantID, resourceID int64) error
@@ -78,6 +89,58 @@ func (r *quotaRepository) GetCurrentBalance(ctx context.Context, tenantID int64,
return status.Balance, nil
}
func (r *quotaRepository) GetAIQuotaStatus(ctx context.Context, tenantID int64, now time.Time) (*AIQuotaStatus, error) {
access, err := loadTenantPlanAccess(ctx, r.db, tenantID, now)
if err != nil {
return nil, err
}
if access == nil {
return nil, pgx.ErrNoRows
}
baseChars := access.AIPointBaseChars()
if !access.HasActiveAccess(now) {
return &AIQuotaStatus{
PlanCode: access.PlanCode,
PlanName: access.PlanName,
BaseChars: baseChars,
}, nil
}
total := access.AIPointsMonthlyLimit()
if total <= 0 {
return &AIQuotaStatus{
PlanCode: access.PlanCode,
PlanName: access.PlanName,
BaseChars: baseChars,
}, nil
}
windowStart, windowEnd, resetAt := access.AIPointsWindow(now)
if windowEnd.IsZero() {
return nil, errors.New("ai points quota window end is missing")
}
used, err := CountEffectiveQuotaReservations(ctx, r.db, tenantID, "ai_points", windowStart, windowEnd)
if err != nil {
return nil, err
}
balance := total - used
if balance < 0 {
balance = 0
}
return &AIQuotaStatus{
PlanCode: access.PlanCode,
PlanName: access.PlanName,
Total: total,
Used: used,
Balance: balance,
BaseChars: baseChars,
ResetAt: resetAt,
}, nil
}
func (r *quotaRepository) GetArticleQuotaStatus(ctx context.Context, tenantID int64, now time.Time) (*ArticleQuotaStatus, error) {
access, err := loadTenantPlanAccess(ctx, r.db, tenantID, now)
if err != nil {