- 新增 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>
259 lines
7.8 KiB
Go
259 lines
7.8 KiB
Go
// 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/json"
|
|
"errors"
|
|
"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
|
|
}
|
|
|
|
func NewV1Handler(v1 *service.V1Service) *V1Handler {
|
|
return &V1Handler{v1: v1}
|
|
}
|
|
|
|
func (h *V1Handler) auth(c *gin.Context) (*service.APIPrincipal, bool) {
|
|
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
|
if err != nil {
|
|
v1Err(c, http.StatusUnauthorized, "4011", "无效的 API Key")
|
|
return nil, false
|
|
}
|
|
return principal, true
|
|
}
|
|
|
|
// 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},
|
|
})
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|
|
|
|
// ---- POST /v1/generate ----
|
|
|
|
func (h *V1Handler) Generate(c *gin.Context) {
|
|
principal, ok := h.auth(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body service.V1JobRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
v1Err(c, http.StatusBadRequest, "4001", "请求体不是合法 JSON")
|
|
return
|
|
}
|
|
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 {
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 已终态:直接回终态帧关流。
|
|
if st := service.JobStatus(ev); st == "succeeded" || st == "failed" {
|
|
writeFrame(ev)
|
|
return
|
|
}
|
|
// 连上即回执 jobId(断流后携它重发的凭据)。
|
|
if !writeFrame(ev) {
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
return body
|
|
}
|
|
|
|
// ---- POST /v1/uploads ----
|
|
|
|
func (h *V1Handler) Uploads(c *gin.Context) {
|
|
principal, ok := h.auth(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Kind string `json:"kind"`
|
|
Mime string `json:"mime"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
v1Err(c, http.StatusBadRequest, "4001", "请求体不是合法 JSON")
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
|
|
// ---- GET /v1/models ----
|
|
|
|
func (h *V1Handler) Models(c *gin.Context) {
|
|
if _, ok := h.auth(c); !ok {
|
|
return
|
|
}
|
|
items, err := h.v1.SellableModels(c.Request.Context())
|
|
if err != nil {
|
|
v1Err(c, http.StatusInternalServerError, "5001", "模型加载失败")
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, items)
|
|
}
|
|
|
|
// ---- GET /v1/balance ----
|
|
|
|
func (h *V1Handler) UserBalance(c *gin.Context) {
|
|
principal, ok := h.auth(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
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 /images URLs for the session path.
|
|
func requestBaseURL(c *gin.Context) string {
|
|
host := c.Request.Host
|
|
if host == "" {
|
|
return ""
|
|
}
|
|
scheme := "http"
|
|
if proto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); proto != "" {
|
|
scheme = strings.ToLower(strings.Split(proto, ",")[0])
|
|
} else if c.Request.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
return scheme + "://" + host
|
|
}
|