Files
ttstd-infinite-canvas/backend/internal/repository/finance.go

210 lines
7.9 KiB
Go

package repository
import (
"crypto/rand"
"encoding/hex"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"yingce/backend/internal/model"
)
var (
ErrInsufficientCredits = errors.New("积分不足,请先充值")
ErrRedeemCodeInvalid = errors.New("兑换码无效或已使用")
ErrActiveTaskLimit = errors.New("同时进行的任务数已达上限,请等待已有任务完成")
ErrTaskNotRetryable = errors.New("该任务当前状态不支持重试")
ErrBillingStateConflict = errors.New("计费状态已变化,请刷新后重试")
ErrBillingUsageUnavailable = errors.New("暂时无法获取用量,计费未完成")
ErrBillingChargeLimit = errors.New("本次费用超过授权上限,已拒绝扣费")
ErrChannelModelInUse = errors.New("该渠道模型正在被使用,无法删除或停用")
)
// 先抢占唯一业务键再更新账户,确保注册和签到奖励在多实例并发下只入账一次。
func (r *Repository) GrantCreditsOnce(userID string, entryType model.CreditLedgerType, amount int64, referenceKey string, note string) (*model.CreditAccount, bool, error) {
var account model.CreditAccount
granted := false
err := r.db.Transaction(func(tx *gorm.DB) error {
account = model.CreditAccount{UserID: userID}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil {
return err
}
entry := model.CreditLedgerEntry{ID: newRepositoryID(), UserID: userID, Type: entryType, AmountMicrocredits: amount, ReferenceKey: &referenceKey, Note: note}
created := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "reference_key"}}, DoNothing: true}).Create(&entry)
if created.Error != nil {
return created.Error
}
if created.RowsAffected == 0 {
return tx.First(&account, "user_id = ?", userID).Error
}
granted = true
if err := tx.Model(&model.CreditAccount{}).Where("user_id = ?", userID).Updates(map[string]any{
"available_microcredits": gorm.Expr("available_microcredits + ?", amount),
"version": gorm.Expr("version + 1"),
"updated_at": time.Now(),
}).Error; err != nil {
return err
}
if err := tx.First(&account, "user_id = ?", userID).Error; err != nil {
return err
}
return tx.Model(&entry).Updates(map[string]any{
"available_delta_microcredits": amount,
"available_after_microcredits": account.AvailableMicrocredits,
"reserved_after_microcredits": account.ReservedMicrocredits,
}).Error
})
return &account, granted, err
}
func (r *Repository) CreditAccount(userID string) (*model.CreditAccount, error) {
account := model.CreditAccount{UserID: userID}
if err := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil {
return nil, err
}
if err := r.db.First(&account, "user_id = ?", userID).Error; err != nil {
return nil, err
}
return &account, nil
}
func (r *Repository) CreditAccounts(userIDs []string) ([]model.CreditAccount, error) {
if len(userIDs) == 0 {
return []model.CreditAccount{}, nil
}
var accounts []model.CreditAccount
err := r.db.Where("user_id IN ?", userIDs).Find(&accounts).Error
return accounts, err
}
func (r *Repository) CreditLedger(userID string, entryType string, limit int, offset int) ([]model.CreditLedgerEntry, int64, error) {
var items []model.CreditLedgerEntry
var total int64
query := r.db.Model(&model.CreditLedgerEntry{}).Where("user_id = ? AND type <> ?", userID, model.CreditLedgerReserve)
switch entryType {
case "income":
query = query.Where("type IN ?", []model.CreditLedgerType{model.CreditLedgerRedeem, model.CreditLedgerAdminGrant, model.CreditLedgerAdminAdjust, model.CreditLedgerSignupBonus, model.CreditLedgerCheckinBonus})
case "consume":
query = query.Where("type = ?", model.CreditLedgerConsume)
case "refund":
query = query.Where("type = ?", model.CreditLedgerRefund)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if limit <= 0 || limit > 100 {
limit = 30
}
if offset < 0 {
offset = 0
}
err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error
return items, total, err
}
func (r *Repository) CreditLedgerReferenceExists(referenceKey string) (bool, error) {
var count int64
err := r.db.Model(&model.CreditLedgerEntry{}).Where("reference_key = ?", referenceKey).Count(&count).Error
return count > 0, err
}
func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *model.BillingOrder, activeTaskLimit int) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := r.requireActiveLogicalModelForTask(tx, task); err != nil {
return err
}
if err := enforceActiveTaskLimit(tx, task.UserID, activeTaskLimit); err != nil {
return err
}
if err := reserveBillingOrder(tx, order); err != nil {
return err
}
return tx.Create(task).Error
})
}
func (r *Repository) CreateTaskWithActiveLimit(task *model.Task, activeTaskLimit int) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := r.requireActiveLogicalModelForTask(tx, task); err != nil {
return err
}
if err := enforceActiveTaskLimit(tx, task.UserID, activeTaskLimit); err != nil {
return err
}
return tx.Create(task).Error
})
}
func (r *Repository) RetryTaskWithBilling(userID string, prepared *model.Task, order *model.BillingOrder, activeTaskLimit int) (*model.Task, error) {
var task model.Task
taskID := prepared.ID
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := enforceActiveTaskLimit(tx, userID, activeTaskLimit); err != nil {
return err
}
if order != nil {
if err := reserveBillingOrder(tx, order); err != nil {
return err
}
}
updates := map[string]any{
"status": model.TaskStatusQueued, "stage": "等待队列调度", "progress": 5, "error": "", "result_json": "",
"execution_diagnostic_json": "", "cancellation_source": "", "cancellation_actor_id": "", "cancellation_requested_at": nil,
"text_draft": "", "started_at": nil, "completed_at": nil,
"provider_request_id": "", "poll_stage": "", "next_poll_at": nil,
"provider_cancel_status": "", "provider_cancel_error": "", "provider_cancel_attempts": 0,
"provider_cancel_requested_at": nil, "provider_cancelled_at": nil, "provider_cancel_next_check_at": nil,
"route_run": gorm.Expr("route_run + ?", 1),
"logical_model_revision_id": prepared.LogicalModelRevisionID, "route_id": prepared.RouteID,
"channel_model_id": prepared.ChannelModelID, "input_json": prepared.InputJSON,
"model": prepared.Model, "provider": prepared.Provider,
"lease_owner": "", "lease_expires_at": nil, "updated_at": time.Now(),
}
if order != nil {
updates["billing_order_id"] = order.ID
} else {
// 免费模式重试必须解除旧订单,避免任务执行阶段复用上一次的计费状态。
updates["billing_order_id"] = ""
}
updated := tx.Model(&model.Task{}).
Where("id = ? AND user_id = ? AND status IN ?", taskID, userID, []model.TaskStatus{model.TaskStatusFailed, model.TaskStatusCancelled}).
Updates(updates)
if updated.Error != nil {
return updated.Error
}
if updated.RowsAffected != 1 {
return ErrTaskNotRetryable
}
if err := tx.Delete(&model.TaskTextDelta{}, "user_id = ? AND task_id = ?", userID, taskID).Error; err != nil {
return err
}
return tx.First(&task, "id = ? AND user_id = ?", taskID, userID).Error
})
return &task, err
}
func enforceActiveTaskLimit(tx *gorm.DB, userID string, activeTaskLimit int) error {
var count int64
if err := tx.Model(&model.Task{}).Where("user_id = ? AND status IN ?", userID, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}).Count(&count).Error; err != nil {
return err
}
if count >= int64(activeTaskLimit) {
return ErrActiveTaskLimit
}
return nil
}
func newRepositoryID() string {
return randomRepositorySuffix()
}
func randomRepositorySuffix() string {
var value [16]byte
if _, err := rand.Read(value[:]); err != nil {
return "fallback"
}
return hex.EncodeToString(value[:])
}