Files
image2api/backend/internal/service/v1.go
T
chiyiandDevin 76510d34c9 feat(protocol): /v1 协议去除 fixed 时长模式,统一 options/range
- /v1/models:单值时长也输出 {"mode":"options","options":[N]},不再发 fixed
- 导入/同步校验只接受 options / range,fixed 分支删除,报错「时长模式仅支持 options / range」
- 渠道模型预览的 fixed 展示分支去掉
- 内部存储不变(durations 数组),历史模型与下单逻辑不受影响

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 21:16:22 +08:00

1998 lines
63 KiB
Go

package service
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/rand"
"path/filepath"
"runtime/debug"
"strings"
"sync"
"time"
"backend/internal/config"
"backend/internal/model"
"backend/internal/repo"
"backend/internal/storage"
"backend/internal/upstream"
"backend/internal/util"
"gorm.io/gorm"
)
var (
ErrMissingAPIKey = errors.New("missing api key")
ErrInvalidAPIKey = errors.New("invalid api key")
ErrUnknownModel = errors.New("unknown model")
ErrUnsupportedParams = errors.New("unsupported or unpriced parameters for this model")
ErrBannedPrompt = errors.New("prompt contains banned content")
ErrInsufficientFunds = errors.New("insufficient credits")
ErrGenerationPending = errors.New("generation executor not implemented yet")
ErrProviderAuth = errors.New("upstream channel credentials rejected")
// ErrNoProviderAccount = no enabled channel is bound to the model.
ErrNoProviderAccount = errors.New("no upstream channel available, please ask an admin to configure one")
// ErrProviderQuota — kept for handler compatibility; channel failover no
// longer produces it (quota errors just rotate to the next channel).
ErrProviderQuota = errors.New("provider quota exhausted")
ErrProviderTemporary = errors.New("upstream temporary unavailable")
ErrProviderExecution = errors.New("upstream request failed")
ErrProviderUnsupported = errors.New("provider not implemented")
ErrReferenceTooLarge = errors.New("reference image too large")
// ErrConcurrencyFull — every eligible channel is busy right now.
ErrConcurrencyFull = errors.New("all upstream channels are busy, please try again shortly")
// ErrUserConcurrencyFull — the caller already has their concurrency-group's
// max generations in flight (画图台 + API key combined). 0 = unlimited.
ErrUserConcurrencyFull = errors.New("too many generations in progress, please wait for one to finish")
// ErrVideoJobNotFound / ErrVideoNotReady — /v1 job lookups (kept names).
ErrVideoJobNotFound = errors.New("job not found")
ErrVideoNotReady = errors.New("job result is not ready yet")
)
// maxReferenceImageBytes bounds a single decoded reference blob. 20 MB
// comfortably covers real photos/screenshots/clips; anything larger is almost
// certainly abuse or a mistake.
const maxReferenceImageBytes = 20 * 1024 * 1024
// videoGenBudget caps one video render end-to-end (submit + stream + download).
const videoGenBudget = 30 * time.Minute
// imageGenBudget caps one image generation end-to-end.
const imageGenBudget = 8 * time.Minute
// Channel concurrency semantics: 0 = unlimited (the Lua slot script passes
// max=0 straight through), matching vivid's channel design.
// maxChannelFailover caps how many channels one request may burn on
// retryable/temporary errors before giving up, so an upstream-wide blip can't
// fan a single request out across every channel.
const maxChannelFailover = 6
// PresignTTL is the lifetime of /v1/uploads PUT urls and result GET urls.
const PresignTTL = 30 * time.Minute
type V1Service struct {
cfg *config.Config
models *repo.ModelRepository
users *repo.UserRepository
events *repo.EventRepository
channels *ChannelService
settings *repo.SiteSettingRepository
cgroups *repo.ConcurrencyGroupRepository
store *storage.Client
up *upstream.Client
// banned is the admin-managed prompt blocklist (set via SetBannedWords).
// nil disables the check.
banned *repo.BannedWordRepository
// inflight maps an in-progress event ID → the cancel func of its generation
// work context, so the maintenance sweep can stop a stuck generation the
// moment it abandons the row.
inflight *InflightRegistry
// conc is the Redis-backed concurrency limiter for BOTH the per-channel
// upstream gate and the per-user gate (画图台 + API key, capped by the
// user's concurrency group). Self-healing + fail-open.
conc *ConcurrencyService
}
// chanAcquire takes one per-channel slot (capped at max; 0 = unlimited), tagged
// with the generation's eventID.
func (s *V1Service) chanAcquire(ctx context.Context, channelID, eventID string, max int) bool {
if max < 0 {
max = 0
}
return s.conc.Acquire(ctx, "conc:c:"+channelID, max, eventID)
}
func (s *V1Service) chanRelease(ctx context.Context, channelID, eventID string) {
s.conc.Release(ctx, "conc:c:"+channelID, eventID)
}
// userAcquire takes one per-user generation slot, capped by the user's
// concurrency group (0 = unlimited). Returns false when the user is already at
// their limit. `token` is a unique per-generation tag passed back to userRelease.
func (s *V1Service) userAcquire(ctx context.Context, user *model.User, token string) bool {
if user == nil {
return true
}
return s.conc.Acquire(ctx, "conc:u:"+user.ID, s.userConcurrencyLimit(ctx, user), token)
}
func (s *V1Service) userRelease(ctx context.Context, userID, token string) {
s.conc.Release(ctx, "conc:u:"+userID, token)
}
// userConcurrencyLimit resolves the user's concurrency-group cap (0 = unlimited),
// falling back to the default group when unset/missing.
func (s *V1Service) userConcurrencyLimit(ctx context.Context, user *model.User) int {
if s.cgroups == nil || user == nil {
return 0
}
var g *model.ConcurrencyGroup
if user.ConcurrencyGroupID != "" {
g, _ = s.cgroups.Get(ctx, user.ConcurrencyGroupID)
}
if g == nil {
g, _ = s.cgroups.GetDefault(ctx)
}
if g == nil {
return 0
}
return g.MaxConcurrency
}
// InflightRegistry tracks the cancel func of every in-progress generation by
// event ID. The generation registers on start and removes on finish; the
// maintenance sweep calls Cancel when it gives up on (abandons) an event.
type InflightRegistry struct {
m sync.Map // eventID -> context.CancelFunc
}
func (r *InflightRegistry) Add(eventID string, cancel context.CancelFunc) {
if eventID != "" {
r.m.Store(eventID, cancel)
}
}
// TryAdd registers an event only when nothing is tracked for it — the resume
// sweep uses it so two ticks can't double-attach to the same upstream job.
func (r *InflightRegistry) TryAdd(eventID string, cancel context.CancelFunc) bool {
if eventID == "" {
return false
}
_, loaded := r.m.LoadOrStore(eventID, cancel)
return !loaded
}
// Done deregisters an event (called on normal completion).
func (r *InflightRegistry) Done(eventID string) { r.m.Delete(eventID) }
// Cancel stops an in-flight generation by event ID. Returns true if one was
// running and got cancelled. No-op (false) if it already finished.
func (r *InflightRegistry) Cancel(eventID string) bool {
if v, ok := r.m.LoadAndDelete(eventID); ok {
v.(context.CancelFunc)()
return true
}
return false
}
type APIPrincipal struct {
User *model.User
TokenType string
}
type V1ImageRequest struct {
Model string
Prompt string
Size string
// Quality is the vivid-protocol quality label (= resolution tier verbatim,
// e.g. "1K"/"2K"/"4K"). Only used when Resolution is blank.
Quality string
AspectRatio string
Resolution string
N int
ReferenceImages []string
// DeAI applies 去AI特征 post-processing (crop / noise / tone jitter +
// re-encode) to the output and charges the per-tier surcharge on top of
// the model price. Playground-only; the /v1 path never sets it.
DeAI bool
// ChannelID pins an admin test to one specific channel (渠道测试). Ignored
// on the normal user paths.
ChannelID string
// BaseURL is the scheme+host of the inbound request, used to build absolute
// /images URLs. Empty falls back to a relative path.
BaseURL string
}
type V1VideoRequest struct {
Model string
Prompt string
Duration string
AspectRatio string
Resolution string
ReferenceImages []string
ReferenceMode string // "frame", "asset" or "media", overrides model default
// ChannelID — see V1ImageRequest.ChannelID.
ChannelID string
// BaseURL — see V1ImageRequest.BaseURL.
BaseURL string
}
func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.UserRepository, events *repo.EventRepository, channels *ChannelService, settings *repo.SiteSettingRepository, cgroups *repo.ConcurrencyGroupRepository, conc *ConcurrencyService, store *storage.Client) *V1Service {
return &V1Service{
cfg: cfg,
models: models,
users: users,
events: events,
channels: channels,
settings: settings,
cgroups: cgroups,
conc: conc,
store: store,
up: channels.Upstream(),
inflight: &InflightRegistry{},
}
}
// Inflight exposes the registry so the maintenance sweep can cancel a stuck
// generation when it abandons that event.
func (s *V1Service) Inflight() *InflightRegistry { return s.inflight }
// SetBannedWords wires the prompt blocklist in after construction.
func (s *V1Service) SetBannedWords(r *repo.BannedWordRepository) { s.banned = r }
// checkBannedPrompt rejects the request when the prompt contains any banned
// word (case-insensitive substring). A hit bumps the word's counter and the
// user's 违禁词触发次数 before rejecting.
func (s *V1Service) checkBannedPrompt(ctx context.Context, principal *APIPrincipal, prompt string) error {
if s.banned == nil || strings.TrimSpace(prompt) == "" {
return nil
}
words, err := s.banned.List(ctx)
if err != nil || len(words) == 0 {
return nil
}
lower := strings.ToLower(prompt)
for _, w := range words {
term := strings.ToLower(strings.TrimSpace(w.Word))
if term == "" || !strings.Contains(lower, term) {
continue
}
userID, userName := "", ""
if principal != nil && principal.User != nil {
userID = principal.User.ID
userName = principal.User.Name
if userName == "" {
userName = principal.User.Email
}
}
s.banned.RecordHit(ctx, w.ID, w.Word, userID, userName, prompt)
return fmt.Errorf("%w: banned word \"%s\"", ErrBannedPrompt, w.Word)
}
return nil
}
// logRejectedEvent records a request rejected BEFORE the pending event exists
// (banned word, concurrency full, unknown model, insufficient credits…) as a
// failed event, so every attempt shows up in the logs.
func (s *V1Service) logRejectedEvent(ctx context.Context, kind, modelID string, principal *APIPrincipal, prompt, source, reason string) {
event := &model.EventLog{
ID: "evt-" + randomUpper(12),
TS: time.Now(),
Kind: kind,
Status: "failed",
Model: strings.TrimSpace(modelID),
Prompt: prompt,
Source: source,
Error: reason,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if m, err := s.models.Get(ctx, event.Model); err == nil {
event.Model = m.ID
event.Provider = m.Provider
}
if principal != nil && principal.User != nil {
event.UserID = principal.User.ID
}
_ = s.events.Create(ctx, event)
}
func (s *V1Service) Authenticate(ctx context.Context, authHeader string) (*APIPrincipal, error) {
token := ParseBearer(authHeader)
if token == "" {
return nil, ErrMissingAPIKey
}
user, err := s.users.GetByAPIKeyHash(ctx, HashAPIKey(token))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrInvalidAPIKey
}
return nil, err
}
if user.Status != "active" {
return nil, ErrInvalidAPIKey
}
_ = s.users.TouchAPIKeyUsage(ctx, HashAPIKey(token))
return &APIPrincipal{
User: user,
TokenType: "user",
}, nil
}
// ===== /v1 public protocol (vivid-compatible) =====
// V1ModelItem is one GET /v1/models entry — identical shape to a vivid
// upstream's, which is what makes channel model-import work symmetrically.
type V1ModelItem struct {
ID string `json:"id"`
Kind string `json:"kind"`
Qualities []string `json:"qualities"`
Duration *V1ModelDur `json:"duration,omitempty"`
RefImages int64 `json:"refImages"`
RefVideos int64 `json:"refVideos"`
RefAudios int64 `json:"refAudios"`
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
}
// V1ModelDur exposes a video model's duration capability: options=discrete
// list (single-choice models too — "fixed" is never emitted), range=continuous
// interval.
type V1ModelDur struct {
Mode string `json:"mode"`
Seconds int64 `json:"seconds,omitempty"`
Min int64 `json:"min,omitempty"`
Max int64 `json:"max,omitempty"`
Options []int64 `json:"options,omitempty"`
}
// SellableModels — GET /v1/models. 配价即上架(无启停开关):绑定到至少一个
// enabled 渠道 AND 全部档位有价才是可售模型(缺价的档位提交时会扣费拒绝,
// 直接不下发)。kind 不限——image/video/text 同构透出,下游才能连导。
func (s *V1Service) SellableModels(ctx context.Context) ([]V1ModelItem, error) {
items, err := s.models.List(ctx)
if err != nil {
return nil, err
}
out := make([]V1ModelItem, 0, len(items))
for _, item := range items {
if !s.modelSellable(ctx, &item) {
continue
}
qualities := repo.JSONStrings(item.Resolutions)
if len(qualities) == 0 {
// Resolution list unset → advertise the priced tiers.
for k := range item.Prices {
qualities = append(qualities, k)
}
}
entry := V1ModelItem{
ID: item.EffectiveName(),
Kind: item.Type,
Qualities: qualities,
RefImages: int64(item.MaxReferenceImages),
RefVideos: int64(item.MaxReferenceVideos),
RefAudios: int64(item.MaxReferenceAudios),
RefVideoSeconds: int64(item.MaxRefVideoSeconds),
RefAudioSeconds: int64(item.MaxRefAudioSeconds),
}
if entry.Qualities == nil {
entry.Qualities = []string{}
}
if item.Type == "video" {
entry.Duration = modelDurationSpec(&item)
}
out = append(out, entry)
}
return out, nil
}
// modelSellable checks binding + pricing so /v1/models only advertises what a
// generate call can actually fulfil.
func (s *V1Service) modelSellable(ctx context.Context, item *model.ModelConfig) bool {
channels, err := s.channels.ChannelsForModel(ctx, item.ID)
if err != nil || len(channels) == 0 {
return false
}
return ModelFullyPriced(item)
}
// ModelFullyPriced — 上架门槛(无启停开关,配价即上架):每个清晰度档位在
// Prices 里有价;视频模型还要求时长有价(离散档逐档,或区间档按秒价)。
func ModelFullyPriced(item *model.ModelConfig) bool {
for _, res := range repo.JSONStrings(item.Resolutions) {
if _, ok := jsonMapFloat(item.Prices, res); !ok {
return false
}
}
if item.Type == "video" {
durs := repo.JSONStrings(item.Durations)
if len(durs) > 0 {
for _, d := range durs {
if _, ok := jsonMapFloat(item.DurationPrices, d); !ok {
return false
}
}
} else if item.DurationMin > 0 || item.DurationMax > 0 {
if _, ok := jsonMapFloat(item.DurationPrices, "per_second"); !ok {
return false
}
}
}
return len(repo.JSONStrings(item.Resolutions)) > 0 || len(item.Prices) > 0
}
func modelDurationSpec(item *model.ModelConfig) *V1ModelDur {
if item.DurationMin > 0 || item.DurationMax > 0 {
return &V1ModelDur{Mode: "range", Min: int64(item.DurationMin), Max: int64(item.DurationMax)}
}
opts := repo.JSONStrings(item.Durations)
if len(opts) == 0 {
return nil
}
vals := make([]int64, 0, len(opts))
for _, d := range opts {
if n := parseDurationSeconds(d); n > 0 {
vals = append(vals, int64(n))
}
}
if len(vals) == 0 {
return nil
}
return &V1ModelDur{Mode: "options", Options: vals}
}
// UserBalance — GET /v1/balance. Re-reads the user row so the number is live.
func (s *V1Service) UserBalance(ctx context.Context, principal *APIPrincipal) (float64, error) {
user := principal.User
if fresh, err := s.users.GetByID(ctx, user.ID); err == nil && fresh != nil {
user = fresh
}
return user.Credits, nil
}
// ===== channel dispatch =====
// generationParams bundles the wire fields one generate call carries.
type generationParams struct {
Kind string // "image" | "video"
Prompt string
Ratio string
Quality string // resolution tier verbatim
DurationSecs int64
Refs [][]byte
// PinChannelID forces this one channel (admin test) — bypasses the enabled
// filter, weighted order and failover.
PinChannelID string
}
// genResult is what a successful dispatch produces.
type genResult struct {
Data []byte
ContentType string
ChannelID string
ChannelName string
UpstreamJob string
Credits float64 // upstream-reported spend
}
// dispatchGenerate routes one generation through the channel pool bound to the
// model: weighted-random order, per-channel concurrency slot, refs re-uploaded
// per attempt (upload sessions are channel-local), failover on auth/temporary
// errors. Deterministic errors (invalid params / failed job) are terminal.
func (s *V1Service) dispatchGenerate(ctx context.Context, eventID string, modelItem *model.ModelConfig, p generationParams) (*genResult, error) {
var channels []repo.ChannelWithUpstream
var err error
if pin := strings.TrimSpace(p.PinChannelID); pin != "" {
// Admin test pin: single channel, disabled included, no failover.
channels, err = s.channels.ChannelsForModelAnyState(ctx, modelItem.ID)
if err != nil {
return nil, err
}
kept := channels[:0]
for _, c := range channels {
if c.Channel.ID == pin {
kept = append(kept, c)
}
}
channels = kept
} else {
channels, err = s.channels.ChannelsForModel(ctx, modelItem.ID)
if err != nil {
return nil, err
}
}
if len(channels) == 0 {
return nil, ErrNoProviderAccount
}
var lastErr error
failovers := 0
for _, cand := range weightedChannelOrder(channels) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
ch := cand.Channel
if !s.chanAcquire(ctx, ch.ID, eventID, ch.Concurrency) {
continue // channel at capacity — try the next one
}
res, attemptErr := s.generateOnChannel(ctx, eventID, ch, cand.UpstreamModel, p)
s.chanRelease(ctx, ch.ID, eventID)
if attemptErr == nil {
return res, nil
}
lastErr = attemptErr
if !upstream.Failoverable(attemptErr) {
return nil, upstreamUserError(attemptErr)
}
if errors.Is(attemptErr, upstream.ErrAuth) {
continue // bad credentials don't burn the failover budget
}
failovers++
if failovers >= maxChannelFailover {
break
}
}
if lastErr == nil {
return nil, ErrConcurrencyFull
}
return nil, fmt.Errorf("%w: %v", ErrProviderTemporary, upstreamUserError(lastErr))
}
// generateOnChannel performs one full attempt on a single channel: upload refs
// → POST /v1/generate (NDJSON, jobId reconnect) → download result bytes.
func (s *V1Service) generateOnChannel(ctx context.Context, eventID string, ch model.Channel, upstreamModel string, p generationParams) (*genResult, error) {
apiKey := s.channels.Decrypt(ch)
if strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("%w: empty key", upstream.ErrAuth)
}
base := strings.TrimRight(ch.BaseURL, "/")
refKeys := make([]string, 0, len(p.Refs))
for _, blob := range p.Refs {
key, err := s.up.UploadRef(ctx, base, apiKey, blob)
if err != nil {
return nil, err
}
refKeys = append(refKeys, key)
}
wait := int64(imageGenBudget / time.Second)
if p.Kind == "video" {
wait = int64(videoGenBudget / time.Second)
}
var upstreamJob string
resultURL, credits, err := s.up.Generate(ctx, base, apiKey, upstream.GenParams{
Model: upstreamModel,
Prompt: p.Prompt,
Quality: p.Quality,
Ratio: p.Ratio,
Duration: p.DurationSecs,
Refs: refKeys,
}, wait, func(job string) {
if upstreamJob == "" {
upstreamJob = job
_ = s.events.SetUpstreamJob(ctx, eventID, job)
// Stamp the owning channel the moment the upstream accepts the job —
// a restart between submit and finish would otherwise leave a
// pending row that can't tell which channel to resume on.
_ = s.events.SetAccount(ctx, eventID, ch.ID, ch.Name)
}
})
if err != nil {
return nil, err
}
data, ct, err := s.up.Download(ctx, resultURL)
if err != nil {
return nil, err
}
s.channels.BumpSuccess(ctx, ch.ID, float64(credits))
_ = s.events.SetAccount(ctx, eventID, ch.ID, ch.Name)
return &genResult{
Data: data,
ContentType: ct,
ChannelID: ch.ID,
ChannelName: ch.Name,
UpstreamJob: upstreamJob,
Credits: float64(credits),
}, nil
}
// weightedChannelOrder builds a weighted-random permutation: each pick draws a
// channel with probability weight/remainingTotal (weight 2 → ~2x first-pick
// rate of weight 1). Ported from vivid's job executor.
func weightedChannelOrder(channels []repo.ChannelWithUpstream) []repo.ChannelWithUpstream {
if len(channels) <= 1 {
return channels
}
rest := append([]repo.ChannelWithUpstream(nil), channels...)
out := make([]repo.ChannelWithUpstream, 0, len(rest))
for len(rest) > 0 {
total := 0
for _, c := range rest {
w := c.Channel.Weight
if w < 1 {
w = 1
}
total += w
}
r := rand.Intn(total)
for i, c := range rest {
w := c.Channel.Weight
if w < 1 {
w = 1
}
if r < w {
out = append(out, c)
rest = append(rest[:i], rest[i+1:]...)
break
}
r -= w
}
}
return out
}
// upstreamUserError maps an upstream error to what the caller sees. failed 帧
// 文案是上游给用户看的中文,透传;4001 参数错同样透传;其余归一为通用文案。
func upstreamUserError(err error) error {
if err == nil {
return ErrProviderTemporary
}
msg := err.Error()
for _, prefix := range []string{
upstream.ErrJobFailed.Error() + ": ",
upstream.ErrInvalid.Error() + ": ",
upstream.ErrAuth.Error() + ": ",
upstream.ErrRetryable.Error() + ": ",
upstream.ErrAmbiguous.Error() + ": ",
} {
msg = strings.TrimSpace(strings.TrimPrefix(msg, prefix))
}
switch upstream.KindOf(err) {
case upstream.KindJobFailed, upstream.KindInvalid:
if msg == "" {
return ErrProviderExecution
}
return fmt.Errorf("%w: %s", ErrProviderExecution, msg)
default:
if msg == "" {
return ErrProviderTemporary
}
return fmt.Errorf("%w: %s", ErrProviderTemporary, msg)
}
}
// ===== session / admin-test generation (unchanged response shapes) =====
func (s *V1Service) prepareSessionImage(ctx context.Context, principal *APIPrincipal, in V1ImageRequest) (map[string]any, error) {
return s.prepareImageExecution(ctx, principal, in, "user", true)
}
func (s *V1Service) prepareAdminTestImage(ctx context.Context, principal *APIPrincipal, in V1ImageRequest) (map[string]any, error) {
return s.prepareImageExecution(ctx, principal, in, "admin", false)
}
func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPrincipal, in V1ImageRequest, source string, charge bool) (map[string]any, error) {
// Detach the whole execution from the request lifecycle. The frontend tracks
// progress by polling /jobs/mine, so a client disconnect must NOT cancel an
// in-flight generation or drop the refund/final-status bookkeeping.
ctx = context.WithoutCancel(ctx)
if source != "admin" {
if err := s.checkBannedPrompt(ctx, principal, in.Prompt); err != nil {
s.logRejectedEvent(ctx, "image", in.Model, principal, in.Prompt, source, err.Error())
return nil, err
}
}
if in.DeAI && !s.deaiEnabled(ctx) {
in.DeAI = false
}
genCtx, cancel := context.WithTimeout(ctx, imageGenBudget)
defer cancel()
if source != "admin" && principal != nil && principal.User != nil {
slot := randomUpper(12)
if !s.userAcquire(ctx, principal.User, slot) {
s.logRejectedEvent(ctx, "image", in.Model, principal, in.Prompt, source, ErrUserConcurrencyFull.Error())
return nil, ErrUserConcurrencyFull
}
defer s.userRelease(ctx, principal.User.ID, slot)
}
modelItem, resolution, aspectRatio, price, err := s.prepareImage(ctx, principal, in, charge)
if err != nil {
s.logRejectedEvent(ctx, "image", in.Model, principal, in.Prompt, source, err.Error())
return nil, err
}
refCount := len(in.ReferenceImages)
fileURL, relativePath := s.allocateOutput(principal, "png", in.BaseURL)
eventID, err := s.logPendingEvent(ctx, "image", modelItem, principal, in.Prompt, aspectRatio, resolution, "", refCount, price, relativePath, source, nil, in.DeAI)
if err != nil {
return nil, err
}
s.inflight.Add(eventID, cancel)
defer s.inflight.Done(eventID)
startedAt := time.Now()
refs, err := decodeReferenceImages(in.ReferenceImages, max(totalRefLimit(modelItem), 1))
if err != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", err.Error(), 0)
return nil, err
}
res, execErr := s.dispatchGenerate(genCtx, eventID, modelItem, generationParams{
Kind: "image", Prompt: in.Prompt, Ratio: aspectRatio, Quality: resolution, Refs: refs,
PinChannelID: in.ChannelID,
})
if execErr != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
return nil, execErr
}
imageBytes := res.Data
if in.DeAI {
if processed, derr := applyDeAI(imageBytes); derr == nil {
imageBytes = processed
}
}
if err := s.store.Put(genCtx, relativePath, imageBytes, "image/png"); err != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", "storage upload failed: "+err.Error(), 0)
return nil, fmt.Errorf("%w: %v", ErrProviderExecution, err)
}
if thumb, terr := makeThumbnail(imageBytes); terr == nil {
_ = s.store.Put(genCtx, ThumbKey(relativePath), thumb, "image/jpeg")
}
elapsedMS := int(time.Since(startedAt).Milliseconds())
if err := s.events.UpdateStatus(ctx, eventID, "success", "", elapsedMS); err != nil {
return nil, err
}
_ = s.models.IncrementGenerationCount(ctx, modelItem.ID)
if principal != nil && principal.User != nil {
_ = s.users.IncrementGenerationCount(ctx, principal.User.ID)
}
if charge {
_ = s.maybeGrantInviteReward(ctx, principal)
}
return map[string]any{
"created": time.Now().Unix(),
"data": []map[string]any{{"url": fileURL, "b64_json": nil}},
"model": modelItem.EffectiveName(),
"provider": res.ChannelName,
"kind": "image",
"url": fileURL,
"elapsed_ms": elapsedMS,
"charged": price,
"credits": principalCredits(principal),
}, nil
}
func (s *V1Service) prepareSessionVideo(ctx context.Context, principal *APIPrincipal, in V1VideoRequest) (map[string]any, error) {
return s.prepareVideoExecution(ctx, principal, in, "user", true)
}
func (s *V1Service) prepareAdminTestVideo(ctx context.Context, principal *APIPrincipal, in V1VideoRequest) (map[string]any, error) {
return s.prepareVideoExecution(ctx, principal, in, "admin", false)
}
func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPrincipal, in V1VideoRequest, source string, charge bool) (map[string]any, error) {
ctx = context.WithoutCancel(ctx)
if source != "admin" {
if err := s.checkBannedPrompt(ctx, principal, in.Prompt); err != nil {
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, source, err.Error())
return nil, err
}
}
genCtx, cancel := context.WithTimeout(ctx, videoGenBudget)
defer cancel()
if source != "admin" && principal != nil && principal.User != nil {
slot := randomUpper(12)
if !s.userAcquire(ctx, principal.User, slot) {
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, source, ErrUserConcurrencyFull.Error())
return nil, ErrUserConcurrencyFull
}
defer s.userRelease(ctx, principal.User.ID, slot)
}
modelItem, resolution, aspectRatio, duration, price, err := s.prepareVideo(ctx, principal, in, charge)
if err != nil {
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, source, err.Error())
return nil, err
}
refCount := len(in.ReferenceImages)
fileURL, relativePath := s.allocateOutput(principal, "mp4", in.BaseURL)
eventID, err := s.logPendingEvent(ctx, "video", modelItem, principal, in.Prompt, aspectRatio, resolution, duration, refCount, price, relativePath, source, nil, false)
if err != nil {
return nil, err
}
s.inflight.Add(eventID, cancel)
defer s.inflight.Done(eventID)
startedAt := time.Now()
refs, err := decodeReferenceImages(in.ReferenceImages, max(totalRefLimit(modelItem), 1))
if err != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", err.Error(), 0)
return nil, err
}
res, execErr := s.dispatchGenerate(genCtx, eventID, modelItem, generationParams{
Kind: "video", Prompt: in.Prompt, Ratio: aspectRatio, Quality: resolution,
DurationSecs: int64(parseDurationSeconds(duration)), Refs: refs,
PinChannelID: in.ChannelID,
})
if execErr != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
return nil, execErr
}
videoBytes := res.Data
if err := s.store.Put(genCtx, relativePath, videoBytes, "video/mp4"); err != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", "storage upload failed: "+err.Error(), 0)
return nil, fmt.Errorf("%w: %v", ErrProviderExecution, err)
}
if thumb, last, terr := extractVideoFrames(genCtx, videoBytes); terr == nil {
if len(thumb) > 0 {
_ = s.store.Put(genCtx, ThumbKey(relativePath), thumb, "image/jpeg")
}
if len(last) > 0 {
_ = s.store.Put(genCtx, LastFrameKey(relativePath), last, "image/jpeg")
}
}
elapsedMS := int(time.Since(startedAt).Milliseconds())
if err := s.events.UpdateStatus(ctx, eventID, "success", "", elapsedMS); err != nil {
return nil, err
}
_ = s.models.IncrementGenerationCount(ctx, modelItem.ID)
if principal != nil && principal.User != nil {
_ = s.users.IncrementGenerationCount(ctx, principal.User.ID)
}
if charge {
_ = s.maybeGrantInviteReward(ctx, principal)
}
return map[string]any{
"created": time.Now().Unix(),
"data": []map[string]any{{"url": fileURL}},
"model": modelItem.EffectiveName(),
"provider": res.ChannelName,
"kind": "video",
"url": fileURL,
"elapsed_ms": elapsedMS,
"charged": price,
"credits": principalCredits(principal),
}, nil
}
// ===== /v1/generate jobs (vivid protocol) =====
// V1JobRequest is the POST /v1/generate body.
type V1JobRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Quality string `json:"quality"`
Ratio *string `json:"ratio"`
Duration *int64 `json:"duration"`
// Refs are object keys issued by /v1/uploads.
Refs []string `json:"refs"`
// JobId reconnect mode: re-attach to an existing job, never creates one.
JobID *string `json:"jobId"`
}
// CreateJob validates + charges + creates the pending event and kicks the
// background render; returns the event row (its ID is the jobId).
func (s *V1Service) CreateJob(ctx context.Context, principal *APIPrincipal, in V1JobRequest) (*model.EventLog, error) {
ctx = context.WithoutCancel(ctx)
if err := s.checkBannedPrompt(ctx, principal, in.Prompt); err != nil {
s.logRejectedEvent(ctx, "", in.Model, principal, in.Prompt, "v1", err.Error())
return nil, err
}
modelItem, err := s.models.Get(ctx, strings.TrimSpace(in.Model))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUnknownModel
}
return nil, err
}
kind := modelItem.Type
if kind != "image" && kind != "video" {
return nil, ErrUnknownModel
}
if strings.TrimSpace(in.Prompt) == "" {
return nil, fmt.Errorf("%w: prompt required", ErrUnsupportedParams)
}
quality := strings.TrimSpace(in.Quality)
if quality == "" {
return nil, fmt.Errorf("%w: quality required", ErrUnsupportedParams)
}
if res := repo.JSONStrings(modelItem.Resolutions); len(res) > 0 && !strSliceContains(res, quality) {
return nil, fmt.Errorf("%w: quality %q not in %v", ErrUnsupportedParams, quality, res)
}
ratio := ""
if in.Ratio != nil {
ratio = snapRatio(strings.TrimSpace(strings.ReplaceAll(*in.Ratio, "x", ":")), repo.JSONStrings(modelItem.Ratios))
}
var durSecs int64
duration := ""
if kind == "video" {
if in.Duration == nil || *in.Duration <= 0 {
return nil, fmt.Errorf("%w: duration required", ErrUnsupportedParams)
}
durSecs = *in.Duration
if err := validateModelDuration(modelItem, durSecs); err != nil {
return nil, err
}
duration = fmt.Sprintf("%ds", durSecs)
}
// Refs: keys must live under the caller's refs/ dir; bytes are fetched now
// (existence + caps + duration checks) and re-uploaded to whatever channel
// wins the dispatch.
refBlobs, refKeys, err := s.resolveJobRefs(ctx, principal, in.Refs, modelItem)
if err != nil {
s.logRejectedEvent(ctx, kind, modelItem.ID, principal, in.Prompt, "v1", err.Error())
return nil, err
}
// The job runs async — acquire the per-user slot now and release it when the
// job settles, so the slot survives past this HTTP request.
slot := randomUpper(12)
if principal != nil && principal.User != nil && !s.userAcquire(ctx, principal.User, slot) {
s.logRejectedEvent(ctx, kind, modelItem.ID, principal, in.Prompt, "v1", ErrUserConcurrencyFull.Error())
return nil, ErrUserConcurrencyFull
}
price, err := s.chargeForModel(ctx, principal, modelItem, kind, quality, duration, 0, true)
if err != nil {
s.userRelease(ctx, principal.User.ID, slot)
s.logRejectedEvent(ctx, kind, modelItem.ID, principal, in.Prompt, "v1", err.Error())
return nil, err
}
eventID, err := s.logPendingEvent(ctx, kind, modelItem, principal, in.Prompt, ratio, quality, duration, len(refBlobs), price, "", "v1", nil, false)
if err != nil {
s.userRelease(ctx, principal.User.ID, slot)
_, _ = s.users.RefundCredits(ctx, principal.User.ID, price)
return nil, err
}
go s.runV1Job(ctx, principal, eventID, modelItem, generationParams{
Kind: kind, Prompt: in.Prompt, Ratio: ratio, Quality: quality,
DurationSecs: durSecs, Refs: refBlobs,
}, refKeys, slot, price)
return s.events.GetByID(ctx, eventID)
}
// JobForUser reloads a job scoped to the caller (reconnect mode + wait loop).
func (s *V1Service) JobForUser(ctx context.Context, principal *APIPrincipal, jobID string) (*model.EventLog, error) {
ev, err := s.events.GetByID(ctx, strings.TrimSpace(jobID))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrVideoJobNotFound
}
return nil, err
}
if ev == nil || (principal != nil && principal.User != nil && ev.UserID != principal.User.ID) {
return nil, ErrVideoJobNotFound
}
return ev, nil
}
// JobStatus maps an event to the vivid wire status.
func JobStatus(ev *model.EventLog) string {
switch ev.Status {
case "success":
return "succeeded"
case "failed":
return "failed"
default:
if strings.TrimSpace(ev.AccountID) != "" {
return "running"
}
return "queued"
}
}
// JobResultURL presigns the stored result for GET (30 min). API-key results
// expire: the URL is only minted within 30 minutes of completion.
func (s *V1Service) JobResultURL(ev *model.EventLog) string {
if ev == nil || ev.Status != "success" || strings.TrimSpace(ev.File) == "" {
return ""
}
if s.store == nil || !s.store.Configured() {
return ""
}
if time.Since(ev.UpdatedAt) > PresignTTL {
return ""
}
url, err := s.store.PresignGet(ev.File, PresignTTL)
if err != nil {
return ""
}
return url
}
// runV1Job executes a /v1 job in the background: dispatch → store under tmp/ →
// final event state. Caller polls the row; the NDJSON stream just re-reads it.
// `slot` is the per-user concurrency token acquired in CreateJob.
func (s *V1Service) runV1Job(ctx context.Context, principal *APIPrincipal, eventID string, modelItem *model.ModelConfig, p generationParams, refKeys []string, slot string, price float64) {
budget := imageGenBudget
if p.Kind == "video" {
budget = videoGenBudget
}
genCtx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
s.inflight.Add(eventID, cancel)
defer s.inflight.Done(eventID)
if principal != nil && principal.User != nil {
defer s.userRelease(ctx, principal.User.ID, slot)
}
// Reference uploads are session-scoped on OUR side too — drop them once the
// job reaches a terminal state.
defer func() {
for _, k := range refKeys {
_ = s.store.Delete(context.Background(), k)
}
}()
startedAt := time.Now()
defer func() {
if r := recover(); r != nil {
log.Printf("[v1] panic in runV1Job event=%s model=%s: %v\n%s", eventID, modelItem.Name, r, debug.Stack())
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", fmt.Sprintf("internal error: %v", r), 0)
}
}()
res, execErr := s.dispatchGenerate(genCtx, eventID, modelItem, p)
if execErr != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
return
}
ext := resultExt(res.ContentType, p.Kind)
rel := filepath.ToSlash(filepath.Join("tmp", eventID+"."+ext))
if err := s.store.Put(genCtx, rel, res.Data, res.ContentType); err != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", "storage upload failed: "+err.Error(), 0)
return
}
_ = s.events.SetFile(ctx, eventID, rel)
if err := s.events.UpdateStatus(ctx, eventID, "success", "", int(time.Since(startedAt).Milliseconds())); err != nil {
return
}
_ = s.models.IncrementGenerationCount(ctx, modelItem.ID)
if principal != nil && principal.User != nil {
_ = s.users.IncrementGenerationCount(ctx, principal.User.ID)
}
_ = s.maybeGrantInviteReward(ctx, principal)
}
// ResumeStaleJob re-attaches to an upstream job whose local goroutine died with
// a process restart. Called by the maintenance sweep; TryAdd guards against
// double-attach across ticks. Terminal failure (upstream job lost/failed,
// channel gone or key broken) fails the event and refunds; transient failure
// leaves it pending for the next sweep (events older than maxResumeAge get
// purged instead of resumed).
func (s *V1Service) ResumeStaleJob(ctx context.Context, ev repo.StaleEvent) {
if ev.UpstreamJob == "" || ev.AccountID == "" {
return
}
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), videoGenBudget)
if !s.inflight.TryAdd(ev.ID, cancel) {
cancel()
return
}
go s.resumeUpstreamJob(rctx, ev)
}
func (s *V1Service) resumeUpstreamJob(ctx context.Context, ev repo.StaleEvent) {
defer s.inflight.Done(ev.ID)
fail := func(msg string) {
s.refundStale(ctx, ev)
elapsed := int(time.Since(ev.TS).Milliseconds())
_ = s.events.UpdateStatus(ctx, ev.ID, "failed", msg, elapsed)
}
ch, err := s.channels.Get(ctx, ev.AccountID)
if err != nil || ch == nil {
fail("上游渠道已删除,任务无法续等")
return
}
apiKey := s.channels.Decrypt(*ch)
if apiKey == "" {
fail("上游渠道密钥不可用,任务无法续等")
return
}
base := strings.TrimRight(ch.BaseURL, "/")
resultURL, credits, err := s.up.Generate(ctx, base, apiKey, upstream.GenParams{JobID: ev.UpstreamJob}, int64(videoGenBudget/time.Second), nil)
if err != nil {
switch upstream.KindOf(err) {
case upstream.KindJobFailed, upstream.KindInvalid, upstream.KindAuth:
fail(upstreamUserError(err).Error())
default:
// Retryable/transport/timeout — the upstream job may still run;
// leave the event pending for the next sweep.
log.Printf("[v1] resume upstream job %s (event %s) retry later: %v", ev.UpstreamJob, ev.ID, err)
}
return
}
data, ct, err := s.up.Download(ctx, resultURL)
if err != nil {
log.Printf("[v1] resume download %s (event %s) retry later: %v", ev.UpstreamJob, ev.ID, err)
return
}
rel := strings.TrimSpace(ev.File)
if rel == "" {
rel = filepath.ToSlash(filepath.Join("tmp", ev.ID+"."+resultExt(ct, ev.Kind)))
}
if err := s.store.Put(ctx, rel, data, ct); err != nil {
fail("storage upload failed: " + err.Error())
return
}
_ = s.events.SetFile(ctx, ev.ID, rel)
if err := s.events.UpdateStatus(ctx, ev.ID, "success", "", int(time.Since(ev.TS).Milliseconds())); err != nil {
return
}
s.channels.BumpSuccess(ctx, ch.ID, float64(credits))
if ev.Model != "" {
_ = s.models.IncrementGenerationCount(ctx, ev.Model)
}
if ev.UserID != "" {
_ = s.users.IncrementGenerationCount(ctx, ev.UserID)
}
log.Printf("[v1] resumed upstream job %s → event %s succeeded", ev.UpstreamJob, ev.ID)
}
// refundStale returns the up-front charge for an abandoned/resume-dead event,
// claimed exactly once via the refunded flag.
func (s *V1Service) refundStale(ctx context.Context, ev repo.StaleEvent) {
if ev.UserID == "" || ev.Cost <= 0 {
return
}
claimed, err := s.events.MarkRefunded(ctx, ev.ID)
if err != nil || !claimed {
return
}
if _, err := s.users.RefundCredits(ctx, ev.UserID, ev.Cost); err != nil {
log.Printf("[v1] refund stale event %s (user %s, %.0f): %v", ev.ID, ev.UserID, ev.Cost, err)
}
}
func resultExt(contentType, kind string) string {
switch {
case strings.Contains(contentType, "webp"):
return "webp"
case strings.Contains(contentType, "jpeg"), strings.Contains(contentType, "jpg"):
return "jpg"
case strings.Contains(contentType, "gif"):
return "gif"
case strings.Contains(contentType, "webm"):
return "webm"
case strings.Contains(contentType, "mp4"):
return "mp4"
case strings.Contains(contentType, "quicktime"):
return "mov"
}
if kind == "video" {
return "mp4"
}
return "png"
}
// validateModelDuration enforces the model's duration spec (options list or
// min/max range) on a /v1 request.
func validateModelDuration(item *model.ModelConfig, secs int64) error {
if item.DurationMin > 0 || item.DurationMax > 0 {
if item.DurationMin > 0 && secs < int64(item.DurationMin) ||
item.DurationMax > 0 && secs > int64(item.DurationMax) {
return fmt.Errorf("%w: duration %ds out of range [%d-%d]", ErrUnsupportedParams, secs, item.DurationMin, item.DurationMax)
}
return nil
}
if durs := repo.JSONStrings(item.Durations); len(durs) > 0 {
for _, d := range durs {
if int64(parseDurationSeconds(d)) == secs {
return nil
}
}
return fmt.Errorf("%w: duration %ds not in %v", ErrUnsupportedParams, secs, durs)
}
return nil
}
// resolveJobRefs fetches + validates /v1 ref keys. Returns blobs (for channel
// upload) and the raw keys (for cleanup on job completion).
func (s *V1Service) resolveJobRefs(ctx context.Context, principal *APIPrincipal, keys []string, modelItem *model.ModelConfig) ([][]byte, []string, error) {
if len(keys) == 0 {
return nil, nil, nil
}
if principal == nil || principal.User == nil {
return nil, nil, ErrInvalidAPIKey
}
uid := principal.User.ID
total := len(keys)
if cap := totalRefLimit(modelItem); cap > 0 && total > cap {
return nil, nil, fmt.Errorf("%w: too many reference files", ErrUnsupportedParams)
}
blobs := make([][]byte, 0, total)
counts := map[string]int{}
secSum := map[string]float64{}
for _, key := range keys {
key = strings.TrimSpace(key)
if !validRefKey(uid, key) {
return nil, nil, fmt.Errorf("%w: 参考素材无效", ErrUnsupportedParams)
}
data, err := s.getObjectBytes(ctx, key)
if err != nil {
return nil, nil, fmt.Errorf("%w: 参考素材已过期或不存在", ErrUnsupportedParams)
}
kind, mime := upstream.RefKindMime(data)
if kind == "" {
return nil, nil, fmt.Errorf("%w: 参考素材格式不支持", ErrUnsupportedParams)
}
counts[kind]++
limit := refLimitFor(modelItem, kind)
if counts[kind] > limit {
return nil, nil, fmt.Errorf("%w: 参考%s最多 %d 个", ErrUnsupportedParams, refKindLabel(kind), limit)
}
if kind == "video" || kind == "audio" {
if sec, ok := util.MediaDurationSeconds(mime, data[:minInt(len(data), 256<<10)]); ok {
secSum[kind] += sec
}
}
blobs = append(blobs, data)
}
if lim := modelItem.MaxRefVideoSeconds; lim > 0 && secSum["video"] > float64(lim) {
return nil, nil, fmt.Errorf("%w: 参考视频总时长 %.0f 秒超过上限 %d 秒", ErrUnsupportedParams, secSum["video"], lim)
}
if lim := modelItem.MaxRefAudioSeconds; lim > 0 && secSum["audio"] > float64(lim) {
return nil, nil, fmt.Errorf("%w: 参考音频总时长 %.0f 秒超过上限 %d 秒", ErrUnsupportedParams, secSum["audio"], lim)
}
return blobs, keys, nil
}
func refLimitFor(item *model.ModelConfig, kind string) int {
switch kind {
case "video":
return item.MaxReferenceVideos
case "audio":
return item.MaxReferenceAudios
default:
return item.MaxReferenceImages
}
}
func refKindLabel(kind string) string {
switch kind {
case "video":
return "视频"
case "audio":
return "音频"
default:
return "图"
}
}
// validRefKey checks ownership + shape: <uid>/refs/<kind>/<name>.<ext>.
func validRefKey(uid, key string) bool {
if uid == "" || !strings.HasPrefix(key, uid+"/refs/") {
return false
}
rest := strings.TrimPrefix(key, uid+"/refs/")
parts := strings.Split(rest, "/")
if len(parts) != 2 {
return false
}
switch parts[0] {
case "image", "video", "audio":
default:
return false
}
name := parts[1]
dot := strings.LastIndexByte(name, '.')
if dot <= 0 || dot == len(name)-1 {
return false
}
switch name[dot+1:] {
case "png", "jpg", "webp", "mp4", "webm", "mov", "mp3", "wav", "m4a", "ogg":
return true
}
return false
}
func (s *V1Service) getObjectBytes(ctx context.Context, key string) ([]byte, error) {
resp, err := s.store.Get(ctx, key, "")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, maxReferenceImageBytes+1))
}
// PresignRefUpload — POST /v1/uploads. Returns the object key + presigned PUT
// URL the caller uploads bytes to within PresignTTL.
type V1UploadView struct {
UUID string `json:"uuid"`
Key string `json:"key"`
URL string `json:"url"`
ExpiresAt int64 `json:"expiresAt"`
}
func (s *V1Service) PresignRefUpload(ctx context.Context, principal *APIPrincipal, kind, mime string) (*V1UploadView, error) {
if principal == nil || principal.User == nil {
return nil, ErrInvalidAPIKey
}
kind = strings.ToLower(strings.TrimSpace(kind))
mime = strings.ToLower(strings.TrimSpace(mime))
if !presignRefMimeOK(kind, mime) {
return nil, fmt.Errorf("%w: 参考素材类型或格式不支持", ErrUnsupportedParams)
}
if s.store == nil || !s.store.Configured() {
return nil, fmt.Errorf("%w: 对象存储不可用", ErrProviderTemporary)
}
id := "r" + strings.ToLower(randomUpper(16))
key := fmt.Sprintf("%s/refs/%s/%s.%s", principal.User.ID, kind, id, refExtOfMime(mime))
url, err := s.store.PresignPut(key, mime, PresignTTL)
if err != nil {
return nil, fmt.Errorf("%w: 预签名签发失败", ErrProviderTemporary)
}
return &V1UploadView{UUID: id, Key: key, URL: url, ExpiresAt: time.Now().Add(PresignTTL).Unix()}, nil
}
func presignRefMimeOK(kind, mime string) bool {
if !strings.HasPrefix(mime, kind+"/") {
return false
}
switch mime {
case "image/png", "image/jpeg", "image/webp",
"video/mp4", "video/webm", "video/quicktime",
"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/mp4", "audio/m4a", "audio/ogg":
return true
}
return false
}
func refExtOfMime(mime string) string {
switch mime {
case "image/png":
return "png"
case "image/jpeg":
return "jpg"
case "image/webp":
return "webp"
case "video/mp4":
return "mp4"
case "video/webm":
return "webm"
case "video/quicktime":
return "mov"
case "audio/mpeg", "audio/mp3":
return "mp3"
case "audio/wav", "audio/x-wav":
return "wav"
case "audio/mp4", "audio/m4a":
return "m4a"
case "audio/ogg":
return "ogg"
default:
return "bin"
}
}
func strSliceContains(list []string, v string) bool {
for _, item := range list {
if strings.TrimSpace(item) == v {
return true
}
}
return false
}
// ===== pricing / charge / refund (unchanged semantics) =====
func (s *V1Service) prepareImage(ctx context.Context, principal *APIPrincipal, in V1ImageRequest, charge bool) (*model.ModelConfig, string, string, float64, error) {
modelID := strings.TrimSpace(in.Model)
prompt := strings.TrimSpace(in.Prompt)
if modelID == "" || prompt == "" {
return nil, "", "", 0, errors.New("model and prompt required")
}
modelItem, err := s.models.Get(ctx, modelID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", "", 0, ErrUnknownModel
}
return nil, "", "", 0, err
}
if modelItem.Type != "image" {
return nil, "", "", 0, ErrUnknownModel
}
// Fail fast before charging if no channel serves this model.
if ok, err := s.hasChannelForModel(ctx, modelItem.ID); err != nil {
return nil, "", "", 0, err
} else if !ok {
return nil, "", "", 0, ErrNoProviderAccount
}
refLimit := 0
if modelItem.ImageToImage {
refLimit = totalRefLimit(modelItem)
if refLimit <= 0 {
refLimit = 1
}
}
if len(in.ReferenceImages) > refLimit {
return nil, "", "", 0, errors.New("too many reference images")
}
if err := ensureReferenceSizes(in.ReferenceImages); err != nil {
return nil, "", "", 0, err
}
aspectRatio, resolution := parseImageSize(in.Size, in.AspectRatio, in.Resolution)
// An explicit quality label (vivid callers) wins over a blank resolution.
if resolution == "" && strings.TrimSpace(in.Quality) != "" {
resolution = strings.TrimSpace(in.Quality)
}
aspectRatio = snapRatio(aspectRatio, repo.JSONStrings(modelItem.Ratios))
if _, ok := modelPrice(modelItem, "image", resolution, "", false); !ok {
if fb := firstPricedResolution(modelItem); fb != "" {
resolution = fb
}
}
var surcharge float64
if in.DeAI {
surcharge = s.deaiSurcharge(ctx, resolution)
}
price, err := s.chargeForModel(ctx, principal, modelItem, "image", resolution, "", surcharge, charge)
if err != nil {
return nil, "", "", 0, err
}
return modelItem, resolution, aspectRatio, price, nil
}
func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, in V1VideoRequest, charge bool) (*model.ModelConfig, string, string, string, float64, error) {
modelID := strings.TrimSpace(in.Model)
prompt := strings.TrimSpace(in.Prompt)
duration := strings.TrimSpace(in.Duration)
if modelID == "" || prompt == "" {
return nil, "", "", "", 0, errors.New("model and prompt required")
}
if duration == "" {
return nil, "", "", "", 0, errors.New("duration required")
}
modelItem, err := s.models.Get(ctx, modelID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", "", "", 0, ErrUnknownModel
}
return nil, "", "", "", 0, err
}
if modelItem.Type != "video" {
return nil, "", "", "", 0, ErrUnknownModel
}
// Validate duration against the model's spec (options list or min/max range).
if secs := int64(parseDurationSeconds(duration)); secs > 0 {
if err := validateModelDuration(modelItem, secs); err != nil {
return nil, "", "", "", 0, err
}
}
if ok, err := s.hasChannelForModel(ctx, modelItem.ID); err != nil {
return nil, "", "", "", 0, err
} else if !ok {
return nil, "", "", "", 0, ErrNoProviderAccount
}
refLimit := totalRefLimit(modelItem)
if refLimit <= 0 && len(in.ReferenceImages) > 0 {
return nil, "", "", "", 0, errors.New("this model does not accept references")
}
if refLimit <= 0 {
refLimit = 10
}
if len(in.ReferenceImages) > refLimit {
return nil, "", "", "", 0, errors.New("too many reference images")
}
if err := ensureReferenceSizes(in.ReferenceImages); err != nil {
return nil, "", "", "", 0, err
}
aspectRatio := strings.TrimSpace(strings.ReplaceAll(in.AspectRatio, "x", ":"))
if aspectRatio == "" {
aspectRatio = "16:9"
}
resolution := strings.TrimSpace(in.Resolution)
if resolution == "" {
if resList := repo.JSONStrings(modelItem.Resolutions); len(resList) > 0 {
resolution = strings.TrimSpace(resList[0])
}
if resolution == "" {
resolution = "720p"
}
}
price, err := s.chargeForModel(ctx, principal, modelItem, "video", resolution, duration, 0, charge)
if err != nil {
return nil, "", "", "", 0, err
}
if n := parseDurationSeconds(duration); n > 0 {
duration = fmt.Sprintf("%ds", n)
}
return modelItem, resolution, aspectRatio, duration, price, nil
}
// hasChannelForModel reports whether at least one enabled channel serves the
// model — the channel-era replacement for the token-pool precheck.
func (s *V1Service) hasChannelForModel(ctx context.Context, modelID string) (bool, error) {
channels, err := s.channels.ChannelsForModel(ctx, modelID)
if err != nil {
return false, err
}
return len(channels) > 0, nil
}
func (s *V1Service) chargeForModel(ctx context.Context, principal *APIPrincipal, modelItem *model.ModelConfig, kind, resolution, duration string, surcharge float64, charge bool) (float64, error) {
// 代理用户走代理价(某档未设代理价则回退普通价)。
agent := principal != nil && principal.User != nil && principal.User.Role == "agent"
price, ok := modelPrice(modelItem, kind, resolution, duration, agent)
if !ok {
return 0, ErrUnsupportedParams
}
price += surcharge
if !charge || principal == nil || principal.User == nil {
return 0, nil
}
updated, debited, err := s.users.TryDebitCredits(ctx, principal.User.ID, price)
if err != nil {
return 0, err
}
if !debited {
if updated != nil {
principal.User = updated
}
return 0, ErrInsufficientFunds
}
principal.User = updated
return price, nil
}
func (s *V1Service) refundIfNeeded(ctx context.Context, principal *APIPrincipal, eventID string, price float64) error {
if principal == nil || principal.User == nil || price <= 0 {
return nil
}
// Exactly-once: claim the refund via the event's `refunded` flag. If another
// path (e.g. the abandoned-purge sweep) already refunded, MarkRefunded
// returns false and we skip — no double refund.
claimed, err := s.events.MarkRefunded(ctx, eventID)
if err != nil {
return err
}
if !claimed {
return nil
}
updated, err := s.users.RefundCredits(ctx, principal.User.ID, price)
if err == nil {
principal.User = updated
}
return err
}
func (s *V1Service) maybeGrantInviteReward(ctx context.Context, principal *APIPrincipal) error {
if principal == nil || principal.User == nil || s.settings == nil {
return nil
}
enabledRaw, err := s.settings.GetValue(ctx, "credits.invite_enabled")
if err != nil {
return err
}
if !parseBoolSetting(enabledRaw, true) {
return nil
}
rewardRaw, err := s.settings.GetValue(ctx, "credits.invite_reward")
if err != nil {
return err
}
_, err = s.users.GrantInviteReward(ctx, principal.User.ID, parseIntSetting(rewardRaw, 3))
return err
}
func (s *V1Service) userDir(principal *APIPrincipal) string {
if principal == nil {
return "anon"
}
return OwnerDir(principal.User)
}
// OwnerDir is the storage directory (= /images/<owner>/ segment) a user's outputs
// live under: sanitized name → sanitized email-local → id → "anon".
func OwnerDir(user *model.User) string {
if user != nil {
if d := sanitizeOwnerName(user.Name); d != "" {
return d
}
if d := sanitizeOwnerName(strings.Split(user.Email, "@")[0]); d != "" {
return d
}
if user.ID != "" {
return user.ID
}
}
return "anon"
}
// allocateOutput builds the object key (= relative path, user-scoped) and the
// /images proxy URL. Nothing is written here — the caller uploads the bytes.
func (s *V1Service) allocateOutput(principal *APIPrincipal, ext, baseURL string) (string, string) {
userDir := s.userDir(principal)
filename := time.Now().Format("20060102-150405") + "-" + randomUpper(8) + "." + strings.TrimPrefix(ext, ".")
relativePath := filepath.ToSlash(filepath.Join(userDir, filename))
if base := strings.TrimRight(strings.TrimSpace(baseURL), "/"); base != "" {
return base + "/images/" + relativePath, relativePath
}
return "/images/" + relativePath, relativePath
}
func (s *V1Service) logPendingEvent(ctx context.Context, kind string, modelItem *model.ModelConfig, principal *APIPrincipal, prompt, ratio, resolution, duration string, refs int, cost float64, file, source string, refFiles []string, deai bool) (string, error) {
event := &model.EventLog{
ID: "evt-" + randomUpper(12),
TS: time.Now(),
Kind: kind,
Status: "pending",
Model: modelItem.ID,
Provider: modelItem.Provider,
Prompt: prompt,
Ratio: ratio,
Resolution: resolution,
Duration: duration,
Refs: refs,
DeAI: deai,
Source: source,
Cost: cost,
File: file,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if len(refFiles) > 0 {
event.RefFiles = jsonArray(refFiles)
}
if principal != nil && principal.User != nil {
event.UserID = principal.User.ID
}
if err := s.events.Create(ctx, event); err != nil {
return "", err
}
return event.ID, nil
}
// ===== small helpers =====
func ensureReferenceSizes(inputs []string) error {
for _, raw := range inputs {
v := strings.TrimSpace(raw)
if v == "" {
continue
}
if (len(v)*3)/4 > maxReferenceImageBytes {
return ErrReferenceTooLarge
}
}
return nil
}
func decodeReferenceImages(inputs []string, limit int) ([][]byte, error) {
if limit <= 0 {
limit = 1
}
if len(inputs) > limit {
return nil, errors.New("too many reference images")
}
out := make([][]byte, 0, len(inputs))
for _, raw := range inputs {
v := strings.TrimSpace(raw)
if v == "" {
continue
}
// Only raw base64 is accepted (no "data:...;base64," URL prefix).
if (len(v)*3)/4 > maxReferenceImageBytes {
return nil, ErrReferenceTooLarge
}
data, err := base64.StdEncoding.DecodeString(v)
if err != nil {
data, err = base64.RawStdEncoding.DecodeString(v)
if err != nil {
return nil, errors.New("invalid reference image encoding")
}
}
if len(data) == 0 {
return nil, errors.New("empty reference image")
}
if len(data) > maxReferenceImageBytes {
return nil, ErrReferenceTooLarge
}
out = append(out, data)
}
return out, nil
}
// totalRefLimit is the combined per-kind cap for a model.
func totalRefLimit(item *model.ModelConfig) int {
if item == nil {
return 0
}
n := item.MaxReferenceImages + item.MaxReferenceVideos + item.MaxReferenceAudios
if item.ImageToImage && n == 0 {
n = 1
}
return n
}
func parseImageSize(size, aspectRatio, resolution string) (string, string) {
ar := strings.TrimSpace(strings.ReplaceAll(aspectRatio, "x", ":"))
rs := strings.TrimSpace(resolution)
if size != "" && strings.Contains(strings.ToLower(size), "x") {
var w, h int
_, _ = fmt.Sscanf(strings.ToLower(size), "%dx%d", &w, &h)
if w > 0 && h > 0 {
if ar == "" {
ar = guessRatio(w, h)
}
if rs == "" {
maxEdge := w
if h > maxEdge {
maxEdge = h
}
switch {
case maxEdge >= 3500:
rs = "4K"
case maxEdge >= 1800:
rs = "2K"
default:
rs = "1K"
}
}
}
}
if ar == "" {
ar = "1:1"
}
if rs == "" {
rs = "2K"
}
return ar, rs
}
// snapRatio returns the entry in supported closest in value to ar ("W:H").
func snapRatio(ar string, supported []string) string {
parse := func(s string) (float64, bool) {
var w, h int
if _, err := fmt.Sscanf(strings.TrimSpace(s), "%d:%d", &w, &h); err != nil || w <= 0 || h <= 0 {
return 0, false
}
return float64(w) / float64(h), true
}
v, ok := parse(ar)
if !ok || len(supported) == 0 {
return ar
}
best, bestDelta := "", 0.0
for _, s := range supported {
if strings.TrimSpace(strings.ReplaceAll(s, "x", ":")) == ar {
return ar
}
sv, sok := parse(strings.ReplaceAll(s, "x", ":"))
if !sok {
continue
}
if d := absFloat(v - sv); best == "" || d < bestDelta {
best, bestDelta = strings.TrimSpace(strings.ReplaceAll(s, "x", ":")), d
}
}
if best == "" {
return ar
}
return best
}
func guessRatio(w, h int) string {
type candidate struct {
W int
H int
}
candidates := []candidate{
{1, 1},
{5, 4}, {4, 3}, {3, 2}, {16, 9}, {2, 1}, {21, 9}, {3, 1}, {4, 1}, {8, 1}, // 横
{4, 5}, {3, 4}, {2, 3}, {9, 16}, {1, 3}, {1, 4}, {1, 8}, // 竖
}
best := candidates[0]
bestDelta := absFloat(float64(w)/float64(h) - float64(best.W)/float64(best.H))
for _, item := range candidates[1:] {
delta := absFloat(float64(w)/float64(h) - float64(item.W)/float64(item.H))
if delta < bestDelta {
best = item
bestDelta = delta
}
}
return fmt.Sprintf("%d:%d", best.W, best.H)
}
// deaiEnabled reports whether the 去AI特征 feature is switched on in system
// settings (default off).
func (s *V1Service) deaiEnabled(ctx context.Context) bool {
if s.settings == nil {
return false
}
raw, err := s.settings.GetValue(ctx, "deai.enabled")
if err != nil {
return false
}
return parseBoolSetting(raw, false)
}
// deaiSurcharge returns the 去AI特征 surcharge (积分) for an image resolution
// tier, from site settings (defaults: 1K=1, 2K=2, 4K=3).
func (s *V1Service) deaiSurcharge(ctx context.Context, resolution string) float64 {
key, def := "deai.price_1k", 1
switch strings.ToUpper(strings.TrimSpace(resolution)) {
case "2K":
key, def = "deai.price_2k", 2
case "4K":
key, def = "deai.price_4k", 3
}
if s.settings == nil {
return float64(def)
}
raw, err := s.settings.GetValue(ctx, key)
if err != nil {
return float64(def)
}
n := parseIntSetting(raw, def)
if n < 0 {
n = 0
}
return float64(n)
}
func firstPricedResolution(item *model.ModelConfig) string {
if item == nil {
return ""
}
for _, r := range []string{"1K", "2K", "4K"} {
if _, ok := jsonMapFloat(item.Prices, r); ok {
return r
}
}
// Fall back to whatever priced tier exists (video-style labels like 720p).
for _, r := range repo.JSONStrings(item.Resolutions) {
if _, ok := jsonMapFloat(item.Prices, r); ok {
return r
}
}
return ""
}
// modelPrice returns the charge for (kind, resolution, duration). The set of
// supported tiers is driven by the NORMAL prices; `agent` only overrides the
// amount with the agent price when one is set for that tier.
func modelPrice(item *model.ModelConfig, kind, resolution, duration string, agent bool) (float64, bool) {
if item == nil {
return 0, false
}
tierPrice := func(normal, agentMap map[string]any, key string) (float64, bool) {
nv, ok := jsonMapFloat(normal, key)
if !ok {
return 0, false
}
if agent {
if av, aok := jsonMapFloat(agentMap, key); aok {
return av, true
}
}
return nv, true
}
if kind == "video" {
rv, rok := tierPrice(item.Prices, item.PricesAgent, resolution)
var dv float64
var dok bool
if pps, hasPerSec := jsonMapFloat(item.DurationPrices, "per_second"); hasPerSec {
secs := parseDurationSeconds(duration)
dv = pps * float64(secs)
dok = true
if agent {
if av, aok := jsonMapFloat(item.DurationPricesAgent, "per_second"); aok {
dv = av * float64(secs)
}
}
} else {
dv, dok = tierPrice(item.DurationPrices, item.DurationPricesAgent, duration)
}
if !rok || !dok {
return 0, false
}
return rv + dv, true
}
return tierPrice(item.Prices, item.PricesAgent, resolution)
}
func jsonMapFloat(m map[string]any, key string) (float64, bool) {
if m == nil {
return 0, false
}
v, ok := m[key]
if !ok || v == nil {
return 0, false
}
switch x := v.(type) {
case float64:
return x, true
case float32:
return float64(x), true
case int:
return float64(x), true
case int64:
return float64(x), true
case json.Number:
// datatypes.JSONMap.Scan decodes with UseNumber(), so values loaded from
// the DB arrive as json.Number — NOT float64.
if f, err := x.Float64(); err == nil {
return f, true
}
case string:
var out float64
if _, err := fmt.Sscanf(strings.TrimSpace(x), "%f", &out); err == nil {
return out, true
}
}
return 0, false
}
func sanitizeOwnerName(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
var b strings.Builder
for _, r := range v {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
}
}
return b.String()
}
func parseDurationSeconds(raw string) int {
raw = strings.ToLower(strings.TrimSpace(raw))
raw = strings.TrimSuffix(raw, "s")
var n int
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 {
return 5
}
return n
}
func absFloat(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func principalCredits(principal *APIPrincipal) float64 {
if principal == nil || principal.User == nil {
return 0
}
return principal.User.Credits
}