batch-job.service.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import { Injectable } from '@angular/core';
  2. import { BehaviorSubject } from 'rxjs';
  3. import { AuthCreditService } from './auth-credit.service';
  4. import {
  5. BatchJob,
  6. BatchJobCreateInput,
  7. BatchJobItem,
  8. BatchJobRunResult,
  9. } from '../models/batch-job.model';
  10. const JOBS_STORAGE_KEY = 'videoWorkflow.batchJobs.v1';
  11. const ITEMS_STORAGE_KEY = 'videoWorkflow.batchJobItems.v1';
  12. type BatchItemRunner = (item: BatchJobItem, job: BatchJob) => Promise<BatchJobRunResult | void>;
  13. @Injectable({ providedIn: 'root' })
  14. export class BatchJobService {
  15. private readonly jobsSubject = new BehaviorSubject<BatchJob[]>(this.readJobs());
  16. private readonly itemsSubject = new BehaviorSubject<BatchJobItem[]>(this.readItems());
  17. readonly jobs$ = this.jobsSubject.asObservable();
  18. readonly items$ = this.itemsSubject.asObservable();
  19. private runningJobId = '';
  20. private stopRequested = false;
  21. constructor(private auth: AuthCreditService) {}
  22. get jobs(): BatchJob[] {
  23. return this.jobsSubject.value;
  24. }
  25. get items(): BatchJobItem[] {
  26. return this.itemsSubject.value;
  27. }
  28. listJobs(): BatchJob[] {
  29. const userId = this.currentUserId();
  30. if (!userId) return [];
  31. return this.jobs.filter((job) => job.userId === userId);
  32. }
  33. listItems(batchJobId: string): BatchJobItem[] {
  34. return this.items.filter((item) => item.batchJobId === batchJobId);
  35. }
  36. create(input: BatchJobCreateInput): BatchJob {
  37. const userId = this.requireUserId();
  38. const now = new Date().toISOString();
  39. const jobId = this.createId('batch');
  40. const items = input.items
  41. .map((item, index) => ({
  42. id: this.createId('batch_item'),
  43. batchJobId: jobId,
  44. topicId: item.topicId,
  45. title: item.title?.trim() || `子任务 ${index + 1}`,
  46. input: this.cloneSerializable(item.input || {}),
  47. status: 'queued' as const,
  48. retryCount: 0,
  49. createdAt: now,
  50. updatedAt: now,
  51. }));
  52. const job: BatchJob = {
  53. id: jobId,
  54. userId,
  55. title: input.title?.trim() || '未命名批量任务',
  56. pipelineId: input.pipelineId,
  57. templateId: input.templateId,
  58. status: 'queued',
  59. total: items.length,
  60. completed: 0,
  61. failed: 0,
  62. estimatedCredits: Math.max(0, Math.ceil(Number(input.estimatedCredits || 0))),
  63. createdAt: now,
  64. updatedAt: now,
  65. };
  66. this.setJobs([job, ...this.jobs]);
  67. this.setItems([...items, ...this.items]);
  68. return job;
  69. }
  70. pause(jobId: string): void {
  71. const job = this.findJob(jobId);
  72. if (!job || job.status !== 'running') return;
  73. this.stopRequested = true;
  74. this.patchJob(jobId, { status: 'paused' });
  75. }
  76. cancel(jobId: string): void {
  77. this.stopRequested = this.runningJobId === jobId;
  78. this.patchJob(jobId, { status: 'cancelled' });
  79. this.setItems(this.items.map((item) => item.batchJobId === jobId && (item.status === 'queued' || item.status === 'running')
  80. ? { ...item, status: 'cancelled', updatedAt: new Date().toISOString() }
  81. : item));
  82. }
  83. retryItem(itemId: string): void {
  84. const item = this.items.find((row) => row.id === itemId);
  85. if (!item || item.status !== 'failed') return;
  86. this.patchItem(itemId, {
  87. status: 'queued',
  88. errorMessage: undefined,
  89. resultUrl: undefined,
  90. retryCount: Number(item.retryCount || 0) + 1,
  91. });
  92. this.recalculateJob(item.batchJobId, 'queued');
  93. }
  94. async run(jobId: string, runner: BatchItemRunner = this.mockRunner): Promise<void> {
  95. const job = this.findJob(jobId);
  96. if (!job || job.status === 'cancelled') return;
  97. if (this.runningJobId && this.runningJobId !== jobId) {
  98. throw new Error('已有批量任务正在执行,请稍后再试');
  99. }
  100. this.runningJobId = jobId;
  101. this.stopRequested = false;
  102. this.patchJob(jobId, { status: 'running' });
  103. try {
  104. while (!this.stopRequested) {
  105. const currentJob = this.findJob(jobId);
  106. if (!currentJob || currentJob.status === 'cancelled') break;
  107. const nextItem = this.listItems(jobId).find((item) => item.status === 'queued');
  108. if (!nextItem) break;
  109. this.patchItem(nextItem.id, { status: 'running', errorMessage: undefined });
  110. try {
  111. const result = await runner(nextItem, currentJob);
  112. this.patchItem(nextItem.id, {
  113. status: 'completed',
  114. resultUrl: result?.resultUrl,
  115. generationTaskId: result?.generationTaskId,
  116. });
  117. } catch (err: any) {
  118. this.patchItem(nextItem.id, {
  119. status: 'failed',
  120. errorMessage: err?.message || '子任务执行失败',
  121. });
  122. }
  123. this.recalculateJob(jobId);
  124. }
  125. } finally {
  126. const finalJob = this.findJob(jobId);
  127. if (finalJob && finalJob.status === 'running') {
  128. this.recalculateJob(jobId);
  129. }
  130. this.runningJobId = '';
  131. }
  132. }
  133. private mockRunner(item: BatchJobItem): Promise<BatchJobRunResult> {
  134. return new Promise((resolve) => {
  135. window.setTimeout(() => resolve({ generationTaskId: `mock-${item.id}` }), 300);
  136. });
  137. }
  138. private recalculateJob(jobId: string, fallbackStatus?: BatchJob['status']): void {
  139. const job = this.findJob(jobId);
  140. if (!job) return;
  141. const items = this.listItems(jobId);
  142. const completed = items.filter((item) => item.status === 'completed').length;
  143. const failed = items.filter((item) => item.status === 'failed').length;
  144. const cancelled = items.filter((item) => item.status === 'cancelled').length;
  145. const queued = items.filter((item) => item.status === 'queued').length;
  146. const running = items.filter((item) => item.status === 'running').length;
  147. const status = (() => {
  148. if (job.status === 'cancelled') return 'cancelled';
  149. if (fallbackStatus) return fallbackStatus;
  150. if (running > 0) return 'running';
  151. if (queued > 0) return job.status === 'paused' ? 'paused' : 'running';
  152. if (completed === items.length && items.length > 0) return 'completed';
  153. if (failed > 0 && completed + failed + cancelled === items.length) return 'failed';
  154. return job.status;
  155. })();
  156. this.patchJob(jobId, { completed, failed, status });
  157. }
  158. private patchJob(id: string, patch: Partial<BatchJob>): void {
  159. const now = new Date().toISOString();
  160. this.setJobs(this.jobs.map((job) => job.id === id ? { ...job, ...patch, id, updatedAt: now } : job));
  161. }
  162. private patchItem(id: string, patch: Partial<BatchJobItem>): void {
  163. const now = new Date().toISOString();
  164. this.setItems(this.items.map((item) => item.id === id ? { ...item, ...patch, id, updatedAt: now } : item));
  165. }
  166. private findJob(id: string): BatchJob | undefined {
  167. const userId = this.currentUserId();
  168. return this.jobs.find((job) => job.id === id && job.userId === userId);
  169. }
  170. private currentUserId(): string {
  171. return this.auth.currentUser?.objectId || 'local-guest';
  172. }
  173. private requireUserId(): string {
  174. return this.currentUserId();
  175. }
  176. private readJobs(): BatchJob[] {
  177. try {
  178. const parsed = JSON.parse(localStorage.getItem(JOBS_STORAGE_KEY) || '[]');
  179. return Array.isArray(parsed) ? parsed : [];
  180. } catch {
  181. return [];
  182. }
  183. }
  184. private readItems(): BatchJobItem[] {
  185. try {
  186. const parsed = JSON.parse(localStorage.getItem(ITEMS_STORAGE_KEY) || '[]');
  187. return Array.isArray(parsed) ? parsed : [];
  188. } catch {
  189. return [];
  190. }
  191. }
  192. private setJobs(jobs: BatchJob[]): void {
  193. const sorted = [...jobs].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)).slice(0, 100);
  194. this.jobsSubject.next(sorted);
  195. localStorage.setItem(JOBS_STORAGE_KEY, JSON.stringify(sorted));
  196. }
  197. private setItems(items: BatchJobItem[]): void {
  198. this.itemsSubject.next(items);
  199. localStorage.setItem(ITEMS_STORAGE_KEY, JSON.stringify(items));
  200. }
  201. private createId(prefix: string): string {
  202. return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
  203. }
  204. private cloneSerializable<T>(value: T): T {
  205. return JSON.parse(JSON.stringify(value || {}));
  206. }
  207. }