TaskScheduler.js 80 KB

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