feat!: 账号池改渠道网关,对外协议换成 vivid /v1
- 新增 channels/channel_models:上游 URL + AES-GCM 加密 key +
并发/权重/本地统计,模型经渠道 /v1/models 导入绑定
- internal/upstream:vivid 协议客户端(NDJSON 流 + jobId 断流重连 +
uploads 预签名 + models + balance + 错误分类)
- service/v1 重写:权重分组调度、Redis 并发槽 conc:c:、故障转移,
站内/测试/对外统一走渠道执行器
- 对外端点换成 vivid 四件套:POST /v1/generate(NDJSON+jobId 重连)、
POST /v1/uploads(SigV4 预签名 PUT)、GET /v1/models、GET /v1/balance,
错误包 {"error":{"code","message"}}
- API key 新发改 vk_+48hex;删除全部内置 provider 与 token 池
- 管理端:渠道 CRUD/拉模型/导入/余额端点,前端账号页换渠道页,
概览/文档/测试弹窗适配
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"backend/internal/service"
|
||||
"backend/internal/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ChannelAdminHandler exposes channel CRUD + upstream model import under
|
||||
// /admin/api/channels*. Replaces the old token/account admin surface.
|
||||
type ChannelAdminHandler struct {
|
||||
channels *service.ChannelService
|
||||
}
|
||||
|
||||
func NewChannelAdminHandler(channels *service.ChannelService) *ChannelAdminHandler {
|
||||
return &ChannelAdminHandler{channels: channels}
|
||||
}
|
||||
|
||||
func (h *ChannelAdminHandler) List(c *gin.Context) {
|
||||
data, err := h.channels.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load channels"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
func (h *ChannelAdminHandler) Save(c *gin.Context) {
|
||||
var body service.SaveChannelDto
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
body.BaseURL = strings.TrimSpace(body.BaseURL)
|
||||
ch, err := h.channels.Save(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": ch.ID})
|
||||
}
|
||||
|
||||
func (h *ChannelAdminHandler) Delete(c *gin.Context) {
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "channel id required"})
|
||||
return
|
||||
}
|
||||
if err := h.channels.Delete(c.Request.Context(), body.ID); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// FetchModels proxies GET /v1/models on a channel (stored creds or ad-hoc
|
||||
// base_url+api_key for a not-yet-saved channel form).
|
||||
func (h *ChannelAdminHandler) FetchModels(c *gin.Context) {
|
||||
var body service.FetchModelsDto
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
items, err := h.channels.FetchModels(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
writeUpstreamErr(c, err)
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []upstream.RemoteModel{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
// ImportModels binds the ticked upstream models to the channel (creating local
|
||||
// model rows on first import).
|
||||
func (h *ChannelAdminHandler) ImportModels(c *gin.Context) {
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
Models []service.ChannelModelSpec `json:"models"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "channel id required"})
|
||||
return
|
||||
}
|
||||
if err := h.channels.ImportModels(c.Request.Context(), body.ID, body.Models); err != nil {
|
||||
writeUpstreamErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// writeUpstreamErr translates upstream-protocol errors into sensible admin
|
||||
// responses (auth → 400 with the upstream's message, temporary → 502).
|
||||
func writeUpstreamErr(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, upstream.ErrAuth):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "上游鉴权失败:" + err.Error()})
|
||||
case errors.Is(err, upstream.ErrInvalid):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
||||
default:
|
||||
c.JSON(http.StatusBadGateway, gin.H{"detail": "上游请求失败:" + err.Error()})
|
||||
}
|
||||
}
|
||||
@@ -1,544 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ProviderAdminHandler struct {
|
||||
tokens *service.TokenService
|
||||
refresh *service.RefreshProfileService
|
||||
}
|
||||
|
||||
func NewProviderAdminHandler(tokens *service.TokenService, refresh *service.RefreshProfileService) *ProviderAdminHandler {
|
||||
return &ProviderAdminHandler{
|
||||
tokens: tokens,
|
||||
refresh: refresh,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokensList(c *gin.Context) {
|
||||
data, err := h.tokens.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load tokens"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokensCreate(c *gin.Context) {
|
||||
var body struct {
|
||||
Pool string `json:"pool"`
|
||||
Value string `json:"value"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.tokens.Add(c.Request.Context(), body.Pool, body.Value, body.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportChatGPTToken(c *gin.Context) {
|
||||
var body struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
token := body.AccessToken
|
||||
if token == "" {
|
||||
token = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportChatGPTToken(c.Request.Context(), token, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportRunwayToken(c *gin.Context) {
|
||||
var body struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
token := body.AccessToken
|
||||
if token == "" {
|
||||
token = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportRunwayToken(c.Request.Context(), token, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportGrokToken(c *gin.Context) {
|
||||
var body struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Value string `json:"value"`
|
||||
SSO string `json:"sso"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
token := body.AccessToken
|
||||
for _, v := range []string{body.Value, body.SSO} {
|
||||
if token == "" {
|
||||
token = v
|
||||
}
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportGrokToken(c.Request.Context(), token, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportCustomAccount(c *gin.Context) {
|
||||
var body struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
URL string `json:"url"`
|
||||
Key string `json:"key"`
|
||||
APIKey string `json:"api_key"`
|
||||
Models string `json:"models"`
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"` // openai (default) | vivid
|
||||
Weight int `json:"weight"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
baseURL := body.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = body.URL
|
||||
}
|
||||
key := body.Key
|
||||
if key == "" {
|
||||
key = body.APIKey
|
||||
}
|
||||
item, err := h.tokens.ImportCustomAccount(c.Request.Context(), baseURL, key, body.Models, body.Name, body.Protocol, body.Weight, body.Concurrency, body.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportKreaCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportKreaCookie(c.Request.Context(), cookie, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportImagineToken(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
cred := body.Cookie
|
||||
if cred == "" {
|
||||
cred = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportImagineToken(c.Request.Context(), cred, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportLeonardoCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportLeonardoCookie(c.Request.Context(), cookie, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportAdobeCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, profile, err := h.tokens.ImportAdobeCookie(c.Request.Context(), cookie, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"profile_id": profile.ID,
|
||||
"id": item.ID,
|
||||
"status": item.Status,
|
||||
"pending": item.Status == "pending",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportCreativeFabricaCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportCreativeFabricaCookie(c.Request.Context(), cookie, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"id": item.ID,
|
||||
"status": item.Status,
|
||||
"pending": item.Status == "pending",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokenUpdate(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.tokens.Update(c.Request.Context(), c.Param("pool"), c.Param("id"), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokenDelete(c *gin.Context) {
|
||||
if err := h.tokens.Delete(c.Request.Context(), c.Param("pool"), c.Param("id")); err != nil {
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "token not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// TokenDeleteBulk removes multiple accounts in one call (account multi-select).
|
||||
func (h *ProviderAdminHandler) TokenDeleteBulk(c *gin.Context) {
|
||||
var body struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if len(body.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "未选择任何账号"})
|
||||
return
|
||||
}
|
||||
n, err := h.tokens.DeleteBulk(c.Request.Context(), body.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "deleted": n})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) AccountsList(c *gin.Context) {
|
||||
data, err := h.tokens.Accounts(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load accounts"})
|
||||
return
|
||||
}
|
||||
|
||||
// KPI stats are computed over the FULL set (每个类型的 成功/失败/限额), independent
|
||||
// of the current filter/page — mirrors the old client-side `stats` computed.
|
||||
stats := accountsStats(data)
|
||||
|
||||
// Server-side filtering (类型 / 状态 / 搜索 邮箱·ID·类型 / dead) so pagination is
|
||||
// correct across pages. ?dead=1 returns only 异常(已失效) accounts — used by
|
||||
// 「删除异常账号」to collect every dead id regardless of the current page.
|
||||
typeFilter := strings.TrimSpace(c.Query("type"))
|
||||
statusFilter := strings.TrimSpace(c.Query("status"))
|
||||
planFilter := strings.TrimSpace(c.Query("plan"))
|
||||
deadOnly := c.Query("dead") == "1"
|
||||
q := strings.ToLower(strings.TrimSpace(c.Query("q")))
|
||||
filtered := make([]map[string]any, 0, len(data))
|
||||
for _, row := range data {
|
||||
if deadOnly && !rowBool(row, "dead") {
|
||||
continue
|
||||
}
|
||||
if typeFilter != "" && rowStr(row, "type") != typeFilter {
|
||||
continue
|
||||
}
|
||||
if statusFilter != "" && rowStr(row, "status") != statusFilter {
|
||||
continue
|
||||
}
|
||||
// plan filter: vip=非free, free=free, sub=子号, master=vip且非子号
|
||||
if planFilter != "" {
|
||||
plan := strings.ToLower(rowStr(row, "plan"))
|
||||
isSub := rowBool(row, "sub_account")
|
||||
isFree := plan == "" || plan == "free"
|
||||
switch planFilter {
|
||||
case "vip":
|
||||
if isFree {
|
||||
continue
|
||||
}
|
||||
case "free":
|
||||
if !isFree {
|
||||
continue
|
||||
}
|
||||
case "sub":
|
||||
if !isSub {
|
||||
continue
|
||||
}
|
||||
case "master":
|
||||
if isFree || isSub {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if q != "" {
|
||||
email := strings.ToLower(rowStr(row, "email"))
|
||||
id := strings.ToLower(rowStr(row, "id"))
|
||||
typ := strings.ToLower(rowStr(row, "type"))
|
||||
if !strings.Contains(email, q) && !strings.Contains(id, q) && !strings.Contains(typ, q) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
|
||||
total := len(filtered)
|
||||
limit, offset := pageParams(c, 20)
|
||||
page := pageSlice(filtered, limit, offset)
|
||||
c.JSON(http.StatusOK, gin.H{"data": page, "total": total, "limit": limit, "offset": offset, "stats": stats})
|
||||
}
|
||||
|
||||
// accountsStats reproduces the 账号 KPI strip: per-type 正常/失效/限额 counts plus a
|
||||
// grand total and total dead count (drives 「删除异常账号 (N)」).
|
||||
func accountsStats(rows []map[string]any) gin.H {
|
||||
types := []string{"openai", "adobe", "runway", "leonardo", "krea", "imagine", "grok", "creativefabrica"}
|
||||
by := map[string]*struct{ N, Ok, Dead, Quota int }{}
|
||||
for _, t := range types {
|
||||
by[t] = &struct{ N, Ok, Dead, Quota int }{}
|
||||
}
|
||||
deadTotal := 0
|
||||
for _, row := range rows {
|
||||
dead := rowBool(row, "dead")
|
||||
if dead {
|
||||
deadTotal++
|
||||
}
|
||||
g, ok := by[rowStr(row, "type")]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
g.N++
|
||||
status := rowStr(row, "status")
|
||||
switch {
|
||||
case status == "active":
|
||||
g.Ok++
|
||||
case dead || status == "disabled":
|
||||
g.Dead++
|
||||
case status == "quota":
|
||||
g.Quota++
|
||||
}
|
||||
}
|
||||
out := gin.H{"total": len(rows), "dead_total": deadTotal}
|
||||
for _, t := range types {
|
||||
g := by[t]
|
||||
out[t] = gin.H{"n": g.N, "ok": g.Ok, "dead": g.Dead, "quota": g.Quota}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rowStr(m map[string]any, key string) string {
|
||||
s, _ := m[key].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func rowBool(m map[string]any, key string) bool {
|
||||
b, _ := m[key].(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) AccountQuota(c *gin.Context) {
|
||||
data, err := h.tokens.Quota(c.Request.Context(), c.Param("pool"), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "account not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) AccountEmail(c *gin.Context) {
|
||||
data, err := h.tokens.Email(c.Request.Context(), c.Param("pool"), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "account not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshProfiles(c *gin.Context) {
|
||||
items, err := h.refresh.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load refresh profiles"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshNow(c *gin.Context) {
|
||||
if err := h.refresh.RefreshNow(c.Request.Context(), c.Param("profile_id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshUpdate(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.refresh.Update(c.Request.Context(), c.Param("profile_id"), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshDelete(c *gin.Context) {
|
||||
if err := h.refresh.Delete(c.Request.Context(), c.Param("profile_id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
@@ -13,6 +14,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/repo"
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "golang.org/x/image/webp"
|
||||
@@ -248,7 +251,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
|
||||
Resolution string `json:"resolution"`
|
||||
Duration string `json:"duration"`
|
||||
ReferenceImages []string `json:"reference_images"`
|
||||
AccountID string `json:"account_id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
@@ -262,7 +265,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
|
||||
Resolution: body.Resolution,
|
||||
Duration: body.Duration,
|
||||
ReferenceImages: body.ReferenceImages,
|
||||
AccountID: body.AccountID,
|
||||
ChannelID: body.ChannelID,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
@@ -451,161 +454,19 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": []gin.H{
|
||||
{
|
||||
"key": "gemini-veo31",
|
||||
"label": "Veo31 Fast",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
"key": "gemini-veo3.1-fast",
|
||||
"label": "Veo 3.1 Fast",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
"key": "gemini-veo3.1",
|
||||
"label": "Veo 3.1",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
},
|
||||
{
|
||||
"key": "adobe-seedance-2.0-fast",
|
||||
"label": "Seedance 2.0 Fast",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "adobe-seedance-2.0",
|
||||
"label": "Seedance 2.0",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "seedance-2.0-fast",
|
||||
"label": "Seedance 2.0 Fast (Creative Fabrica)",
|
||||
"type": "video",
|
||||
"provider": "creativefabrica",
|
||||
"durations": []string{"14s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
// Creative Fabrica 上游只有普通参考图(VIDEO_FRAME_TYPE_REFERENCE),
|
||||
// 没有首尾帧,也不收视频/音频参考。
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "asset",
|
||||
"max_videos": 0,
|
||||
"max_audios": 0,
|
||||
},
|
||||
{
|
||||
"key": "seedance-2.0",
|
||||
"label": "Seedance 2.0 (Creative Fabrica)",
|
||||
"type": "video",
|
||||
"provider": "creativefabrica",
|
||||
"durations": []string{"10s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
// Creative Fabrica 上游只有普通参考图(VIDEO_FRAME_TYPE_REFERENCE),
|
||||
// 没有首尾帧,也不收视频/音频参考。
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "asset",
|
||||
"max_videos": 0,
|
||||
"max_audios": 0,
|
||||
},
|
||||
{
|
||||
"key": "seedance-2.0-不卡人脸",
|
||||
"label": "Seedance 2.0 (Leonardo 私有)",
|
||||
"type": "video",
|
||||
"provider": "leonardo",
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"max_reference_images": 8,
|
||||
"max_images": 4,
|
||||
"max_videos": 3,
|
||||
"max_audios": 1,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "seedance-2.0-fast-不卡人脸",
|
||||
"label": "Seedance 2.0 Fast (Leonardo 私有)",
|
||||
"type": "video",
|
||||
"provider": "leonardo",
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"max_reference_images": 8,
|
||||
"max_images": 4,
|
||||
"max_videos": 3,
|
||||
"max_audios": 1,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "minimax-h3",
|
||||
"label": "MiniMax H3 (Leonardo 私有)",
|
||||
"type": "video",
|
||||
"provider": "leonardo",
|
||||
"durations": []string{"5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"1440p"},
|
||||
"max_reference_images": 8,
|
||||
"max_images": 5,
|
||||
"max_videos": 0,
|
||||
"max_audios": 3,
|
||||
"max_audio_seconds": 15,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "firefly-video",
|
||||
"label": "Firefly Video",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"5s"},
|
||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||
"resolutions": []string{"540p", "720p", "1080p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
"key": "runway-gen4-turbo",
|
||||
"label": "Runway Gen-4 Turbo",
|
||||
"type": "video",
|
||||
"provider": "runway",
|
||||
"durations": []string{"5s", "10s"},
|
||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
||||
"resolutions": []string{"2K"},
|
||||
"max_reference_images": 1,
|
||||
"reference_mode": "frame",
|
||||
// Runway is strictly image-to-video — a first-frame image is required
|
||||
// (no text2video), so the UI must block submit without one.
|
||||
"requires_reference": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
models, err := h.admin.Models(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load video presets"})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(models))
|
||||
for _, m := range models {
|
||||
if m.Type != "video" || !m.Enabled {
|
||||
continue
|
||||
}
|
||||
out = append(out, modelPreset(m))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) Catalog(c *gin.Context) {
|
||||
@@ -620,7 +481,7 @@ func (h *UserGenerationHandler) Catalog(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) Models(c *gin.Context) {
|
||||
items, err := h.publicModels()
|
||||
items, err := h.publicModels(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
||||
return
|
||||
@@ -628,537 +489,85 @@ func (h *UserGenerationHandler) Models(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
// modelPreset projects one model_configs row into the preset shape the studio
|
||||
// (画图台) / model pickers consume. Database-driven now: providers are gone and
|
||||
// every model arrives via a channel import.
|
||||
func modelPreset(m model.ModelConfig) gin.H {
|
||||
durations := repo.JSONStrings(m.Durations)
|
||||
if len(durations) == 0 && m.DurationMin > 0 && m.DurationMax >= m.DurationMin {
|
||||
// Range spec → expand integer seconds (capped so absurd ranges can't
|
||||
// flood a dropdown).
|
||||
for d := m.DurationMin; d <= m.DurationMax && len(durations) < 61; d++ {
|
||||
durations = append(durations, fmt.Sprintf("%ds", d))
|
||||
}
|
||||
}
|
||||
_, perSecond := m.DurationPrices["per_second"]
|
||||
return gin.H{
|
||||
"key": m.EffectiveName(),
|
||||
"id": m.ID,
|
||||
"label": m.EffectiveName(),
|
||||
"type": m.Type,
|
||||
"provider": m.Provider,
|
||||
"ratios": repo.JSONStrings(m.Ratios),
|
||||
"resolutions": repo.JSONStrings(m.Resolutions),
|
||||
"durations": durations,
|
||||
"duration_min": m.DurationMin,
|
||||
"duration_max": m.DurationMax,
|
||||
"per_second": perSecond,
|
||||
"max_reference_images": m.MaxReferenceImages,
|
||||
"max_images": m.MaxReferenceImages,
|
||||
"max_videos": m.MaxReferenceVideos,
|
||||
"max_audios": m.MaxReferenceAudios,
|
||||
"max_reference_videos": m.MaxReferenceVideos,
|
||||
"max_reference_audios": m.MaxReferenceAudios,
|
||||
"max_video_seconds": m.MaxRefVideoSeconds,
|
||||
"max_audio_seconds": m.MaxRefAudioSeconds,
|
||||
"reference_mode": m.ReferenceMode,
|
||||
"image_to_image": m.ImageToImage,
|
||||
"description": m.Name,
|
||||
"added": true,
|
||||
}
|
||||
}
|
||||
|
||||
// catalogEntries lists the managed models (previously a static picklist of
|
||||
// offerable provider models). With channel imports the DB IS the catalog —
|
||||
// rows are already "added", so the admin picklist degrades gracefully.
|
||||
func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) {
|
||||
items := []gin.H{
|
||||
{
|
||||
"id": "gpt-image-2",
|
||||
"provider": "chatgpt",
|
||||
"type": "image",
|
||||
// ChatGPT web backend only reliably produces 1K and honors a limited
|
||||
// ratio set; size params are advisory prompt hints. Mirrors the Python
|
||||
// reference (providers/chatgpt/provider.py) — do not offer 2K/4K.
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"description": "ChatGPT image generation",
|
||||
},
|
||||
{
|
||||
"id": "firefly-gpt-image-2",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "5:4", "9:16", "21:9", "16:9", "4:3", "3:2", "4:5", "3:4", "2:3"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"description": "Adobe Firefly GPT Image",
|
||||
},
|
||||
{
|
||||
"id": "firefly-image-5",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"image_to_image": true,
|
||||
"description": "Adobe Firefly Image 5",
|
||||
},
|
||||
{
|
||||
"id": "runway-nano-banana-2",
|
||||
"provider": "runway",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"reference_mode": "asset",
|
||||
"description": "Runway Nano Banana 2 (图/参考图)",
|
||||
},
|
||||
{
|
||||
"id": "runway-nano-banana-pro",
|
||||
"provider": "runway",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"reference_mode": "asset",
|
||||
"description": "Runway Nano Banana Pro (图/参考图)",
|
||||
},
|
||||
{
|
||||
"id": "nano-banana-2",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"reference_mode": "asset",
|
||||
"description": "Nano Banana 2 (图/参考图)",
|
||||
},
|
||||
{
|
||||
"id": "nano-banana-pro",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"reference_mode": "asset",
|
||||
"description": "Nano Banana Pro (图/参考图)",
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1-fast",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"max_reference_images": 3,
|
||||
"reference_mode": "frame",
|
||||
"description": "Veo 3.1 Fast",
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"max_reference_images": 3,
|
||||
"reference_mode": "style",
|
||||
"description": "Veo 3.1",
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"5s", "10s"},
|
||||
"max_reference_images": 2,
|
||||
"reference_mode": "frame",
|
||||
"description": "Luma Ray video",
|
||||
},
|
||||
{
|
||||
"id": "firefly-video",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"5s"},
|
||||
"max_reference_images": 2,
|
||||
"reference_mode": "frame",
|
||||
"description": "Adobe Firefly Video",
|
||||
},
|
||||
{
|
||||
"id": "adobe-seedance-2.0-fast",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "style",
|
||||
"description": "Seedance 2.0 Fast",
|
||||
},
|
||||
{
|
||||
"id": "adobe-seedance-2.0",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "style",
|
||||
"description": "Seedance 2.0",
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-fast",
|
||||
"provider": "creativefabrica",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
// 一次性账号:积分刚好够一次生成,时长固定 14 秒。
|
||||
"durations": []string{"14s"},
|
||||
// Creative Fabrica 上游只有普通参考图,没有首尾帧,也不收视频/音频参考。
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "asset",
|
||||
"description": "Seedance 2.0 Fast (Creative Fabrica)",
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0",
|
||||
"provider": "creativefabrica",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
// 一次性账号:积分刚好够一次生成,时长固定 10 秒。
|
||||
"durations": []string{"10s"},
|
||||
// Creative Fabrica 上游只有普通参考图,没有首尾帧,也不收视频/音频参考。
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "asset",
|
||||
"description": "Seedance 2.0 (Creative Fabrica)",
|
||||
},
|
||||
{
|
||||
"id": "runway-gen4-turbo",
|
||||
"provider": "runway",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
||||
"resolutions": []string{"720p"},
|
||||
"durations": []string{"5s", "10s"},
|
||||
"max_reference_images": 1,
|
||||
"reference_mode": "frame",
|
||||
"description": "Runway Gen-4 Turbo video (图生视频)",
|
||||
},
|
||||
{
|
||||
"id": "grok-video",
|
||||
"provider": "grok",
|
||||
"type": "video",
|
||||
"ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"},
|
||||
"resolutions": []string{"720p"},
|
||||
// Console 视频吃 1–15 秒的整数时长,所以按秒计价(同 seedance);
|
||||
// 参考图只有 1 张首帧(上游只有 image 字段,没有尾帧)。
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
"max_reference_images": 1,
|
||||
"reference_mode": "frame",
|
||||
"description": "Grok Imagine video (文/图生视频)",
|
||||
},
|
||||
{
|
||||
"id": "grok-image",
|
||||
"provider": "grok",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 3,
|
||||
"reference_mode": "asset",
|
||||
"description": "Grok Imagine image (文生图 / 图生图)",
|
||||
},
|
||||
{
|
||||
"id": "seedream-4.5",
|
||||
"provider": "leonardo",
|
||||
"type": "image",
|
||||
"ratios": []string{"2:3", "1:1", "16:9", "4:3", "4:5", "9:16", "2:1"},
|
||||
"resolutions": []string{"2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"description": "Leonardo Seedream 4.5 (生图 / 图生图)",
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-不卡人脸",
|
||||
"provider": "leonardo",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
// 参考资产总上限 8 = 4 图 + 1 音频 + 3 视频(分类上限在服务端校验)。
|
||||
"max_reference_images": 8,
|
||||
"reference_mode": "style",
|
||||
"description": "Leonardo Seedance 2.0 (私有生成 / 图音视频参考)",
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-fast-不卡人脸",
|
||||
"provider": "leonardo",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
"max_reference_images": 8,
|
||||
"reference_mode": "style",
|
||||
"description": "Leonardo Seedance 2.0 Fast (私有生成 / 图音视频参考)",
|
||||
},
|
||||
{
|
||||
"id": "minimax-h3",
|
||||
"provider": "leonardo",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"1440p"},
|
||||
// 上游吃 5–15 秒整数时长,所以按秒计价(后台只填一个 /s 价)。
|
||||
"durations": []string{"5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
// 参考资产总上限 8 = 5 图 + 3 音频(分类上限在服务端校验)。
|
||||
"max_reference_images": 8,
|
||||
"reference_mode": "style",
|
||||
"description": "Leonardo MiniMax H3 / hailuo-03 (1440p / 图音参考)",
|
||||
},
|
||||
{
|
||||
"id": "flux-klein-2",
|
||||
"provider": "krea",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "4:3", "3:4", "16:9", "9:16"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 4,
|
||||
"description": "Krea Flux Klein (生图 / 图生图)",
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5",
|
||||
"provider": "imagine",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"2K"},
|
||||
"max_reference_images": 0,
|
||||
"description": "Imagine 1.5 (文生图)",
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5pro",
|
||||
"provider": "imagine",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"4K"},
|
||||
"max_reference_images": 0,
|
||||
"description": "Imagine 1.5 Pro (文生图)",
|
||||
},
|
||||
models, err := h.admin.Models(c.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing := map[string]bool{}
|
||||
if h.admin != nil {
|
||||
models, err := h.admin.Models(c.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range models {
|
||||
existing[item.ID] = true
|
||||
}
|
||||
}
|
||||
for i := range items {
|
||||
items[i]["added"] = existing[items[i]["id"].(string)]
|
||||
items := make([]gin.H, 0, len(models))
|
||||
for _, m := range models {
|
||||
row := modelPreset(m)
|
||||
row["id"] = m.EffectiveName()
|
||||
items = append(items, row)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
||||
items := []gin.H{
|
||||
{
|
||||
"id": "gpt-image-2",
|
||||
"provider": "chatgpt",
|
||||
"kind": "image",
|
||||
// See catalogEntries — ChatGPT only reliably does 1K and a limited
|
||||
// ratio set; matches the Python reference. Keep both lists in sync.
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K"},
|
||||
"description": "ChatGPT image generation",
|
||||
// publicModels — GET /api/models for the public site (docs/landing pickers).
|
||||
// Enabled models only, in the same shape as before minus the provider id.
|
||||
func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) {
|
||||
models, err := h.admin.Models(c.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]gin.H, 0, len(models))
|
||||
for _, m := range models {
|
||||
if !m.Enabled {
|
||||
continue
|
||||
}
|
||||
items = append(items, gin.H{
|
||||
"id": m.EffectiveName(),
|
||||
"kind": m.Type,
|
||||
"type": m.Type,
|
||||
"ratios": repo.JSONStrings(m.Ratios),
|
||||
"resolutions": repo.JSONStrings(m.Resolutions),
|
||||
"durations": repo.JSONStrings(m.Durations),
|
||||
"description": m.Name,
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-gpt-image-2",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "5:4", "9:16", "21:9", "16:9", "4:3", "3:2", "4:5", "3:4", "2:3"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Adobe Firefly GPT Image",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-image-5",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"description": "Adobe Firefly Image 5",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "runway-nano-banana-2",
|
||||
"provider": "runway",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Runway Nano Banana 2",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "runway-nano-banana-pro",
|
||||
"provider": "runway",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Runway Nano Banana Pro",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "nano-banana-2",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Nano Banana 2",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "nano-banana-pro",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Nano Banana Pro",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1-fast",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"description": "Veo 3.1 Fast",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Veo 3.1",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-ray",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Luma Ray video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-video",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"description": "Adobe Firefly Video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "adobe-seedance-2.0-fast",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Seedance 2.0 Fast",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "adobe-seedance-2.0",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"description": "Seedance 2.0",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-fast",
|
||||
"provider": "creativefabrica",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Seedance 2.0 Fast (Creative Fabrica)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0",
|
||||
"provider": "creativefabrica",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Seedance 2.0 (Creative Fabrica)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "runway-gen4-turbo",
|
||||
"provider": "runway",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Runway Gen-4 Turbo video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "grok-video",
|
||||
"provider": "grok",
|
||||
"kind": "video",
|
||||
"ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Grok Imagine video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "grok-image",
|
||||
"provider": "grok",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"description": "Grok Imagine image",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedream-4.5",
|
||||
"provider": "leonardo",
|
||||
"kind": "image",
|
||||
"ratios": []string{"2:3", "1:1", "16:9", "4:3", "4:5", "9:16", "2:1"},
|
||||
"resolutions": []string{"2K", "4K"},
|
||||
"description": "Leonardo Seedream 4.5",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-不卡人脸",
|
||||
"provider": "leonardo",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Leonardo Seedance 2.0 (私有生成)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-fast-不卡人脸",
|
||||
"provider": "leonardo",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Leonardo Seedance 2.0 Fast (私有生成)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "minimax-h3",
|
||||
"provider": "leonardo",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"1440p"},
|
||||
"description": "Leonardo MiniMax H3 (1440p)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "flux-klein-2",
|
||||
"provider": "krea",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "4:3", "3:4", "16:9", "9:16"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"description": "Krea Flux Klein",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5",
|
||||
"provider": "imagine",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"2K"},
|
||||
"description": "Imagine 1.5",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5pro",
|
||||
"provider": "imagine",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"4K"},
|
||||
"description": "Imagine 1.5 Pro",
|
||||
"stub": false,
|
||||
},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
+174
-332
@@ -1,19 +1,33 @@
|
||||
// Package handler — /v1/* 消费者 HTTP 接口(vivid 协议同款)。
|
||||
// 这套接口同时是渠道上游契约:同构实例经相同的四个端点互相对接。
|
||||
//
|
||||
// POST /v1/generate 创建生成任务(NDJSON 流:首帧 jobId → 终态帧;断流携 jobId 重发续等)
|
||||
// POST /v1/uploads 参考素材预签名直传(返回 key 随 generate 的 refs 提交)
|
||||
// GET /v1/models 可售模型裸数组(启用 + 有渠道绑定 + 全部档位已配价)
|
||||
// GET /v1/balance {balance}:余额积分
|
||||
//
|
||||
// 鉴权:Authorization: Bearer vk_...(用户 API Key,哈希落库)。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// v1MaxWait 同步等待终态的上限。视频单渠道预算 30 分钟、最多换几个渠道,
|
||||
// 留足余量取 2 小时(与 vivid 一致)。
|
||||
const v1MaxWait = 2 * time.Hour
|
||||
|
||||
const v1PollInterval = time.Second
|
||||
|
||||
type V1Handler struct {
|
||||
v1 *service.V1Service
|
||||
}
|
||||
@@ -22,385 +36,213 @@ func NewV1Handler(v1 *service.V1Service) *V1Handler {
|
||||
return &V1Handler{v1: v1}
|
||||
}
|
||||
|
||||
func (h *V1Handler) Models(c *gin.Context) {
|
||||
func (h *V1Handler) auth(c *gin.Context) (*service.APIPrincipal, bool) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
v1Err(c, http.StatusUnauthorized, "4011", "无效的 API Key")
|
||||
return nil, false
|
||||
}
|
||||
_ = principal
|
||||
return principal, true
|
||||
}
|
||||
|
||||
items, err := h.v1.ListModels(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": items,
|
||||
// v1Err writes the {"error":{"code","message"}} envelope — the wire shape every
|
||||
// vivid-protocol caller parses.
|
||||
func v1Err(c *gin.Context, httpStatus int, code, msg string) {
|
||||
c.JSON(httpStatus, gin.H{
|
||||
"error": gin.H{"code": code, "message": msg},
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// v1Fail maps a service error to the vivid code/HTTP status pair.
|
||||
func v1Fail(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrMissingAPIKey), errors.Is(err, service.ErrInvalidAPIKey):
|
||||
v1Err(c, http.StatusUnauthorized, "4011", "无效的 API Key")
|
||||
case errors.Is(err, service.ErrInsufficientFunds):
|
||||
v1Err(c, http.StatusBadRequest, "4002", "积分不足,请先充值")
|
||||
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
|
||||
v1Err(c, http.StatusTooManyRequests, "4293", "当前并发已满,请稍后重试")
|
||||
case errors.Is(err, service.ErrVideoJobNotFound):
|
||||
v1Err(c, http.StatusBadRequest, "4001", "任务不存在")
|
||||
case errors.Is(err, service.ErrReferenceTooLarge):
|
||||
v1Err(c, http.StatusRequestEntityTooLarge, "4001", "参考素材超过大小上限")
|
||||
case errors.Is(err, service.ErrUnknownModel):
|
||||
v1Err(c, http.StatusBadRequest, "4001", "模型不存在或未启用")
|
||||
case errors.Is(err, service.ErrNoProviderAccount):
|
||||
v1Err(c, http.StatusBadRequest, "4001", "模型无可用渠道")
|
||||
case errors.Is(err, service.ErrBannedPrompt):
|
||||
v1Err(c, http.StatusBadRequest, "4001", "提示词包含违禁内容")
|
||||
case errors.Is(err, service.ErrUnsupportedParams):
|
||||
v1Err(c, http.StatusBadRequest, "4001", err.Error())
|
||||
case errors.Is(err, service.ErrProviderTemporary), errors.Is(err, service.ErrProviderExecution),
|
||||
errors.Is(err, service.ErrProviderAuth):
|
||||
v1Err(c, http.StatusInternalServerError, "5001", "生成暂时不可用,积分已退还")
|
||||
default:
|
||||
v1Err(c, http.StatusBadRequest, "4001", err.Error())
|
||||
}
|
||||
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}]}.
|
||||
func (h *V1Handler) ImageGenerations(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
// ---- POST /v1/generate ----
|
||||
|
||||
func (h *V1Handler) Generate(c *gin.Context) {
|
||||
principal, ok := h.auth(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
N int `json:"n"`
|
||||
Size string `json:"size"`
|
||||
Quality string `json:"quality"`
|
||||
ResponseFormat string `json:"response_format"`
|
||||
Background string `json:"background"`
|
||||
OutputFormat string `json:"output_format"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
var body service.V1JobRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
v1Err(c, http.StatusBadRequest, "4001", "请求体不是合法 JSON")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.v1.PrepareImageRequest(c.Request.Context(), principal, service.V1ImageRequest{
|
||||
Model: body.Model,
|
||||
Prompt: body.Prompt,
|
||||
N: body.N,
|
||||
Size: body.Size,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, resp)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, openaiImageResponse(resp))
|
||||
}
|
||||
|
||||
// ImageEdits — OpenAI POST /v1/images/edits (image-to-image). multipart/form-data
|
||||
// only: image / image[] file uploads (+ optional mask), prompt, model, n, size,
|
||||
// quality. Files become reference images. Returns {created, data:[{b64_json}]}.
|
||||
func (h *V1Handler) ImageEdits(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "images/edits requires multipart/form-data"})
|
||||
return
|
||||
}
|
||||
if err := c.Request.ParseMultipartForm(64 << 20); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"})
|
||||
return
|
||||
}
|
||||
refs := readMultipartImages(c, "image", "image[]")
|
||||
if len(refs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "images/edits requires at least one image file"})
|
||||
return
|
||||
}
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("n")))
|
||||
resp, err := h.v1.PrepareImageRequest(c.Request.Context(), principal, service.V1ImageRequest{
|
||||
Model: c.PostForm("model"),
|
||||
Prompt: c.PostForm("prompt"),
|
||||
N: n,
|
||||
Size: c.PostForm("size"),
|
||||
ReferenceImages: refs,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, resp)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, openaiImageResponse(resp))
|
||||
}
|
||||
|
||||
// CreateVideo — OpenAI POST /v1/videos. Creates an async job and returns the
|
||||
// video object immediately ({id, status:"queued"}). Accepts JSON {model, prompt,
|
||||
// seconds, size} or multipart (with an input_reference file). size→ratio+
|
||||
// resolution, seconds→duration.
|
||||
func (h *V1Handler) CreateVideo(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
var modelID, prompt, seconds, size, referenceMode string
|
||||
var refs []string
|
||||
if strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
|
||||
if err := c.Request.ParseMultipartForm(256 << 20); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"})
|
||||
return
|
||||
}
|
||||
modelID = c.PostForm("model")
|
||||
prompt = c.PostForm("prompt")
|
||||
seconds = c.PostForm("seconds")
|
||||
size = c.PostForm("size")
|
||||
referenceMode = c.PostForm("reference_mode")
|
||||
refs = readMultipartImages(c, "input_reference", "input_reference[]")
|
||||
var ev *model.EventLog
|
||||
var err error
|
||||
// 重连模式:只带 jobId → 不建新任务,回读该任务并等待终态。
|
||||
if body.JobID != nil && strings.TrimSpace(*body.JobID) != "" {
|
||||
ev, err = h.v1.JobForUser(c.Request.Context(), principal, *body.JobID)
|
||||
} else {
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Seconds json.RawMessage `json:"seconds"`
|
||||
Size string `json:"size"`
|
||||
ReferenceMode string `json:"reference_mode"`
|
||||
InputReference []string `json:"input_reference"`
|
||||
ReferenceImages []string `json:"reference_images"`
|
||||
ev, err = h.v1.CreateJob(c.Request.Context(), principal, body)
|
||||
}
|
||||
if err != nil {
|
||||
v1Fail(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
wait := 300 * time.Second
|
||||
if h := strings.TrimSpace(c.GetHeader("X-Wait-Seconds")); h != "" {
|
||||
if sec, serr := strconv.Atoi(h); serr == nil && sec > 0 && time.Duration(sec)*time.Second <= v1MaxWait {
|
||||
wait = time.Duration(sec) * time.Second
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "application/x-ndjson")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
|
||||
writeFrame := func(v *model.EventLog) bool {
|
||||
frame, _ := json.Marshal(v1JobBody(h.v1, v))
|
||||
if _, werr := c.Writer.Write(append(frame, '\n')); werr != nil {
|
||||
return false
|
||||
}
|
||||
modelID, prompt, size = body.Model, body.Prompt, body.Size
|
||||
seconds = rawToString(body.Seconds)
|
||||
referenceMode = strings.TrimSpace(body.ReferenceMode)
|
||||
refs = append(body.InputReference, body.ReferenceImages...)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return true
|
||||
}
|
||||
duration := strings.TrimSpace(seconds)
|
||||
if duration != "" && !strings.HasSuffix(duration, "s") {
|
||||
duration += "s"
|
||||
}
|
||||
aspect, resolution := videoSizeToInternal(size)
|
||||
resp, err := h.v1.StartVideoJob(c.Request.Context(), principal, service.V1VideoRequest{
|
||||
Model: modelID,
|
||||
Prompt: prompt,
|
||||
Duration: duration,
|
||||
AspectRatio: aspect,
|
||||
Resolution: resolution,
|
||||
ReferenceImages: refs,
|
||||
ReferenceMode: referenceMode,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetVideo — OpenAI GET /v1/videos/{id}. Returns the job status object.
|
||||
func (h *V1Handler) GetVideo(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
// 已终态:直接回终态帧关流。
|
||||
if st := service.JobStatus(ev); st == "succeeded" || st == "failed" {
|
||||
writeFrame(ev)
|
||||
return
|
||||
}
|
||||
resp, err := h.v1.VideoJob(c.Request.Context(), principal, c.Param("id"))
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
// 连上即回执 jobId(断流后携它重发的凭据)。
|
||||
if !writeFrame(ev) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetVideoContent — OpenAI GET /v1/videos/{id}/content. Streams the rendered mp4
|
||||
// by proxying the stored upstream URL (downloaded on demand, never persisted).
|
||||
func (h *V1Handler) GetVideoContent(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
body, contentType, err := h.v1.OpenVideoContent(c.Request.Context(), principal, c.Param("id"))
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = io.Copy(c.Writer, body)
|
||||
}
|
||||
|
||||
// GetImageContent — GET /v1/images/{id}/content. Streams a no-store image by
|
||||
// proxying its stored (possibly auth-gated) upstream URL. Never persisted.
|
||||
func (h *V1Handler) GetImageContent(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
body, contentType, err := h.v1.OpenImageContent(c.Request.Context(), principal, c.Param("id"))
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = io.Copy(c.Writer, body)
|
||||
}
|
||||
|
||||
// readMultipartImages reads the given file fields and returns each as base64.
|
||||
func readMultipartImages(c *gin.Context, keys ...string) []string {
|
||||
var out []string
|
||||
form := c.Request.MultipartForm
|
||||
if form == nil {
|
||||
return out
|
||||
}
|
||||
for _, key := range keys {
|
||||
for _, fh := range form.File[key] {
|
||||
f, e := fh.Open()
|
||||
if e != nil {
|
||||
deadline := time.Now().Add(wait)
|
||||
poll := time.NewTicker(v1PollInterval)
|
||||
heartbeat := time.NewTicker(25 * time.Second)
|
||||
defer poll.Stop()
|
||||
defer heartbeat.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return // client gone — the job keeps running; caller may reconnect with jobId
|
||||
case <-poll.C:
|
||||
latest, lerr := h.v1.JobForUser(c.Request.Context(), principal, ev.ID)
|
||||
if lerr != nil || latest == nil {
|
||||
continue
|
||||
}
|
||||
b, _ := io.ReadAll(io.LimitReader(f, 20<<20+1))
|
||||
f.Close()
|
||||
if len(b) > 0 {
|
||||
out = append(out, base64.StdEncoding.EncodeToString(b))
|
||||
ev = latest
|
||||
if st := service.JobStatus(ev); st == "succeeded" || st == "failed" {
|
||||
writeFrame(ev)
|
||||
return
|
||||
}
|
||||
case <-heartbeat.C:
|
||||
if _, werr := c.Writer.WriteString("\n"); werr != nil {
|
||||
return
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
case <-time.After(time.Until(deadline)):
|
||||
writeFrame(ev) // snapshot; client reconnects with jobId
|
||||
return
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rawToString accepts OpenAI's `seconds` whether sent as a JSON string or number.
|
||||
func rawToString(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
// v1JobBody assembles one NDJSON frame (running receipt and terminal frame
|
||||
// share the shape; succeeded adds result+creditsCharged, failed adds error).
|
||||
func v1JobBody(v1 *service.V1Service, ev *model.EventLog) map[string]any {
|
||||
body := map[string]any{"jobId": ev.ID, "status": service.JobStatus(ev)}
|
||||
switch service.JobStatus(ev) {
|
||||
case "succeeded":
|
||||
body["result"] = v1.JobResultURL(ev)
|
||||
body["creditsCharged"] = ev.Cost
|
||||
case "failed":
|
||||
body["error"] = ev.Error
|
||||
body["refunded"] = true
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return s
|
||||
}
|
||||
var n json.Number
|
||||
if json.Unmarshal(raw, &n) == nil {
|
||||
return n.String()
|
||||
}
|
||||
return strings.Trim(string(raw), `"`)
|
||||
return body
|
||||
}
|
||||
|
||||
// videoSizeToInternal maps OpenAI's "WxH" size to our aspect ratio + resolution
|
||||
// tier. An absent/unparsable size leaves the resolution empty so the caller can
|
||||
// fall back to whatever tier the model actually prices — hardcoding 720p here
|
||||
// rejects models that only offer 1440p.
|
||||
func videoSizeToInternal(size string) (ratio, resolution string) {
|
||||
var w, h int
|
||||
if s := strings.TrimSpace(strings.ToLower(size)); s != "" {
|
||||
_, _ = fmt.Sscanf(s, "%dx%d", &w, &h)
|
||||
// ---- POST /v1/uploads ----
|
||||
|
||||
func (h *V1Handler) Uploads(c *gin.Context) {
|
||||
principal, ok := h.auth(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if w == 0 || h == 0 {
|
||||
return "16:9", ""
|
||||
var body struct {
|
||||
Kind string `json:"kind"`
|
||||
Mime string `json:"mime"`
|
||||
}
|
||||
// The "p" resolution is the SHORT edge (720p = 1280×720, 1080p = 1920×1080),
|
||||
// so a standard 1280×720 must read as 720p — not 1080p off the long edge.
|
||||
resolution = "720p"
|
||||
switch {
|
||||
case min(w, h) >= 1440:
|
||||
resolution = "1440p"
|
||||
case min(w, h) >= 1080:
|
||||
resolution = "1080p"
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
v1Err(c, http.StatusBadRequest, "4001", "请求体不是合法 JSON")
|
||||
return
|
||||
}
|
||||
return guessRatioWH(w, h), resolution
|
||||
view, err := h.v1.PresignRefUpload(c.Request.Context(), principal, body.Kind, body.Mime)
|
||||
if err != nil {
|
||||
v1Fail(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
func guessRatioWH(w, h int) string {
|
||||
if w == h {
|
||||
return "1:1"
|
||||
// ---- GET /v1/models ----
|
||||
|
||||
func (h *V1Handler) Models(c *gin.Context) {
|
||||
if _, ok := h.auth(c); !ok {
|
||||
return
|
||||
}
|
||||
r := float64(w) / float64(h)
|
||||
cands := []struct {
|
||||
name string
|
||||
v float64
|
||||
}{{"16:9", 16.0 / 9}, {"9:16", 9.0 / 16}, {"4:3", 4.0 / 3}, {"3:4", 3.0 / 4}, {"1:1", 1}}
|
||||
best, bestD := "16:9", 1e9
|
||||
for _, cd := range cands {
|
||||
d := r - cd.v
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if d < bestD {
|
||||
best, bestD = cd.name, d
|
||||
}
|
||||
items, err := h.v1.SellableModels(c.Request.Context())
|
||||
if err != nil {
|
||||
v1Err(c, http.StatusInternalServerError, "5001", "模型加载失败")
|
||||
return
|
||||
}
|
||||
return best
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// openaiImageResponse strips our rich internal map down to OpenAI's image shape.
|
||||
func openaiImageResponse(m map[string]any) gin.H {
|
||||
out := gin.H{"created": m["created"]}
|
||||
if d, ok := m["data"]; ok && d != nil {
|
||||
out["data"] = d
|
||||
} else {
|
||||
out["data"] = []any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// ---- GET /v1/balance ----
|
||||
|
||||
func (h *V1Handler) writeAuthError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrMissingAPIKey):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrInvalidAPIKey):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to validate api key"})
|
||||
func (h *V1Handler) UserBalance(c *gin.Context) {
|
||||
principal, ok := h.auth(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *V1Handler) writeV1Error(c *gin.Context, err error, payload map[string]any) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUnknownModel):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrUnsupportedParams), errors.Is(err, service.ErrBannedPrompt):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrInsufficientFunds):
|
||||
c.JSON(http.StatusPaymentRequired, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrReferenceTooLarge):
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrNoProviderAccount):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderAuth):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderQuota):
|
||||
// Match the Python contract: provider quota exhaustion maps to 401
|
||||
// (QuotaExhaustedError is handled alongside AuthError in routes.py).
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderTemporary):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrVideoJobNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrVideoNotReady):
|
||||
c.JSON(http.StatusConflict, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderUnsupported):
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderExecution):
|
||||
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrGenerationPending):
|
||||
c.JSON(http.StatusNotImplemented, payload)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
bal, err := h.v1.UserBalance(c.Request.Context(), principal)
|
||||
if err != nil {
|
||||
v1Err(c, http.StatusInternalServerError, "5001", "余额查询失败")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"balance": bal})
|
||||
}
|
||||
|
||||
// requestBaseURL derives the scheme+host of the inbound request so the service
|
||||
// layer can build absolute, directly-downloadable output URLs. Honors
|
||||
// X-Forwarded-Proto (reverse-proxy / TLS termination) before falling back to
|
||||
// the connection's TLS state. Returns "" when the host is unknown, which makes
|
||||
// the service fall back to a relative path.
|
||||
// layer can build absolute /images URLs for the session path.
|
||||
func requestBaseURL(c *gin.Context) string {
|
||||
host := c.Request.Host
|
||||
if host == "" {
|
||||
|
||||
Reference in New Issue
Block a user