TaskScheduler.js 68 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547
  1. const crypto = require('crypto')
  2. const bcryptjs = require('bcryptjs')
  3. const config = require('../../config.json')
  4. const db = require('../../plugin/DataBase/db')
  5. const Redis = require('../../plugin/DataBase/Redis')
  6. const Logger = require('../Logger')
  7. const TASK_STATUS = {
  8. PAUSED: 'paused',
  9. PENDING: 'pending',
  10. ASSIGNED: 'assigned',
  11. RUNNING: 'running',
  12. SUCCESS: 'success',
  13. FAILED: 'failed',
  14. CANCELLED: 'cancelled'
  15. }
  16. const REPORT_LOG_EVENTS = new Set([
  17. 'request_result',
  18. 'progress_snapshot',
  19. 'grab_success',
  20. 'grab_fail'
  21. ])
  22. class TaskScheduler {
  23. constructor(options = {}) {
  24. this.leaseMs = options.leaseMs || config.qk?.leaseMs || 90 * 1000
  25. this.heartbeatTtlSeconds = options.heartbeatTtlSeconds || config.qk?.heartbeatTtlSeconds || 45
  26. this.pullLockTtlSeconds = options.pullLockTtlSeconds || 5
  27. this.staleTaskMs = options.staleTaskMs || config.qk?.staleTaskMs || 60 * 60 * 1000
  28. this.memPerSlotMb = options.memPerSlotMb || config.qk?.memPerSlotMb || 3072
  29. this.memReserveMb = options.memReserveMb || config.qk?.memReserveMb || 1024
  30. this.maxSlotsCap = options.maxSlotsCap || config.qk?.maxSlotsCap || 10
  31. this.slotCapacityFactor = options.slotCapacityFactor || config.qk?.slotCapacityFactor || 1.5
  32. this.logger = options.logger || new Logger()
  33. }
  34. calculateMaxSlots(profile = {}) {
  35. const freeMb = Math.max(0, Number(profile.free_mem_mb) || 0)
  36. const totalMb = Math.max(0, Number(profile.total_mem_mb) || 0)
  37. const threads = Math.max(1, Number(profile.cpu_threads) || 1)
  38. const allocatableFreeMb = Math.max(0, freeMb - this.memReserveMb)
  39. const allocatableTotalMb = Math.max(0, totalMb - this.memReserveMb)
  40. const byFreeMem = Math.floor(allocatableFreeMb / this.memPerSlotMb)
  41. const byTotalMem = Math.floor(allocatableTotalMb / this.memPerSlotMb)
  42. const byCpu = Math.floor(threads * 0.8)
  43. const fallbackMem = totalMb > 0 ? byTotalMem : 1
  44. const byMem = freeMb > 0 ? Math.min(byFreeMem, byTotalMem) : fallbackMem
  45. const base = Math.max(1, Math.min(byMem, byCpu))
  46. const scaled = Math.max(1, Math.floor(base * this.slotCapacityFactor))
  47. const cap = Math.max(1, Math.floor(this.maxSlotsCap * this.slotCapacityFactor))
  48. return Math.max(1, Math.min(50, cap, scaled))
  49. }
  50. resolveClientMaxSlots(client, payload = {}) {
  51. const fromPayload = this.calculateMaxSlots({
  52. free_mem_mb: payload.free_mem_mb ?? client.free_mem_mb,
  53. total_mem_mb: payload.total_mem_mb ?? client.total_mem_mb,
  54. cpu_threads: payload.cpu_threads ?? client.cpu_threads
  55. })
  56. const reported = Number(payload.max_slots || client.max_slots || 0)
  57. if (!reported) return fromPayload
  58. return Math.max(1, Math.min(reported, fromPayload))
  59. }
  60. getClientAvailableSlots(client, payload = {}) {
  61. const maxSlots = this.resolveClientMaxSlots(client, payload)
  62. const currentSlots = Math.max(0, Number(client.current_slots ?? 0))
  63. return Math.max(0, maxSlots - currentSlots)
  64. }
  65. async countClientActiveTasks(clientId) {
  66. if (!clientId) return 0
  67. const rows = await db.query(
  68. 'SELECT COUNT(*) AS cnt FROM qk_task WHERE assigned_client_id = ? AND status IN (?, ?)',
  69. [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  70. )
  71. return Math.max(0, Number(rows?.[0]?.cnt || 0))
  72. }
  73. async syncClientSlots(clientId, now = Date.now()) {
  74. if (!clientId) return 0
  75. const count = await this.countClientActiveTasks(clientId)
  76. await db.query(
  77. 'UPDATE qk_client SET current_slots = ?, update_time = ? WHERE client_id = ?',
  78. [count, now, clientId]
  79. )
  80. try {
  81. await Redis.set(`qk:client:slots:${clientId}`, String(count), { EX: this.heartbeatTtlSeconds })
  82. } catch (_) {}
  83. return count
  84. }
  85. async findRevokedClientTasks(clientId, runningTaskIds = []) {
  86. const ids = (runningTaskIds || []).map(Number).filter(Boolean)
  87. if (!ids.length) return []
  88. const placeholders = ids.map(() => '?').join(',')
  89. const rows = await db.query(
  90. `SELECT id FROM qk_task WHERE id IN (${placeholders})
  91. AND (assigned_client_id IS NULL OR assigned_client_id <> ? OR status NOT IN (?, ?))`,
  92. [...ids, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  93. )
  94. return (rows || []).map(row => row.id)
  95. }
  96. async getClientAvailableSlotsAsync(client, payload = {}) {
  97. const maxSlots = this.resolveClientMaxSlots(client, payload)
  98. const activeCount = await this.countClientActiveTasks(client.client_id)
  99. return Math.max(0, maxSlots - activeCount)
  100. }
  101. getInFlightTaskStatuses() {
  102. return [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  103. }
  104. /** 同一学号在单次拉取中只保留最早创建的一条待分配任务 */
  105. pickPullableTasks(rows, limit) {
  106. const picked = []
  107. const seenStudentNums = new Set()
  108. for (const row of rows || []) {
  109. const studentNum = String(row.student_num || '').trim()
  110. if (!studentNum || seenStudentNums.has(studentNum)) {
  111. continue
  112. }
  113. seenStudentNums.add(studentNum)
  114. picked.push(row)
  115. if (picked.length >= limit) {
  116. break
  117. }
  118. }
  119. return picked
  120. }
  121. async countOnlineClients() {
  122. const now = Date.now()
  123. const threshold = now - this.heartbeatTtlSeconds * 1000
  124. const rows = await db.query(
  125. `SELECT COUNT(*) AS total FROM qk_client
  126. WHERE enabled = 1 AND online = 1
  127. AND last_heartbeat_at IS NOT NULL AND last_heartbeat_at >= ?`,
  128. [threshold]
  129. )
  130. return Number(rows?.[0]?.total || 0)
  131. }
  132. safeStringify(obj) {
  133. const seen = new WeakSet()
  134. return JSON.stringify(obj, (key, value) => {
  135. if (typeof value === 'object' && value !== null) {
  136. if (seen.has(value)) return '[Circular]'
  137. seen.add(value)
  138. }
  139. return value
  140. })
  141. }
  142. sanitizeForLog(payload) {
  143. if (!payload || typeof payload !== 'object') {
  144. return payload
  145. }
  146. const copy = Array.isArray(payload) ? [...payload] : { ...payload }
  147. for (const key of ['password', 'pass', 'password_enc', 'client_secret']) {
  148. if (key in copy) {
  149. copy[key] = '***'
  150. }
  151. }
  152. return copy
  153. }
  154. buildLogPrefix(tag, ctx = {}) {
  155. const parts = ['[QK]', `[${tag}]`]
  156. if (ctx.taskId) parts.push(`[taskId=${ctx.taskId}]`)
  157. if (ctx.clientId) parts.push(`[clientId=${ctx.clientId}]`)
  158. if (ctx.uuid) parts.push(`[uuid=${ctx.uuid}]`)
  159. return parts.join('')
  160. }
  161. logInfo(tag, message, ctx = {}, data = null) {
  162. const prefix = this.buildLogPrefix(tag, ctx)
  163. const suffix = data != null ? ` ${this.safeStringify(this.sanitizeForLog(data))}` : ''
  164. this.logger.info(`${prefix} ${message}${suffix}`)
  165. }
  166. logWarn(tag, message, ctx = {}, data = null) {
  167. const prefix = this.buildLogPrefix(tag, ctx)
  168. const suffix = data != null ? ` ${this.safeStringify(this.sanitizeForLog(data))}` : ''
  169. this.logger.warn(`${prefix} ${message}${suffix}`)
  170. }
  171. logError(tag, message, ctx = {}, err = null) {
  172. const prefix = this.buildLogPrefix(tag, ctx)
  173. const suffix = err ? ` ${err.stack || err}` : ''
  174. this.logger.error(`${prefix} ${message}${suffix}`)
  175. }
  176. getPasswordKey() {
  177. const source = process.env.QK_PASSWORD_KEY || config.qk?.passwordAesKey || config.database?.password || 'runforge-qk-default-key'
  178. return crypto.createHash('sha256').update(String(source)).digest()
  179. }
  180. encryptPassword(password) {
  181. const iv = crypto.randomBytes(16)
  182. const cipher = crypto.createCipheriv('aes-256-cbc', this.getPasswordKey(), iv)
  183. let encrypted = cipher.update(String(password), 'utf8', 'base64')
  184. encrypted += cipher.final('base64')
  185. return `${iv.toString('base64')}:${encrypted}`
  186. }
  187. decryptPassword(encrypted) {
  188. const [ivText, payload] = String(encrypted || '').split(':')
  189. if (!ivText || !payload) {
  190. return ''
  191. }
  192. const decipher = crypto.createDecipheriv('aes-256-cbc', this.getPasswordKey(), Buffer.from(ivText, 'base64'))
  193. let decrypted = decipher.update(payload, 'base64', 'utf8')
  194. decrypted += decipher.final('utf8')
  195. return decrypted
  196. }
  197. normalizeArray(value) {
  198. if (Array.isArray(value)) {
  199. return value.map(item => String(item).trim()).filter(Boolean)
  200. }
  201. if (typeof value === 'string') {
  202. const trimmed = value.trim()
  203. if (!trimmed) {
  204. return []
  205. }
  206. try {
  207. const parsed = JSON.parse(trimmed)
  208. if (Array.isArray(parsed)) {
  209. return parsed.map(item => String(item).trim()).filter(Boolean)
  210. }
  211. } catch (_) {
  212. return trimmed.split(/[\n,,]/).map(item => item.trim()).filter(Boolean)
  213. }
  214. }
  215. return []
  216. }
  217. countTaskTargets(courses = [], courseGroups = []) {
  218. return this.normalizeArray(courses).length + this.normalizeArray(courseGroups).length
  219. }
  220. getMinTaskInterval(totalCount) {
  221. return totalCount >= 5 ? 500 : 200
  222. }
  223. validateTaskCoursesAndInterval(courses, courseGroups, intervalMs) {
  224. const normalizedCourses = this.normalizeArray(courses)
  225. const normalizedGroups = this.normalizeArray(courseGroups)
  226. const totalCount = normalizedCourses.length + normalizedGroups.length
  227. if (totalCount <= 0) {
  228. throw new Error('至少需要填写一门课程或一个课程分组')
  229. }
  230. if (totalCount > 10) {
  231. throw new Error('每个任务最多选择10门课程或分组')
  232. }
  233. const minInterval = this.getMinTaskInterval(totalCount)
  234. const safeInterval = Number(intervalMs)
  235. if (!Number.isFinite(safeInterval) || safeInterval < minInterval || safeInterval > 10000) {
  236. throw new Error(`当前共 ${totalCount} 个抢课目标,间隔需在 ${minInterval}-10000ms 之间`)
  237. }
  238. return {
  239. courses: normalizedCourses,
  240. courseGroups: normalizedGroups,
  241. intervalMs: safeInterval,
  242. totalCount,
  243. minInterval
  244. }
  245. }
  246. serializeTask(row, includeSecret = false) {
  247. const result = { ...row }
  248. result.enable_ggxxk = Number(result.enable_ggxxk) === 1
  249. result.courses = this.normalizeArray(result.courses)
  250. result.course_groups = this.normalizeArray(result.course_groups)
  251. if (typeof result.result_json === 'string' && result.result_json) {
  252. try {
  253. result.result_json = JSON.parse(result.result_json)
  254. } catch (_) {}
  255. }
  256. result.jx0502zbid = row.batch_jx0502zbid || row.jx0502zbid || ''
  257. result.batch_name = row.batch_name || ''
  258. if (row.batch_enabled !== undefined) {
  259. result.batch_enabled = Number(row.batch_enabled) === 1
  260. }
  261. delete result.batch_jx0502zbid
  262. if (includeSecret) {
  263. result.password = this.decryptPassword(result.password_enc)
  264. }
  265. delete result.password_enc
  266. return result
  267. }
  268. taskBatchJoinSql(alias = 't') {
  269. return `LEFT JOIN qk_batch b ON b.id = ${alias}.batch_id`
  270. }
  271. taskBatchSelectSql(alias = 't') {
  272. return `, b.name AS batch_name, b.jx0502zbid AS batch_jx0502zbid, b.enabled AS batch_enabled`
  273. }
  274. async getBatchById(batchId) {
  275. const rows = await db.query('SELECT * FROM qk_batch WHERE id = ?', [batchId])
  276. if (!rows || rows.length === 0) {
  277. throw new Error('抢课批次不存在')
  278. }
  279. return rows[0]
  280. }
  281. async requireEnabledBatch(batchId) {
  282. const batch = await this.getBatchById(batchId)
  283. if (!Number(batch.enabled)) {
  284. throw new Error('所选抢课批次未启用')
  285. }
  286. return batch
  287. }
  288. resolveTaskBatchId(payload = {}) {
  289. const batchId = Number(payload.batch_id || payload.batchId)
  290. if (!batchId) {
  291. throw new Error('请选择抢课批次')
  292. }
  293. return batchId
  294. }
  295. async releaseAssignedTask(task) {
  296. return !!(task && [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status))
  297. }
  298. async pauseTasksByBatch(batchId, reason = '批次已停用,任务已收回') {
  299. const rows = await db.query(
  300. 'SELECT id, status, assigned_client_id FROM qk_task WHERE batch_id = ? AND status IN (?, ?, ?)',
  301. [batchId, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  302. )
  303. const now = Date.now()
  304. const affectedClients = new Set()
  305. let count = 0
  306. for (const row of rows || []) {
  307. if (row.assigned_client_id) {
  308. affectedClients.add(row.assigned_client_id)
  309. }
  310. const result = await db.query(
  311. `UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  312. assigned_at = NULL, exclude_client_id = NULL, update_time = ? WHERE id = ? AND status IN (?, ?, ?)`,
  313. [TASK_STATUS.PAUSED, now, row.id, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  314. )
  315. if (result && result.affectedRows > 0) {
  316. count += 1
  317. await this.logTask(row.id, row.assigned_client_id, 'batch_paused', reason)
  318. }
  319. }
  320. for (const clientId of affectedClients) {
  321. await this.syncClientSlots(clientId, now)
  322. }
  323. if (count > 0) {
  324. this.logInfo('pauseTasksByBatch', reason, { batchId }, { affected: count })
  325. }
  326. return count
  327. }
  328. async redispatchTasksByBatch(batchId, reason = '批次 ID 已变更,任务已重新进入分配队列') {
  329. const rows = await db.query(
  330. 'SELECT id, status, assigned_client_id FROM qk_task WHERE batch_id = ? AND status IN (?, ?, ?)',
  331. [batchId, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  332. )
  333. const now = Date.now()
  334. let count = 0
  335. for (const row of rows || []) {
  336. if ([TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(row.status)) {
  337. await this.releaseAssignedTask(row, now)
  338. const result = await db.query(
  339. `UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  340. assigned_at = NULL, exclude_client_id = NULL, update_time = ? WHERE id = ? AND status IN (?, ?)`,
  341. [TASK_STATUS.PENDING, now, row.id, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  342. )
  343. if (result && result.affectedRows > 0) {
  344. count += 1
  345. await this.logTask(row.id, row.assigned_client_id, 'batch_redispatch', reason)
  346. }
  347. }
  348. }
  349. if (count > 0) {
  350. this.logInfo('redispatchTasksByBatch', reason, { batchId }, { affected: count })
  351. }
  352. return count
  353. }
  354. serializeBatch(row) {
  355. return {
  356. id: row.id,
  357. name: row.name,
  358. jx0502zbid: row.jx0502zbid,
  359. enabled: Number(row.enabled) === 1,
  360. create_time: row.create_time,
  361. update_time: row.update_time
  362. }
  363. }
  364. async listBatches(options = {}) {
  365. const enabledOnly = !!options.enabledOnly
  366. const where = enabledOnly ? 'WHERE enabled = 1' : ''
  367. const rows = await db.query(`SELECT * FROM qk_batch ${where} ORDER BY update_time DESC, id DESC`)
  368. return (rows || []).map(row => this.serializeBatch(row))
  369. }
  370. async createBatch(payload = {}) {
  371. const name = String(payload.name || '').trim()
  372. const jx0502zbid = String(payload.jx0502zbid || '').trim()
  373. if (!name || !jx0502zbid) {
  374. throw new Error('批次名称和批次 ID 不能为空')
  375. }
  376. const now = Date.now()
  377. const result = await db.query(
  378. 'INSERT INTO qk_batch (name, jx0502zbid, enabled, create_time, update_time) VALUES (?, ?, ?, ?, ?)',
  379. [name, jx0502zbid, payload.enabled === false ? 0 : 1, now, now]
  380. )
  381. if (!result || result.affectedRows <= 0) {
  382. throw new Error('创建抢课批次失败')
  383. }
  384. this.logInfo('createBatch', '抢课批次已创建', { batchId: result.insertId }, { name, jx0502zbid })
  385. return result.insertId
  386. }
  387. async updateBatch(batchId, payload = {}) {
  388. const existing = await this.getBatchById(batchId)
  389. const name = payload.name !== undefined ? String(payload.name || '').trim() : existing.name
  390. const jx0502zbid = payload.jx0502zbid !== undefined ? String(payload.jx0502zbid || '').trim() : existing.jx0502zbid
  391. const enabled = payload.enabled !== undefined ? (payload.enabled ? 1 : 0) : Number(existing.enabled)
  392. if (!name || !jx0502zbid) {
  393. throw new Error('批次名称和批次 ID 不能为空')
  394. }
  395. const now = Date.now()
  396. const result = await db.query(
  397. 'UPDATE qk_batch SET name = ?, jx0502zbid = ?, enabled = ?, update_time = ? WHERE id = ?',
  398. [name, jx0502zbid, enabled, now, batchId]
  399. )
  400. if (!result || result.affectedRows <= 0) {
  401. throw new Error('更新抢课批次失败')
  402. }
  403. const disabledNow = Number(existing.enabled) === 1 && enabled === 0
  404. const idChanged = String(existing.jx0502zbid) !== jx0502zbid
  405. if (disabledNow) {
  406. await this.pauseTasksByBatch(batchId)
  407. } else if (idChanged && enabled === 1) {
  408. await this.redispatchTasksByBatch(batchId)
  409. }
  410. this.logInfo('updateBatch', '抢课批次已更新', { batchId }, {
  411. name,
  412. jx0502zbid,
  413. enabled: enabled === 1,
  414. disabledNow,
  415. idChanged
  416. })
  417. }
  418. async logTask(taskId, clientId, event, message = '', payload = null) {
  419. const sql = 'INSERT INTO qk_task_log (task_id, client_id, event, message, payload_json, create_time) VALUES (?, ?, ?, ?, ?, ?)'
  420. await db.query(sql, [
  421. taskId,
  422. clientId || null,
  423. event,
  424. message || '',
  425. payload ? JSON.stringify(payload) : null,
  426. Date.now()
  427. ])
  428. this.logInfo('taskLog', message || event, { taskId, clientId }, payload ? { event, ...this.sanitizeForLog(payload) } : { event })
  429. }
  430. serializeReportLog(row) {
  431. const result = { ...row }
  432. if (typeof result.payload_json === 'string' && result.payload_json) {
  433. try {
  434. result.payload_json = JSON.parse(result.payload_json)
  435. } catch (_) {}
  436. }
  437. return result
  438. }
  439. buildReportMessage(payload = {}) {
  440. if (payload.message) {
  441. return String(payload.message)
  442. }
  443. if (payload.error_msg) {
  444. return String(payload.error_msg)
  445. }
  446. if (payload.error) {
  447. return String(payload.error)
  448. }
  449. if (payload.success === true) {
  450. return payload.label ? `${payload.label} 成功` : '请求成功'
  451. }
  452. return payload.label ? `${payload.label} 失败` : '请求失败'
  453. }
  454. async assertClientTaskAccess(clientId, taskId) {
  455. const rows = await db.query(
  456. 'SELECT id, status, assigned_client_id FROM qk_task WHERE id = ?',
  457. [taskId]
  458. )
  459. if (!rows || rows.length === 0) {
  460. throw new Error('任务不存在')
  461. }
  462. const task = rows[0]
  463. if (task.assigned_client_id !== clientId) {
  464. throw new Error('任务不属于当前客户端')
  465. }
  466. if (![TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)) {
  467. throw new Error('任务当前状态不可上报')
  468. }
  469. return task
  470. }
  471. async reportProgress(clientId, clientSecret, payload = {}) {
  472. const client = await this.authenticateClient(clientId, clientSecret)
  473. if (!client) {
  474. throw new Error('客户端凭证无效')
  475. }
  476. const taskId = Number(payload.task_id || payload.id)
  477. if (!taskId) {
  478. throw new Error('缺少任务 ID')
  479. }
  480. const event = String(payload.event || 'request_result')
  481. if (!REPORT_LOG_EVENTS.has(event) || event === 'grab_success' || event === 'grab_fail') {
  482. throw new Error('不支持的上报类型')
  483. }
  484. const task = await this.assertClientTaskAccess(clientId, taskId)
  485. const message = this.buildReportMessage(payload)
  486. const now = Date.now()
  487. await this.logTask(taskId, clientId, event, message, payload)
  488. const updates = ['update_time = ?']
  489. const params = [now]
  490. if (task.status === TASK_STATUS.ASSIGNED) {
  491. updates.push('status = ?')
  492. params.push(TASK_STATUS.RUNNING)
  493. }
  494. if (payload.success !== true && message) {
  495. updates.push('error_msg = ?')
  496. params.push(message)
  497. }
  498. params.push(taskId, clientId)
  499. await db.query(
  500. `UPDATE qk_task SET ${updates.join(', ')} WHERE id = ? AND assigned_client_id = ?`,
  501. params
  502. )
  503. this.logInfo('reportProgress', '客户端上报抢课进度', { taskId, clientId }, {
  504. event,
  505. success: payload.success === true,
  506. message
  507. })
  508. return { task_id: taskId, event, message }
  509. }
  510. async createTask(uuid, payload) {
  511. const validated = this.validateTaskCoursesAndInterval(
  512. payload.courses || payload.COURSES,
  513. payload.course_groups || payload.COURSE_GROUPS,
  514. payload.interval_ms || payload.INTERVAL_MS || 500
  515. )
  516. const { courses, courseGroups, intervalMs } = validated
  517. const batchId = this.resolveTaskBatchId(payload)
  518. await this.requireEnabledBatch(batchId)
  519. const time = Date.now()
  520. const sql = `INSERT INTO qk_task
  521. (create_user, name, batch_id, jx0502zbid, student_num, password_enc, courses, course_groups, enable_ggxxk, interval_ms, status, create_time, update_time)
  522. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
  523. const result = await db.query(sql, [
  524. uuid,
  525. payload.name,
  526. batchId,
  527. '',
  528. payload.student_num || payload.user,
  529. this.encryptPassword(payload.password || payload.pass),
  530. JSON.stringify(courses),
  531. JSON.stringify(courseGroups),
  532. payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
  533. intervalMs,
  534. TASK_STATUS.PAUSED,
  535. time,
  536. time
  537. ])
  538. if (!result || result.affectedRows <= 0) {
  539. throw new Error('创建抢课任务失败')
  540. }
  541. await this.logTask(result.insertId, null, 'created', '用户提交抢课任务(未开始)')
  542. this.logInfo('createTask', '抢课任务已创建', { taskId: result.insertId, uuid }, {
  543. name: payload.name,
  544. batch_id: batchId,
  545. student_num: payload.student_num || payload.user,
  546. courses_count: courses.length,
  547. course_groups_count: courseGroups.length,
  548. interval_ms: intervalMs,
  549. enable_ggxxk: !!(payload.enable_ggxxk || payload.ENABLE_GGXXK)
  550. })
  551. return result.insertId
  552. }
  553. async updateTask(uuid, taskId, payload) {
  554. const rows = await db.query('SELECT status FROM qk_task WHERE id = ? AND create_user = ?', [taskId, uuid])
  555. if (!rows || rows.length === 0) {
  556. throw new Error('任务不存在')
  557. }
  558. if (![TASK_STATUS.PAUSED, TASK_STATUS.FAILED, TASK_STATUS.CANCELLED].includes(rows[0].status)) {
  559. throw new Error('任务进行中或已分配,请先暂停后再修改')
  560. }
  561. const validated = this.validateTaskCoursesAndInterval(
  562. payload.courses || payload.COURSES,
  563. payload.course_groups || payload.COURSE_GROUPS,
  564. payload.interval_ms || payload.INTERVAL_MS || 500
  565. )
  566. const { courses, courseGroups, intervalMs } = validated
  567. const batchId = this.resolveTaskBatchId(payload)
  568. await this.requireEnabledBatch(batchId)
  569. const passwordSql = payload.password || payload.pass ? ', password_enc = ?' : ''
  570. const params = [
  571. payload.name,
  572. batchId,
  573. payload.student_num || payload.user,
  574. JSON.stringify(courses),
  575. JSON.stringify(courseGroups),
  576. payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
  577. intervalMs,
  578. TASK_STATUS.PAUSED,
  579. Date.now()
  580. ]
  581. if (passwordSql) {
  582. params.splice(3, 0, this.encryptPassword(payload.password || payload.pass))
  583. }
  584. params.push(taskId, uuid)
  585. const sql = `UPDATE qk_task SET name = ?, batch_id = ?, student_num = ?${passwordSql}, courses = ?, course_groups = ?, enable_ggxxk = ?, interval_ms = ?, status = ?, jx0502zbid = '', update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL WHERE id = ? AND create_user = ?`
  586. const result = await db.query(sql, params)
  587. if (!result || result.affectedRows <= 0) {
  588. throw new Error('更新抢课任务失败')
  589. }
  590. await this.logTask(taskId, null, 'updated', '用户更新抢课任务')
  591. this.logInfo('updateTask', '抢课任务已更新(未开始)', { taskId, uuid }, {
  592. name: payload.name,
  593. student_num: payload.student_num || payload.user,
  594. courses_count: courses.length,
  595. course_groups_count: courseGroups.length,
  596. interval_ms: intervalMs,
  597. password_changed: !!(payload.password || payload.pass)
  598. })
  599. }
  600. async listUserTasks(uuid, filters = {}) {
  601. const where = ['t.create_user = ?']
  602. const params = [uuid]
  603. if (filters.status) {
  604. where.push('t.status = ?')
  605. params.push(filters.status)
  606. }
  607. if (filters.student_num) {
  608. where.push('t.student_num LIKE ?')
  609. params.push(`%${filters.student_num}%`)
  610. }
  611. if (filters.name) {
  612. where.push('t.name LIKE ?')
  613. params.push(`%${filters.name}%`)
  614. }
  615. if (filters.batch_id) {
  616. where.push('t.batch_id = ?')
  617. params.push(Number(filters.batch_id))
  618. }
  619. const whereSql = where.join(' AND ')
  620. const rows = await db.query(
  621. `SELECT t.*${this.taskBatchSelectSql('t')}
  622. FROM qk_task t
  623. ${this.taskBatchJoinSql('t')}
  624. WHERE ${whereSql}
  625. ORDER BY t.create_time DESC`,
  626. params
  627. )
  628. return (rows || []).map(row => this.serializeTask(row))
  629. }
  630. async getTaskDetail(uuid, taskId) {
  631. const rows = await db.query(
  632. `SELECT t.*${this.taskBatchSelectSql('t')}
  633. FROM qk_task t
  634. ${this.taskBatchJoinSql('t')}
  635. WHERE t.id = ? AND t.create_user = ?`,
  636. [taskId, uuid]
  637. )
  638. if (!rows || rows.length === 0) {
  639. return null
  640. }
  641. const logs = await db.query('SELECT client_id, event, message, payload_json, create_time FROM qk_task_log WHERE task_id = ? ORDER BY create_time DESC LIMIT 100', [taskId])
  642. return {
  643. task: this.serializeTask(rows[0], true),
  644. logs: (logs || []).map(log => {
  645. if (typeof log.payload_json === 'string' && log.payload_json) {
  646. try {
  647. log.payload_json = JSON.parse(log.payload_json)
  648. } catch (_) {}
  649. }
  650. return log
  651. })
  652. }
  653. }
  654. async cancelTask(uuid, taskId) {
  655. const result = await db.query(
  656. 'UPDATE qk_task SET status = ?, update_time = ?, finished_time = ? WHERE id = ? AND create_user = ? AND status IN (?, ?, ?)',
  657. [TASK_STATUS.CANCELLED, Date.now(), Date.now(), taskId, uuid, TASK_STATUS.PAUSED, TASK_STATUS.PENDING, TASK_STATUS.FAILED]
  658. )
  659. if (!result || result.affectedRows <= 0) {
  660. throw new Error('任务不存在或当前状态不可取消')
  661. }
  662. await this.logTask(taskId, null, 'cancelled', '用户取消抢课任务')
  663. this.logInfo('cancelTask', '用户已取消抢课任务', { taskId, uuid })
  664. }
  665. async startTask(uuid, taskId) {
  666. const rows = await db.query('SELECT * FROM qk_task WHERE id = ? AND create_user = ?', [taskId, uuid])
  667. if (!rows || rows.length === 0) {
  668. throw new Error('任务不存在')
  669. }
  670. const task = rows[0]
  671. if (![TASK_STATUS.PAUSED, TASK_STATUS.FAILED].includes(task.status)) {
  672. throw new Error('仅未开始或失败的任务可开启')
  673. }
  674. if (task.batch_id) {
  675. await this.requireEnabledBatch(task.batch_id)
  676. }
  677. const now = Date.now()
  678. const result = await db.query(
  679. `UPDATE qk_task SET status = ?, update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  680. assigned_at = NULL, exclude_client_id = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL
  681. WHERE id = ? AND create_user = ? AND status IN (?, ?)`,
  682. [TASK_STATUS.PENDING, now, taskId, uuid, TASK_STATUS.PAUSED, TASK_STATUS.FAILED]
  683. )
  684. if (!result || result.affectedRows <= 0) {
  685. throw new Error('开启抢课任务失败')
  686. }
  687. await this.logTask(taskId, null, 'started', '用户开启抢课任务,等待分配')
  688. this.logInfo('startTask', '用户已开启抢课任务', { taskId, uuid })
  689. }
  690. async pauseTask(uuid, taskId) {
  691. const rows = await db.query('SELECT * FROM qk_task WHERE id = ? AND create_user = ?', [taskId, uuid])
  692. if (!rows || rows.length === 0) {
  693. throw new Error('任务不存在')
  694. }
  695. const task = rows[0]
  696. if (![TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)) {
  697. throw new Error('当前状态不可暂停')
  698. }
  699. const now = Date.now()
  700. const wasAssigned = [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)
  701. const releasedClientId = wasAssigned ? task.assigned_client_id : null
  702. const result = await db.query(
  703. `UPDATE qk_task SET status = ?, update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  704. assigned_at = NULL, exclude_client_id = NULL
  705. WHERE id = ? AND create_user = ? AND status IN (?, ?, ?)`,
  706. [TASK_STATUS.PAUSED, now, taskId, uuid, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  707. )
  708. if (!result || result.affectedRows <= 0) {
  709. throw new Error('暂停抢课任务失败')
  710. }
  711. if (releasedClientId) {
  712. await this.syncClientSlots(releasedClientId, now)
  713. }
  714. await this.logTask(taskId, task.assigned_client_id, 'paused', wasAssigned ? '用户暂停任务,已收回客户端' : '用户暂停抢课任务')
  715. this.logInfo('pauseTask', '用户已暂停抢课任务', { taskId, uuid }, {
  716. released_from_client: wasAssigned ? task.assigned_client_id : null
  717. })
  718. }
  719. async getAdminTask(taskId) {
  720. const rows = await db.query(
  721. `SELECT t.*, u.username, u.avatar${this.taskBatchSelectSql('t')}
  722. FROM qk_task t
  723. LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
  724. ${this.taskBatchJoinSql('t')}
  725. WHERE t.id = ?`,
  726. [taskId]
  727. )
  728. if (!rows || rows.length === 0) {
  729. return null
  730. }
  731. return this.serializeTask(rows[0], true)
  732. }
  733. async adminUpdateTask(taskId, payload) {
  734. const rows = await db.query('SELECT * FROM qk_task WHERE id = ?', [taskId])
  735. if (!rows || rows.length === 0) {
  736. throw new Error('任务不存在')
  737. }
  738. const task = rows[0]
  739. if (task.status === TASK_STATUS.SUCCESS) {
  740. throw new Error('已成功的任务不可编辑')
  741. }
  742. const validated = this.validateTaskCoursesAndInterval(
  743. payload.courses || payload.COURSES,
  744. payload.course_groups || payload.COURSE_GROUPS,
  745. payload.interval_ms || payload.INTERVAL_MS || task.interval_ms || 500
  746. )
  747. const { courses, courseGroups, intervalMs } = validated
  748. const now = Date.now()
  749. const wasAssigned = [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)
  750. const releasedClientId = wasAssigned ? task.assigned_client_id : null
  751. const batchId = this.resolveTaskBatchId(payload)
  752. const passwordSql = payload.password || payload.pass ? ', password_enc = ?' : ''
  753. const params = [
  754. payload.name,
  755. batchId,
  756. payload.student_num || payload.user,
  757. JSON.stringify(courses),
  758. JSON.stringify(courseGroups),
  759. payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
  760. intervalMs,
  761. TASK_STATUS.PAUSED,
  762. now
  763. ]
  764. if (passwordSql) {
  765. params.splice(3, 0, this.encryptPassword(payload.password || payload.pass))
  766. }
  767. params.push(taskId)
  768. const sql = `UPDATE qk_task SET name = ?, batch_id = ?, student_num = ?${passwordSql}, courses = ?, course_groups = ?, enable_ggxxk = ?, interval_ms = ?, status = ?, jx0502zbid = '', update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL, exclude_client_id = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL WHERE id = ?`
  769. const result = await db.query(sql, params)
  770. if (!result || result.affectedRows <= 0) {
  771. throw new Error('更新抢课任务失败')
  772. }
  773. if (releasedClientId) {
  774. await this.syncClientSlots(releasedClientId, now)
  775. }
  776. await this.logTask(taskId, task.assigned_client_id, 'admin_updated', wasAssigned ? '管理员更新任务并收回(未开始)' : '管理员更新抢课任务')
  777. this.logInfo('adminUpdateTask', '管理员已更新抢课任务', { taskId }, {
  778. name: payload.name,
  779. student_num: payload.student_num || payload.user,
  780. released_from_client: wasAssigned ? task.assigned_client_id : null
  781. })
  782. }
  783. async adminCancelTask(taskId) {
  784. const rows = await db.query('SELECT id, status, assigned_client_id FROM qk_task WHERE id = ?', [taskId])
  785. if (!rows || rows.length === 0) {
  786. throw new Error('任务不存在')
  787. }
  788. const task = rows[0]
  789. if (![TASK_STATUS.PAUSED, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, TASK_STATUS.FAILED].includes(task.status)) {
  790. throw new Error('当前状态不可取消')
  791. }
  792. const now = Date.now()
  793. const releasedClientId = [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)
  794. ? task.assigned_client_id
  795. : null
  796. const result = await db.query(
  797. `UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL,
  798. exclude_client_id = NULL, update_time = ?, finished_time = ? WHERE id = ? AND status IN (?, ?, ?, ?, ?)`,
  799. [TASK_STATUS.CANCELLED, now, now, taskId, TASK_STATUS.PAUSED, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, TASK_STATUS.FAILED]
  800. )
  801. if (!result || result.affectedRows <= 0) {
  802. throw new Error('取消抢课任务失败')
  803. }
  804. if (releasedClientId) {
  805. await this.syncClientSlots(releasedClientId, now)
  806. }
  807. await this.logTask(taskId, task.assigned_client_id, 'admin_cancelled', '管理员取消抢课任务')
  808. this.logInfo('adminCancelTask', '管理员已取消抢课任务', { taskId })
  809. }
  810. async adminRetryTask(taskId) {
  811. const rows = await db.query('SELECT id, status FROM qk_task WHERE id = ?', [taskId])
  812. if (!rows || rows.length === 0) {
  813. throw new Error('任务不存在')
  814. }
  815. if (![TASK_STATUS.FAILED, TASK_STATUS.CANCELLED].includes(rows[0].status)) {
  816. throw new Error('仅失败或已取消的任务可重试')
  817. }
  818. const now = Date.now()
  819. const result = await db.query(
  820. `UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL,
  821. exclude_client_id = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL, update_time = ?
  822. WHERE id = ? AND status IN (?, ?)`,
  823. [TASK_STATUS.PENDING, now, taskId, TASK_STATUS.FAILED, TASK_STATUS.CANCELLED]
  824. )
  825. if (!result || result.affectedRows <= 0) {
  826. throw new Error('重试抢课任务失败')
  827. }
  828. await this.logTask(taskId, null, 'admin_retry', '管理员将任务重新加入待分配队列')
  829. this.logInfo('adminRetryTask', '管理员已重试抢课任务', { taskId })
  830. }
  831. async adminStartTask(taskId) {
  832. const rows = await db.query('SELECT * FROM qk_task WHERE id = ?', [taskId])
  833. if (!rows || rows.length === 0) {
  834. throw new Error('任务不存在')
  835. }
  836. const task = rows[0]
  837. if (![TASK_STATUS.PAUSED, TASK_STATUS.FAILED].includes(task.status)) {
  838. throw new Error('仅未开始或失败的任务可开启')
  839. }
  840. if (task.batch_id) {
  841. await this.requireEnabledBatch(task.batch_id)
  842. }
  843. const now = Date.now()
  844. const result = await db.query(
  845. `UPDATE qk_task SET status = ?, update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  846. assigned_at = NULL, exclude_client_id = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL
  847. WHERE id = ? AND status IN (?, ?)`,
  848. [TASK_STATUS.PENDING, now, taskId, TASK_STATUS.PAUSED, TASK_STATUS.FAILED]
  849. )
  850. if (!result || result.affectedRows <= 0) {
  851. throw new Error('开启抢课任务失败')
  852. }
  853. await this.logTask(taskId, null, 'admin_started', '管理员开启抢课任务,等待分配')
  854. this.logInfo('adminStartTask', '管理员已开启抢课任务', { taskId })
  855. }
  856. async adminPauseTask(taskId) {
  857. const rows = await db.query('SELECT * FROM qk_task WHERE id = ?', [taskId])
  858. if (!rows || rows.length === 0) {
  859. throw new Error('任务不存在')
  860. }
  861. const task = rows[0]
  862. if (![TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)) {
  863. throw new Error('当前状态不可暂停')
  864. }
  865. const now = Date.now()
  866. const wasAssigned = [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)
  867. const releasedClientId = wasAssigned ? task.assigned_client_id : null
  868. const result = await db.query(
  869. `UPDATE qk_task SET status = ?, update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  870. assigned_at = NULL, exclude_client_id = NULL
  871. WHERE id = ? AND status IN (?, ?, ?)`,
  872. [TASK_STATUS.PAUSED, now, taskId, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  873. )
  874. if (!result || result.affectedRows <= 0) {
  875. throw new Error('暂停抢课任务失败')
  876. }
  877. if (releasedClientId) {
  878. await this.syncClientSlots(releasedClientId, now)
  879. }
  880. await this.logTask(taskId, task.assigned_client_id, 'admin_paused', wasAssigned ? '管理员暂停任务,已收回客户端' : '管理员暂停抢课任务')
  881. this.logInfo('adminPauseTask', '管理员已暂停抢课任务', { taskId }, {
  882. released_from_client: wasAssigned ? task.assigned_client_id : null
  883. })
  884. }
  885. async authenticateClient(clientId, clientSecret) {
  886. if (!clientId || !clientSecret) {
  887. this.logWarn('authClient', '客户端认证失败:缺少凭证', { clientId: clientId || 'unknown' })
  888. return null
  889. }
  890. const rows = await db.query('SELECT * FROM qk_client WHERE client_id = ? AND enabled = 1', [clientId])
  891. if (!rows || rows.length !== 1) {
  892. this.logWarn('authClient', '客户端认证失败:客户端不存在或已禁用', { clientId })
  893. return null
  894. }
  895. if (!bcryptjs.compareSync(String(clientSecret), rows[0].client_secret_hash)) {
  896. this.logWarn('authClient', '客户端认证失败:密钥不匹配', { clientId })
  897. return null
  898. }
  899. return rows[0]
  900. }
  901. async enrollOrAuthenticateClient(clientId, clientSecret, payload = {}) {
  902. const existing = await this.authenticateClient(clientId, clientSecret)
  903. if (existing) {
  904. return existing
  905. }
  906. if (!clientId || !clientSecret || !String(clientId).startsWith('qk-cli-')) {
  907. throw new Error('客户端凭证无效')
  908. }
  909. const rows = await db.query('SELECT * FROM qk_client WHERE client_id = ?', [clientId])
  910. if (rows && rows.length > 0) {
  911. throw new Error('客户端凭证无效')
  912. }
  913. const time = Date.now()
  914. const label = payload.label || payload.hostname || `auto-${clientId}`
  915. try {
  916. const result = await db.query(
  917. 'INSERT INTO qk_client (client_id, client_secret_hash, label, create_time, update_time) VALUES (?, ?, ?, ?, ?)',
  918. [clientId, bcryptjs.hashSync(String(clientSecret), 10), label, time, time]
  919. )
  920. if (!result || result.affectedRows <= 0) {
  921. throw new Error('客户端自动注册失败')
  922. }
  923. } catch (err) {
  924. if (err?.code === 'ER_DUP_ENTRY') {
  925. const raced = await this.authenticateClient(clientId, clientSecret)
  926. if (raced) {
  927. return raced
  928. }
  929. }
  930. throw err
  931. }
  932. const created = await db.query('SELECT * FROM qk_client WHERE client_id = ? AND enabled = 1', [clientId])
  933. if (!created || created.length !== 1) {
  934. throw new Error('客户端自动注册失败')
  935. }
  936. this.logInfo('enrollClient', '抢课客户端已自动注册', { clientId }, { label })
  937. return created[0]
  938. }
  939. async registerClient(clientId, clientSecret, payload = {}) {
  940. const client = await this.enrollOrAuthenticateClient(clientId, clientSecret, payload)
  941. const maxSlots = this.resolveClientMaxSlots(client, payload)
  942. const time = Date.now()
  943. await db.query(
  944. 'UPDATE qk_client SET label = COALESCE(NULLIF(?, \'\'), label), max_slots = ?, hostname = ?, os_username = ?, cpu_model = ?, cpu_threads = ?, total_mem_mb = ?, free_mem_mb = ?, platform = ?, last_heartbeat_at = ?, online = 1, update_time = ? WHERE client_id = ?',
  945. [
  946. payload.label || '',
  947. maxSlots,
  948. payload.hostname || null,
  949. payload.os_username || null,
  950. payload.cpu_model || null,
  951. payload.cpu_threads || null,
  952. payload.total_mem_mb || null,
  953. payload.free_mem_mb || null,
  954. payload.platform || null,
  955. time,
  956. time,
  957. clientId
  958. ]
  959. )
  960. await Redis.set(`qk:client:hb:${clientId}`, String(time), { EX: this.heartbeatTtlSeconds })
  961. const currentSlots = await this.syncClientSlots(clientId, time)
  962. this.logInfo('registerClient', '抢课客户端已注册/上线', { clientId }, {
  963. label: payload.label || client.label,
  964. max_slots: maxSlots,
  965. current_slots: currentSlots,
  966. hostname: payload.hostname,
  967. platform: payload.platform,
  968. cpu_threads: payload.cpu_threads,
  969. total_mem_mb: payload.total_mem_mb,
  970. free_mem_mb: payload.free_mem_mb
  971. })
  972. return { client_id: clientId, max_slots: maxSlots, current_slots: currentSlots }
  973. }
  974. async heartbeat(clientId, clientSecret, payload = {}) {
  975. const client = await this.authenticateClient(clientId, clientSecret)
  976. if (!client) {
  977. throw new Error('客户端凭证无效')
  978. }
  979. const runningTasks = Array.isArray(payload.running_tasks) ? payload.running_tasks.map(Number).filter(Boolean) : []
  980. const revokedTaskIds = await this.findRevokedClientTasks(clientId, runningTasks)
  981. const activeRunningTasks = runningTasks.filter(id => !revokedTaskIds.includes(Number(id)))
  982. const maxSlots = this.resolveClientMaxSlots(client, payload)
  983. const now = Date.now()
  984. await db.query(
  985. 'UPDATE qk_client SET max_slots = ?, hostname = ?, os_username = ?, cpu_model = ?, cpu_threads = ?, total_mem_mb = ?, free_mem_mb = ?, platform = ?, last_heartbeat_at = ?, online = 1, update_time = ? WHERE client_id = ?',
  986. [
  987. maxSlots,
  988. payload.hostname || null,
  989. payload.os_username || null,
  990. payload.cpu_model || null,
  991. payload.cpu_threads || null,
  992. payload.total_mem_mb || null,
  993. payload.free_mem_mb || null,
  994. payload.platform || null,
  995. now,
  996. now,
  997. clientId
  998. ]
  999. )
  1000. await Redis.set(`qk:client:hb:${clientId}`, String(now), { EX: this.heartbeatTtlSeconds })
  1001. if (activeRunningTasks.length > 0) {
  1002. const leaseExpireAt = now + this.leaseMs
  1003. const placeholders = activeRunningTasks.map(() => '?').join(',')
  1004. await db.query(
  1005. `UPDATE qk_task SET status = ?, lease_expire_at = ?, update_time = ? WHERE assigned_client_id = ? AND id IN (${placeholders}) AND status IN (?, ?)`,
  1006. [TASK_STATUS.RUNNING, leaseExpireAt, now, clientId, ...activeRunningTasks, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1007. )
  1008. this.logInfo('heartbeat', '客户端心跳续租运行中任务', { clientId }, {
  1009. running_tasks: activeRunningTasks,
  1010. revoked_task_ids: revokedTaskIds,
  1011. lease_expire_at: leaseExpireAt,
  1012. max_slots: maxSlots
  1013. })
  1014. } else if (revokedTaskIds.length > 0) {
  1015. this.logInfo('heartbeat', '客户端上报任务已被服务端收回', { clientId }, {
  1016. revoked_task_ids: revokedTaskIds
  1017. })
  1018. }
  1019. await this.releaseOrphanedClientTasks(clientId, activeRunningTasks, now)
  1020. const currentSlots = await this.syncClientSlots(clientId, now)
  1021. return { client_id: clientId, current_slots: currentSlots, max_slots: maxSlots, revoked_task_ids: revokedTaskIds }
  1022. }
  1023. async releaseOrphanedClientTasks(clientId, runningTasks, now = Date.now()) {
  1024. const assigned = await db.query(
  1025. 'SELECT id FROM qk_task WHERE assigned_client_id = ? AND status IN (?, ?)',
  1026. [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1027. )
  1028. const runningSet = new Set((runningTasks || []).map(Number).filter(Boolean))
  1029. let released = 0
  1030. for (const row of assigned || []) {
  1031. if (runningSet.has(row.id)) continue
  1032. const result = await db.query(
  1033. 'UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL, update_time = ? WHERE id = ? AND assigned_client_id = ? AND status IN (?, ?)',
  1034. [TASK_STATUS.PENDING, now, row.id, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1035. )
  1036. if (result && result.affectedRows > 0) {
  1037. released += 1
  1038. await this.logTask(row.id, clientId, 'released', '客户端未继续执行,任务已释放回队列')
  1039. this.logInfo('releaseOrphaned', '释放未在运行的已分配任务', { taskId: row.id, clientId })
  1040. }
  1041. }
  1042. if (released > 0) {
  1043. await this.syncClientSlots(clientId, now)
  1044. }
  1045. return released
  1046. }
  1047. async reclaimTasks(clientId, clientSecret) {
  1048. const client = await this.authenticateClient(clientId, clientSecret)
  1049. if (!client) {
  1050. throw new Error('客户端凭证无效')
  1051. }
  1052. const now = Date.now()
  1053. const leaseExpireAt = now + this.leaseMs
  1054. const rows = await db.query(
  1055. `SELECT t.*, b.jx0502zbid AS batch_jx0502zbid, b.name AS batch_name, b.enabled AS batch_enabled
  1056. FROM qk_task t
  1057. LEFT JOIN qk_batch b ON b.id = t.batch_id
  1058. WHERE t.assigned_client_id = ? AND t.status IN (?, ?)
  1059. ORDER BY t.create_time ASC`,
  1060. [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1061. )
  1062. if (!rows || rows.length === 0) {
  1063. return []
  1064. }
  1065. for (const row of rows) {
  1066. await db.query(
  1067. 'UPDATE qk_task SET status = ?, lease_expire_at = ?, update_time = ? WHERE id = ? AND assigned_client_id = ? AND status IN (?, ?)',
  1068. [TASK_STATUS.RUNNING, leaseExpireAt, now, row.id, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1069. )
  1070. await this.logTask(row.id, clientId, 'reclaimed', '客户端重连后回收任务继续执行')
  1071. }
  1072. this.logInfo('reclaimTasks', `客户端回收 ${rows.length} 个进行中任务`, { clientId }, {
  1073. task_ids: rows.map(row => row.id),
  1074. lease_expire_at: leaseExpireAt
  1075. })
  1076. return rows.map(row => this.serializeTask({
  1077. ...row,
  1078. assigned_client_id: clientId,
  1079. lease_expire_at: leaseExpireAt,
  1080. status: TASK_STATUS.RUNNING
  1081. }, true))
  1082. }
  1083. async releaseTasks(clientId, clientSecret, payload = {}) {
  1084. const client = await this.authenticateClient(clientId, clientSecret)
  1085. if (!client) {
  1086. throw new Error('客户端凭证无效')
  1087. }
  1088. const now = Date.now()
  1089. const taskIds = Array.isArray(payload.task_ids)
  1090. ? payload.task_ids.map(Number).filter(Boolean)
  1091. : []
  1092. let rows = []
  1093. if (taskIds.length > 0) {
  1094. const placeholders = taskIds.map(() => '?').join(',')
  1095. rows = await db.query(
  1096. `SELECT id FROM qk_task WHERE assigned_client_id = ? AND id IN (${placeholders}) AND status IN (?, ?)`,
  1097. [clientId, ...taskIds, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1098. )
  1099. } else {
  1100. rows = await db.query(
  1101. 'SELECT id FROM qk_task WHERE assigned_client_id = ? AND status IN (?, ?)',
  1102. [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1103. )
  1104. }
  1105. let released = 0
  1106. for (const row of rows || []) {
  1107. const result = await db.query(
  1108. 'UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL, update_time = ? WHERE id = ? AND assigned_client_id = ? AND status IN (?, ?)',
  1109. [TASK_STATUS.PENDING, now, row.id, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1110. )
  1111. if (result && result.affectedRows > 0) {
  1112. released += 1
  1113. await this.logTask(row.id, clientId, 'released', '客户端主动释放任务')
  1114. }
  1115. }
  1116. if (released > 0) {
  1117. await this.syncClientSlots(clientId, now)
  1118. this.logInfo('releaseTasks', `客户端释放 ${released} 个任务`, { clientId }, {
  1119. task_ids: (rows || []).map(row => row.id)
  1120. })
  1121. }
  1122. return { released, task_ids: (rows || []).map(row => row.id) }
  1123. }
  1124. async pullTasks(clientId, clientSecret, count) {
  1125. const client = await this.authenticateClient(clientId, clientSecret)
  1126. if (!client) {
  1127. throw new Error('客户端凭证无效')
  1128. }
  1129. const availableSlots = await this.getClientAvailableSlotsAsync(client)
  1130. const safeCount = Math.max(0, Math.min(50, Number(count || 0), availableSlots))
  1131. if (safeCount <= 0) {
  1132. this.logWarn('pullTasks', '客户端无可用槽位或拉取数量无效,已忽略', { clientId }, {
  1133. count,
  1134. available_slots: availableSlots,
  1135. max_slots: this.resolveClientMaxSlots(client),
  1136. current_slots: client.current_slots,
  1137. free_mem_mb: client.free_mem_mb
  1138. })
  1139. return []
  1140. }
  1141. const lockKey = `qk:pull:lock:${clientId}`
  1142. const locked = await Redis.set(lockKey, '1', { NX: true, EX: this.pullLockTtlSeconds })
  1143. if (!locked) {
  1144. this.logWarn('pullTasks', '拉取任务被并发锁拦截,本次跳过', { clientId }, { count: safeCount })
  1145. return []
  1146. }
  1147. const conn = await db.connect()
  1148. try {
  1149. await conn.beginTransaction()
  1150. const inFlightStatuses = this.getInFlightTaskStatuses()
  1151. const candidateLimit = Math.min(50, Math.max(safeCount, safeCount * 5))
  1152. const [candidateRows] = await conn.execute(
  1153. `SELECT t.*, b.jx0502zbid AS batch_jx0502zbid, b.name AS batch_name, b.enabled AS batch_enabled
  1154. FROM qk_task t
  1155. LEFT JOIN qk_batch b ON b.id = t.batch_id
  1156. WHERE t.status = ?
  1157. AND (t.batch_id IS NULL OR b.enabled = 1)
  1158. AND (t.exclude_client_id IS NULL OR t.exclude_client_id <> ?)
  1159. AND NOT EXISTS (
  1160. SELECT 1 FROM qk_task active
  1161. WHERE active.student_num = t.student_num
  1162. AND active.status IN (?, ?)
  1163. )
  1164. ORDER BY t.create_time ASC LIMIT ${candidateLimit} FOR UPDATE`,
  1165. [TASK_STATUS.PENDING, clientId, ...inFlightStatuses]
  1166. )
  1167. const rows = this.pickPullableTasks(candidateRows, safeCount)
  1168. const now = Date.now()
  1169. const leaseExpireAt = now + this.leaseMs
  1170. for (const row of rows) {
  1171. await conn.execute(
  1172. 'UPDATE qk_task SET status = ?, assigned_client_id = ?, lease_expire_at = ?, assigned_at = ?, exclude_client_id = NULL, update_time = ? WHERE id = ?',
  1173. [TASK_STATUS.ASSIGNED, clientId, leaseExpireAt, now, now, row.id]
  1174. )
  1175. }
  1176. await conn.commit()
  1177. if (rows.length > 0) {
  1178. await this.syncClientSlots(clientId, now)
  1179. }
  1180. for (const row of rows) {
  1181. await this.logTask(row.id, clientId, 'assigned', '任务已分配给客户端')
  1182. }
  1183. if (rows.length > 0) {
  1184. this.logInfo('pullTasks', `已分配 ${rows.length} 个抢课任务`, { clientId }, {
  1185. task_ids: rows.map(row => row.id),
  1186. lease_expire_at: leaseExpireAt,
  1187. requested_count: safeCount
  1188. })
  1189. }
  1190. return rows.map(row => this.serializeTask({ ...row, assigned_client_id: clientId, lease_expire_at: leaseExpireAt, status: TASK_STATUS.ASSIGNED }, true))
  1191. } catch (err) {
  1192. await conn.rollback()
  1193. this.logError('pullTasks', '拉取并分配任务失败,事务已回滚', { clientId }, err)
  1194. throw err
  1195. } finally {
  1196. await Redis.del(lockKey)
  1197. }
  1198. }
  1199. async reportResult(clientId, clientSecret, payload = {}) {
  1200. const client = await this.authenticateClient(clientId, clientSecret)
  1201. if (!client) {
  1202. throw new Error('客户端凭证无效')
  1203. }
  1204. const taskId = Number(payload.task_id || payload.id)
  1205. const success = payload.success === true || payload.status === TASK_STATUS.SUCCESS
  1206. const status = success ? TASK_STATUS.SUCCESS : TASK_STATUS.FAILED
  1207. const now = Date.now()
  1208. const resultJson = payload.result ? JSON.stringify(payload.result) : JSON.stringify({
  1209. course: payload.course || '',
  1210. message: payload.message || ''
  1211. })
  1212. const result = await db.query(
  1213. 'UPDATE qk_task SET status = ?, result_json = ?, error_msg = ?, update_time = ?, finished_time = ?, lease_expire_at = NULL WHERE id = ? AND assigned_client_id = ? AND status IN (?, ?)',
  1214. [status, resultJson, success ? null : (payload.error_msg || payload.message || '抢课失败'), now, now, taskId, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1215. )
  1216. if (!result || result.affectedRows <= 0) {
  1217. this.logWarn('reportResult', '任务结果上报被拒绝:任务不存在或不属于当前客户端', { taskId, clientId }, {
  1218. success,
  1219. status
  1220. })
  1221. throw new Error('任务不存在或不属于当前客户端')
  1222. }
  1223. await this.syncClientSlots(clientId, now)
  1224. await db.query(
  1225. `UPDATE qk_client SET total_completed = total_completed + 1, total_success = total_success + ?, update_time = ? WHERE client_id = ?`,
  1226. [success ? 1 : 0, now, clientId]
  1227. )
  1228. await this.logTask(taskId, clientId, success ? 'grab_success' : 'grab_fail', payload.message || payload.error_msg || '', payload.result || payload)
  1229. this.logInfo('reportResult', success ? '抢课成功' : '抢课失败', { taskId, clientId }, {
  1230. success,
  1231. message: payload.message || payload.error_msg || '',
  1232. course: payload.course || payload.result?.course || '',
  1233. result: payload.result || null
  1234. })
  1235. return { task_id: taskId, status }
  1236. }
  1237. async listClients() {
  1238. const rows = await db.query('SELECT id, client_id, label, enabled, max_slots, current_slots, hostname, os_username, cpu_model, cpu_threads, total_mem_mb, free_mem_mb, platform, last_heartbeat_at, online, total_completed, total_success, create_time, update_time FROM qk_client ORDER BY update_time DESC')
  1239. return rows || []
  1240. }
  1241. async deleteClient(clientId) {
  1242. const result = await db.query('UPDATE qk_client SET enabled = 0, online = 0, update_time = ? WHERE client_id = ?', [Date.now(), clientId])
  1243. if (!result || result.affectedRows <= 0) {
  1244. throw new Error('客户端不存在')
  1245. }
  1246. this.logInfo('deleteClient', '抢课客户端已禁用', { clientId })
  1247. }
  1248. async listAdminTasks(filters = {}) {
  1249. const pagesize = Math.max(1, Math.min(100, Number(filters.pagesize || 20)))
  1250. const current = Math.max(1, Number(filters.current || 1))
  1251. const where = ['1 = 1']
  1252. const params = []
  1253. const countParams = []
  1254. if (filters.status) {
  1255. where.push('t.status = ?')
  1256. params.push(filters.status)
  1257. countParams.push(filters.status)
  1258. }
  1259. if (filters.client_id) {
  1260. where.push('t.assigned_client_id = ?')
  1261. params.push(filters.client_id)
  1262. countParams.push(filters.client_id)
  1263. }
  1264. if (filters.student_num) {
  1265. where.push('t.student_num LIKE ?')
  1266. params.push(`%${filters.student_num}%`)
  1267. countParams.push(`%${filters.student_num}%`)
  1268. }
  1269. if (filters.username) {
  1270. where.push('u.username COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
  1271. params.push(`%${filters.username}%`)
  1272. countParams.push(`%${filters.username}%`)
  1273. }
  1274. if (filters.batch_id) {
  1275. where.push('t.batch_id = ?')
  1276. params.push(Number(filters.batch_id))
  1277. countParams.push(Number(filters.batch_id))
  1278. }
  1279. const whereSql = where.join(' AND ')
  1280. const offset = (current - 1) * pagesize
  1281. const countRows = await db.query(
  1282. `SELECT COUNT(*) AS total FROM qk_task t
  1283. LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
  1284. WHERE ${whereSql}`,
  1285. countParams
  1286. )
  1287. const rows = await db.query(
  1288. `SELECT t.*, u.username, u.avatar${this.taskBatchSelectSql('t')}
  1289. FROM qk_task t
  1290. LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
  1291. ${this.taskBatchJoinSql('t')}
  1292. WHERE ${whereSql}
  1293. ORDER BY t.create_time DESC
  1294. LIMIT ${pagesize} OFFSET ${offset}`,
  1295. params
  1296. )
  1297. return {
  1298. list: (rows || []).map(row => this.serializeTask(row, true)),
  1299. total: countRows?.[0]?.total || 0,
  1300. current,
  1301. pagesize
  1302. }
  1303. }
  1304. async listAdminReports(filters = {}) {
  1305. const pagesize = Math.max(1, Math.min(100, Number(filters.pagesize || 20)))
  1306. const current = Math.max(1, Number(filters.current || 1))
  1307. const where = ['l.event IN (?, ?, ?, ?)']
  1308. const params = ['request_result', 'progress_snapshot', 'grab_success', 'grab_fail']
  1309. const countParams = ['request_result', 'progress_snapshot', 'grab_success', 'grab_fail']
  1310. if (filters.task_id) {
  1311. where.push('l.task_id = ?')
  1312. params.push(Number(filters.task_id))
  1313. countParams.push(Number(filters.task_id))
  1314. }
  1315. if (filters.client_id) {
  1316. where.push('l.client_id = ?')
  1317. params.push(String(filters.client_id))
  1318. countParams.push(String(filters.client_id))
  1319. }
  1320. if (filters.event) {
  1321. where.push('l.event = ?')
  1322. params.push(String(filters.event))
  1323. countParams.push(String(filters.event))
  1324. }
  1325. if (filters.start_time) {
  1326. where.push('l.create_time >= ?')
  1327. params.push(Number(filters.start_time))
  1328. countParams.push(Number(filters.start_time))
  1329. }
  1330. if (filters.end_time) {
  1331. where.push('l.create_time <= ?')
  1332. params.push(Number(filters.end_time))
  1333. countParams.push(Number(filters.end_time))
  1334. }
  1335. if (filters.student_num) {
  1336. where.push('t.student_num LIKE ?')
  1337. params.push(`%${filters.student_num}%`)
  1338. countParams.push(`%${filters.student_num}%`)
  1339. }
  1340. if (filters.name || filters.task_name) {
  1341. where.push('t.name LIKE ?')
  1342. params.push(`%${filters.name || filters.task_name}%`)
  1343. countParams.push(`%${filters.name || filters.task_name}%`)
  1344. }
  1345. if (filters.username) {
  1346. where.push('u.username COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
  1347. params.push(`%${filters.username}%`)
  1348. countParams.push(`%${filters.username}%`)
  1349. }
  1350. if (filters.client_label) {
  1351. where.push('c.label LIKE ?')
  1352. params.push(`%${filters.client_label}%`)
  1353. countParams.push(`%${filters.client_label}%`)
  1354. }
  1355. const whereSql = where.join(' AND ')
  1356. const offset = (current - 1) * pagesize
  1357. const joinSql = `
  1358. FROM qk_task_log l
  1359. LEFT JOIN qk_task t ON t.id = l.task_id
  1360. LEFT JOIN qk_client c ON c.client_id = l.client_id
  1361. LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci`
  1362. const countRows = await db.query(
  1363. `SELECT COUNT(*) AS total ${joinSql} WHERE ${whereSql}`,
  1364. countParams
  1365. )
  1366. const rows = await db.query(
  1367. `SELECT l.id, l.task_id, l.client_id, l.event, l.message, l.payload_json, l.create_time,
  1368. t.name AS task_name, t.student_num, t.status AS task_status,
  1369. c.label AS client_label, u.username, u.avatar
  1370. ${joinSql}
  1371. WHERE ${whereSql}
  1372. ORDER BY l.create_time DESC
  1373. LIMIT ${pagesize} OFFSET ${offset}`,
  1374. params
  1375. )
  1376. return {
  1377. list: (rows || []).map(row => this.serializeReportLog(row)),
  1378. total: countRows?.[0]?.total || 0,
  1379. current,
  1380. pagesize
  1381. }
  1382. }
  1383. async reassignStaleRunningTasks() {
  1384. const onlineCount = await this.countOnlineClients()
  1385. if (onlineCount <= 1) {
  1386. return 0
  1387. }
  1388. const now = Date.now()
  1389. const staleBefore = now - this.staleTaskMs
  1390. const rows = await db.query(
  1391. `SELECT id, assigned_client_id FROM qk_task
  1392. WHERE status IN (?, ?)
  1393. AND assigned_at IS NOT NULL
  1394. AND assigned_at < ?`,
  1395. [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, staleBefore]
  1396. )
  1397. let count = 0
  1398. const affectedClients = new Set()
  1399. for (const row of rows || []) {
  1400. const result = await db.query(
  1401. `UPDATE qk_task
  1402. SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  1403. assigned_at = NULL, exclude_client_id = ?, update_time = ?
  1404. WHERE id = ? AND status IN (?, ?)`,
  1405. [TASK_STATUS.PENDING, row.assigned_client_id, now, row.id, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1406. )
  1407. if (result && result.affectedRows > 0) {
  1408. count += 1
  1409. if (row.assigned_client_id) {
  1410. affectedClients.add(row.assigned_client_id)
  1411. }
  1412. await this.logTask(row.id, row.assigned_client_id, 'reassigned', '任务超过1小时未成功,已收回并等待分配给其他客户端')
  1413. this.logWarn('reassignStale', '长时间未成功任务已收回', {
  1414. taskId: row.id,
  1415. clientId: row.assigned_client_id
  1416. }, { online_clients: onlineCount })
  1417. }
  1418. }
  1419. for (const clientId of affectedClients) {
  1420. await this.syncClientSlots(clientId, now)
  1421. }
  1422. return count
  1423. }
  1424. async requeueExpiredTasks() {
  1425. const now = Date.now()
  1426. const rows = await db.query(
  1427. 'SELECT id, assigned_client_id FROM qk_task WHERE status IN (?, ?) AND lease_expire_at IS NOT NULL AND lease_expire_at < ?',
  1428. [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, now]
  1429. )
  1430. let count = 0
  1431. const affectedClients = new Set()
  1432. for (const row of rows || []) {
  1433. const result = await db.query(
  1434. 'UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL, update_time = ? WHERE id = ? AND status IN (?, ?)',
  1435. [TASK_STATUS.PENDING, now, row.id, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1436. )
  1437. if (result && result.affectedRows > 0) {
  1438. count++
  1439. if (row.assigned_client_id) {
  1440. affectedClients.add(row.assigned_client_id)
  1441. }
  1442. await this.logTask(row.id, row.assigned_client_id, 'reassigned', '租约过期,任务已重新进入待分配队列')
  1443. this.logWarn('requeueExpired', '租约过期,任务已重新入队', {
  1444. taskId: row.id,
  1445. clientId: row.assigned_client_id
  1446. })
  1447. }
  1448. }
  1449. for (const clientId of affectedClients) {
  1450. await this.syncClientSlots(clientId, now)
  1451. }
  1452. const offlineResult = await db.query(
  1453. 'UPDATE qk_client SET online = 0, current_slots = 0, update_time = ? WHERE online = 1 AND (last_heartbeat_at IS NULL OR last_heartbeat_at < ?)',
  1454. [now, now - this.heartbeatTtlSeconds * 1000]
  1455. )
  1456. const offlineCount = offlineResult?.affectedRows || 0
  1457. const staleCount = await this.reassignStaleRunningTasks()
  1458. if (count > 0 || offlineCount > 0 || staleCount > 0) {
  1459. this.logInfo('requeueExpired', '租约巡检完成', {}, {
  1460. expired_tasks: (rows || []).length,
  1461. requeued: count,
  1462. stale_reassigned: staleCount,
  1463. clients_marked_offline: offlineCount
  1464. })
  1465. }
  1466. return count + staleCount
  1467. }
  1468. }
  1469. module.exports = {
  1470. TaskScheduler,
  1471. TASK_STATUS
  1472. }