feat(tenant): bill AI points by output characters
Deployment Config CI / Deployment Config (push) Successful in 39s
Backend CI / Backend (push) Successful in 18m14s

Previously AI point cost was computed from request (input) characters at
reservation time. Switch to reserving a single point up front and
settling the final cost from the generated output length on completion.

- Reserve 1 point (or FixedPoints) instead of pricing on request chars
- On completion, recompute points from output chars vs base chars,
  update the reservation amounts, and post a ledger delta for the
  top-up/refund; invalidate the workspace quota summary cache
- MarkCompleted now persists final request_chars/base_chars/points
- Use ceil division in calculateAIPointCost so an exact base boundary
  charges one point instead of rolling over
- Thread output text through all CompleteAIPoints callers (article
  selection, KOL assist, question expansion, template assist, compliance
  judge) and return the settled reservation
- Add unit tests plus an integration test gated on TEST_DATABASE_URL
- Update the user manual: billing is by output characters

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 17:04:37 +08:00
parent c8dceb43d7
commit f69edc2218
12 changed files with 612 additions and 51 deletions
+117 -17
View File
@@ -44,11 +44,20 @@ type AIPointReserveInput struct {
type AIPointReservation struct {
ReservationID int64
UsageLogID int64
OperatorID int64
Points int
RequestChars int
BaseChars int
}
type aiPointOutputSettlement struct {
OutputChars int
BaseChars int
FinalPoints int
ReservedPoints int
LedgerDelta int
}
func ReserveAIPoints(
ctx context.Context,
pool *pgxpool.Pool,
@@ -85,8 +94,8 @@ func ReserveAIPoints(
}
requestChars := countAIPointChars(input.MeteredText)
points := calculateAIPointCost(requestChars, status.BaseChars)
if input.FixedPoints > 0 {
points := 1
if input.FixedPoints > points {
points = input.FixedPoints
}
if status.Total <= 0 || status.Balance < points {
@@ -160,6 +169,7 @@ func ReserveAIPoints(
return &AIPointReservation{
ReservationID: reservationID,
UsageLogID: usageID,
OperatorID: input.OperatorID,
Points: points,
RequestChars: requestChars,
BaseChars: status.BaseChars,
@@ -169,36 +179,79 @@ func ReserveAIPoints(
func CompleteAIPoints(
ctx context.Context,
pool *pgxpool.Pool,
cache sharedcache.Cache,
tenantID int64,
reservation AIPointReservation,
model string,
) error {
meteredOutputText string,
) (*AIPointReservation, error) {
completed := reservation
if pool == nil || reservation.ReservationID <= 0 || reservation.UsageLogID <= 0 {
return nil
return &completed, nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return err
return nil, err
}
defer rollbackGenerationTx(tx)
pending, err := lockPendingAIPointUsage(ctx, tx, tenantID, reservation.UsageLogID)
if err != nil || !pending {
return err
if err := lockTenantAIPoints(ctx, tx, tenantID); err != nil {
return nil, err
}
if err := confirmAIPointReservationIfPending(ctx, tx, tenantID, reservation.ReservationID); err != nil {
return err
pending, err := lockPendingAIPointUsage(ctx, tx, tenantID, reservation.UsageLogID)
if err != nil {
return nil, err
}
if !pending {
return &completed, nil
}
settlement := calculateAIPointOutputSettlement(reservation, meteredOutputText)
completed.RequestChars = settlement.OutputChars
completed.BaseChars = settlement.BaseChars
completed.Points = settlement.FinalPoints
quotaRepo := repository.NewQuotaRepository(tx)
adjusted, err := completeAIPointReservationWithFinalAmountIfPending(ctx, tx, tenantID, reservation.ReservationID, settlement.FinalPoints)
if err != nil {
return nil, err
}
if adjusted && settlement.LedgerDelta != 0 {
currentBalance, err := quotaRepo.GetCurrentBalance(ctx, tenantID, aiPointsQuotaType)
if err != nil {
return nil, err
}
reason := "ai_points_output_settle"
referenceType := "ai_point_usage"
usageID := reservation.UsageLogID
if _, err := quotaRepo.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
TenantID: tenantID,
OperatorID: reservation.OperatorID,
QuotaType: aiPointsQuotaType,
Delta: settlement.LedgerDelta,
BalanceAfter: currentBalance + settlement.LedgerDelta,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &usageID,
}); err != nil {
return nil, 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
if err := repository.NewAIPointUsageRepository(tx).MarkCompleted(ctx, tenantID, reservation.UsageLogID, settlement.OutputChars, settlement.BaseChars, settlement.FinalPoints, modelPtr); err != nil {
return nil, err
}
return tx.Commit(ctx)
if err := tx.Commit(ctx); err != nil {
return nil, err
}
deleteCacheKey(ctx, cache, workspaceQuotaSummaryCacheKey(tenantID))
return &completed, nil
}
func RefundAIPoints(
@@ -267,17 +320,19 @@ func RefundAIPoints(
func CompletePendingAIPointsByResource(
ctx context.Context,
pool *pgxpool.Pool,
cache sharedcache.Cache,
tenantID int64,
resourceType string,
resourceID *int64,
resourceUID *string,
model string,
) error {
meteredOutputText string,
) (*AIPointReservation, error) {
reservation, err := findPendingAIPointReservation(ctx, pool, tenantID, resourceType, resourceID, resourceUID)
if err != nil || reservation == nil {
return err
return reservation, err
}
return CompleteAIPoints(ctx, pool, tenantID, *reservation, model)
return CompleteAIPoints(ctx, pool, cache, tenantID, *reservation, model, meteredOutputText)
}
func RefundPendingAIPointsByResource(
@@ -337,6 +392,7 @@ func findPendingAIPointReservationWithOperator(
return &AIPointReservation{
ReservationID: *log.QuotaReservationID,
UsageLogID: log.ID,
OperatorID: operatorID,
Points: log.Points,
RequestChars: log.RequestChars,
BaseChars: log.BaseChars,
@@ -354,7 +410,27 @@ func calculateAIPointCost(chars, baseChars int) int {
if chars <= 0 {
return 1
}
return chars/baseChars + 1
return (chars + baseChars - 1) / baseChars
}
func calculateAIPointOutputSettlement(reservation AIPointReservation, meteredOutputText string) aiPointOutputSettlement {
baseChars := reservation.BaseChars
if baseChars <= 0 {
baseChars = 1000
}
outputChars := countAIPointChars(meteredOutputText)
finalPoints := calculateAIPointCost(outputChars, baseChars)
reservedPoints := reservation.Points
if reservedPoints <= 0 {
reservedPoints = 1
}
return aiPointOutputSettlement{
OutputChars: outputChars,
BaseChars: baseChars,
FinalPoints: finalPoints,
ReservedPoints: reservedPoints,
LedgerDelta: reservedPoints - finalPoints,
}
}
func lockTenantAIPoints(ctx context.Context, tx pgx.Tx, tenantID int64) error {
@@ -379,6 +455,30 @@ func lockPendingAIPointUsage(ctx context.Context, tx pgx.Tx, tenantID, usageLogI
return status == "pending", nil
}
func completeAIPointReservationWithFinalAmountIfPending(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64, finalPoints int) (bool, error) {
if finalPoints <= 0 {
finalPoints = 1
}
tag, err := tx.Exec(ctx, `
UPDATE quota_reservations
SET status = 'confirmed',
reserved_amount = $4,
consumed_amount = $4,
updated_at = NOW()
WHERE id = $1
AND tenant_id = $2
AND quota_type = $3
AND status = 'pending'
`, reservationID, tenantID, aiPointsQuotaType, finalPoints)
if err != nil {
return false, err
}
if tag.RowsAffected() > 0 {
return true, nil
}
return false, ignoreAIPointReservationReset(ctx, tx, false, tenantID, reservationID)
}
func confirmAIPointReservationIfPending(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64) error {
tag, err := tx.Exec(ctx, `
UPDATE quota_reservations