Files
ttstd-infinite-canvas/backend/internal/app/provider_task_cancellation.go
2026-09-14 01:11:56 +08:00

281 lines
11 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 (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
const (
providerCancellationLeaseDuration = time.Minute
providerCancellationMaxAttempts = 41
)
type providerCancellationOutcome string
type geminiOperation map[string]any
const (
providerCancellationPending providerCancellationOutcome = "pending"
providerCancellationConfirmed providerCancellationOutcome = "confirmed"
providerCancellationSucceeded providerCancellationOutcome = "succeeded"
providerCancellationFailed providerCancellationOutcome = "failed"
)
func (s *Service) startProviderCancellationReconciliation(ctx context.Context) {
s.runWorkerLoop(func(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
task, err := s.repo.ClaimNextTaskProviderCancellation("provider-cancel:"+s.workerID, providerCancellationLeaseDuration)
if err != nil || task == nil {
continue
}
reconcileCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
if err := s.reconcileProviderCancellation(reconcileCtx, task); err != nil {
_ = s.log(task.UserID, task.ID, "error", "上游取消状态对账失败", err.Error())
}
cancel()
}
}
})
}
func (s *Service) requestProviderCancellation(ctx context.Context, task *model.Task) error {
if task == nil || task.ID == "" {
return errors.New("任务取消状态无效")
}
s.hydrateTaskProviderRequestID(task)
if task.ProviderRequestID != "" {
if err := s.repo.UpdateTaskProviderState(task.ID, task.ProviderRequestID, task.PollStage, task.NextPollAt); err != nil {
return err
}
}
if err := s.repo.ClaimTaskProviderCancellation(task.UserID, task.ID, time.Now()); err != nil {
if errors.Is(err, repository.ErrTaskProviderCancellationConflict) {
return nil
}
return err
}
if task.ProviderRequestID == "" {
return s.markProviderCancellationUncertain(task, "上游未返回任务 ID,无法发送取消请求,费用待核对")
}
input, err := s.providerCancellationInput(task)
if err != nil {
return s.markProviderCancellationUncertain(task, "读取上游取消配置失败,费用待核对:"+err.Error())
}
if !supportsProviderCancellation(input.Config.InterfaceType) {
return s.markProviderCancellationUncertain(task, "当前上游协议不支持取消,费用待核对")
}
requestCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
requestTask := *task
requestTask.InputJSON = mustJSON(input)
requestCtx = withProviderRequestKind(withProviderAnalytics(requestCtx, s, requestTask), "cancel")
if err := cancelProviderTask(requestCtx, input.Config, task.ProviderRequestID); err != nil {
return s.markProviderCancellationUncertain(task, "上游取消请求结果不明确,费用待核对:"+safeProviderLogError(err))
}
_ = s.log(task.UserID, task.ID, "info", "已请求上游取消,等待供应商确认", task.ProviderRequestID)
return nil
}
func (s *Service) reconcileProviderCancellation(ctx context.Context, task *model.Task) error {
billing := s.taskBilling()
if task.ProviderCancelAttempts >= providerCancellationMaxAttempts {
return s.markProviderCancellationUncertain(task, "上游取消状态长时间未确认,费用待核对")
}
input, err := s.providerCancellationInput(task)
if err != nil {
return s.markProviderCancellationUncertain(task, "读取上游对账配置失败,费用待核对:"+err.Error())
}
queryTask := *task
queryTask.InputJSON = mustJSON(input)
queryCtx := withProviderRequestKind(withProviderAnalytics(ctx, s, queryTask), "cancel-query")
outcome, providerStatus, err := queryProviderCancellation(queryCtx, input.Config, task.ProviderRequestID)
if err != nil {
if task.ProviderCancelAttempts >= providerCancellationMaxAttempts-1 {
return s.markProviderCancellationUncertain(task, "连续查询上游取消状态失败,费用待核对:"+safeProviderLogError(err))
}
next := time.Now().Add(30 * time.Second)
return s.repo.UpdateTaskProviderCancellation(task.ID, model.ProviderCancelStatusRequested, model.ProviderCancelStatusRequested, safeProviderLogError(err), &next, nil)
}
switch outcome {
case providerCancellationConfirmed:
if err := billing.RefundBilling(task.BillingOrderID, "上游已确认取消"); err != nil {
return s.deferProviderCancellation(task, "上游已取消,但积分退回失败:"+err.Error())
}
now := time.Now()
if err := s.repo.UpdateTaskProviderCancellation(task.ID, model.ProviderCancelStatusRequested, model.ProviderCancelStatusConfirmed, "", nil, &now); err != nil {
return err
}
_ = s.log(task.UserID, task.ID, "info", "上游已确认取消,积分已退回", providerStatus)
case providerCancellationSucceeded:
errorText := "取消请求发出前上游已完成,费用已结算"
var billingErr error
if err := billing.SettleBilling(task.BillingOrderID, task.ProviderRequestID); err != nil {
errorText = "取消请求发出前上游已完成,但费用结算失败,需人工核对:" + err.Error()
billingErr = fmt.Errorf("取消确认后的计费结算失败:%w", err)
if uncertainErr := billing.MarkBillingUncertain(task.BillingOrderID, errorText); uncertainErr != nil {
billingErr = errors.Join(billingErr, fmt.Errorf("记录取消确认后的计费待核对状态失败:%w", uncertainErr))
}
}
if err := s.repo.UpdateTaskProviderCancellation(task.ID, model.ProviderCancelStatusRequested, model.ProviderCancelStatusUncertain, errorText, nil, nil); err != nil {
return err
}
_ = s.log(task.UserID, task.ID, "warn", "上游任务在取消确认前已完成", providerStatus)
if billingErr != nil {
return billingErr
}
case providerCancellationFailed:
errorText := "上游任务已失败,取消状态无法确认,积分已退回"
if err := billing.RefundBilling(task.BillingOrderID, errorText); err != nil {
return s.deferProviderCancellation(task, "上游任务已失败,但积分退回失败:"+err.Error())
}
if err := s.repo.UpdateTaskProviderCancellation(task.ID, model.ProviderCancelStatusRequested, model.ProviderCancelStatusUncertain, errorText, nil, nil); err != nil {
return err
}
_ = s.log(task.UserID, task.ID, "warn", errorText, providerStatus)
default:
next := time.Now().Add(15 * time.Second)
return s.repo.UpdateTaskProviderCancellation(task.ID, model.ProviderCancelStatusRequested, model.ProviderCancelStatusRequested, "", &next, nil)
}
return nil
}
func (s *Service) deferProviderCancellation(task *model.Task, errorText string) error {
next := time.Now().Add(30 * time.Second)
return s.repo.UpdateTaskProviderCancellation(task.ID, model.ProviderCancelStatusRequested, model.ProviderCancelStatusRequested, truncateRunes(errorText, 1000), &next, nil)
}
func (s *Service) markProviderCancellationUncertain(task *model.Task, errorText string) error {
message := truncateRunes(errorText, 1000)
if err := s.repo.UpdateTaskProviderCancellation(task.ID, model.ProviderCancelStatusRequested, model.ProviderCancelStatusUncertain, message, nil, nil); err != nil {
return err
}
if err := s.taskBilling().MarkBillingUncertain(task.BillingOrderID, message); err != nil {
_ = s.log(task.UserID, task.ID, "error", "上游取消已转入人工核对,但计费状态更新失败", err.Error())
return err
}
_ = s.log(task.UserID, task.ID, "warn", "上游取消无法确认", message)
return nil
}
func (s *Service) providerCancellationInput(task *model.Task) (canvasGenerationInput, error) {
decrypted, err := s.decryptTaskInputJSON(task.InputJSON)
if err != nil {
return canvasGenerationInput{}, err
}
var input canvasGenerationInput
if err := json.Unmarshal([]byte(decrypted), &input); err != nil {
return canvasGenerationInput{}, err
}
config, err := s.resolveProviderConfig(input.Config)
if err != nil {
return canvasGenerationInput{}, err
}
input.Config = config
return input, nil
}
func supportsProviderCancellation(interfaceType string) bool {
return interfaceType == string(model.ChannelInterfaceGeminiVeo) || interfaceType == string(model.ChannelInterfaceVolcengineArkVideo)
}
func cancelProviderTask(ctx context.Context, config providerConfig, providerRequestID string) error {
switch config.InterfaceType {
case string(model.ChannelInterfaceGeminiVeo):
path := "/" + strings.TrimLeft(providerRequestID, "/") + ":cancel"
return postGeminiJSON(ctx, config, path, map[string]any{}, &map[string]any{})
case string(model.ChannelInterfaceVolcengineArkVideo):
path := "/contents/generations/tasks/" + url.PathEscape(providerRequestID)
return deleteProviderTask(ctx, config, path)
default:
return errors.New("当前上游协议不支持取消")
}
}
func deleteProviderTask(ctx context.Context, config providerConfig, path string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, apiURL(config.BaseURL, path), nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+config.APIKey)
ApplyOutboundHeaders(req, config.Headers)
data, _, err := doBinary(req)
if err != nil {
return err
}
if len(bytes.TrimSpace(data)) > 0 && !json.Valid(data) {
return errors.New("上游取消接口返回了无法识别的内容")
}
return nil
}
func queryProviderCancellation(ctx context.Context, config providerConfig, providerRequestID string) (providerCancellationOutcome, string, error) {
switch config.InterfaceType {
case string(model.ChannelInterfaceGeminiVeo):
// Gemini 用 operation.error 表示取消终态,此处必须保留该字段再判断,
// 不能让通用 JSON 解包提前把它转换成请求失败。
var operation geminiOperation
if err := getGeminiJSON(ctx, config, "/"+strings.TrimLeft(providerRequestID, "/"), &operation); err != nil {
return "", "", err
}
if done, _ := operation["done"].(bool); !done {
return providerCancellationPending, "running", nil
}
if errorValue, ok := operation["error"].(map[string]any); ok {
code := strings.TrimSpace(fmt.Sprint(errorValue["code"]))
message := strings.ToLower(stringField(errorValue, "message"))
if code == "1" || code == "1.0" || strings.Contains(message, "cancel") {
return providerCancellationConfirmed, "cancelled", nil
}
return providerCancellationFailed, firstNonEmpty(message, "failed"), nil
}
return providerCancellationSucceeded, "succeeded", nil
case string(model.ChannelInterfaceVolcengineArkVideo):
var state map[string]any
if err := getJSON(ctx, config, "/contents/generations/tasks/"+url.PathEscape(providerRequestID), &state); err != nil {
return "", "", err
}
if data, ok := state["data"].(map[string]any); ok {
state = data
}
status := strings.ToLower(strings.TrimSpace(stringField(state, "status")))
switch status {
case "cancelled", "canceled":
return providerCancellationConfirmed, status, nil
case "succeeded", "completed":
return providerCancellationSucceeded, status, nil
case "failed", "expired":
return providerCancellationFailed, status, nil
case "queued", "running", "processing", "pending":
return providerCancellationPending, status, nil
default:
return providerCancellationPending, firstNonEmpty(status, "unknown"), nil
}
default:
return "", "", errors.New("当前上游协议不支持取消状态查询")
}
}
func mustJSON(value any) string {
data, _ := json.Marshal(value)
return string(data)
}