TaskScheduler.js 76 KB

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