Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
230 lines
7.9 KiB
Go
230 lines
7.9 KiB
Go
package model
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// DetectionRun is both the durable task and wallet ledger. Public projections
|
|
// are built explicitly; this record must never be serialized to anonymous users.
|
|
type DetectionRun struct {
|
|
ID string `gorm:"primaryKey;type:varchar(64)"`
|
|
UserID int `gorm:"index"`
|
|
RequestKey string `gorm:"uniqueIndex;type:varchar(128)"`
|
|
ChannelID int `gorm:"index"`
|
|
GroupName string
|
|
Kind string `gorm:"type:varchar(24)"`
|
|
Status string `gorm:"type:varchar(24);index"`
|
|
CreatedAt int64 `gorm:"autoCreateTime:false;index"`
|
|
FinishedAt int64
|
|
Deadline int64
|
|
Reserved int
|
|
Charged int
|
|
Price string `gorm:"type:text"`
|
|
RemoteID string
|
|
Quality string
|
|
HTML string `gorm:"type:text"`
|
|
Result string `gorm:"type:text"`
|
|
Error string
|
|
Settled bool
|
|
LogJSON string `gorm:"type:text"`
|
|
LogDelivered bool `gorm:"index"`
|
|
}
|
|
|
|
type DetectionCall struct {
|
|
ID string `gorm:"primaryKey;type:varchar(64)"`
|
|
RunID string `gorm:"index;type:varchar(64)"`
|
|
SecretHash string `gorm:"uniqueIndex;type:varchar(64)"`
|
|
ExpiresAt int64
|
|
StartedAt int64
|
|
FinishedAt int64
|
|
State string `gorm:"type:varchar(24)"`
|
|
Usage string `gorm:"type:text"`
|
|
Sample string `gorm:"type:text"`
|
|
Quota int
|
|
Available *bool
|
|
Error string
|
|
}
|
|
|
|
func DetectionSecretHash(secret string) string {
|
|
sum := sha256.Sum256([]byte(secret))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func CreateDetection(run *DetectionRun) error {
|
|
if run.Reserved < 0 || run.Reserved > common.MaxWalletQuota {
|
|
return errors.New("invalid reservation")
|
|
}
|
|
original := *run
|
|
err := relayAttemptTransaction(func(tx *gorm.DB) error {
|
|
*run = original
|
|
if run.UserID > 0 {
|
|
var user User
|
|
if err := lockForUpdate(tx).First(&user, run.UserID).Error; err != nil {
|
|
return err
|
|
}
|
|
var previous DetectionRun
|
|
if err := tx.Where("request_key = ?", run.RequestKey).First(&previous).Error; err == nil {
|
|
if previous.UserID != run.UserID || previous.GroupName != run.GroupName || previous.Kind != run.Kind {
|
|
return errors.New("idempotency key conflict")
|
|
}
|
|
*run = previous
|
|
return nil
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
var active int64
|
|
if err := tx.Model(&DetectionRun{}).Where("user_id = ? AND settled = ?", run.UserID, false).Count(&active).Error; err != nil {
|
|
return err
|
|
}
|
|
if active > 0 {
|
|
return errors.New("a detection is already running")
|
|
}
|
|
if err := tx.Model(&DetectionRun{}).Where("user_id = ? AND group_name = ? AND kind = ? AND created_at > ?", run.UserID, run.GroupName, run.Kind, time.Now().Unix()-300).Count(&active).Error; err != nil {
|
|
return err
|
|
}
|
|
if active > 0 {
|
|
return errors.New("please wait five minutes between detections")
|
|
}
|
|
if user.Status != common.UserStatusEnabled || user.Quota < run.Reserved {
|
|
return errors.New("insufficient wallet balance")
|
|
}
|
|
if err := tx.Model(&User{}).Where("id = ?", run.UserID).Update("quota", gorm.Expr("quota - ?", run.Reserved)).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Create(run).Error
|
|
})
|
|
if err == nil && run.UserID > 0 {
|
|
_ = invalidateUserCache(run.UserID)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func ClaimDetectionCall(hash string) (*DetectionCall, error) {
|
|
var call DetectionCall
|
|
err := relayAttemptTransaction(func(tx *gorm.DB) error {
|
|
if err := tx.Where("secret_hash = ?", hash).First(&call).Error; err != nil {
|
|
return err
|
|
}
|
|
var run DetectionRun
|
|
if err := lockForUpdate(tx).First(&run, "id = ?", call.RunID).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := lockForUpdate(tx).Where("id = ?", call.ID).First(&call).Error; err != nil {
|
|
return err
|
|
}
|
|
now := time.Now().Unix()
|
|
if run.Kind != "pelican" || call.State != "ready" || call.ExpiresAt <= now || run.Settled || run.Status == "failed" || run.Status == "succeeded" || run.Deadline-10 <= now {
|
|
return errors.New("expired or consumed credential")
|
|
}
|
|
update := tx.Model(&DetectionCall{}).Where("id = ? AND state = ?", call.ID, "ready").Updates(map[string]any{"state": "running", "started_at": now})
|
|
if update.Error != nil {
|
|
return update.Error
|
|
}
|
|
if update.RowsAffected != 1 {
|
|
return errors.New("credential already consumed")
|
|
}
|
|
call.State = "running"
|
|
call.StartedAt = now
|
|
return nil
|
|
})
|
|
return &call, err
|
|
}
|
|
|
|
func FinishDetectionCall(call *DetectionCall) error {
|
|
if call.Quota < 0 || call.Quota > common.MaxWalletQuota {
|
|
return errors.New("invalid charge")
|
|
}
|
|
return DB.Model(&DetectionCall{}).Where("id = ? AND state = ?", call.ID, "running").Updates(map[string]any{
|
|
"state": "finished", "finished_at": time.Now().Unix(), "usage": call.Usage, "sample": call.Sample, "quota": call.Quota, "available": call.Available, "error": call.Error,
|
|
}).Error
|
|
}
|
|
|
|
func SettleDetection(id string) error {
|
|
var owner int
|
|
err := relayAttemptTransaction(func(tx *gorm.DB) error {
|
|
var run DetectionRun
|
|
if err := lockForUpdate(tx).First(&run, "id = ?", id).Error; err != nil {
|
|
return err
|
|
}
|
|
owner = run.UserID
|
|
if run.Settled {
|
|
return nil
|
|
}
|
|
var calls []DetectionCall
|
|
if err := tx.Where("run_id = ?", id).Find(&calls).Error; err != nil {
|
|
return err
|
|
}
|
|
charge := 0
|
|
for _, call := range calls {
|
|
if call.State == "running" {
|
|
return errors.New("generation still running")
|
|
}
|
|
if call.Quota < 0 || call.Quota > run.Reserved-charge {
|
|
return errors.New("charge exceeds reservation")
|
|
}
|
|
charge += call.Quota
|
|
}
|
|
logJSON := ""
|
|
if owner > 0 {
|
|
var user User
|
|
if err := lockForUpdate(tx).Unscoped().First(&user, owner).Error; err != nil {
|
|
return err
|
|
}
|
|
if user.Quota > common.MaxWalletQuota-(run.Reserved-charge) || user.UsedQuota > common.MaxWalletQuota-charge {
|
|
return errors.New("wallet settlement exceeds quota limit")
|
|
}
|
|
if err := tx.Unscoped().Model(&User{}).Where("id = ?", owner).Updates(map[string]any{"quota": gorm.Expr("quota + ?", run.Reserved-charge), "used_quota": gorm.Expr("used_quota + ?", charge)}).Error; err != nil {
|
|
return err
|
|
}
|
|
key := "detection:" + id
|
|
other, _ := common.Marshal(map[string]any{"billing_source": "wallet", "detection_id": id, "detection_kind": run.Kind, "reserved": run.Reserved, "refunded": run.Reserved - charge})
|
|
entry := Log{AttemptKey: &key, UserId: owner, Username: user.Username, CreatedAt: time.Now().Unix(), Type: LogTypeSystem, Content: fmt.Sprintf("%s detection: charged %d quota; refunded %d quota", run.Kind, charge, run.Reserved-charge), Quota: charge, Group: run.GroupName, ModelName: "gpt-6-astra", RequestId: id, Other: string(other)}
|
|
encoded, err := common.Marshal(entry)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logJSON = string(encoded)
|
|
} else {
|
|
charge = 0
|
|
}
|
|
if err := tx.Model(&DetectionCall{}).Where("run_id = ? AND state = ?", id, "ready").Update("state", "revoked").Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Model(&DetectionRun{}).Where("id = ?", id).Updates(map[string]any{"settled": true, "charged": charge, "finished_at": time.Now().Unix(), "log_json": logJSON}).Error
|
|
})
|
|
if err == nil && owner > 0 {
|
|
_ = invalidateUserCache(owner)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Detection ledger delivery is idempotent and separate from request metrics.
|
|
func DeliverDetectionLogs() error {
|
|
var runs []DetectionRun
|
|
if err := DB.Where("settled = ? AND log_delivered = ? AND log_json <> ?", true, false, "").Limit(100).Find(&runs).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, run := range runs {
|
|
var entry Log
|
|
if err := common.UnmarshalJsonStr(run.LogJSON, &entry); err != nil {
|
|
return err
|
|
}
|
|
if err := LOG_DB.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "attempt_key"}}, DoNothing: true}).Create(&entry).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := DB.Model(&DetectionRun{}).Where("id = ?", run.ID).Update("log_delivered", true).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|