Files

1021 lines
39 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package task
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/shopspring/decimal"
"github.com/qianfree/team-api/internal/dispatchadapter"
"github.com/qianfree/team-api/internal/logic/billing"
lcommon "github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/logic/monitor"
"github.com/qianfree/team-api/internal/logic/relay"
"github.com/qianfree/team-api/relay/channel"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
"github.com/qianfree/team-api/relay/dto"
"github.com/qianfree/team-api/relaykit/dispatch"
"github.com/qianfree/team-api/relaykit/types"
)
// 同步图片厂商「异步化」worker 池。
//
// 背景:OpenAI/DALL·E 等图片厂商同步阻塞返回(10–60s)。走 /v1/images/generations/async
// 端点时,提交阶段立即建 QUEUED 任务并返回 task_id,真正持有上游长连接的动作交给这里的
// 有界 worker 池(默认 100)在后台执行,客户端轮询 /fetch 取结果。
//
// 并发控制:① 全局池(worker 数硬上限)② 单渠道(调度引擎容量租约 softLimit)
// ③ 渠道间(调度引擎 HRW 权重选择 + 失败按 FSM 决策换渠道)。
const (
syncImageWorkerCount = 100 // 全局 worker 池大小(层①)
syncImageQueueSize = 1000 // 有界队列容量,满则提交侧退款+429
syncImageMaxAttempts = 16 // 单任务选渠道最大尝试次数(防全饱和时空转)
syncImageDownloadCap = 20 << 20
// syncImageShutdownWait 停机时等待在途任务收尾的最长时间。超过则强制停止,
// 把仍未完成的在途任务标记为超时(TIMEOUT)并退款,不再无限等待上游返回。
syncImageShutdownWait = 3 * time.Minute
)
var (
syncImageQueue chan *SyncImageJob
syncImageStop chan struct{} // 关闭 → worker 不再领取新任务,处理完在途任务即退出
syncImageWg sync.WaitGroup
syncImageShutdown atomic.Bool // 停机中:拒绝新任务入队
// 本实例在途任务登记表(TaskID -> job)。用于停机超时时,只对**本实例**正在执行的
// 任务做超时+退款,避免误伤多实例部署下其他实例的 IN_PROGRESS 任务。
syncImageRunningMu sync.Mutex
syncImageRunning = make(map[int64]*SyncImageJob)
// 池状态计数器(进程内累计,重启归零;供实时监控面板展示)。
syncImageBusy atomic.Int64 // 忙碌 worker 数(瞬时)
syncImageEnqueued atomic.Int64 // 累计入队成功
syncImageRejected atomic.Int64 // 累计拒绝(队列满 → 429 退款)
syncImageSucceeded atomic.Int64 // 累计 worker 处理成功
syncImageFailed atomic.Int64 // 累计 worker 处理失败
syncImageFileSvc *lcommon.FileService
syncImageFileSvcMu sync.Mutex // 保护 syncImageFileSvc 的惰性重建
syncImageBilling common.TaskBillingProvider
syncImageRelayProv common.DataProvider
)
// acquireSyncImageFileSvc 返回共享的对象存储 FileService,惰性重建。
//
// 对象存储配置存于数据库(sys_options),由管理后台**运行时**配置。若进程启动时存储尚未
// 配置,StartSyncImageWorkers 首次构造会失败、syncImageFileSvc 为 nil。此处按需重建:一旦
// 存储在管理后台配置好,下一个 b64_json 图片任务即可成功取到 FileService,**无需重启进程**。
// 这与 admin / data_governance 每次按数据库配置重建 FileService 的行为对齐——只有本 worker
// 池此前把启动时的构造结果缓存成唯一真相源,才导致「配置后仍报未配置」。
//
// 声明为包级变量,便于单测在无数据库环境下覆盖(避免触达 sys_options 查询)。
var acquireSyncImageFileSvc = func(ctx context.Context) (*lcommon.FileService, error) {
syncImageFileSvcMu.Lock()
defer syncImageFileSvcMu.Unlock()
if syncImageFileSvc != nil {
return syncImageFileSvc, nil
}
// AI 图片 re-host 强制要求对象存储:re-host 产出的 URL 直接喂给租户控制台 <img>,
// 而 NewFileServiceFromConfig 在未配对象存储时会降级到 local,返回的 admin 侧 serve 相对
// URL(/api/admin/files/{id}/serve)租户侧既无对应端点、也无 admin 鉴权,图片无法展示。
// 且 local 仅单实例适用,不适合 AI 图片这类高频读路径。故在此显式拒绝,imageFailReason
// 会据此给出「请配置对象存储」的友好提示。
if !lcommon.IsStorageConfigured(ctx) {
return nil, lcommon.ErrStorageNotConfigured
}
fs, err := lcommon.NewFileServiceFromConfig(ctx)
if err != nil {
return nil, err
}
syncImageFileSvc = fs
return fs, nil
}
// SyncImageJob 同步图片任务的内存载荷(请求体只放内存,不落库,无需崩溃重放)。
type SyncImageJob struct {
TaskID int64 // tsk_model_tasks 主键
PublicTaskID string
RequestID string
TenantID int64
UserID int64
ApiKeyID int64
ProjectID int64
Model string
RequestBody []byte
PreDeductAmount decimal.Decimal
Ratios map[string]any
SubmitTime time.Time
}
// StartSyncImageWorkers 启动 worker 池,与 StartAsyncPolling 同期在 cmd.go 接线。
func StartSyncImageWorkers(ctx context.Context) {
syncImageQueue = make(chan *SyncImageJob, syncImageQueueSize)
syncImageStop = make(chan struct{})
syncImageShutdown.Store(false)
syncImageRunningMu.Lock()
syncImageRunning = make(map[int64]*SyncImageJob)
syncImageRunningMu.Unlock()
syncImageBilling = billing.NewTaskBillingProvider()
syncImageRelayProv = relay.NewDataProvider()
// 对象存储配置存于数据库,可能在进程启动后才由管理后台配置。这里尝试预热一次;
// 未配置时保持 nil,后续任务运行时由 acquireSyncImageFileSvc 惰性重建(无需重启)。
if fs, err := lcommon.NewFileServiceFromConfig(ctx); err != nil {
g.Log().Warningf(ctx, "sync_image: object storage not yet configured (%v); will lazily rebuild when a b64_json image task runs", err)
} else {
syncImageFileSvcMu.Lock()
syncImageFileSvc = fs
syncImageFileSvcMu.Unlock()
}
for i := 0; i < syncImageWorkerCount; i++ {
syncImageWg.Add(1)
go syncImageWorkerLoop(i)
}
g.Log().Infof(ctx, "sync_image: started %d workers (queue=%d)", syncImageWorkerCount, syncImageQueueSize)
// 注册状态取数函数,供管理后台实时监控面板展示池状态。
monitor.RegisterSyncImagePoolProvider(SyncImageWorkerStats)
}
// StopSyncImageWorkers 停机:
// 1. 置停机标志,拒绝新任务入队,通知 worker 不再领取新任务;
// 2. 将队列中未开始的排队任务全部置为 FAILURE 并退款;
// 3. 有界等待在途任务收尾(最长 syncImageShutdownWait);超过则强制停止,
// 把仍未完成的在途任务标记为超时(TIMEOUT)并退款,不再无限等待上游返回。
func StopSyncImageWorkers() {
if syncImageQueue == nil {
return
}
ctx := gctx.New()
// 1. 拒绝新入队,并通知 worker 停止领取新任务。
syncImageShutdown.Store(true)
// 幂等:避免重复 close panic。
select {
case <-syncImageStop:
default:
close(syncImageStop)
}
// 2. 将队列中未开始的排队任务全部失败并退款。
// 此时 worker 收到 stop 后不再领取新任务;即便有个别 worker 在关闭瞬间抢走一条,
// channel 接收是原子的,该任务转为在途(由步骤 3 处理),不会与本次排干重复。
failQueuedOnShutdown(ctx)
// 3. 有界等待在途任务收尾。
done := make(chan struct{})
go func() {
syncImageWg.Wait()
close(done)
}()
select {
case <-done:
g.Log().Info(ctx, "sync_image: 所有在途任务已完成,worker 池已停止")
case <-time.After(syncImageShutdownWait):
// 超过等待上限:强制停止,未完成的在途任务标记为超时并退款。
// 残留 worker goroutine 仍卡在上游,随进程退出而终止;其后续收尾会因 CAS
// 谓词失配而 no-op,不会与这里的超时处理重复结算。
g.Log().Warningf(ctx, "sync_image: 等待在途任务超过 %v,强制停止;未完成任务标记为超时并退款", syncImageShutdownWait)
timeoutRunningOnShutdown(ctx)
}
}
// failQueuedOnShutdown 排干队列,把未开始的排队任务全部置为 FAILURE 并退款。
func failQueuedOnShutdown(ctx context.Context) {
failed := 0
for {
select {
case job := <-syncImageQueue:
if job == nil {
continue
}
failQueuedSyncImageJob(ctx, job, "server shutting down")
failed++
default:
if failed > 0 {
g.Log().Infof(ctx, "sync_image: 停机将 %d 个未开始的排队任务置为失败并退款", failed)
}
return
}
}
}
// timeoutRunningOnShutdown 停机超时:把本实例所有仍未完成的在途任务标记为超时并退款。
func timeoutRunningOnShutdown(ctx context.Context) {
syncImageRunningMu.Lock()
jobs := make([]*SyncImageJob, 0, len(syncImageRunning))
for _, j := range syncImageRunning {
jobs = append(jobs, j)
}
syncImageRunningMu.Unlock()
timedOut := 0
for _, job := range jobs {
if timeoutRunningSyncImageJob(ctx, job) {
timedOut++
}
}
if timedOut > 0 {
g.Log().Warningf(ctx, "sync_image: 停机强制将 %d 个在途任务标记为超时并退款", timedOut)
}
}
// timeoutRunningSyncImageJob 把单个在途任务标记为超时并退款(语义对齐 handleTimedOutTasks):
// DB status 置 FAILURE(task timed out)、审计 task_status 置 TIMEOUT。CAS 谓词 IN_PROGRESS
// 保护:若残留 worker 已抢先落终态,这里 no-op,避免重复退款/重复减 active 计数。
func timeoutRunningSyncImageJob(ctx context.Context, job *SyncImageJob) bool {
now := time.Now()
const reason = "task timed out"
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "FAILURE",
FailReason: reason,
FinishTime: &now,
}, "IN_PROGRESS"); err != nil {
return false // 已被 worker 收尾或其他方抢占
}
settled := true
if job.PreDeductAmount.GreaterThan(billing.Zero) {
if err := syncImageBilling.SettleTaskFailed(ctx, job.TenantID, job.RequestID, job.PreDeductAmount); err != nil {
g.Log().Warningf(ctx, "sync_image: timeout task %s refund failed (unsettled net will retry): %v", job.PublicTaskID, err)
settled = false
}
}
if settled {
_ = DefaultAsyncProvider.UpdateTask(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "FAILURE",
FailReason: reason,
FinishTime: &now,
BillingSettled: true,
})
}
syncImageFailed.Add(1)
DecrActiveTask()
monitor.UnregisterRequestByTaskID(job.PublicTaskID)
usageTask := buildUsageTask(job, nil, "FAILURE", billing.Zero, now)
recordTaskUsage(usageTask, nil, false, reason, nil)
// 闭环审计:把提交阶段的 SUBMITTED 审计记录更新为 TIMEOUT(与超时兜底网一致)。
recordTaskCompletionAudit(usageTask, "TIMEOUT", reason, nil)
return true
}
// EnqueueSyncImageJob 非阻塞入队;队列满返回 false,供提交侧退款+429。
func EnqueueSyncImageJob(job *SyncImageJob) bool {
if syncImageQueue == nil || syncImageShutdown.Load() {
syncImageRejected.Add(1)
return false
}
select {
case syncImageQueue <- job:
syncImageEnqueued.Add(1)
return true
default:
syncImageRejected.Add(1)
return false
}
}
// SyncImageWorkerStats 返回 worker 池当前状态快照,供管理后台实时监控面板展示。
// 在 StartSyncImageWorkers 中注册进 monitor 包(Provider 注入,避免 monitor → task 导入环)。
// 渠道级并发已由调度引擎容量租约统一承担,per-channel inflight 不再由 worker 维护(恒为空)。
func SyncImageWorkerStats() monitor.SyncImagePoolSnapshot {
inflight := map[int64]int{}
qLen, qCap := 0, 0
if syncImageQueue != nil {
qLen, qCap = len(syncImageQueue), cap(syncImageQueue)
}
return monitor.SyncImagePoolSnapshot{
WorkerTotal: syncImageWorkerCount,
WorkerBusy: int(syncImageBusy.Load()),
QueueDepth: qLen,
QueueCap: qCap,
Enqueued: syncImageEnqueued.Load(),
Rejected: syncImageRejected.Load(),
Succeeded: syncImageSucceeded.Load(),
Failed: syncImageFailed.Load(),
ChannelInflight: inflight,
}
}
func syncImageWorkerLoop(workerID int) {
defer syncImageWg.Done()
for {
// 优先响应停机:一旦收到停机信号,不再领取新的排队任务。已在执行中的在途任务
// 由 runSyncImageJob 同步跑完(syncImageWg 会等待),未开始的排队任务由
// StopSyncImageWorkers 在 worker 全部退出后统一置为 FAILURE。
select {
case <-syncImageStop:
return
default:
}
select {
case <-syncImageStop:
return
case job := <-syncImageQueue:
if job == nil {
return
}
runSyncImageJob(workerID, job)
}
}
}
// failQueuedSyncImageJob 停机时把仍处于 QUEUED(未开始)的任务置为 FAILURE 并退款。
// 用 CAS QUEUED->FAILURE 保护:若该任务已被某 worker 抢走(IN_PROGRESS/终态),这里 no-op,
// 由该 worker 负责结算,避免重复退款。
func failQueuedSyncImageJob(ctx context.Context, job *SyncImageJob, reason string) {
now := time.Now()
reason = truncateStr(reason, 500)
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "FAILURE",
FailReason: reason,
FinishTime: &now,
}, "QUEUED"); err != nil {
return
}
settled := true
if job.PreDeductAmount.GreaterThan(billing.Zero) {
if err := syncImageBilling.SettleTaskFailed(ctx, job.TenantID, job.RequestID, job.PreDeductAmount); err != nil {
g.Log().Warningf(ctx, "sync_image: shutdown-fail task %s refund failed (unsettled net will retry): %v", job.PublicTaskID, err)
settled = false
}
}
if settled {
_ = DefaultAsyncProvider.UpdateTask(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "FAILURE",
FailReason: reason,
FinishTime: &now,
BillingSettled: true,
})
}
syncImageFailed.Add(1)
DecrActiveTask()
monitor.UnregisterRequestByTaskID(job.PublicTaskID)
usageTask := buildUsageTask(job, nil, "FAILURE", billing.Zero, now)
recordTaskUsage(usageTask, nil, false, reason, nil)
// 闭环审计:把提交阶段的 SUBMITTED 审计记录更新为 FAILURE。
recordTaskCompletionAudit(usageTask, "FAILURE", reason, nil)
}
// runSyncImageJob 每任务 recover 兜 panic,防单任务崩溃拖垮 worker。
func runSyncImageJob(workerID int, job *SyncImageJob) {
syncImageBusy.Add(1)
// 登记为本实例在途任务,供停机超时时定向处理;defer 保证 panic 也能摘除。
syncImageRunningMu.Lock()
syncImageRunning[job.TaskID] = job
syncImageRunningMu.Unlock()
defer func() {
syncImageRunningMu.Lock()
delete(syncImageRunning, job.TaskID)
syncImageRunningMu.Unlock()
syncImageBusy.Add(-1)
if r := recover(); r != nil {
g.Log().Errorf(gctx.New(), "sync_image: worker %d panic on task %s: %v", workerID, job.PublicTaskID, r)
}
}()
processSyncImageJob(job)
}
func processSyncImageJob(job *SyncImageJob) {
ctx := gctx.New()
// 1. CAS QUEUED -> IN_PROGRESS(与超时网互斥)
now := time.Now()
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "IN_PROGRESS",
Progress: "0%",
StartTime: &now,
}, "QUEUED"); err != nil {
g.Log().Warningf(ctx, "sync_image: task %s CAS QUEUED->IN_PROGRESS failed (already taken): %v", job.PublicTaskID, err)
return
}
// 2. 渠道调度(新调度引擎 Route/Next/Report/Finish)+ 复刻管线(失败换渠道重试)
enabled, channelScope, accessErr := syncImageRelayProv.CheckTenantModelAccess(ctx, job.TenantID, job.Model)
if accessErr != nil || !enabled {
failSyncImageJob(ctx, job, nil, "select channel: model not enabled for tenant")
return
}
// 异步图片任务有任务级结算补偿,沿用历史「失败换渠道重试」语义;
// R2 的 ReplayUnsafe+MaybeSent 保护针对同步端点的自动重放,故此处标 ReplayCostly。
sess := dispatchadapter.Coordinator(ctx).Route(ctx, dispatch.RequestProfile{
RequestID: job.RequestID,
TenantID: job.TenantID,
UserID: job.UserID,
APIKeyID: job.ApiKeyID,
Model: job.Model,
Scope: channelScope,
Replay: dispatch.ReplayCostly,
Policy: dispatchadapter.TenantRoutingPolicy(ctx, job.TenantID),
})
defer sess.Finish(context.WithoutCancel(ctx), false, 0)
lastErr := "no available channel"
// 渠道调试日志:镜像 RelayHandler 重试循环的接线——首个开启调试且匹配过滤的尝试创建
// 会话(段1 = 客户端提交的原始请求体;后台执行无客户端交互,段4 留空)。中间失败尝试
// Submit 立即落库(is_final=false),成功尝试 MarkFinal 后提交最终记录
var dbgSess *common.DebugSession
for attempt := 0; attempt < syncImageMaxAttempts; attempt++ {
d := sess.Next(ctx)
if d == nil {
monitor.TrackDispatchNoCandidate()
break // 无更多候选 / FSM 已终止
}
monitor.TrackDispatchSelection(string(d.Reason), string(d.Channel.Tier), string(d.SessionKey.Source))
sel, mErr := syncImageRelayProv.MaterializeSelection(ctx, d.Channel.ID, d.KeyID, job.Model)
if mErr != nil {
lastErr = fmt.Sprintf("materialize channel %d: %v", d.Channel.ID, mErr)
dec, _ := sess.Report(ctx, 0, types.NewError(mErr, types.ErrorCodeChannelNoAvailableKey), dispatch.DeliveryNotSent, 0, 0)
monitor.TrackDispatchRetry(dispatch.ErrClassChannelFatal.String(), dec.String())
if dec == dispatch.DecisionAbort {
break
}
continue
}
var dbgAttempt *common.DebugAttempt
if sel.Settings.DebugLogEnabled && sel.Settings.DebugTargetMatch(job.TenantID, job.UserID, job.ApiKeyID) {
if dbgSess == nil {
dbgSess = common.NewDebugSession(job.RequestID, job.TenantID, job.UserID, job.ApiKeyID, "/v1/images/generations/async")
dbgSess.CaptureClientRequest(http.Header{"Content-Type": []string{"application/json"}}, job.RequestBody)
}
dbgAttempt = dbgSess.BeginTaskAttempt(&common.ChannelMeta{
ChannelID: sel.ChannelID,
ChannelName: sel.ChannelName,
ChannelType: sel.ChannelType,
UpstreamModelName: sel.UpstreamModelName,
}, job.Model, "images_generations", attempt)
}
ok, memW, perr := runImagePipelineWithRelease(ctx, job, sel, dbgAttempt)
if ok {
sess.Finish(context.WithoutCancel(ctx), true, 0)
settleSyncImageSuccess(ctx, job, sel, memW)
dbgAttempt.MarkFinal(nil)
dbgSess.FinalizeAndSubmit(0, 0)
return
}
// 失败:上报健康并按 FSM 决策原地重试/换渠道/终止(绑定不删除)
lastErr = perr
dbgAttempt.Submit(errors.New(perr))
dec, backoff := sess.Report(ctx, 0, errors.New(perr), dispatch.DeliveryResponseReceived, 0, 0)
monitor.TrackDispatchRetry(dispatch.Classify(0, errors.New(perr), dispatch.DeliveryResponseReceived).String(), dec.String())
if dec == dispatch.DecisionAbort {
break
}
if backoff > 0 {
time.Sleep(backoff)
} else {
syncImageJitter()
}
}
// 3. 全饱和/无候选/重试耗尽 → FAILURE + 退款
failSyncImageJob(ctx, job, nil, lastErr)
}
// runImagePipelineWithRelease 管线执行期间保活调度租约(租约获取/释放由调度会话负责)。
// dbg 为当前尝试的调试捕获器(nil = 渠道未开启调试),注入 ctx 供传输层镜像段2/3。
func runImagePipelineWithRelease(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection, dbg *common.DebugAttempt) (ok bool, memW *memResponseWriter, failReason string) {
stopHeartbeat := make(chan struct{})
go refreshSyncImageChannelLease(sel.ChannelID, job.RequestID, stopHeartbeat)
defer close(stopHeartbeat)
return runImagePipeline(ctx, job, sel, dbg)
}
func refreshSyncImageChannelLease(channelID int64, requestID string, stop <-chan struct{}) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
dispatchadapter.RefreshDispatchLease(ctx, channelID, requestID)
cancel()
case <-stop:
return
}
}
}
// runImagePipeline 复刻 RelayHandler 的最小管线,把上游响应捕获到内存 writer。
func runImagePipeline(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection, dbg *common.DebugAttempt) (bool, *memResponseWriter, string) {
// 调试捕获器注入 ctx(nil 时透传),同步适配器经 NewRequestWithContext 携带到传输层
pipeCtx := ctx
if dbg != nil {
pipeCtx = common.WithDebugAttempt(ctx, dbg.Capture)
}
info := &common.RelayInfo{
Context: pipeCtx,
TenantID: job.TenantID,
UserID: job.UserID,
ApiKeyID: job.ApiKeyID,
ProjectID: job.ProjectID,
RequestID: job.RequestID,
RelayMode: int(constant.RelayModeImagesGenerations),
IsStream: false,
OriginModelName: job.Model,
BaseModelName: job.Model,
RequestURLPath: "/v1/images/generations",
RequestHeaders: http.Header{
"Content-Type": []string{"application/json"},
"Accept": []string{"application/json"},
},
StartTime: time.Now(),
StreamStatus: common.NewStreamStatus(),
InboundFormat: constant.RelayFormatOpenAI,
ClientFormat: constant.RelayFormatOpenAI,
ChannelMeta: &common.ChannelMeta{
ChannelID: sel.ChannelID,
ChannelType: sel.ChannelType,
ChannelName: sel.ChannelName,
BaseURL: sel.BaseURL,
ApiKey: sel.ApiKey,
UpstreamModelName: sel.UpstreamModelName,
IsModelMapped: sel.IsModelMapped,
Settings: sel.Settings,
},
}
adaptor := channel.GetAdaptor(sel.ChannelType)
if adaptor == nil {
return false, nil, fmt.Sprintf("no adaptor for channelType %d", sel.ChannelType)
}
adaptor.Init(info)
dbg.CaptureProtocol(info)
reader, err := adaptor.ConvertRequest(ctx, info, job.RequestBody)
if err != nil {
return false, nil, fmt.Sprintf("convert request: %v", err)
}
// 上游长连接用后台 context(不随停机被斩断),阻塞 10–60s。
// WithoutCancel 保留 ctx values,调试捕获器随请求到达传输层
resp, err := adaptor.DoRequest(context.WithoutCancel(pipeCtx), info, reader)
if err != nil {
return false, nil, fmt.Sprintf("do request: %v", err)
}
memW := newMemResponseWriter()
if _, err := adaptor.DoResponse(ctx, resp, info, memW); err != nil {
return false, memW, fmt.Sprintf("do response: %v", err)
}
if memW.StatusCode() != http.StatusOK {
return false, memW, fmt.Sprintf("upstream status %d: %s", memW.StatusCode(), truncateStr(string(memW.Bytes()), 300))
}
return true, memW, ""
}
// settleSyncImageSuccess 成功收尾:先 CAS 赢终态,再结算钱包,最后标记 billing_settled。
// 崩溃窗口(终态已写、settled 未写)由 handleUnsettledTasks 的 sync_image 分支兜底。
func settleSyncImageSuccess(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection, memW *memResponseWriter) {
resultURL, normalized, err := buildImageResult(ctx, job, memW.Bytes())
if err != nil {
failSyncImageJob(ctx, job, sel, imageFailReason(err))
return
}
// 回填上游返回的 token 用量:gpt-image-1/2 等在响应体 usage 里带 input/output/total_tokens,
// DALL·E 等则没有。据此重算实际费用(token 计费模型多退少补),并写入计费/用量记录,
// 避免「同步转异步后 token 恒为 0、费用只等于预扣」的问题。
promptTokens, completionTokens, totalTokens := extractImageUsage(memW.Bytes())
now := time.Now()
// 默认实际费用 == 预扣(按次计费图片,如 DALL·E,无 token 用量)。
actualCost := job.PreDeductAmount
// 上游返回了 token 用量 → 按真实 token 重算;仅 token 计费模型返回 >0,按次模型返回 0 保持预扣。
if totalTokens > 0 {
if tokenCost, rerr := syncImageBilling.RecalculateByTokens(ctx, job.TenantID, job.Model, totalTokens, job.Ratios, job.SubmitTime); rerr == nil && tokenCost.GreaterThan(billing.Zero) {
actualCost = tokenCost
}
}
// 1. 赢得终态(billing_settled=false 先落)
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "SUCCESS",
Progress: "100%",
ResultURL: resultURL,
Data: normalized,
FinishTime: &now,
}, "IN_PROGRESS"); err != nil {
// 已被超时网抢占,放弃结算(抢占方负责退款/DecrActiveTask)
g.Log().Warningf(ctx, "sync_image: task %s terminal CAS failed (preempted): %v", job.PublicTaskID, err)
return
}
// 2. 结算钱包(传真实 token 用量:驱动 bil_records.output_tokens 与计费快照的 token 明细;
// billAt=提交时刻,时段定价按受理时刻计价)
settleResult, serr := syncImageBilling.SettleTaskSuccess(ctx, job.TenantID, job.UserID, job.ApiKeyID, sel.ChannelID,
job.Model, job.RequestID, actualCost, job.PreDeductAmount, totalTokens, completionTokens, job.Ratios, job.PublicTaskID, job.SubmitTime)
if serr != nil {
// 保留 billing_settled=false,由未结算兜底网重放结算
g.Log().Warningf(ctx, "sync_image: task %s settle success failed (unsettled net will retry): %v", job.PublicTaskID, serr)
} else {
// 3. 标记已结算
_ = DefaultAsyncProvider.UpdateTask(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "SUCCESS",
Progress: "100%",
ResultURL: resultURL,
Data: normalized,
FinishTime: &now,
BillingSettled: true,
ActualCost: actualCost,
})
// 幂等重复结算(DuplicateSkip)不得重复累加 Key 额度
if settleResult == nil || !settleResult.DuplicateSkip {
syncImageBilling.IncrApiKeyQuotaUsed(ctx, job.ApiKeyID, actualCost)
}
}
// 4. 收尾(渠道健康上报由调度会话 Finish 完成)
syncImageSucceeded.Add(1)
DecrActiveTask()
monitor.UnregisterRequestByTaskID(job.PublicTaskID)
chBasic := &common.ChannelBasicInfo{ID: sel.ChannelID, Type: sel.ChannelType, Name: sel.ChannelName}
usageTask := buildUsageTask(job, sel, "SUCCESS", actualCost, now)
usageTask.PromptTokens = promptTokens
usageTask.CompletionTokens = completionTokens
usageTask.TotalTokens = totalTokens
recordTaskUsage(usageTask, chBasic, true, "", settleResult)
// 闭环审计:把提交阶段写入的 SUBMITTED 审计记录更新为终态(与 pollSingleTask 一致),
// 否则请求审计日志里的任务状态会一直停留在「已提交」。
recordTaskCompletionAudit(usageTask, "SUCCESS", string(normalized), nil)
}
// failSyncImageJob 失败收尾:CAS 赢终态 → 退款 → 标记已结算 → 用量/计数。
func failSyncImageJob(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection, reason string) {
now := time.Now()
reason = truncateStr(reason, 500)
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "FAILURE",
FailReason: reason,
FinishTime: &now,
}, "IN_PROGRESS"); err != nil {
g.Log().Warningf(ctx, "sync_image: task %s fail CAS failed (already finalized): %v", job.PublicTaskID, err)
return
}
settled := true
if job.PreDeductAmount.GreaterThan(billing.Zero) {
if err := syncImageBilling.SettleTaskFailed(ctx, job.TenantID, job.RequestID, job.PreDeductAmount); err != nil {
g.Log().Warningf(ctx, "sync_image: task %s refund failed (unsettled net will retry): %v", job.PublicTaskID, err)
settled = false
}
}
if settled {
_ = DefaultAsyncProvider.UpdateTask(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "FAILURE",
FailReason: reason,
FinishTime: &now,
BillingSettled: true,
})
}
syncImageFailed.Add(1)
DecrActiveTask()
monitor.UnregisterRequestByTaskID(job.PublicTaskID)
var chBasic *common.ChannelBasicInfo
if sel != nil {
chBasic = &common.ChannelBasicInfo{ID: sel.ChannelID, Type: sel.ChannelType, Name: sel.ChannelName}
}
usageTask := buildUsageTask(job, sel, "FAILURE", billing.Zero, now)
recordTaskUsage(usageTask, chBasic, false, reason, nil)
// 闭环审计:更新提交阶段的 SUBMITTED 审计记录为 FAILURE。
recordTaskCompletionAudit(usageTask, "FAILURE", reason, nil)
}
// buildImageResult 解析上游图片响应,re-host **全部**图片,返回首图 URL(向后兼容单值
// ResultURL)与归一化后的多图响应体(供落库 Data;fetch 端点据此吐出 data 数组)。
// b64_json 无条件 re-host;url 按配置开关(默认透传,开启则下载 re-host)。
//
// 采用 all-or-nothing:任一张解码 / re-host 失败即整体返回错误,由调用方走 FAILURE + 全额
// 退款(与单图行为一致)。计费按次扁平,多图不额外计费,与同步端点口径保持一致。
func buildImageResult(ctx context.Context, job *SyncImageJob, body []byte) (resultURL string, normalized []byte, err error) {
var imgResp dto.ImageResponse
if e := json.Unmarshal(body, &imgResp); e != nil {
return "", nil, fmt.Errorf("parse image response: %w", e)
}
if len(imgResp.Data) == 0 {
return "", nil, fmt.Errorf("empty image data")
}
outData := make([]dto.ImageData, 0, len(imgResp.Data))
for i, img := range imgResp.Data {
var url string
switch {
case img.B64JSON != "":
data, e := base64.StdEncoding.DecodeString(img.B64JSON)
if e != nil {
return "", nil, fmt.Errorf("decode b64_json[%d]: %w", i, e)
}
if url, e = rehostImage(ctx, job, data, "image/png", ".png", i); e != nil {
return "", nil, fmt.Errorf("rehost b64_json[%d]: %w", i, e)
}
case img.URL != "":
if rehostURLEnabled(ctx) {
if url, err = rehostFromURL(ctx, job, img.URL, i); err != nil {
return "", nil, fmt.Errorf("rehost url[%d]: %w", i, err)
}
} else {
url = img.URL
}
default:
return "", nil, fmt.Errorf("no url or b64_json in image response at index %d", i)
}
outData = append(outData, dto.ImageData{URL: url, RevisedPrompt: img.RevisedPrompt})
}
resultURL = outData[0].URL
// 归一化落库:存全部结果 URL(不含大体积 b64 原文),供 fetch 吐 data 数组。
normalized, _ = json.Marshal(dto.ImageResponse{
Created: imgResp.Created,
Data: outData,
})
return resultURL, normalized, nil
}
// extractImageUsage 从上游图片响应体解析 token 用量。gpt-image-1/2 在 usage 里返回
// input_tokens / output_tokens / total_tokens;DALL·E 等无 usage 字段,返回全 0。
// total 缺失时用 input+output 兜底。映射:prompt=input、completion=output。
func extractImageUsage(body []byte) (prompt, completion, total int) {
var resp struct {
Usage *dto.ImageUsage `json:"usage"`
}
if err := json.Unmarshal(body, &resp); err != nil || resp.Usage == nil {
return 0, 0, 0
}
prompt = resp.Usage.InputTokens
completion = resp.Usage.OutputTokens
total = resp.Usage.TotalTokens
if total == 0 {
total = prompt + completion
}
return prompt, completion, total
}
// rehostImage 把单张图片字节上传对象存储并返回下载 URL。index 用于区分同一任务的多张图片,
// 避免多图共用同一对象键相互覆盖。
// imageFailReason 把「结果处理」阶段的内部错误映射为**面向用户**的失败原因(写入 task
// FailReason → fetch 端点的 error 字段 → 在线体验/客户端可见)。
//
// 对象存储未配置是最常见、且用户可操作的错误:图片其实已在上游生成成功,只是网关无处保存
// (b64_json 必须 re-host 到对象存储)。对这种情况给出中文友好提示,引导去系统设置配置
// 对象存储;其余错误(上传/下载失败、上游响应异常等)保留技术细节,便于运维排查。
func imageFailReason(err error) string {
if errors.Is(err, lcommon.ErrStorageNotConfigured) {
return "图片已生成,但平台尚未配置对象存储(OSS/S3/COS),无法保存图片。请联系管理员在系统设置中配置对象存储后重试。"
}
return "build result: " + err.Error()
}
func rehostImage(ctx context.Context, job *SyncImageJob, data []byte, contentType, ext string, index int) (string, error) {
fs, err := acquireSyncImageFileSvc(ctx)
if err != nil {
return "", fmt.Errorf("object storage not configured, cannot re-host image: %w", err)
}
rec, err := fs.Upload(ctx, &lcommon.FileUpload{
Reader: bytes.NewReader(data),
Filename: fmt.Sprintf("%s_%d%s", job.PublicTaskID, index, ext),
ContentType: contentType,
Size: int64(len(data)),
TenantID: job.TenantID,
UserID: job.UserID,
})
if err != nil {
return "", err
}
return fs.GetDownloadURL(ctx, rec.ID, job.TenantID)
}
// syncImageDownloadClient 用于从上游返回的图片 URL 下载**已生成好的**图片做 re-host。
// 注意:这不是图片生成本身——生成走 adaptor 的 nonStreamClient(300s 超时),此处仅下载
// 成品图片文件。显式设置超时,避免 http.DefaultClient 无超时时慢速/挂起的图片服务器长期
// 占用 worker(io.LimitReader 只限体积不限时间)。120s 对 ≤20MB 的成品图很充裕,且远低于
// 生成超时——放宽是为了避免「生成已成功却因下载慢被判失败+退款」的资损。
//
// SSRF 加固:Transport 使用 ssrfSafeDialContext,在连接建立阶段拒绝 loopback / link-local
// (含 169.254.169.254 云元数据)/ RFC1918 私网 / multicast / unspecified 地址。在 dial 时
// 校验而非仅在请求前校验,可关闭 DNS rebinding 的 TOCTOU 窗口(攻击者 DNS 先返回公网 IP
// 绕过预检、实际连接时再返回内网 IP)。
var syncImageDownloadClient = &http.Client{
Timeout: 120 * time.Second,
Transport: &http.Transport{
DialContext: ssrfSafeDialContext,
Proxy: nil, // 显式禁用代理,避免通过代理绕过 IP 校验访问内网
IdleConnTimeout: 30 * time.Second,
},
}
// errSSRFBlocked 表示 URL 解析出的目标地址落在禁止访问的网段。
var errSSRFBlocked = errors.New("url host resolves to a forbidden (private/link-local/loopback) address")
// isForbiddenIP 判断 ip 是否落在服务端抓取永远不应触达的地址段(SSRF 黑名单):
// loopback、link-local(含 169.254.169.254 云元数据)、RFC1918 私网、multicast、unspecified。
func isForbiddenIP(ip net.IP) bool {
if ip == nil {
return true
}
return ip.IsLoopback() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsPrivate() ||
ip.IsMulticast() ||
ip.IsUnspecified()
}
// validateSSRFURL 对即将下载的 URL 做请求前的快速校验:仅允许 http/https scheme,
// 并对字面量 IP 主机直接判定网段。域名主机由 ssrfSafeDialContext 在 dial 时再校验。
func validateSSRFURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("parse url: %w", err)
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return fmt.Errorf("unsupported scheme %q (only http/https allowed)", u.Scheme)
}
host := u.Hostname()
if host == "" {
return errors.New("url host is empty")
}
// 字面量 IP 在请求前即可判定;域名留待 dial 时按解析结果判定(防 DNS rebinding)。
if ip := net.ParseIP(host); ip != nil && isForbiddenIP(ip) {
return errSSRFBlocked
}
return nil
}
// ssrfSafeDialContext 在连接阶段解析主机名并拒绝任何指向 loopback / link-local / 私网 /
// multicast / unspecified 的地址,随后用解析到的 IP 直接建立连接(不再二次解析,杜绝
// rebinding)。检查放在 dial 而非仅请求前,可关闭「预检解析为公网、连接时解析为内网」的
// TOCTOU 窗口。
func ssrfSafeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if isForbiddenIP(ip.IP) {
return nil, errSSRFBlocked
}
}
// 直接用已校验的 IP 拨号,避免 Transport 内部再次走 DNS(可能被 rebind 到内网)。
dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
var lastErr error
for _, ip := range ips {
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port))
if err != nil {
lastErr = err
continue
}
return conn, nil
}
if lastErr == nil {
lastErr = errors.New("no usable address after SSRF filter")
}
return nil, lastErr
}
func rehostFromURL(ctx context.Context, job *SyncImageJob, url string, index int) (string, error) {
// 先确认存储可用,避免存储未配置时白白下载一遍成品图。
if _, err := acquireSyncImageFileSvc(ctx); err != nil {
return "", fmt.Errorf("object storage not configured, cannot re-host image: %w", err)
}
// SSRF 预检:拒绝 file://、gopher:// 等非 http(s) scheme,以及字面量内网 IP。
// 域名主机的网段校验由 syncImageDownloadClient 的 DialContext 在 dial 时兜底。
if err := validateSSRFURL(url); err != nil {
return "", fmt.Errorf("reject image url: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := syncImageDownloadClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download image status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, syncImageDownloadCap))
if err != nil {
return "", err
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "image/png"
}
return rehostImage(ctx, job, data, contentType, extFromContentType(contentType), index)
}
// rehostURLEnabled 判断上游返回 url 时是否下载 re-host(默认透传)。
// 声明为包级变量而非普通函数,便于单测覆盖多图 url 透传路径(无需初始化配置中心)。
var rehostURLEnabled = func(ctx context.Context) bool {
return lcommon.Config().GetBool(ctx, "sync_image_rehost_url")
}
// buildUsageTask 构造供 recordTaskUsage 使用的 AsyncTask(用量日志需要的字段)。
func buildUsageTask(job *SyncImageJob, sel *common.ChannelSelection, status string, actualCost decimal.Decimal, finish time.Time) *common.AsyncTask {
submit := job.SubmitTime
t := &common.AsyncTask{
ID: job.TaskID,
PublicTaskID: job.PublicTaskID,
RequestID: job.RequestID,
Platform: string(constant.TaskPlatformSyncImage),
Status: status,
TenantID: job.TenantID,
UserID: job.UserID,
ApiKeyID: job.ApiKeyID,
ModelName: job.Model,
PreDeductAmount: job.PreDeductAmount,
ActualCost: actualCost,
SubmitTime: &submit,
FinishTime: &finish,
}
if sel != nil {
t.ChannelID = sel.ChannelID
t.UpstreamModel = sel.UpstreamModelName
}
return t
}
// 注:渠道级并发控制已统一由调度引擎的容量租约(softLimit)承担,
// 旧的 per-channel 进程内占槽(tryOccupyChannel/decInflight)随迁移移除。
func syncImageJitter() {
time.Sleep(time.Duration(50+rand.Intn(150)) * time.Millisecond)
}
// truncateStr 将字符串截断到不超过 max 字节,且按 rune 边界截断——避免把多字节 UTF-8
// 字符(如中文)从中间切断,产生非法字节写入 DB 文本列。回退到 max 之内最后一个完整 rune。
func truncateStr(s string, max int) string {
if len(s) <= max {
return s
}
truncated := s[:max]
for len(truncated) > 0 && !utf8.ValidString(truncated) {
truncated = truncated[:len(truncated)-1]
}
return truncated
}
func extFromContentType(ct string) string {
switch {
case bytes.Contains([]byte(ct), []byte("jpeg")), bytes.Contains([]byte(ct), []byte("jpg")):
return ".jpg"
case bytes.Contains([]byte(ct), []byte("webp")):
return ".webp"
default:
return ".png"
}
}