You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

568 lines
19 KiB

  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "sort"
  9. "strings"
  10. "time"
  11. "github.com/QuantumNous/new-api/common"
  12. "github.com/QuantumNous/new-api/constant"
  13. "github.com/QuantumNous/new-api/dto"
  14. "github.com/QuantumNous/new-api/logger"
  15. "github.com/QuantumNous/new-api/model"
  16. "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
  17. relaycommon "github.com/QuantumNous/new-api/relay/common"
  18. "github.com/QuantumNous/new-api/types"
  19. "github.com/samber/lo"
  20. )
  21. // TaskPollingAdaptor 定义轮询所需的最小适配器接口,避免 service -> relay 的循环依赖
  22. type TaskPollingAdaptor interface {
  23. Init(info *relaycommon.RelayInfo)
  24. FetchTask(baseURL string, key string, body map[string]any, proxy string) (*http.Response, error)
  25. ParseTaskResult(body []byte) (*relaycommon.TaskInfo, error)
  26. // AdjustBillingOnComplete 在任务到达终态(成功/失败)时由轮询循环调用。
  27. // 返回正数触发差额结算(补扣/退还),返回 0 保持预扣费金额不变。
  28. AdjustBillingOnComplete(task *model.Task, taskResult *relaycommon.TaskInfo) int
  29. }
  30. // GetTaskAdaptorFunc 由 main 包注入,用于获取指定平台的任务适配器。
  31. // 打破 service -> relay -> relay/channel -> service 的循环依赖。
  32. var GetTaskAdaptorFunc func(platform constant.TaskPlatform) TaskPollingAdaptor
  33. // sweepTimedOutTasks 在主轮询之前独立清理超时任务。
  34. // 每次最多处理 100 条,剩余的下个周期继续处理。
  35. // 使用 per-task CAS (UpdateWithStatus) 防止覆盖被正常轮询已推进的任务。
  36. func sweepTimedOutTasks(ctx context.Context) {
  37. if constant.TaskTimeoutMinutes <= 0 {
  38. return
  39. }
  40. cutoff := time.Now().Unix() - int64(constant.TaskTimeoutMinutes)*60
  41. tasks := model.GetTimedOutUnfinishedTasks(cutoff, 100)
  42. if len(tasks) == 0 {
  43. return
  44. }
  45. const legacyTaskCutoff int64 = 1740182400 // 2026-02-22 00:00:00 UTC
  46. reason := fmt.Sprintf("任务超时(%d分钟)", constant.TaskTimeoutMinutes)
  47. legacyReason := "任务超时(旧系统遗留任务,不进行退款,请联系管理员)"
  48. now := time.Now().Unix()
  49. timedOutCount := 0
  50. for _, task := range tasks {
  51. isLegacy := task.SubmitTime > 0 && task.SubmitTime < legacyTaskCutoff
  52. oldStatus := task.Status
  53. task.Status = model.TaskStatusFailure
  54. task.Progress = "100%"
  55. task.FinishTime = now
  56. if isLegacy {
  57. task.FailReason = legacyReason
  58. } else {
  59. task.FailReason = reason
  60. }
  61. won, err := task.UpdateWithStatus(oldStatus)
  62. if err != nil {
  63. logger.LogError(ctx, fmt.Sprintf("sweepTimedOutTasks CAS update error for task %s: %v", task.TaskID, err))
  64. continue
  65. }
  66. if !won {
  67. logger.LogInfo(ctx, fmt.Sprintf("sweepTimedOutTasks: task %s already transitioned, skip", task.TaskID))
  68. continue
  69. }
  70. timedOutCount++
  71. if !isLegacy && task.Quota != 0 {
  72. RefundTaskQuota(ctx, task, reason)
  73. }
  74. }
  75. if timedOutCount > 0 {
  76. logger.LogInfo(ctx, fmt.Sprintf("sweepTimedOutTasks: timed out %d tasks", timedOutCount))
  77. }
  78. }
  79. // TaskPollingLoop 主轮询循环,每 15 秒检查一次未完成的任务
  80. func TaskPollingLoop() {
  81. for {
  82. time.Sleep(time.Duration(15) * time.Second)
  83. common.SysLog("任务进度轮询开始")
  84. ctx := context.TODO()
  85. sweepTimedOutTasks(ctx)
  86. allTasks := model.GetAllUnFinishSyncTasks(constant.TaskQueryLimit)
  87. platformTask := make(map[constant.TaskPlatform][]*model.Task)
  88. for _, t := range allTasks {
  89. platformTask[t.Platform] = append(platformTask[t.Platform], t)
  90. }
  91. for platform, tasks := range platformTask {
  92. if len(tasks) == 0 {
  93. continue
  94. }
  95. taskChannelM := make(map[int][]string)
  96. taskM := make(map[string]*model.Task)
  97. nullTaskIds := make([]int64, 0)
  98. for _, task := range tasks {
  99. upstreamID := task.GetUpstreamTaskID()
  100. if upstreamID == "" {
  101. // 统计失败的未完成任务
  102. nullTaskIds = append(nullTaskIds, task.ID)
  103. continue
  104. }
  105. taskM[upstreamID] = task
  106. taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], upstreamID)
  107. }
  108. if len(nullTaskIds) > 0 {
  109. err := model.TaskBulkUpdateByID(nullTaskIds, map[string]any{
  110. "status": "FAILURE",
  111. "progress": "100%",
  112. })
  113. if err != nil {
  114. logger.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err))
  115. } else {
  116. logger.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds))
  117. }
  118. }
  119. if len(taskChannelM) == 0 {
  120. continue
  121. }
  122. DispatchPlatformUpdate(platform, taskChannelM, taskM)
  123. }
  124. common.SysLog("任务进度轮询完成")
  125. }
  126. }
  127. // DispatchPlatformUpdate 按平台分发轮询更新
  128. func DispatchPlatformUpdate(platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) {
  129. switch platform {
  130. case constant.TaskPlatformMidjourney:
  131. // MJ 轮询由其自身处理,这里预留入口
  132. case constant.TaskPlatformSuno:
  133. _ = UpdateSunoTasks(context.Background(), taskChannelM, taskM)
  134. default:
  135. if err := UpdateVideoTasks(context.Background(), platform, taskChannelM, taskM); err != nil {
  136. common.SysLog(fmt.Sprintf("UpdateVideoTasks fail: %s", err))
  137. }
  138. }
  139. }
  140. func failTaskWithRefund(ctx context.Context, task *model.Task, reason string, finishTime int64, fromStatus ...model.TaskStatus) {
  141. if task == nil {
  142. return
  143. }
  144. oldStatus := task.Status
  145. if len(fromStatus) > 0 {
  146. oldStatus = fromStatus[0]
  147. }
  148. task.Status = model.TaskStatusFailure
  149. task.Progress = taskcommon.ProgressComplete
  150. task.FinishTime = finishTime
  151. task.FailReason = reason
  152. won, err := task.UpdateWithStatus(oldStatus)
  153. if err != nil {
  154. logger.LogError(ctx, fmt.Sprintf("failTaskWithRefund CAS update error for task %s: %v", task.TaskID, err))
  155. return
  156. }
  157. if !won {
  158. logger.LogInfo(ctx, fmt.Sprintf("failTaskWithRefund: task %s already transitioned, skip refund", task.TaskID))
  159. return
  160. }
  161. if task.Quota != 0 {
  162. RefundTaskQuota(ctx, task, reason)
  163. }
  164. }
  165. func failTasksForChannel(ctx context.Context, taskIds []string, taskM map[string]*model.Task, reason string) {
  166. now := time.Now().Unix()
  167. for _, upstreamID := range taskIds {
  168. if t, ok := taskM[upstreamID]; ok {
  169. failTaskWithRefund(ctx, t, reason, now)
  170. }
  171. }
  172. }
  173. // UpdateSunoTasks 按渠道更新所有 Suno 任务
  174. func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
  175. for channelId, taskIds := range taskChannelM {
  176. err := updateSunoTasks(ctx, channelId, taskIds, taskM)
  177. if err != nil {
  178. logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %s", channelId, err.Error()))
  179. }
  180. }
  181. return nil
  182. }
  183. func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error {
  184. logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
  185. if len(taskIds) == 0 {
  186. return nil
  187. }
  188. ch, err := model.CacheGetChannel(channelId)
  189. if err != nil {
  190. common.SysLog(fmt.Sprintf("CacheGetChannel: %v", err))
  191. failTasksForChannel(ctx, taskIds, taskM, fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId))
  192. return fmt.Errorf("CacheGetChannel failed: %w", err)
  193. }
  194. adaptor := GetTaskAdaptorFunc(constant.TaskPlatformSuno)
  195. if adaptor == nil {
  196. return errors.New("adaptor not found")
  197. }
  198. proxy := ch.GetSetting().Proxy
  199. resp, err := adaptor.FetchTask(*ch.BaseURL, ch.Key, map[string]any{
  200. "ids": taskIds,
  201. }, proxy)
  202. if err != nil {
  203. common.SysLog(fmt.Sprintf("Get Task Do req error: %v", err))
  204. return err
  205. }
  206. if resp.StatusCode != http.StatusOK {
  207. logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
  208. return fmt.Errorf("Get Task status code: %d", resp.StatusCode)
  209. }
  210. defer resp.Body.Close()
  211. responseBody, err := io.ReadAll(resp.Body)
  212. if err != nil {
  213. common.SysLog(fmt.Sprintf("Get Suno Task parse body error: %v", err))
  214. return err
  215. }
  216. var responseItems dto.TaskResponse[[]dto.SunoDataResponse]
  217. err = common.Unmarshal(responseBody, &responseItems)
  218. if err != nil {
  219. logger.LogError(ctx, fmt.Sprintf("Get Suno Task parse body error2: %v, body: %s", err, string(responseBody)))
  220. return err
  221. }
  222. if !responseItems.IsSuccess() {
  223. common.SysLog(fmt.Sprintf("渠道 #%d 未完成的任务有: %d, 成功获取到任务数: %s", channelId, len(taskIds), string(responseBody)))
  224. return err
  225. }
  226. for _, responseItem := range responseItems.Data {
  227. task := taskM[responseItem.TaskID]
  228. if !taskNeedsUpdate(task, responseItem) {
  229. continue
  230. }
  231. oldStatus := task.Status
  232. task.Status = lo.If(model.TaskStatus(responseItem.Status) != "", model.TaskStatus(responseItem.Status)).Else(task.Status)
  233. task.FailReason = lo.If(responseItem.FailReason != "", responseItem.FailReason).Else(task.FailReason)
  234. task.SubmitTime = lo.If(responseItem.SubmitTime != 0, responseItem.SubmitTime).Else(task.SubmitTime)
  235. task.StartTime = lo.If(responseItem.StartTime != 0, responseItem.StartTime).Else(task.StartTime)
  236. task.FinishTime = lo.If(responseItem.FinishTime != 0, responseItem.FinishTime).Else(task.FinishTime)
  237. if responseItem.FailReason != "" || task.Status == model.TaskStatusFailure {
  238. logger.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason)
  239. failTaskWithRefund(ctx, task, task.FailReason, task.FinishTime, oldStatus)
  240. continue
  241. }
  242. if responseItem.Status == model.TaskStatusSuccess {
  243. task.Progress = "100%"
  244. }
  245. task.Data = responseItem.Data
  246. err = task.Update()
  247. if err != nil {
  248. common.SysLog("UpdateSunoTask task error: " + err.Error())
  249. }
  250. }
  251. return nil
  252. }
  253. // taskNeedsUpdate 检查 Suno 任务是否需要更新
  254. func taskNeedsUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool {
  255. if oldTask.SubmitTime != newTask.SubmitTime {
  256. return true
  257. }
  258. if oldTask.StartTime != newTask.StartTime {
  259. return true
  260. }
  261. if oldTask.FinishTime != newTask.FinishTime {
  262. return true
  263. }
  264. if string(oldTask.Status) != newTask.Status {
  265. return true
  266. }
  267. if oldTask.FailReason != newTask.FailReason {
  268. return true
  269. }
  270. if (oldTask.Status == model.TaskStatusFailure || oldTask.Status == model.TaskStatusSuccess) && oldTask.Progress != "100%" {
  271. return true
  272. }
  273. oldData, _ := common.Marshal(oldTask.Data)
  274. newData, _ := common.Marshal(newTask.Data)
  275. sort.Slice(oldData, func(i, j int) bool {
  276. return oldData[i] < oldData[j]
  277. })
  278. sort.Slice(newData, func(i, j int) bool {
  279. return newData[i] < newData[j]
  280. })
  281. if string(oldData) != string(newData) {
  282. return true
  283. }
  284. return false
  285. }
  286. // UpdateVideoTasks 按渠道更新所有视频任务
  287. func UpdateVideoTasks(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
  288. for channelId, taskIds := range taskChannelM {
  289. if err := updateVideoTasks(ctx, platform, channelId, taskIds, taskM); err != nil {
  290. logger.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
  291. }
  292. }
  293. return nil
  294. }
  295. func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error {
  296. logger.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
  297. if len(taskIds) == 0 {
  298. return nil
  299. }
  300. cacheGetChannel, err := model.CacheGetChannel(channelId)
  301. if err != nil {
  302. failTasksForChannel(ctx, taskIds, taskM, fmt.Sprintf("Failed to get channel info, channel ID: %d", channelId))
  303. return fmt.Errorf("CacheGetChannel failed: %w", err)
  304. }
  305. adaptor := GetTaskAdaptorFunc(platform)
  306. if adaptor == nil {
  307. return fmt.Errorf("video adaptor not found")
  308. }
  309. info := &relaycommon.RelayInfo{}
  310. info.ChannelMeta = &relaycommon.ChannelMeta{
  311. ChannelBaseUrl: cacheGetChannel.GetBaseURL(),
  312. }
  313. info.ApiKey = cacheGetChannel.Key
  314. adaptor.Init(info)
  315. for _, taskId := range taskIds {
  316. if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
  317. logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
  318. }
  319. }
  320. return nil
  321. }
  322. func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *model.Channel, taskId string, taskM map[string]*model.Task) error {
  323. baseURL := constant.ChannelBaseURLs[ch.Type]
  324. if ch.GetBaseURL() != "" {
  325. baseURL = ch.GetBaseURL()
  326. }
  327. proxy := ch.GetSetting().Proxy
  328. task := taskM[taskId]
  329. if task == nil {
  330. logger.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
  331. return fmt.Errorf("task %s not found", taskId)
  332. }
  333. key := ch.Key
  334. privateData := task.PrivateData
  335. if privateData.Key != "" {
  336. key = privateData.Key
  337. }
  338. resp, err := adaptor.FetchTask(baseURL, key, map[string]any{
  339. "task_id": task.GetUpstreamTaskID(),
  340. "action": task.Action,
  341. }, proxy)
  342. if err != nil {
  343. return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err)
  344. }
  345. defer resp.Body.Close()
  346. responseBody, err := io.ReadAll(resp.Body)
  347. if err != nil {
  348. return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
  349. }
  350. logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask response: %s", string(responseBody)))
  351. snap := task.Snapshot()
  352. taskResult := &relaycommon.TaskInfo{}
  353. // try parse as New API response format
  354. var responseItems dto.TaskResponse[model.Task]
  355. if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
  356. logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask parsed as new api response format: %+v", responseItems))
  357. t := responseItems.Data
  358. taskResult.TaskID = t.TaskID
  359. taskResult.Status = string(t.Status)
  360. taskResult.Url = t.GetResultURL()
  361. taskResult.Progress = t.Progress
  362. taskResult.Reason = t.FailReason
  363. task.Data = t.Data
  364. } else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
  365. return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
  366. } else {
  367. task.Data = redactVideoResponseBody(responseBody)
  368. }
  369. logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask taskResult: %+v", taskResult))
  370. now := time.Now().Unix()
  371. if taskResult.Status == "" {
  372. taskResult = relaycommon.FailTaskInfo("upstream returned empty status")
  373. }
  374. shouldRefund := false
  375. shouldSettle := false
  376. quota := task.Quota
  377. task.Status = model.TaskStatus(taskResult.Status)
  378. switch taskResult.Status {
  379. case model.TaskStatusSubmitted:
  380. task.Progress = taskcommon.ProgressSubmitted
  381. case model.TaskStatusQueued:
  382. task.Progress = taskcommon.ProgressQueued
  383. case model.TaskStatusInProgress:
  384. task.Progress = taskcommon.ProgressInProgress
  385. if task.StartTime == 0 {
  386. task.StartTime = now
  387. }
  388. case model.TaskStatusSuccess:
  389. task.Progress = taskcommon.ProgressComplete
  390. if task.FinishTime == 0 {
  391. task.FinishTime = now
  392. }
  393. if strings.HasPrefix(taskResult.Url, "data:") {
  394. // data: URI (e.g. Vertex base64 encoded video) — keep in Data, not in ResultURL
  395. } else if taskResult.Url != "" {
  396. // Direct upstream URL (e.g. Kling, Ali, Doubao, etc.)
  397. task.PrivateData.ResultURL = taskResult.Url
  398. } else {
  399. // No URL from adaptor — construct proxy URL using public task ID
  400. task.PrivateData.ResultURL = taskcommon.BuildProxyURL(task.TaskID)
  401. }
  402. shouldSettle = true
  403. case model.TaskStatusFailure:
  404. logger.LogJson(ctx, fmt.Sprintf("Task %s failed", taskId), task)
  405. task.Status = model.TaskStatusFailure
  406. task.Progress = taskcommon.ProgressComplete
  407. if task.FinishTime == 0 {
  408. task.FinishTime = now
  409. }
  410. task.FailReason = taskResult.Reason
  411. logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
  412. taskResult.Progress = taskcommon.ProgressComplete
  413. if quota != 0 {
  414. shouldRefund = true
  415. }
  416. default:
  417. return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, task.TaskID)
  418. }
  419. if taskResult.Progress != "" {
  420. task.Progress = taskResult.Progress
  421. }
  422. isDone := task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure
  423. if isDone && snap.Status != task.Status {
  424. won, err := task.UpdateWithStatus(snap.Status)
  425. if err != nil {
  426. logger.LogError(ctx, fmt.Sprintf("UpdateWithStatus failed for task %s: %s", task.TaskID, err.Error()))
  427. shouldRefund = false
  428. shouldSettle = false
  429. } else if !won {
  430. logger.LogWarn(ctx, fmt.Sprintf("Task %s already transitioned by another process, skip billing", task.TaskID))
  431. shouldRefund = false
  432. shouldSettle = false
  433. }
  434. } else if !snap.Equal(task.Snapshot()) {
  435. if _, err := task.UpdateWithStatus(snap.Status); err != nil {
  436. logger.LogError(ctx, fmt.Sprintf("Failed to update task %s: %s", task.TaskID, err.Error()))
  437. }
  438. } else {
  439. // No changes, skip update
  440. logger.LogDebug(ctx, fmt.Sprintf("No update needed for task %s", task.TaskID))
  441. }
  442. if shouldSettle {
  443. settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
  444. }
  445. if shouldRefund {
  446. RefundTaskQuota(ctx, task, task.FailReason)
  447. }
  448. return nil
  449. }
  450. func redactVideoResponseBody(body []byte) []byte {
  451. var m map[string]any
  452. if err := common.Unmarshal(body, &m); err != nil {
  453. return body
  454. }
  455. resp, _ := m["response"].(map[string]any)
  456. if resp != nil {
  457. delete(resp, "bytesBase64Encoded")
  458. if v, ok := resp["video"].(string); ok {
  459. resp["video"] = truncateBase64(v)
  460. }
  461. if vs, ok := resp["videos"].([]any); ok {
  462. for i := range vs {
  463. if vm, ok := vs[i].(map[string]any); ok {
  464. delete(vm, "bytesBase64Encoded")
  465. }
  466. }
  467. }
  468. }
  469. b, err := common.Marshal(m)
  470. if err != nil {
  471. return body
  472. }
  473. return b
  474. }
  475. func truncateBase64(s string) string {
  476. const maxKeep = 256
  477. if len(s) <= maxKeep {
  478. return s
  479. }
  480. return s[:maxKeep] + "..."
  481. }
  482. // settleTaskBillingOnComplete 任务完成时的统一计费调整。
  483. // 优先级:1. adaptor.AdjustBillingOnComplete 返回正数 → 使用 adaptor 计算的额度
  484. //
  485. // 2. taskResult.TotalTokens > 0 → 按 token 重算
  486. // 3. 都不满足 → 保持预扣额度不变
  487. func settleTaskBillingOnComplete(ctx context.Context, adaptor TaskPollingAdaptor, task *model.Task, taskResult *relaycommon.TaskInfo) {
  488. if bc := task.PrivateData.BillingContext; bc != nil && bc.BillingMode == types.BillingModeMatrix {
  489. _, billingUnit, _, err := types.ResolveMatrixPricingSnapshot(bc.BillingUnit, bc.PricingSnapshot)
  490. if err != nil {
  491. logger.LogError(ctx, fmt.Sprintf("task %s matrix pricing snapshot invalid: %s", task.TaskID, err.Error()))
  492. return
  493. }
  494. if billingUnit == types.BillingUnitPerCall {
  495. logger.LogInfo(ctx, fmt.Sprintf("task %s matrix per_call billing, skip settlement", task.TaskID))
  496. return
  497. }
  498. if billingUnit == types.BillingUnitPer1MTokens {
  499. RecalculateMatrixUsageTaskQuota(ctx, task, taskResult.TotalTokens)
  500. return
  501. }
  502. logger.LogError(ctx, fmt.Sprintf("task %s unknown matrix billing_unit: %s", task.TaskID, billingUnit))
  503. return
  504. }
  505. // 0. 按次计费的任务不做差额结算
  506. if bc := task.PrivateData.BillingContext; bc != nil && bc.PerCallBilling {
  507. logger.LogInfo(ctx, fmt.Sprintf("任务 %s 按次计费,跳过差额结算", task.TaskID))
  508. return
  509. }
  510. // 1. 优先让 adaptor 决定最终额度
  511. if actualQuota := adaptor.AdjustBillingOnComplete(task, taskResult); actualQuota > 0 {
  512. RecalculateTaskQuota(ctx, task, actualQuota, "adaptor计费调整")
  513. return
  514. }
  515. // 2. 回退到 token 重算
  516. if taskResult.TotalTokens > 0 {
  517. RecalculateTaskQuotaByTokens(ctx, task, taskResult.TotalTokens)
  518. return
  519. }
  520. // 3. 无调整,保持预扣额度
  521. }