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:
GlossSeaDress
2026-08-10 14:59:48 +08:00
parent 2c47344ed9
commit f94a7ace5f
9 changed files with 79 additions and 6 deletions
+1 -1
View File
@@ -90,7 +90,7 @@ It's more than an API proxy: it ships with **credit billing, CDK top-ups, referr
- **De-AI fingerprint** (optional): one-click toggle on the playground — generated images get anti-AI-detection post-processing (subtle detail jitter + metadata stripping), charged as a per-tier surcharge (defaults 1K+1 / 2K+2 / 4K+3 credits, admin-configurable, can be disabled globally); processed works carry a "de-AI" badge across the playground, gallery, logs and admin image manager - **De-AI fingerprint** (optional): one-click toggle on the playground — generated images get anti-AI-detection post-processing (subtle detail jitter + metadata stripping), charged as a per-tier surcharge (defaults 1K+1 / 2K+2 / 4K+3 credits, admin-configurable, can be disabled globally); processed works carry a "de-AI" badge across the playground, gallery, logs and admin image manager
#### 🔌 OpenAI Compatible #### 🔌 OpenAI Compatible
- Text-to-image `/v1/images/generations` · image-to-image `/v1/images/edits` (multipart ref upload) · video `/v1/videos` (Sora-style async: create → poll → `/content`) · `/v1/models` - Text-to-image `/v1/images/generations` · image-to-image `/v1/images/edits` (multipart ref upload) · video `/v1/videos` (Sora-style async: create → poll → `/content`) · `/v1/models` · balance `/v1/user/balance` (remaining / cumulative used)
- **Strict OpenAI params**: `size` drives **both aspect ratio + resolution tier** (images by long edge → 1K/2K/4K, videos by short edge → 720p/1080p) — just swap `base_url` + `api_key` into an existing OpenAI SDK - **Strict OpenAI params**: `size` drives **both aspect ratio + resolution tier** (images by long edge → 1K/2K/4K, videos by short edge → 720p/1080p) — just swap `base_url` + `api_key` into an existing OpenAI SDK
- Image results returned **inline as base64** — nothing stored server-side, privacy-friendly; the in-app **/docs** ships a size ↔ tier reference table - Image results returned **inline as base64** — nothing stored server-side, privacy-friendly; the in-app **/docs** ships a size ↔ tier reference table
+1 -1
View File
@@ -90,7 +90,7 @@
- **去AI特征**(可选):画图台一键开启,生成图片自动做去AI痕迹处理(细节微扰 + 去除元数据),按画质档位加收积分(默认 1K+1 / 2K+2 / 4K+3,后台可改价、可整体关闭);带标记的作品在画图台、创作记录、日志与后台图片管理中均有「去AI特征」标识 - **去AI特征**(可选):画图台一键开启,生成图片自动做去AI痕迹处理(细节微扰 + 去除元数据),按画质档位加收积分(默认 1K+1 / 2K+2 / 4K+3,后台可改价、可整体关闭);带标记的作品在画图台、创作记录、日志与后台图片管理中均有「去AI特征」标识
#### 🔌 OpenAI 兼容 #### 🔌 OpenAI 兼容
- 文生图 `/v1/images/generations` · 图生图 `/v1/images/edits`(multipart 上传参考图) · 视频 `/v1/videos`(Sora 式异步:创建→轮询→`/content` 下载) · `/v1/models` - 文生图 `/v1/images/generations` · 图生图 `/v1/images/edits`(multipart 上传参考图) · 视频 `/v1/videos`(Sora 式异步:创建→轮询→`/content` 下载) · `/v1/models` · 余额 `/v1/user/balance`(剩余/累计已用)
- **严格 OpenAI 入参**:`size` **同时决定比例 + 分辨率档**(图像看长边 → 1K/2K/4K,视频看短边 → 720p/1080p),改个 `base_url` + `api_key` 即接现有 OpenAI SDK - **严格 OpenAI 入参**:`size` **同时决定比例 + 分辨率档**(图像看长边 → 1K/2K/4K,视频看短边 → 720p/1080p),改个 `base_url` + `api_key` 即接现有 OpenAI SDK
- 图片结果 **base64 直返**,服务端不留存文件,隐私友好;站内 **/docs** 附「分辨率对照表」直接查 `size` 该传什么 - 图片结果 **base64 直返**,服务端不留存文件,隐私友好;站内 **/docs** 附「分辨率对照表」直接查 `size` 该传什么
+16
View File
@@ -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). // ImageGenerations — OpenAI POST /v1/images/generations (text-to-image only).
// Accepts exactly OpenAI's fields; size→aspect ratio and quality→resolution tier // Accepts exactly OpenAI's fields; size→aspect ratio and quality→resolution tier
// are mapped server-side. Returns {created, data:[{b64_json}]}. // are mapped server-side. Returns {created, data:[{b64_json}]}.
+1
View File
@@ -49,6 +49,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
engine.GET("/health", handlers.Health.Handle) engine.GET("/health", handlers.Health.Handle)
engine.GET("/images/:user/:name", handlers.Images.Serve) engine.GET("/images/:user/:name", handlers.Images.Serve)
engine.GET("/v1/models", handlers.V1.Models) 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/generations", handlers.V1.ImageGenerations)
engine.POST("/v1/images/edits", handlers.V1.ImageEdits) engine.POST("/v1/images/edits", handlers.V1.ImageEdits)
// OpenAI Sora-style async video: create job → poll → stream content. // OpenAI Sora-style async video: create job → poll → stream content.
+1
View File
@@ -15,6 +15,7 @@ type User struct {
Role string `gorm:"size:32;index;not null"` Role string `gorm:"size:32;index;not null"`
Status string `gorm:"size:32;index;not null"` Status string `gorm:"size:32;index;not null"`
Credits float64 `gorm:"not null;default:0"` Credits float64 `gorm:"not null;default:0"`
CreditsUsed float64 `gorm:"not null;default:0"` // 累计已消耗额度:扣费时加、退款时减,充值/发放不计入
Notes string `gorm:"type:text"` Notes string `gorm:"type:text"`
ConcurrencyGroupID string `gorm:"size:32;index"` ConcurrencyGroupID string `gorm:"size:32;index"`
AnnouncementSeen string `gorm:"size:32"` // version hash of the last announcement this user dismissed AnnouncementSeen string `gorm:"size:32"` // version hash of the last announcement this user dismissed
+33 -2
View File
@@ -524,6 +524,35 @@ func (r *UserRepository) AdjustCredits(ctx context.Context, userID string, delta
return r.GetByID(ctx, userID) 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. // 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 // The row is locked for the duration of the transaction so it stays consistent
// with concurrent AdjustCredits/TryDebitCredits operations. // 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{}). if err := tx.Model(&model.User{}).
Where("id = ?", userID). Where("id = ?", userID).
Updates(map[string]any{ Updates(map[string]any{
"credits": nextCredits, "credits": nextCredits,
"updated_at": time.Now(), "credits_used": user.CreditsUsed + amount,
"updated_at": time.Now(),
}).Error; err != nil { }).Error; err != nil {
return err return err
} }
user.Credits = nextCredits user.Credits = nextCredits
user.CreditsUsed += amount
user.UpdatedAt = time.Now() user.UpdatedAt = time.Now()
result = &user result = &user
debited = true debited = true
+1 -1
View File
@@ -239,7 +239,7 @@ func (m *MaintenanceService) tick(ctx context.Context) {
if !claimed { if !claimed {
continue 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) log.Printf("maintenance: refund abandoned event %s (user %s, %.0f): %v", e.ID, e.UserID, e.Cost, err)
} else { } else {
refunded++ refunded++
+16 -1
View File
@@ -391,6 +391,21 @@ func (s *V1Service) ListModels(ctx context.Context) ([]map[string]any, error) {
return out, nil 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) { func (s *V1Service) PrepareImageRequest(ctx context.Context, principal *APIPrincipal, in V1ImageRequest) (map[string]any, error) {
return s.prepareImageExecution(ctx, principal, in, "v1", true) return s.prepareImageExecution(ctx, principal, in, "v1", true)
} }
@@ -3279,7 +3294,7 @@ func (s *V1Service) refundIfNeeded(ctx context.Context, principal *APIPrincipal,
if !claimed { if !claimed {
return nil 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 { if err == nil {
principal.User = updated principal.User = updated
} }
+9
View File
@@ -231,6 +231,14 @@ if s["status"] == "completed":
`curl ${base.value}/v1/models \\ `curl ${base.value}/v1/models \\
-H "Authorization: Bearer ${keyHint.value}"`, -H "Authorization: Bearer ${keyHint.value}"`,
}, },
{
title: '查询余额 · curl',
code:
`curl ${base.value}/v1/user/balance \\
-H "Authorization: Bearer ${keyHint.value}"
# => {"object":"user.balance","balance":12000,"used":680,"total":12680}`,
},
]) ])
// ---- copy + toast ---- // ---- copy + toast ----
@@ -272,6 +280,7 @@ async function copy(text) {
<h2 class="text-sm font-semibold text-white/80">端点</h2> <h2 class="text-sm font-semibold text-white/80">端点</h2>
<ul class="mt-4 space-y-2.5 text-sm font-mono"> <ul class="mt-4 space-y-2.5 text-sm font-mono">
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/models</span></li> <li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/models</span></li>
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/user/balance</span><span class="text-white/35 font-sans text-xs">查余额</span></li>
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/generations</span><span class="text-white/35 font-sans text-xs">文生图</span></li> <li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/generations</span><span class="text-white/35 font-sans text-xs">文生图</span></li>
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/edits</span><span class="text-white/35 font-sans text-xs">图生图(multipart)</span></li> <li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/edits</span><span class="text-white/35 font-sans text-xs">图生图(multipart)</span></li>
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/videos</span><span class="text-white/35 font-sans text-xs">建视频任务</span></li> <li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/videos</span><span class="text-white/35 font-sans text-xs">建视频任务</span></li>