|
@@ -26,6 +26,8 @@ export class TaskService {
|
|
|
private readonly pollIntervalMs = 5000
|
|
private readonly pollIntervalMs = 5000
|
|
|
private readonly taskBatchSize = 50
|
|
private readonly taskBatchSize = 50
|
|
|
private readonly instanceId = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`
|
|
private readonly instanceId = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`
|
|
|
|
|
+ private processingSince: number | null = null
|
|
|
|
|
+ private readonly maxProcessingMs = 2 * 60 * 1000 // 防护:单轮处理超过 2 分钟则自愈
|
|
|
|
|
|
|
|
constructor(app: FastifyInstance) {
|
|
constructor(app: FastifyInstance) {
|
|
|
this.app = app
|
|
this.app = app
|
|
@@ -157,6 +159,9 @@ export class TaskService {
|
|
|
status: TaskStatus.SENDING,
|
|
status: TaskStatus.SENDING,
|
|
|
startedAt: task.startedAt ?? new Date()
|
|
startedAt: task.startedAt ?? new Date()
|
|
|
})
|
|
})
|
|
|
|
|
+
|
|
|
|
|
+ // 立即触发一次发送周期,避免依赖下次轮询
|
|
|
|
|
+ void this.taskSendCycle()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async pauseTask(id: number): Promise<void> {
|
|
async pauseTask(id: number): Promise<void> {
|
|
@@ -193,9 +198,17 @@ export class TaskService {
|
|
|
|
|
|
|
|
private async taskSendCycle() {
|
|
private async taskSendCycle() {
|
|
|
if (this.processing) {
|
|
if (this.processing) {
|
|
|
- return
|
|
|
|
|
|
|
+ const now = Date.now()
|
|
|
|
|
+ if (this.processingSince && now - this.processingSince > this.maxProcessingMs) {
|
|
|
|
|
+ this.app.log.warn('taskSendCycle 卡死自愈,重置 processing 标记')
|
|
|
|
|
+ this.processing = false
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.app.log.debug?.('taskSendCycle skipped: processing=true')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
this.processing = true
|
|
this.processing = true
|
|
|
|
|
+ this.processingSince = Date.now()
|
|
|
try {
|
|
try {
|
|
|
await this.startTaskSend()
|
|
await this.startTaskSend()
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
@@ -203,6 +216,7 @@ export class TaskService {
|
|
|
this.app.log.error(`处理发送任务失败: ${msg}`)
|
|
this.app.log.error(`处理发送任务失败: ${msg}`)
|
|
|
} finally {
|
|
} finally {
|
|
|
this.processing = false
|
|
this.processing = false
|
|
|
|
|
+ this.processingSince = null
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -213,20 +227,30 @@ export class TaskService {
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
if (!task) {
|
|
if (!task) {
|
|
|
|
|
+ this.app.log.debug?.('taskSendCycle: 未发现发送中的任务')
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
+ this.app.log.info(
|
|
|
|
|
+ `taskSendCycle: 捕获发送任务 id=${task.id}, startedAt=${task.startedAt?.toISOString?.() ?? 'null'}`
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
const configuredSendLimit =
|
|
const configuredSendLimit =
|
|
|
task.sendLimit && Number(task.sendLimit) > 0 ? Number(task.sendLimit) : this.defaultSenderSendLimit
|
|
task.sendLimit && Number(task.sendLimit) > 0 ? Number(task.sendLimit) : this.defaultSenderSendLimit
|
|
|
|
|
+ const sendIntervalMs = Math.max(0, Number(task.sendInterval ?? 0) * 1000)
|
|
|
|
|
+ const batchSize =
|
|
|
|
|
+ task.sendBatchSize && Number(task.sendBatchSize) > 0 ? Number(task.sendBatchSize) : this.taskBatchSize
|
|
|
|
|
+ const concurrentCount = task.concurrentCount && Number(task.concurrentCount) > 0 ? Number(task.concurrentCount) : 1
|
|
|
|
|
+ const batchTotal = batchSize
|
|
|
|
|
|
|
|
this.currentSenderSendLimit = configuredSendLimit
|
|
this.currentSenderSendLimit = configuredSendLimit
|
|
|
this.senderUsageInBatch.clear()
|
|
this.senderUsageInBatch.clear()
|
|
|
this.senderCursor = 0
|
|
this.senderCursor = 0
|
|
|
|
|
+ await this.refreshSenderCache()
|
|
|
|
|
|
|
|
const pendingItems = await this.taskItemRepository.find({
|
|
const pendingItems = await this.taskItemRepository.find({
|
|
|
where: { taskId: task.id, status: TaskItemStatus.PENDING },
|
|
where: { taskId: task.id, status: TaskItemStatus.PENDING },
|
|
|
order: { id: 'ASC' },
|
|
order: { id: 'ASC' },
|
|
|
- take: this.taskBatchSize
|
|
|
|
|
|
|
+ take: batchTotal
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
if (pendingItems.length === 0) {
|
|
if (pendingItems.length === 0) {
|
|
@@ -234,27 +258,30 @@ export class TaskService {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ const queue = [...pendingItems]
|
|
|
|
|
+ const workerCount = Math.min(concurrentCount, queue.length)
|
|
|
|
|
+
|
|
|
|
|
+ const workerResults = await Promise.allSettled(
|
|
|
|
|
+ Array.from({ length: workerCount }, (_, index) => this.runSenderWorker(task, queue, sendIntervalMs, index))
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
let batchSent = 0
|
|
let batchSent = 0
|
|
|
let batchSuccess = 0
|
|
let batchSuccess = 0
|
|
|
- let batchFailed = 0
|
|
|
|
|
-
|
|
|
|
|
- for (const item of pendingItems) {
|
|
|
|
|
- const current = await this.taskRepository.findOne({ where: { id: task.id } })
|
|
|
|
|
- if (!current || current.status !== TaskStatus.SENDING) {
|
|
|
|
|
- this.app.log.info(`任务 ${task.id} 已暂停或停止,终止本批次发送`)
|
|
|
|
|
- break
|
|
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- try {
|
|
|
|
|
- await this.sendTaskItem(task, item)
|
|
|
|
|
- batchSuccess++
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- const msg = error instanceof Error ? error.message : '未知错误'
|
|
|
|
|
- batchFailed++
|
|
|
|
|
- this.app.log.warn(`发送失败 taskId=${task.id}, item=${item.id}: ${msg}`)
|
|
|
|
|
|
|
+ workerResults.forEach((result, index) => {
|
|
|
|
|
+ if (result.status === 'fulfilled') {
|
|
|
|
|
+ batchSent += result.value.sent
|
|
|
|
|
+ batchSuccess += result.value.success
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const msg =
|
|
|
|
|
+ result.reason instanceof Error
|
|
|
|
|
+ ? result.reason.message
|
|
|
|
|
+ : typeof result.reason === 'string'
|
|
|
|
|
+ ? result.reason
|
|
|
|
|
+ : '未知错误'
|
|
|
|
|
+ this.app.log.error(`worker=${index} 执行失败: ${msg}`)
|
|
|
}
|
|
}
|
|
|
- batchSent++
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ })
|
|
|
|
|
|
|
|
if (batchSent > 0) {
|
|
if (batchSent > 0) {
|
|
|
await this.taskRepository.increment({ id: task.id }, 'sent', batchSent)
|
|
await this.taskRepository.increment({ id: task.id }, 'sent', batchSent)
|
|
@@ -263,21 +290,114 @@ export class TaskService {
|
|
|
await this.taskRepository.increment({ id: task.id }, 'successCount', batchSuccess)
|
|
await this.taskRepository.increment({ id: task.id }, 'successCount', batchSuccess)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (batchSent < pendingItems.length) {
|
|
|
|
|
|
|
+ const latest = await this.taskRepository.findOne({ where: { id: task.id } })
|
|
|
|
|
+ if (!latest || latest.status !== TaskStatus.SENDING) {
|
|
|
|
|
+ this.app.log.info(`任务 ${task.id} 已暂停或停止,本轮结束`)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
await this.finalizeTaskIfDone(task.id)
|
|
await this.finalizeTaskIfDone(task.id)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private async sendTaskItem(task: Task, taskItem: TaskItem): Promise<void> {
|
|
|
|
|
- const sender = await this.pickSender()
|
|
|
|
|
- const sessionString = await this.ensureSessionString(sender)
|
|
|
|
|
|
|
+ private async finalizeTaskIfDone(taskId: number): Promise<void> {
|
|
|
|
|
+ const pendingCount = await this.taskItemRepository.count({
|
|
|
|
|
+ where: { taskId, status: TaskItemStatus.PENDING }
|
|
|
|
|
+ })
|
|
|
|
|
+ if (pendingCount > 0) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const successCount = await this.taskItemRepository.count({
|
|
|
|
|
+ where: { taskId, status: TaskItemStatus.SUCCESS }
|
|
|
|
|
+ })
|
|
|
|
|
+ const failedCount = await this.taskItemRepository.count({
|
|
|
|
|
+ where: { taskId, status: TaskItemStatus.FAILED }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ await this.taskRepository.update(taskId, {
|
|
|
|
|
+ status: TaskStatus.COMPLETED,
|
|
|
|
|
+ sent: successCount + failedCount,
|
|
|
|
|
+ successCount
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
|
|
+ private async runSenderWorker(
|
|
|
|
|
+ task: Task,
|
|
|
|
|
+ queue: TaskItem[],
|
|
|
|
|
+ sendIntervalMs: number,
|
|
|
|
|
+ workerIndex: number
|
|
|
|
|
+ ): Promise<{ sent: number; success: number; failed: number }> {
|
|
|
|
|
+ let sent = 0
|
|
|
|
|
+ let success = 0
|
|
|
|
|
+ let failed = 0
|
|
|
|
|
+ let sender: Sender | null = null
|
|
|
let client: TelegramClient | null = null
|
|
let client: TelegramClient | null = null
|
|
|
- try {
|
|
|
|
|
- client = await this.tgClientService.connect(sessionString)
|
|
|
|
|
|
|
+ let senderSentInRound = 0
|
|
|
|
|
+
|
|
|
|
|
+ while (true) {
|
|
|
|
|
+ const taskItem = queue.shift()
|
|
|
|
|
+ if (!taskItem) {
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const stillSending = await this.isTaskSending(task.id)
|
|
|
|
|
+ if (!stillSending) {
|
|
|
|
|
+ this.app.log.info(`任务 ${task.id} 已暂停/停止,worker=${workerIndex} 提前结束`)
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!sender || senderSentInRound >= this.currentSenderSendLimit) {
|
|
|
|
|
+ await this.tgClientService.disconnectClient(client)
|
|
|
|
|
+ sender = await this.pickSender()
|
|
|
|
|
+ const sessionString = await this.ensureSessionString(sender)
|
|
|
|
|
+ client = await this.tgClientService.createConnectedClient(sessionString)
|
|
|
|
|
+ senderSentInRound = 0
|
|
|
|
|
+
|
|
|
|
|
+ const me = await client.getMe().catch(() => null)
|
|
|
|
|
+ const delaySeconds = this.getRandomDelaySeconds()
|
|
|
|
|
+ const displayName = `${me?.firstName ?? ''} ${me?.lastName ?? ''}`.trim() || me?.username || ''
|
|
|
|
|
+ this.app.log.info(
|
|
|
|
|
+ `worker=${workerIndex} ,当前登录账号: id: ${me?.id ?? sender.id} ,name: ${displayName || sender.id},延迟 ${delaySeconds}s 后开始发送`
|
|
|
|
|
+ )
|
|
|
|
|
+ await this.sleep(delaySeconds * 1000)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.sendTaskItemWithClient(task, taskItem, sender!, client!, workerIndex)
|
|
|
|
|
+ success++
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ failed++
|
|
|
|
|
+ const msg = error instanceof Error ? error.message : '未知错误'
|
|
|
|
|
+ this.app.log.warn(
|
|
|
|
|
+ `❌ 发送失败 taskId=${task.id}, item=${taskItem.id}, sender=${sender?.id ?? '未知'}, worker=${workerIndex}, error: ${msg}`
|
|
|
|
|
+ )
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ sent++
|
|
|
|
|
+ senderSentInRound++
|
|
|
|
|
+ if (sender) {
|
|
|
|
|
+ const used = (this.senderUsageInBatch.get(sender.id) ?? 0) + 1
|
|
|
|
|
+ this.senderUsageInBatch.set(sender.id, used)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (sendIntervalMs > 0) {
|
|
|
|
|
+ await this.sleep(sendIntervalMs)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ await this.tgClientService.disconnectClient(client)
|
|
|
|
|
+
|
|
|
|
|
+ return { sent, success, failed }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async sendTaskItemWithClient(
|
|
|
|
|
+ task: Task,
|
|
|
|
|
+ taskItem: TaskItem,
|
|
|
|
|
+ sender: Sender,
|
|
|
|
|
+ client: TelegramClient,
|
|
|
|
|
+ workerIndex: number
|
|
|
|
|
+ ): Promise<void> {
|
|
|
|
|
+ try {
|
|
|
const parsedTarget = this.parseTarget(taskItem.target)
|
|
const parsedTarget = this.parseTarget(taskItem.target)
|
|
|
if (!parsedTarget) {
|
|
if (!parsedTarget) {
|
|
|
throw new Error('target 格式错误,请检查是否正确')
|
|
throw new Error('target 格式错误,请检查是否正确')
|
|
@@ -294,20 +414,8 @@ export class TaskService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
await this.tgClientService.sendMessageToPeer(client, targetPeer, task.message)
|
|
await this.tgClientService.sendMessageToPeer(client, targetPeer, task.message)
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- await this.tgClientService.clearConversation(client, targetPeer)
|
|
|
|
|
- } catch (clearError) {
|
|
|
|
|
- const msg = clearError instanceof Error ? clearError.message : '未知错误'
|
|
|
|
|
- this.app.log.warn(`清除会话失败 [${taskItem.target}]: ${msg}`)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- await this.tgClientService.deleteTempContact(client, (targetPeer as any).id)
|
|
|
|
|
- } catch (deleteError) {
|
|
|
|
|
- const msg = deleteError instanceof Error ? deleteError.message : '未知错误'
|
|
|
|
|
- this.app.log.warn(`删除临时联系人失败 [${taskItem.target}]: ${msg}`)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ await this.tgClientService.clearConversation(client, targetPeer).catch(() => {})
|
|
|
|
|
+ await this.tgClientService.deleteTempContact(client, (targetPeer as any).id).catch(() => {})
|
|
|
|
|
|
|
|
await this.taskItemRepository.update(taskItem.id, {
|
|
await this.taskItemRepository.update(taskItem.id, {
|
|
|
status: TaskItemStatus.SUCCESS,
|
|
status: TaskItemStatus.SUCCESS,
|
|
@@ -317,14 +425,7 @@ export class TaskService {
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
await this.senderService.incrementUsageCount(sender.id)
|
|
await this.senderService.incrementUsageCount(sender.id)
|
|
|
-
|
|
|
|
|
- const used = (this.senderUsageInBatch.get(sender.id) ?? 0) + 1
|
|
|
|
|
- this.senderUsageInBatch.set(sender.id, used)
|
|
|
|
|
-
|
|
|
|
|
- if (used >= this.currentSenderSendLimit) {
|
|
|
|
|
- this.app.log.info(`sender=${sender.id} 已达单次发送上限 ${this.currentSenderSendLimit},切换下一个账号`)
|
|
|
|
|
- await this.tgClientService.disconnect()
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ this.app.log.info(`✅ 发送成功 taskId=${task.id}, itemId=${taskItem.id}, sender=${sender.id}, worker=${workerIndex}`)
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
const msg = error instanceof Error ? error.message : '未知错误'
|
|
const msg = error instanceof Error ? error.message : '未知错误'
|
|
|
await this.taskItemRepository.update(taskItem.id, {
|
|
await this.taskItemRepository.update(taskItem.id, {
|
|
@@ -333,40 +434,30 @@ export class TaskService {
|
|
|
senderId: sender.id,
|
|
senderId: sender.id,
|
|
|
errorMsg: msg
|
|
errorMsg: msg
|
|
|
})
|
|
})
|
|
|
-
|
|
|
|
|
- if (client) {
|
|
|
|
|
- try {
|
|
|
|
|
- await this.tgClientService.disconnect()
|
|
|
|
|
- } catch (disconnectError) {
|
|
|
|
|
- const disconnectMsg = disconnectError instanceof Error ? disconnectError.message : '未知错误'
|
|
|
|
|
- this.app.log.warn(`断开连接失败: ${disconnectMsg}`)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
|
|
+ await this.senderService.incrementUsageCount(sender.id)
|
|
|
throw error
|
|
throw error
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private async finalizeTaskIfDone(taskId: number): Promise<void> {
|
|
|
|
|
- const pendingCount = await this.taskItemRepository.count({
|
|
|
|
|
- where: { taskId, status: TaskItemStatus.PENDING }
|
|
|
|
|
|
|
+ private async refreshSenderCache(): Promise<void> {
|
|
|
|
|
+ this.senderCache = await this.senderRepository.find({
|
|
|
|
|
+ where: { delFlag: false },
|
|
|
|
|
+ order: { lastUsageTime: 'ASC', usageCount: 'ASC' }
|
|
|
})
|
|
})
|
|
|
- if (pendingCount > 0) {
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ this.senderCursor = 0
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- const successCount = await this.taskItemRepository.count({
|
|
|
|
|
- where: { taskId, status: TaskItemStatus.SUCCESS }
|
|
|
|
|
- })
|
|
|
|
|
- const failedCount = await this.taskItemRepository.count({
|
|
|
|
|
- where: { taskId, status: TaskItemStatus.FAILED }
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ private async isTaskSending(taskId: number): Promise<boolean> {
|
|
|
|
|
+ const current = await this.taskRepository.findOne({ where: { id: taskId } })
|
|
|
|
|
+ return !!current && current.status === TaskStatus.SENDING && current.delFlag === false
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- await this.taskRepository.update(taskId, {
|
|
|
|
|
- status: TaskStatus.COMPLETED,
|
|
|
|
|
- sent: successCount + failedCount,
|
|
|
|
|
- successCount
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ private async sleep(ms: number): Promise<void> {
|
|
|
|
|
+ return await new Promise(resolve => setTimeout(resolve, ms))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private getRandomDelaySeconds(min: number = 10, max: number = 20): number {
|
|
|
|
|
+ return Math.floor(Math.random() * (max - min + 1)) + min
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private async pickSender(): Promise<Sender> {
|
|
private async pickSender(): Promise<Sender> {
|
|
@@ -393,7 +484,6 @@ export class TaskService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 所有 sender 均已达到当前批次上限,重置计数重新分配
|
|
|
|
|
this.app.log.info('所有 sender 均已达到当前批次上限,重置计数后重新轮询')
|
|
this.app.log.info('所有 sender 均已达到当前批次上限,重置计数后重新轮询')
|
|
|
this.senderUsageInBatch.clear()
|
|
this.senderUsageInBatch.clear()
|
|
|
this.senderCursor = 0
|
|
this.senderCursor = 0
|