feat(api): GET /v1/user/balance 查询用户余额
users 表新增 credits_used(累计消耗积分):扣费累加、失败退款回减(refundIfNeeded / 维护清扫改走 RefundCredits),充值/CDK/签到发放不计入。接口返回 {object,balance,used,total},Bearer API Key 鉴权,与其他 v1 接口同一 CORS 与错误格式。站内接口文档与 README 补充端点说明。
This commit is contained in:
@@ -41,6 +41,22 @@ func (h *V1Handler) Models(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// UserBalance — GET /v1/user/balance. 返回 API Key 所属用户的账户级余额
|
||||
// (剩余 / 累计已用),与具体令牌无关。
|
||||
func (h *V1Handler) UserBalance(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.v1.UserBalance(c.Request.Context(), principal)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load balance"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ImageGenerations — OpenAI POST /v1/images/generations (text-to-image only).
|
||||
// Accepts exactly OpenAI's fields; size→aspect ratio and quality→resolution tier
|
||||
// are mapped server-side. Returns {created, data:[{b64_json}]}.
|
||||
|
||||
@@ -49,6 +49,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
engine.GET("/health", handlers.Health.Handle)
|
||||
engine.GET("/images/:user/:name", handlers.Images.Serve)
|
||||
engine.GET("/v1/models", handlers.V1.Models)
|
||||
engine.GET("/v1/user/balance", handlers.V1.UserBalance)
|
||||
engine.POST("/v1/images/generations", handlers.V1.ImageGenerations)
|
||||
engine.POST("/v1/images/edits", handlers.V1.ImageEdits)
|
||||
// OpenAI Sora-style async video: create job → poll → stream content.
|
||||
|
||||
@@ -15,6 +15,7 @@ type User struct {
|
||||
Role string `gorm:"size:32;index;not null"`
|
||||
Status string `gorm:"size:32;index;not null"`
|
||||
Credits float64 `gorm:"not null;default:0"`
|
||||
CreditsUsed float64 `gorm:"not null;default:0"` // 累计已消耗额度:扣费时加、退款时减,充值/发放不计入
|
||||
Notes string `gorm:"type:text"`
|
||||
ConcurrencyGroupID string `gorm:"size:32;index"`
|
||||
AnnouncementSeen string `gorm:"size:32"` // version hash of the last announcement this user dismissed
|
||||
|
||||
@@ -524,6 +524,35 @@ func (r *UserRepository) AdjustCredits(ctx context.Context, userID string, delta
|
||||
return r.GetByID(ctx, userID)
|
||||
}
|
||||
|
||||
// RefundCredits 归还一次生成的预扣费:余额加回、累计消耗 credits_used 相应减少
|
||||
// (不低于 0)。发放类加钱(管理员调整 / CDK / 签到)走 AdjustCredits,不动 credits_used。
|
||||
func (r *UserRepository) RefundCredits(ctx context.Context, userID string, amount float64) (*model.User, error) {
|
||||
if amount <= 0 {
|
||||
return r.GetByID(ctx, userID)
|
||||
}
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var user model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
nextUsed := user.CreditsUsed - amount
|
||||
if nextUsed < 0 {
|
||||
nextUsed = 0
|
||||
}
|
||||
return tx.Model(&model.User{}).
|
||||
Where("id = ?", userID).
|
||||
Updates(map[string]any{
|
||||
"credits": user.Credits + amount,
|
||||
"credits_used": nextUsed,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.GetByID(ctx, userID)
|
||||
}
|
||||
|
||||
// SetCredits sets a user's credit balance to an absolute (non-negative) value.
|
||||
// The row is locked for the duration of the transaction so it stays consistent
|
||||
// with concurrent AdjustCredits/TryDebitCredits operations.
|
||||
@@ -570,12 +599,14 @@ func (r *UserRepository) TryDebitCredits(ctx context.Context, userID string, amo
|
||||
if err := tx.Model(&model.User{}).
|
||||
Where("id = ?", userID).
|
||||
Updates(map[string]any{
|
||||
"credits": nextCredits,
|
||||
"updated_at": time.Now(),
|
||||
"credits": nextCredits,
|
||||
"credits_used": user.CreditsUsed + amount,
|
||||
"updated_at": time.Now(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
user.Credits = nextCredits
|
||||
user.CreditsUsed += amount
|
||||
user.UpdatedAt = time.Now()
|
||||
result = &user
|
||||
debited = true
|
||||
|
||||
@@ -239,7 +239,7 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
||||
if !claimed {
|
||||
continue
|
||||
}
|
||||
if _, err := m.users.AdjustCredits(ctx, e.UserID, e.Cost); err != nil {
|
||||
if _, err := m.users.RefundCredits(ctx, e.UserID, e.Cost); err != nil {
|
||||
log.Printf("maintenance: refund abandoned event %s (user %s, %.0f): %v", e.ID, e.UserID, e.Cost, err)
|
||||
} else {
|
||||
refunded++
|
||||
|
||||
@@ -391,6 +391,21 @@ func (s *V1Service) ListModels(ctx context.Context) ([]map[string]any, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UserBalance — GET /v1/user/balance 的数据。重新读一次用户行保证实时
|
||||
// (principal 里那份是鉴权时读的,可能已经过期)。
|
||||
func (s *V1Service) UserBalance(ctx context.Context, principal *APIPrincipal) (map[string]any, error) {
|
||||
user := principal.User
|
||||
if fresh, err := s.users.GetByID(ctx, user.ID); err == nil && fresh != nil {
|
||||
user = fresh
|
||||
}
|
||||
return map[string]any{
|
||||
"object": "user.balance",
|
||||
"balance": user.Credits,
|
||||
"used": user.CreditsUsed,
|
||||
"total": user.Credits + user.CreditsUsed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *V1Service) PrepareImageRequest(ctx context.Context, principal *APIPrincipal, in V1ImageRequest) (map[string]any, error) {
|
||||
return s.prepareImageExecution(ctx, principal, in, "v1", true)
|
||||
}
|
||||
@@ -3279,7 +3294,7 @@ func (s *V1Service) refundIfNeeded(ctx context.Context, principal *APIPrincipal,
|
||||
if !claimed {
|
||||
return nil
|
||||
}
|
||||
updated, err := s.users.AdjustCredits(ctx, principal.User.ID, price)
|
||||
updated, err := s.users.RefundCredits(ctx, principal.User.ID, price)
|
||||
if err == nil {
|
||||
principal.User = updated
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user