Files
chiyiandDevin b4efe6b192 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>
2026-10-16 21:14:18 +08:00

574 lines
20 KiB
Go

package handler
import (
"bytes"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"strings"
"sync"
"time"
"backend/internal/model"
"backend/internal/repo"
"backend/internal/service"
"github.com/gin-gonic/gin"
_ "golang.org/x/image/webp"
)
type UserGenerationHandler struct {
userGen *service.UserGenerationService
admin *service.AdminReadService
idem *idemStore
}
func NewUserGenerationHandler(userGen *service.UserGenerationService, admin *service.AdminReadService) *UserGenerationHandler {
return &UserGenerationHandler{
userGen: userGen,
admin: admin,
idem: &idemStore{m: map[string]*idemEntry{}},
}
}
// /generate 是同步长请求(视频要跑好几分钟)。等待期间连接一旦被重置(CDN 回源
// 超时 / HTTP2 GOAWAY),浏览器会把还没拿到响应的 POST 透明重发,后端就会再生成
// 一次、再扣一次积分。前端为每个任务带一个 Idempotency-Key,同一个 key 只真正
// 执行一次:原任务还在跑就直接拒绝,已经跑完就把原结果返回。
const idemTTL = 10 * time.Minute
type idemEntry struct {
done bool
resp map[string]any
at time.Time
}
type idemStore struct {
mu sync.Mutex
m map[string]*idemEntry
}
// begin 登记一个 key。第二个返回值为 true 表示这个 key 已经在处理或刚处理完,
// 返回的 entry 是原来那次的状态。
func (s *idemStore) begin(key string) (*idemEntry, bool) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for k, e := range s.m {
if e.done && now.Sub(e.at) > idemTTL {
delete(s.m, k)
}
}
if e, ok := s.m[key]; ok {
return e, true
}
e := &idemEntry{at: now}
s.m[key] = e
return e, false
}
// finish 记下结果供重发命中;resp 为 nil(本次失败)时直接释放 key,允许用户重试。
func (s *idemStore) finish(key string, resp map[string]any) {
s.mu.Lock()
defer s.mu.Unlock()
if resp == nil {
delete(s.m, key)
return
}
if e, ok := s.m[key]; ok {
e.done, e.resp, e.at = true, resp, time.Now()
}
}
// MyImages returns the current user's own recently generated images (scoped to
// their owner directory) — used by the showcase "选择已生成" picker so an admin
// only sees their own images, not everyone's.
func (h *UserGenerationHandler) MyImages(c *gin.Context) {
user := currentUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
return
}
items, err := h.admin.RecentImagesOwned(c.Request.Context(), service.OwnerDir(user), 60)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load images"})
return
}
c.JSON(http.StatusOK, gin.H{"data": items})
}
// UploadMyImage stores one user-supplied image (multipart field "file") under
// the caller's owner directory, so it can be placed on the infinite canvas and
// shows up in the 我的图片 picker alongside generated outputs. Returns the
// storage key ("owner/name") the frontend feeds to generatedUrl/thumbUrl.
func (h *UserGenerationHandler) UploadMyImage(c *gin.Context) {
user := currentUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 20<<20)
f, _, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "请选择图片文件"})
return
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, 15<<20+1))
if err != nil || len(data) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"detail": "读取文件失败"})
return
}
if len(data) > 15<<20 {
c.JSON(http.StatusBadRequest, gin.H{"detail": "图片不能超过 15MB"})
return
}
_, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "不是有效的图片文件(支持 png/jpg/webp/gif)"})
return
}
key, err := h.admin.SaveOwnedImage(c.Request.Context(), service.OwnerDir(user), format, data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"name": key})
}
// DeleteMyFile removes ONE of the caller's own generated files (plus its
// thumbnail) and blanks the log rows referencing it, so the 画图台 grid and
// 创作记录 gallery stop showing it. ?file= is the storage key (owner/name).
func (h *UserGenerationHandler) DeleteMyFile(c *gin.Context) {
user := currentUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
return
}
if err := h.admin.DeleteOwnedFile(c.Request.Context(), service.OwnerDir(user), c.Query("file")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *UserGenerationHandler) Generate(c *gin.Context) {
user := currentUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
return
}
var body struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Ratio string `json:"ratio"`
Resolution string `json:"resolution"`
Duration string `json:"duration"`
ReferenceImages []string `json:"reference_images"`
ReferenceMode string `json:"reference_mode"`
DeAI bool `json:"deai"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
var generated map[string]any // 成功时的响应,供同 key 的重发直接命中
if key := strings.TrimSpace(c.GetHeader("Idempotency-Key")); key != "" {
key = user.ID + "|" + key
if e, dup := h.idem.begin(key); dup {
if e.done {
c.JSON(http.StatusOK, e.resp)
} else {
c.JSON(http.StatusConflict, gin.H{"detail": "该任务已在生成中,已忽略重复提交"})
}
return
}
defer func() { h.idem.finish(key, generated) }()
}
resp, err := h.userGen.Generate(c.Request.Context(), user, service.UserGenerateRequest{
Model: body.Model,
Prompt: body.Prompt,
Ratio: body.Ratio,
Resolution: body.Resolution,
Duration: body.Duration,
ReferenceImages: body.ReferenceImages,
ReferenceMode: body.ReferenceMode,
DeAI: body.DeAI,
})
if err != nil {
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": "积分不足"})
case errors.Is(err, service.ErrNoProviderAccount):
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrProviderAuth), errors.Is(err, service.ErrProviderTemporary):
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrProviderQuota):
c.JSON(http.StatusTooManyRequests, 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.ErrProviderExecution):
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
default:
if err.Error() == "已有正在生成的任务,请稍候" {
c.JSON(http.StatusConflict, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
}
return
}
generated = resp
c.JSON(http.StatusOK, resp)
}
func (h *UserGenerationHandler) Test(c *gin.Context) {
user := currentUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
return
}
if user.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"detail": "需要管理员权限"})
return
}
var body struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Ratio string `json:"ratio"`
Resolution string `json:"resolution"`
Duration string `json:"duration"`
ReferenceImages []string `json:"reference_images"`
ChannelID string `json:"channel_id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
resp, err := h.userGen.AdminTest(c.Request.Context(), user, service.UserGenerateRequest{
Model: body.Model,
Prompt: body.Prompt,
Ratio: body.Ratio,
Resolution: body.Resolution,
Duration: body.Duration,
ReferenceImages: body.ReferenceImages,
ChannelID: body.ChannelID,
})
if err != nil {
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.ErrProviderQuota):
c.JSON(http.StatusTooManyRequests, 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.ErrNoProviderAccount):
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrProviderAuth), errors.Is(err, service.ErrProviderTemporary):
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrProviderExecution):
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
}
return
}
c.JSON(http.StatusOK, resp)
}
func (h *UserGenerationHandler) MyJobs(c *gin.Context) {
user := currentUser(c)
if user == nil {
c.JSON(http.StatusOK, gin.H{"pending": nil, "latest": nil})
return
}
data, err := h.userGen.MyJobs(c.Request.Context(), user, c.Query("source"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load jobs"})
return
}
c.JSON(http.StatusOK, data)
}
func (h *UserGenerationHandler) Logs(c *gin.Context) {
user := currentUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
return
}
limit := parseInt(c.Query("limit"), 50)
offset := parseInt(c.Query("offset"), 0)
kind := c.Query("kind")
status := c.Query("status")
// statuses=pending,success → status IN (...). Used by the 画图台 grid so it can
// fetch exactly the rows it shows (进行中 + 成功) in one query, server-side.
var statuses []string
if s := strings.TrimSpace(c.Query("statuses")); s != "" {
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
statuses = append(statuses, p)
}
}
}
// Secure-by-default: always scope to the caller's OWN records. This endpoint
// serves the front-end 日志 / 创作记录 pages, so an admin viewing their personal
// records must NOT see other users' work. Only an admin who explicitly opts
// into the full view (?scope=all — the admin 日志 page) sees everyone's logs.
// API-key ("v1") usage IS included for the caller's own records so the user
// can audit their key's calls on /mylogs; the image-only 创作记录 gallery still
// hides them client-side (they have no stored file).
userID := user.ID
excludeSource := ""
if user.Role == "admin" && c.Query("scope") == "all" {
userID = ""
}
// 来源筛选: "v1" = API key, "user" = 前台画图, "admin" = 测试模型. 始终生效 ——
// 普通用户已被 userID 限定为本人记录,按来源服务端筛选 + 分页(/mylogs 翻全部历史)。
source := c.Query("source")
// 创作记录 gallery passes has_file=1 so server-side pagination counts only
// rows with real media (success + stored file), not failed/pending events.
hasFile := c.Query("has_file") == "1" || c.Query("has_file") == "true"
// Media views hide homepage showcase files — those belong to the public
// landing page, not to the caller's personal works. Galleries imply it via
// has_file; the 画图台 grid opts in with exclude_showcase=1.
excludeShowcase := hasFile || c.Query("exclude_showcase") == "1"
// media=1 (画图台 grid): only pending rows or rows with a stored file, so a
// deleted work's blanked row doesn't consume one of the grid's slots.
mediaOnly := c.Query("media") == "1"
// ?user= — admin-only 用户搜索 (the 日志管理 page with scope=all). Ignored for
// normal users, whose rows are already pinned to their own userID.
var userIDs []string
if term := strings.TrimSpace(c.Query("user")); term != "" && userID == "" {
ids, uerr := h.admin.MatchUserIDs(c.Request.Context(), term)
if uerr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
return
}
if len(ids) == 0 {
ids = []string{"__no_match__"}
}
userIDs = ids
}
items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, statuses, nil, userID, userIDs, strings.TrimSpace(c.Query("q")), excludeSource, source, hasFile, excludeShowcase, mediaOnly)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
return
}
// Resolve user_id -> display name (mirrors admin.py / AdminReadHandler.Logs).
// Without this the log table showed every row as "匿名".
nameByID, err := h.admin.UserNameMap(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
return
}
// Resolve account_id -> account label so the log table can show which
// provider account fulfilled each generation under the user.
accountByID, err := h.admin.AccountNameMap(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
return
}
modelByID, err := h.admin.ModelNameMap(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
return
}
out := make([]gin.H, 0, len(items))
for _, item := range items {
var userName any
if item.UserID == "" {
userName = "匿名"
} else if name, ok := nameByID[item.UserID]; ok {
userName = name
} else {
userName = item.UserID
}
// Provider account identity is admin-only: normal users must not see
// which upstream account (email) fulfilled their generation.
var accountName, accountID any
if userID == "" {
if item.AccountEmail != "" {
// Email stamped on the row itself survives account deletion/re-import.
accountName = item.AccountEmail
} else if item.AccountID != "" {
if label, ok := accountByID[item.AccountID]; ok {
accountName = label
} else {
accountName = item.AccountID
}
}
accountID = emptyStringNil(item.AccountID)
}
out = append(out, gin.H{
"id": item.ID,
"ts": item.TS.Unix(),
"kind": item.Kind,
"status": item.Status,
"model": displayModelName(modelByID, item.Model),
"provider": item.Provider,
"prompt": item.Prompt,
"ratio": item.Ratio,
"resolution": item.Resolution,
"duration": item.Duration,
"refs": item.Refs,
"deai": item.DeAI,
"source": emptyStringNil(item.Source),
"user_id": emptyStringNil(item.UserID),
"user_name": userName,
"account_id": accountID,
"account": accountName,
"cost": item.Cost,
"elapsed_ms": item.ElapsedMS,
"file": emptyStringNil(item.File),
"error": emptyStringNil(item.Error),
"created_at": unixSec(item.CreatedAt),
"updated_at": unixSec(item.UpdatedAt),
})
}
c.JSON(http.StatusOK, gin.H{
"data": out,
"total": total,
"limit": limit,
"offset": offset,
"stats": stats,
})
}
func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
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) {
items, err := h.catalogEntries(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load catalog"})
return
}
c.JSON(http.StatusOK, gin.H{
"data": items,
})
}
func (h *UserGenerationHandler) Models(c *gin.Context) {
items, err := h.publicModels(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
return
}
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) {
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 {
row := modelPreset(m)
row["id"] = m.EffectiveName()
items = append(items, row)
}
return items, nil
}
// 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,
})
}
return items, nil
}