diff --git a/README.en.md b/README.en.md index 579cd22..cb3c74a 100644 --- a/README.en.md +++ b/README.en.md @@ -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 #### 🔌 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 - Image results returned **inline as base64** — nothing stored server-side, privacy-friendly; the in-app **/docs** ships a size ↔ tier reference table diff --git a/README.md b/README.md index 21841e6..977a16e 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ - **去AI特征**(可选):画图台一键开启,生成图片自动做去AI痕迹处理(细节微扰 + 去除元数据),按画质档位加收积分(默认 1K+1 / 2K+2 / 4K+3,后台可改价、可整体关闭);带标记的作品在画图台、创作记录、日志与后台图片管理中均有「去AI特征」标识 #### 🔌 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 - 图片结果 **base64 直返**,服务端不留存文件,隐私友好;站内 **/docs** 附「分辨率对照表」直接查 `size` 该传什么 diff --git a/backend/internal/http/handler/v1.go b/backend/internal/http/handler/v1.go index f6569dc..7593aa2 100644 --- a/backend/internal/http/handler/v1.go +++ b/backend/internal/http/handler/v1.go @@ -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}]}. diff --git a/backend/internal/http/router/router.go b/backend/internal/http/router/router.go index 38bfc16..dc4c87f 100644 --- a/backend/internal/http/router/router.go +++ b/backend/internal/http/router/router.go @@ -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. diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 4197809..9b5aeb5 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -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 diff --git a/backend/internal/repo/user_repo.go b/backend/internal/repo/user_repo.go index cff3027..af396a4 100644 --- a/backend/internal/repo/user_repo.go +++ b/backend/internal/repo/user_repo.go @@ -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 diff --git a/backend/internal/service/maintenance.go b/backend/internal/service/maintenance.go index dd831dd..10c825a 100644 --- a/backend/internal/service/maintenance.go +++ b/backend/internal/service/maintenance.go @@ -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++ diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index 076593a..bb5d29e 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -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 } diff --git a/frontend/src/views/DocsView.vue b/frontend/src/views/DocsView.vue index ffff8e3..11354a6 100644 --- a/frontend/src/views/DocsView.vue +++ b/frontend/src/views/DocsView.vue @@ -231,6 +231,14 @@ if s["status"] == "completed": `curl ${base.value}/v1/models \\ -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 ---- @@ -272,6 +280,7 @@ async function copy(text) {

端点