Files
ttstd-infinite-canvas/backend/internal/app/provider_task_recovery.go

440 lines
16 KiB
Go
Raw 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 app
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"sync"
"time"
"yingce/backend/internal/model"
"yingce/backend/internal/repository"
)
const providerTaskRecoveryLeaseDuration = 10 * time.Minute
type ProviderTaskQueryResult struct {
Task *model.Task `json:"task"`
ProviderStatus string `json:"providerStatus"`
Recovered bool `json:"recovered"`
BillingSettled bool `json:"billingSettled"`
Error string `json:"error,omitempty"`
}
type ProviderTaskRecoveryItem struct {
LogID string `json:"logId"`
Recovered bool `json:"recovered"`
ProviderStatus string `json:"providerStatus,omitempty"`
Error string `json:"error,omitempty"`
}
type ProviderTaskRecoveryBatchResult struct {
Items []ProviderTaskRecoveryItem `json:"items"`
Success int `json:"success"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
}
func (s *Service) QueryFailedVideoTask(ctx context.Context, userID string, taskID string) (*ProviderTaskQueryResult, error) {
task, err := s.repo.TaskForUser(strings.TrimSpace(userID), strings.TrimSpace(taskID))
if err != nil {
return nil, err
}
return s.queryFailedVideoTask(ctx, task, strings.TrimSpace(userID))
}
func (s *Service) AdminQueryFailedVideoTask(ctx context.Context, actor *model.User, logID string, requestedProviderID string) (result *ProviderTaskQueryResult, returnErr error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
log, err := s.repo.APICallLog(strings.TrimSpace(logID))
if err != nil {
return nil, err
}
var task *model.Task
defer func() {
metadata := map[string]any{
"apiCallLogId": log.ID,
"taskId": log.TaskID,
"suppliedProviderRequestId": strings.TrimSpace(requestedProviderID),
"providerRequestId": "",
"providerStatus": "",
"recovered": result != nil && result.Recovered,
"outcome": "failed",
"error": errorString(returnErr),
}
if task != nil {
metadata["providerRequestId"] = task.ProviderRequestID
}
if result != nil {
metadata["providerStatus"] = result.ProviderStatus
if result.Recovered {
metadata["outcome"] = "recovered"
} else {
metadata["outcome"] = "provider_processing"
}
}
_ = s.appendAdminAudit(actor, "api_log.query_provider_task", "api_call_log", log.ID, "人工查询失败视频任务", metadata)
}()
if log.Capability != "video" || strings.TrimSpace(log.TaskID) == "" {
return nil, BadAuthRequest("该请求没有可查询的视频任务")
}
task, returnErr = s.repo.Task(log.TaskID)
if returnErr != nil {
return nil, returnErr
}
if task.UserID != log.UserID {
return nil, BadAuthRequest("请求与任务归属不一致")
}
if task.Status == model.TaskStatusSucceeded {
result = &ProviderTaskQueryResult{Task: taskForOutput(*task), ProviderStatus: "succeeded", Recovered: true, BillingSettled: s.billingSettled(task)}
return result, nil
}
if strings.TrimSpace(requestedProviderID) != "" {
task.ProviderRequestID = strings.TrimSpace(requestedProviderID)
}
if task.ProviderRequestID == "" {
task.ProviderRequestID = strings.TrimSpace(log.ProviderRequestID)
}
result, returnErr = s.queryFailedVideoTask(ctx, task, "")
return result, returnErr
}
func (s *Service) AdminRecoverVideoByURL(ctx context.Context, actor *model.User, logID string, rawURL string, providerRequestID string) (result *ProviderTaskQueryResult, returnErr error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
log, task, err := s.adminRecoveryTask(logID)
if err != nil {
return nil, err
}
parsedURL := ""
defer func() {
metadata := map[string]any{
"apiCallLogId": log.ID,
"taskId": task.ID,
"suppliedProviderRequestId": strings.TrimSpace(providerRequestID),
"providerRequestId": task.ProviderRequestID,
"url": parsedURL,
"recovered": result != nil && result.Recovered,
"outcome": "failed",
"error": errorString(returnErr),
}
if result != nil {
metadata["providerStatus"] = result.ProviderStatus
if result.Recovered {
metadata["outcome"] = "recovered"
}
}
_ = s.appendAdminAudit(actor, "api_log.recover_video_url", "api_call_log", log.ID, "人工转存上游视频并回写", metadata)
}()
if task.Status == model.TaskStatusSucceeded {
result = &ProviderTaskQueryResult{Task: taskForOutput(*task), ProviderStatus: "succeeded", Recovered: true, BillingSettled: s.billingSettled(task)}
return result, nil
}
if task.Status != model.TaskStatusFailed {
return nil, BadAuthRequest("只能恢复失败的视频任务")
}
if strings.TrimSpace(providerRequestID) != "" {
task.ProviderRequestID = strings.TrimSpace(providerRequestID)
}
if task.ProviderRequestID == "" {
task.ProviderRequestID = strings.TrimSpace(log.ProviderRequestID)
}
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, BadAuthRequest("视频 URL 只支持有效的 http/https 地址")
}
parsedURL = recoveryURLForAudit(parsed)
owner := "manual-recovery:" + newID()
if err := s.repo.ClaimFailedTaskProviderRecovery(task.ID, "", owner, providerTaskRecoveryLeaseDuration); err != nil {
if errors.Is(err, repository.ErrTaskProviderRecoveryConflict) {
return nil, &AuthError{Status: 409, Message: "该任务正在恢复中,请稍后再试"}
}
return nil, err
}
task.LeaseOwner = owner
defer func() { _ = s.repo.ReleaseTaskProviderRecovery(task.ID, owner) }()
policy, err := s.RuntimePolicy()
if err != nil {
return nil, err
}
payload, err := downloadRemoteResource(parsed.String(), megabytes(policy.Resource.GeneratedFileMB)+1)
if err != nil {
return nil, err
}
if !strings.HasPrefix(strings.ToLower(payload.mimeType), "video/") {
return nil, BadAuthRequest("上游地址返回的不是视频文件")
}
resultPayload := map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(payload.mimeType, payload.data), "mimeType": payload.mimeType}}
resultPayload, err = s.persistGeneratedMediaResult(task.UserID, resultPayload)
if err != nil {
return nil, err
}
resultJSON, err := json.Marshal(resultPayload)
if err != nil {
return nil, err
}
task.Error = ""
task.PollStage = "succeeded"
task.NextPollAt = nil
if err := s.saveTaskCompletionWithinStorageQuota(task, resultJSON, nil, false); err != nil {
return nil, err
}
if err := s.settleRecoveredBilling(task, task.ProviderRequestID); err != nil {
uncertainErr := s.taskBilling().MarkBillingUncertain(task.BillingOrderID, "人工转存视频已成功,但积分结算失败:"+err.Error())
if uncertainErr != nil {
return nil, errors.Join(err, uncertainErr)
}
return nil, err
}
if err := s.RegisterTaskOutputFromTask(*task); err != nil {
return nil, err
}
result = &ProviderTaskQueryResult{Task: taskForOutput(*task), ProviderStatus: "succeeded", Recovered: true, BillingSettled: true}
return result, nil
}
func (s *Service) AdminBatchQueryFailedVideoTasks(ctx context.Context, actor *model.User, logIDs []string) (*ProviderTaskRecoveryBatchResult, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
ids := uniqueNonEmpty(logIDs)
result := &ProviderTaskRecoveryBatchResult{Items: make([]ProviderTaskRecoveryItem, len(ids))}
sem := make(chan struct{}, 4)
var wg sync.WaitGroup
for index, id := range ids {
index, id := index, id
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
item := ProviderTaskRecoveryItem{LogID: id}
log, logErr := s.repo.APICallLog(id)
if logErr != nil {
item.Error = logErr.Error()
_ = s.appendAdminAudit(actor, "api_log.query_provider_task", "api_call_log", id, "批量查询视频任务失败", map[string]any{"apiCallLogId": id, "outcome": "failed", "error": item.Error})
result.Items[index] = item
return
}
if log.Capability != "video" {
item.Error = "跳过:不是视频请求明细"
_ = s.appendAdminAudit(actor, "api_log.query_provider_task", "api_call_log", id, "批量查询跳过非视频请求", map[string]any{"apiCallLogId": id, "outcome": "skipped", "error": item.Error})
result.Items[index] = item
return
}
queryResult, queryErr := s.AdminQueryFailedVideoTask(ctx, actor, id, "")
if queryErr != nil {
item.Error = queryErr.Error()
} else {
item.Recovered, item.ProviderStatus = queryResult.Recovered, queryResult.ProviderStatus
}
result.Items[index] = item
}()
}
wg.Wait()
for _, item := range result.Items {
if strings.HasPrefix(item.Error, "跳过:") {
result.Skipped++
} else if item.Error != "" || !item.Recovered {
result.Failed++
} else {
result.Success++
}
}
return result, nil
}
func (s *Service) adminRecoveryTask(logID string) (*model.ApiCallLog, *model.Task, error) {
log, err := s.repo.APICallLog(strings.TrimSpace(logID))
if err != nil {
return nil, nil, err
}
if log.Capability != "video" || strings.TrimSpace(log.TaskID) == "" {
return nil, nil, BadAuthRequest("该请求没有可恢复的视频任务")
}
task, err := s.repo.Task(log.TaskID)
if err != nil {
return nil, nil, err
}
if task.UserID != log.UserID {
return nil, nil, BadAuthRequest("请求与任务归属不一致")
}
return log, task, nil
}
func (s *Service) billingSettled(task *model.Task) bool {
if task == nil || task.BillingOrderID == "" {
return true
}
order, err := s.repo.BillingOrder(task.BillingOrderID)
return err == nil && order.Status == model.BillingStatusSettled
}
func (s *Service) settleRecoveredBilling(task *model.Task, providerRequestID string) error {
if task == nil || task.BillingOrderID == "" {
return nil
}
billing := s.taskBilling()
order, err := billing.BillingOrder(task.BillingOrderID)
if err != nil {
return err
}
if order == nil {
return fmt.Errorf("任务计费订单不存在:%s", task.BillingOrderID)
}
if order.Status == model.BillingStatusSettled {
return nil
}
if order.Status == model.BillingStatusRefunded {
return billing.RestoreRefundedBilling(task.BillingOrderID, providerRequestID)
}
return billing.SettleBilling(task.BillingOrderID, providerRequestID)
}
func errorString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func recoveryURLForAudit(value *url.URL) string {
if value == nil {
return ""
}
return value.Scheme + "://" + value.Host + value.Path
}
func (s *Service) queryFailedVideoTask(ctx context.Context, task *model.Task, claimUserID string) (*ProviderTaskQueryResult, error) {
billing := s.taskBilling()
ctx = withProtocolRegistry(ctx, s.protocolRegistry())
if task == nil || task.ID == "" {
return nil, BadAuthRequest("任务不存在")
}
if task.Status != model.TaskStatusFailed {
return nil, BadAuthRequest("只能人工查询状态为失败的任务")
}
if task.MediaRecoveryJSON != "" {
return nil, BadAuthRequest("该任务已生成作品,请在任务中心重试保存,无需重新查询生成")
}
if !strings.HasPrefix(task.Type, "canvas_video") && !strings.HasPrefix(task.Type, "video_") {
return nil, BadAuthRequest("该任务不是视频生成任务")
}
s.hydrateTaskProviderRequestID(task)
providerRequestID := strings.TrimSpace(task.ProviderRequestID)
if providerRequestID == "" {
return nil, BadAuthRequest("该任务没有可恢复的上游任务 ID")
}
if task.BillingOrderID != "" {
order, err := s.repo.BillingOrder(task.BillingOrderID)
if err != nil {
return nil, err
}
if order.UserID != task.UserID || order.TaskID != task.ID {
return nil, BadAuthRequest("任务与计费订单归属不一致")
}
}
decryptedInput, err := s.decryptTaskInputJSON(task.InputJSON)
if err != nil {
return nil, fmt.Errorf("读取任务配置失败:%w", err)
}
var input canvasGenerationInput
if err := json.Unmarshal([]byte(decryptedInput), &input); err != nil {
return nil, fmt.Errorf("任务输入解析失败:%w", err)
}
config, err := s.resolveProviderConfig(input.Config)
if err != nil {
return nil, err
}
ctx = ensureOfficialProtocolAdapter(ctx, config.InterfaceType)
adapter, declarative := declarativeProtocolAdapterForContext(ctx, config.InterfaceType)
if !declarative {
return nil, BadAuthRequest("该任务的请求协议不支持安全查询上游状态")
}
input.Config = config
task.InputJSON = decryptedInput
task.ProviderRequestID = providerRequestID
owner := "manual-recovery:" + newID()
if err := s.repo.ClaimFailedTaskProviderRecovery(task.ID, claimUserID, owner, providerTaskRecoveryLeaseDuration); err != nil {
if errors.Is(err, repository.ErrTaskProviderRecoveryConflict) {
return nil, &AuthError{Status: 409, Message: "该任务正在查询上游状态,请稍后再试"}
}
return nil, err
}
task.LeaseOwner = owner
defer func() {
if releaseErr := s.repo.ReleaseTaskProviderRecovery(task.ID, owner); releaseErr != nil {
_ = s.log(task.UserID, task.ID, "error", "人工查询租约释放失败", releaseErr.Error())
}
}()
// 上游已成功后的完整媒体下载和本地入库可能持续数十秒。浏览器关闭抽屉、
// 页面刷新或代理断开都不应中断这项运维恢复,否则任务会再次停在
// failed/refunded。保留请求值用于审计,但把执行生命周期交给恢复租约控制。
recoveryCtx, cancelRecovery := providerTaskRecoveryContext(ctx)
defer cancelRecovery()
queryCtx := withProviderAnalytics(recoveryCtx, s, *task)
var result map[string]interface{}
var providerStatus string
result, providerStatus, err = queryProtocolAdapterVideoTask(queryCtx, input, adapter, providerRequestID)
if err != nil {
_ = s.log(task.UserID, task.ID, "error", "人工查询上游视频任务失败", err.Error())
return nil, err
}
if result == nil {
_ = s.log(task.UserID, task.ID, "info", "人工查询完成,上游任务仍在处理", providerStatus)
return &ProviderTaskQueryResult{Task: taskForOutput(*task), ProviderStatus: providerStatus, Recovered: false}, nil
}
result, err = s.persistGeneratedMediaResult(task.UserID, result)
if err != nil {
_ = s.log(task.UserID, task.ID, "error", "人工查询已取得视频,但结果保存失败", err.Error())
return nil, err
}
resultJSON, err := json.Marshal(result)
if err != nil {
return nil, err
}
task.Error = ""
task.PollStage = strings.ToLower(providerStatus)
task.NextPollAt = nil
if err := s.saveTaskCompletionWithinStorageQuota(task, resultJSON, nil, false); err != nil {
uncertainErr := billing.MarkBillingUncertain(task.BillingOrderID, "人工查询确认上游成功,但任务结果未保存:"+err.Error())
_ = s.log(task.UserID, task.ID, "error", "人工查询已取得视频,但任务恢复失败", err.Error())
if uncertainErr != nil {
return nil, errors.Join(err, fmt.Errorf("记录任务结果未保存的计费待核对状态失败:%w", uncertainErr))
}
return nil, err
}
billingSettled := true
var billingErr error
billingErr = s.settleRecoveredBilling(task, providerRequestID)
if billingErr != nil {
billingSettled = false
uncertainErr := billing.MarkBillingUncertain(task.BillingOrderID, "人工查询确认生成成功,但积分结算失败:"+billingErr.Error())
_ = s.log(task.UserID, task.ID, "error", "任务恢复成功但积分结算失败,已进入待核对", billingErr.Error())
if uncertainErr != nil {
return nil, errors.Join(billingErr, fmt.Errorf("记录任务恢复后的计费待核对状态失败:%w", uncertainErr))
}
return nil, billingErr
}
if err := s.RegisterTaskOutputFromTask(*task); err != nil {
_ = s.log(task.UserID, task.ID, "error", "任务恢复成功但项目产物登记失败", err.Error())
return nil, fmt.Errorf("任务已恢复并完成扣费,但项目素材登记失败:%w", err)
}
_ = s.log(task.UserID, task.ID, "info", "人工查询确认生成成功,任务已恢复、完成结算并登记项目产物", providerStatus)
return &ProviderTaskQueryResult{Task: taskForOutput(*task), ProviderStatus: providerStatus, Recovered: true, BillingSettled: billingSettled}, nil
}
func providerTaskRecoveryContext(parent context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(parent), providerTaskRecoveryLeaseDuration)
}