Files
team-api/internal/logic/task/async_polling.go
T

458 lines
14 KiB
Go

package task
import (
"context"
"encoding/json"
"io"
"net/http"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/internal/logic/billing"
"github.com/qianfree/team-api/internal/logic/monitor"
"github.com/qianfree/team-api/internal/logic/relay"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
"github.com/qianfree/team-api/relay/taskchannel"
)
const (
pollInterval = 15 * time.Second
taskTimeout = 30 * time.Minute
pollBatchSize = 500
timeoutBatchSize = 100
)
// StartAsyncPolling 启动异步任务轮询 goroutine
func StartAsyncPolling(ctx context.Context) {
g.Log().Info(ctx, "Starting async task polling...")
go func() {
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
g.Log().Info(ctx, "Async task polling stopped")
return
case <-ticker.C:
pollOnce(ctx)
}
}
}()
}
// pollOnce 执行一次轮询
func pollOnce(ctx context.Context) {
if !HasActiveTasks() {
return
}
// 1. 处理超时任务
handleTimedOutTasks(ctx)
// 2. 重试未结算的终态任务
handleUnsettledTasks(ctx)
// 3. 轮询非终态任务
tasks, err := DefaultAsyncProvider.GetNonTerminalTasks(ctx, pollBatchSize)
if err != nil {
g.Log().Warningf(ctx, "poll: get non-terminal tasks: %v", err)
return
}
if len(tasks) == 0 {
return
}
g.Log().Debugf(ctx, "poll: processing %d active tasks", len(tasks))
// 3. 按 platform 分组处理
platformGroups := make(map[string][]*common.AsyncTask)
for _, t := range tasks {
platformGroups[t.Platform] = append(platformGroups[t.Platform], t)
}
for platform, platformTasks := range platformGroups {
processPlatformTasks(ctx, platform, platformTasks)
}
}
// handleTimedOutTasks 处理超时任务
func handleTimedOutTasks(ctx context.Context) {
cutoff := time.Now().Add(-taskTimeout).Unix()
tasks, err := DefaultAsyncProvider.GetTimedOutTasks(ctx, cutoff, timeoutBatchSize)
if err != nil {
g.Log().Warningf(ctx, "poll: get timed-out tasks: %v", err)
return
}
for _, t := range tasks {
t.Status = "FAILURE"
t.FailReason = "task timed out"
now := time.Now()
t.FinishTime = &now
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, t, t.Status); err != nil {
g.Log().Warningf(ctx, "poll: mark timeout task %s: %v", t.PublicTaskID, err)
continue
}
DecrActiveTask()
monitor.UnregisterRequestByTaskID(t.PublicTaskID)
// 退还预扣费用
if t.PreDeductAmount > 0 {
taskBilling := billing.NewTaskBillingProvider()
if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.RequestID, t.PreDeductAmount); err != nil {
g.Log().Warningf(ctx, "poll: refund timed-out task %s: %v", t.PublicTaskID, err)
}
billing.CleanupPreDeduct(ctx, t.TenantID, t.RequestID+"_adjust")
}
g.Log().Infof(ctx, "poll: task %s timed out", t.PublicTaskID)
// 查询渠道信息并记录用量日志
var ch *common.ChannelBasicInfo
if t.ChannelID > 0 {
ch, _ = DefaultAsyncProvider.GetChannelByID(ctx, t.ChannelID)
}
recordTaskUsage(t, ch, false, "task timed out", nil)
// 更新审计记录
recordTaskCompletionAudit(t, "TIMEOUT", "", nil)
}
}
// handleUnsettledTasks 重试终态但未结算的任务
func handleUnsettledTasks(ctx context.Context) {
tasks, err := DefaultAsyncProvider.GetUnsettledTasks(ctx, timeoutBatchSize)
if err != nil {
g.Log().Warningf(ctx, "poll: get unsettled tasks: %v", err)
return
}
for _, t := range tasks {
var pd privateData
if err := json.Unmarshal(t.PrivateData, &pd); err != nil || pd.UpstreamTaskID == "" {
// 无法恢复,直接退还预扣
g.Log().Warningf(ctx, "poll: unsettled task %s has invalid private_data, refunding", t.PublicTaskID)
taskBilling := billing.NewTaskBillingProvider()
if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.RequestID, t.PreDeductAmount); err != nil {
g.Log().Errorf(ctx, "poll: refund unsettled task %s: %v", t.PublicTaskID, err)
} else {
t.BillingSettled = true
DefaultAsyncProvider.UpdateTask(ctx, t)
}
billing.CleanupPreDeduct(ctx, t.TenantID, t.RequestID+"_adjust")
continue
}
if t.Status == "SUCCESS" {
// 成功任务:用 ActualCost(已在上次轮询中计算)结算
actualCost := t.ActualCost
if actualCost <= 0 {
actualCost = t.PreDeductAmount
}
taskBilling := billing.NewTaskBillingProvider()
_, err := taskBilling.SettleTaskSuccess(ctx, t.TenantID, t.UserID, t.ApiKeyID, t.ChannelID,
t.ModelName, t.RequestID, actualCost, t.PreDeductAmount,
0, 0, pd.BillingContext.Ratios, t.PublicTaskID)
if err != nil {
g.Log().Warningf(ctx, "poll: retry settle task %s: %v", t.PublicTaskID, err)
} else {
t.BillingSettled = true
t.ActualCost = actualCost
DefaultAsyncProvider.UpdateTask(ctx, t)
g.Log().Infof(ctx, "poll: retried settlement for task %s", t.PublicTaskID)
}
} else {
// 失败任务:退还预扣
taskBilling := billing.NewTaskBillingProvider()
if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.RequestID, t.PreDeductAmount); err != nil {
g.Log().Warningf(ctx, "poll: retry refund task %s: %v", t.PublicTaskID, err)
} else {
t.BillingSettled = true
DefaultAsyncProvider.UpdateTask(ctx, t)
g.Log().Infof(ctx, "poll: retried refund for task %s", t.PublicTaskID)
}
billing.CleanupPreDeduct(ctx, t.TenantID, t.RequestID+"_adjust")
}
}
}
// processPlatformTasks 处理同一平台的任务
func processPlatformTasks(ctx context.Context, platform string, tasks []*common.AsyncTask) {
// 按 channel_id 分组
channelGroups := make(map[int64][]*common.AsyncTask)
for _, t := range tasks {
channelGroups[t.ChannelID] = append(channelGroups[t.ChannelID], t)
}
for channelID, channelTasks := range channelGroups {
processChannelTasks(ctx, channelID, channelTasks)
}
}
// processChannelTasks 处理同一渠道的任务
func processChannelTasks(ctx context.Context, channelID int64, tasks []*common.AsyncTask) {
// 获取渠道信息
channel, err := DefaultAsyncProvider.GetChannelByID(ctx, channelID)
if err != nil || channel == nil {
g.Log().Warningf(ctx, "poll: get channel %d: %v", channelID, err)
return
}
providerType := constant.ProviderType(channel.Type)
adaptor, err := taskchannel.GetAdaptor(providerType)
if err != nil {
g.Log().Warningf(ctx, "poll: get adaptor for channel %d: %v", channelID, err)
return
}
for _, t := range tasks {
pollSingleTask(ctx, adaptor, channel, t)
}
}
// privateData PrivateData 反序列化结构
type privateData struct {
UpstreamTaskID string `json:"upstream_task_id"`
TaskType string `json:"task_type"`
BillingContext struct {
Ratios map[string]float64 `json:"ratios"`
ModelName string `json:"model_name"`
PreDeduct float64 `json:"pre_deduct"`
} `json:"billing_context"`
}
// pollSingleTask 轮询单个任务
func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *common.ChannelBasicInfo, task *common.AsyncTask) {
// 从 PrivateData 提取上游任务 ID 和计费上下文
var pd privateData
if err := json.Unmarshal(task.PrivateData, &pd); err != nil || pd.UpstreamTaskID == "" {
g.Log().Warningf(ctx, "poll: invalid private data for task %s", task.PublicTaskID)
return
}
// 查询上游状态
taskData, _ := json.Marshal(map[string]any{
"task_id": pd.UpstreamTaskID,
})
resp, err := adaptor.FetchTask(channel.BaseURL, channel.ApiKey, taskData)
if err != nil {
g.Log().Warningf(ctx, "poll: fetch task %s: %v", task.PublicTaskID, err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
g.Log().Warningf(ctx, "poll: read response for task %s: %v", task.PublicTaskID, err)
return
}
// 解析结果
taskInfo, err := adaptor.ParseTaskResult(body)
if err != nil {
g.Log().Warningf(ctx, "poll: parse result for task %s: %v", task.PublicTaskID, err)
return
}
// CAS 更新状态
oldStatus := task.Status
task.Status = string(taskInfo.Status)
task.Progress = taskInfo.Progress
task.Data = body
if taskInfo.FailReason != "" {
task.FailReason = taskInfo.FailReason
}
now := time.Now()
switch {
case taskInfo.Status == common.TaskStatusInProgress:
task.StartTime = &now
case taskInfo.Status.IsTerminal():
task.FinishTime = &now
if task.StartTime == nil {
task.StartTime = &now
}
// 保留上游返回的 token 用量
if taskInfo.TotalTokens > 0 {
task.CompletionTokens = taskInfo.CompletionTokens
task.PromptTokens = taskInfo.PromptTokens
task.TotalTokens = taskInfo.TotalTokens
}
}
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, task, oldStatus); err != nil {
g.Log().Debugf(ctx, "poll: CAS conflict for task %s (status changed by another process): %v", task.PublicTaskID, err)
return
}
if taskInfo.Status.IsTerminal() {
DecrActiveTask()
monitor.UnregisterRequestByTaskID(task.PublicTaskID)
}
// 处理终态
if taskInfo.Status == common.TaskStatusSuccess {
task.ResultURL = taskInfo.ResultURL
DefaultAsyncProvider.UpdateTask(ctx, task) // 更新 result_url
// 结算计费
var settleResult *common.SettlementResult
if task.PreDeductAmount > 0 && !task.BillingSettled {
taskBilling := billing.NewTaskBillingProvider()
actualCost := task.PreDeductAmount
// 优先用上游返回的 ActualCost
if taskInfo.ActualCost > 0 {
actualCost = taskInfo.ActualCost
} else if taskInfo.TotalTokens > 0 && pd.BillingContext.Ratios != nil {
// 用上游 total_tokens + 保存的 ratios 重算
if tokenCost, err := taskBilling.RecalculateByTokens(ctx, task.TenantID, task.ModelName, taskInfo.TotalTokens, pd.BillingContext.Ratios); err == nil && tokenCost > 0 {
actualCost = tokenCost
}
}
task.ActualCost = actualCost
settleResult, err = taskBilling.SettleTaskSuccess(ctx, task.TenantID, task.UserID, task.ApiKeyID, task.ChannelID, task.ModelName, task.RequestID, actualCost, task.PreDeductAmount, taskInfo.TotalTokens, taskInfo.CompletionTokens, pd.BillingContext.Ratios, task.PublicTaskID)
if err != nil {
g.Log().Warningf(ctx, "poll: settle task %s: %v", task.PublicTaskID, err)
} else {
task.BillingSettled = true
DefaultAsyncProvider.UpdateTask(ctx, task)
}
billing.CleanupPreDeduct(ctx, task.TenantID, task.RequestID+"_adjust")
}
g.Log().Infof(ctx, "poll: task %s completed", task.PublicTaskID)
// 记录用量日志
recordTaskUsage(task, channel, true, "", settleResult)
// 更新审计记录
recordTaskCompletionAudit(task, "SUCCESS", string(body), upstreamRespHeaders(resp))
} else if taskInfo.Status == common.TaskStatusFailure {
// 退还预扣费用
if task.PreDeductAmount > 0 && !task.BillingSettled {
taskBilling := billing.NewTaskBillingProvider()
if err := taskBilling.SettleTaskFailed(ctx, task.TenantID, task.RequestID, task.PreDeductAmount); err != nil {
g.Log().Warningf(ctx, "poll: refund failed task %s: %v", task.PublicTaskID, err)
} else {
task.BillingSettled = true
DefaultAsyncProvider.UpdateTask(ctx, task)
}
billing.CleanupPreDeduct(ctx, task.TenantID, task.RequestID+"_adjust")
}
g.Log().Infof(ctx, "poll: task %s failed: %s", task.PublicTaskID, task.FailReason)
// 记录用量日志
recordTaskUsage(task, channel, false, task.FailReason, nil)
// 更新审计记录
recordTaskCompletionAudit(task, "FAILURE", string(body), upstreamRespHeaders(resp))
}
}
// recordTaskUsage 异步记录视频/音乐等异步任务的用量日志
func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, success bool, errMsg string, settleResult *common.SettlementResult) {
latencyMs := 0
if task.SubmitTime != nil && task.FinishTime != nil {
latencyMs = int(task.FinishTime.Sub(*task.SubmitTime).Milliseconds())
}
// 提取渠道名称和类型
var channelName string
var channelType int
if channel != nil {
channelName = channel.Name
channelType = channel.Type
}
status := "success"
if !success {
status = "error"
}
record := &common.UsageRecord{
TenantID: task.TenantID,
UserID: task.UserID,
ApiKeyID: task.ApiKeyID,
ChannelID: task.ChannelID,
ChannelName: channelName,
ChannelType: channelType,
ModelName: task.ModelName,
RelayMode: int(constant.RelayModeVideoGenerations),
RequestType: 3, // async
LatencyMs: float64(latencyMs),
IsStream: false,
Success: success,
RequestID: task.RequestID,
Status: status,
ErrorMessage: errMsg,
PromptTokens: task.PromptTokens,
CompletionTokens: task.CompletionTokens,
TotalTokens: task.TotalTokens,
TotalCost: task.ActualCost,
ActualCost: task.ActualCost,
PreDeductAmount: task.PreDeductAmount,
BillingSource: "task",
TaskID: task.PublicTaskID,
}
// 从结算结果填充计费快照
if settleResult != nil {
record.BillingSnapshot = settleResult.BillingSnapshot
record.BillingSummary = settleResult.BillingSummary
record.BillingMode = settleResult.BillingMode
record.BillingSource = settleResult.BillingSource
record.RateMultiplier = settleResult.RateMultiplier
record.InputCost = settleResult.InputCost
record.OutputCost = settleResult.OutputCost
record.RefundAmount = settleResult.RefundAmount
record.SupplementAmount = settleResult.SupplementAmount
record.Currency = "USD"
} else {
// 失败/超时任务:从定价中获取计费模式
billingMode := "per_request"
if pricing, err := billing.GetModelPrice(context.Background(), task.TenantID, task.ModelName); err == nil {
if pricing.BillingMode != "" {
billingMode = pricing.BillingMode
}
}
record.BillingMode = billingMode
}
relay.NewDataProvider().RecordUsage(context.Background(), record)
}
// recordTaskCompletionAudit 更新提交阶段写入的审计记录,补充异步任务最终结果
func recordTaskCompletionAudit(task *common.AsyncTask, status string, resultBody string, upstreamHeaders map[string]string) {
now := time.Now()
relay.NewDataProvider().UpdateTaskAudit(context.Background(), &common.AuditRecord{
TenantID: task.TenantID,
TaskID: task.PublicTaskID,
TaskStatus: status,
TaskResult: resultBody,
TaskUpstreamHeaders: upstreamHeaders,
TaskCompletedAt: &now,
})
}
// upstreamRespHeaders 从上游 HTTP 响应中提取响应头
func upstreamRespHeaders(resp *http.Response) map[string]string {
if resp == nil || resp.Header == nil {
return nil
}
headers := make(map[string]string)
for k, vals := range resp.Header {
if len(vals) > 0 {
headers[k] = vals[0]
}
}
return headers
}