TaskScheduler.js 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940
  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. PENDING: 'pending',
  9. ASSIGNED: 'assigned',
  10. RUNNING: 'running',
  11. SUCCESS: 'success',
  12. FAILED: 'failed',
  13. CANCELLED: 'cancelled'
  14. }
  15. class TaskScheduler {
  16. constructor(options = {}) {
  17. this.leaseMs = options.leaseMs || config.qk?.leaseMs || 90 * 1000
  18. this.heartbeatTtlSeconds = options.heartbeatTtlSeconds || config.qk?.heartbeatTtlSeconds || 45
  19. this.pullLockTtlSeconds = options.pullLockTtlSeconds || 5
  20. this.staleTaskMs = options.staleTaskMs || config.qk?.staleTaskMs || 60 * 60 * 1000
  21. this.memPerSlotMb = options.memPerSlotMb || config.qk?.memPerSlotMb || 3072
  22. this.memReserveMb = options.memReserveMb || config.qk?.memReserveMb || 1024
  23. this.maxSlotsCap = options.maxSlotsCap || config.qk?.maxSlotsCap || 10
  24. this.logger = options.logger || new Logger()
  25. }
  26. calculateMaxSlots(profile = {}) {
  27. const freeMb = Math.max(0, Number(profile.free_mem_mb) || 0)
  28. const totalMb = Math.max(0, Number(profile.total_mem_mb) || 0)
  29. const threads = Math.max(1, Number(profile.cpu_threads) || 1)
  30. const allocatableFreeMb = Math.max(0, freeMb - this.memReserveMb)
  31. const allocatableTotalMb = Math.max(0, totalMb - this.memReserveMb)
  32. const byFreeMem = Math.floor(allocatableFreeMb / this.memPerSlotMb)
  33. const byTotalMem = Math.floor(allocatableTotalMb / this.memPerSlotMb)
  34. const byCpu = Math.floor(threads * 0.8)
  35. const fallbackMem = totalMb > 0 ? byTotalMem : 1
  36. const byMem = freeMb > 0 ? Math.min(byFreeMem, byTotalMem) : fallbackMem
  37. return Math.max(1, Math.min(50, this.maxSlotsCap, byMem, byCpu))
  38. }
  39. resolveClientMaxSlots(client, payload = {}) {
  40. const fromPayload = this.calculateMaxSlots({
  41. free_mem_mb: payload.free_mem_mb ?? client.free_mem_mb,
  42. total_mem_mb: payload.total_mem_mb ?? client.total_mem_mb,
  43. cpu_threads: payload.cpu_threads ?? client.cpu_threads
  44. })
  45. const reported = Number(payload.max_slots || client.max_slots || 0)
  46. if (!reported) return fromPayload
  47. return Math.max(1, Math.min(reported, fromPayload))
  48. }
  49. getClientAvailableSlots(client, payload = {}) {
  50. const maxSlots = this.resolveClientMaxSlots(client, payload)
  51. const currentSlots = Math.max(0, Number(payload.current_slots ?? client.current_slots ?? 0))
  52. return Math.max(0, maxSlots - currentSlots)
  53. }
  54. async countOnlineClients() {
  55. const now = Date.now()
  56. const threshold = now - this.heartbeatTtlSeconds * 1000
  57. const rows = await db.query(
  58. `SELECT COUNT(*) AS total FROM qk_client
  59. WHERE enabled = 1 AND online = 1
  60. AND last_heartbeat_at IS NOT NULL AND last_heartbeat_at >= ?`,
  61. [threshold]
  62. )
  63. return Number(rows?.[0]?.total || 0)
  64. }
  65. safeStringify(obj) {
  66. const seen = new WeakSet()
  67. return JSON.stringify(obj, (key, value) => {
  68. if (typeof value === 'object' && value !== null) {
  69. if (seen.has(value)) return '[Circular]'
  70. seen.add(value)
  71. }
  72. return value
  73. })
  74. }
  75. sanitizeForLog(payload) {
  76. if (!payload || typeof payload !== 'object') {
  77. return payload
  78. }
  79. const copy = Array.isArray(payload) ? [...payload] : { ...payload }
  80. for (const key of ['password', 'pass', 'password_enc', 'client_secret']) {
  81. if (key in copy) {
  82. copy[key] = '***'
  83. }
  84. }
  85. return copy
  86. }
  87. buildLogPrefix(tag, ctx = {}) {
  88. const parts = ['[QK]', `[${tag}]`]
  89. if (ctx.taskId) parts.push(`[taskId=${ctx.taskId}]`)
  90. if (ctx.clientId) parts.push(`[clientId=${ctx.clientId}]`)
  91. if (ctx.uuid) parts.push(`[uuid=${ctx.uuid}]`)
  92. return parts.join('')
  93. }
  94. logInfo(tag, message, ctx = {}, data = null) {
  95. const prefix = this.buildLogPrefix(tag, ctx)
  96. const suffix = data != null ? ` ${this.safeStringify(this.sanitizeForLog(data))}` : ''
  97. this.logger.info(`${prefix} ${message}${suffix}`)
  98. }
  99. logWarn(tag, message, ctx = {}, data = null) {
  100. const prefix = this.buildLogPrefix(tag, ctx)
  101. const suffix = data != null ? ` ${this.safeStringify(this.sanitizeForLog(data))}` : ''
  102. this.logger.warn(`${prefix} ${message}${suffix}`)
  103. }
  104. logError(tag, message, ctx = {}, err = null) {
  105. const prefix = this.buildLogPrefix(tag, ctx)
  106. const suffix = err ? ` ${err.stack || err}` : ''
  107. this.logger.error(`${prefix} ${message}${suffix}`)
  108. }
  109. getPasswordKey() {
  110. const source = process.env.QK_PASSWORD_KEY || config.qk?.passwordAesKey || config.database?.password || 'runforge-qk-default-key'
  111. return crypto.createHash('sha256').update(String(source)).digest()
  112. }
  113. encryptPassword(password) {
  114. const iv = crypto.randomBytes(16)
  115. const cipher = crypto.createCipheriv('aes-256-cbc', this.getPasswordKey(), iv)
  116. let encrypted = cipher.update(String(password), 'utf8', 'base64')
  117. encrypted += cipher.final('base64')
  118. return `${iv.toString('base64')}:${encrypted}`
  119. }
  120. decryptPassword(encrypted) {
  121. const [ivText, payload] = String(encrypted || '').split(':')
  122. if (!ivText || !payload) {
  123. return ''
  124. }
  125. const decipher = crypto.createDecipheriv('aes-256-cbc', this.getPasswordKey(), Buffer.from(ivText, 'base64'))
  126. let decrypted = decipher.update(payload, 'base64', 'utf8')
  127. decrypted += decipher.final('utf8')
  128. return decrypted
  129. }
  130. normalizeArray(value) {
  131. if (Array.isArray(value)) {
  132. return value.map(item => String(item).trim()).filter(Boolean)
  133. }
  134. if (typeof value === 'string') {
  135. const trimmed = value.trim()
  136. if (!trimmed) {
  137. return []
  138. }
  139. try {
  140. const parsed = JSON.parse(trimmed)
  141. if (Array.isArray(parsed)) {
  142. return parsed.map(item => String(item).trim()).filter(Boolean)
  143. }
  144. } catch (_) {
  145. return trimmed.split(/[\n,,]/).map(item => item.trim()).filter(Boolean)
  146. }
  147. }
  148. return []
  149. }
  150. serializeTask(row, includeSecret = false) {
  151. const result = { ...row }
  152. result.enable_ggxxk = Number(result.enable_ggxxk) === 1
  153. result.courses = this.normalizeArray(result.courses)
  154. result.course_groups = this.normalizeArray(result.course_groups)
  155. if (typeof result.result_json === 'string' && result.result_json) {
  156. try {
  157. result.result_json = JSON.parse(result.result_json)
  158. } catch (_) {}
  159. }
  160. if (includeSecret) {
  161. result.password = this.decryptPassword(result.password_enc)
  162. }
  163. delete result.password_enc
  164. return result
  165. }
  166. async logTask(taskId, clientId, event, message = '', payload = null) {
  167. const sql = 'INSERT INTO qk_task_log (task_id, client_id, event, message, payload_json, create_time) VALUES (?, ?, ?, ?, ?, ?)'
  168. await db.query(sql, [
  169. taskId,
  170. clientId || null,
  171. event,
  172. message || '',
  173. payload ? JSON.stringify(payload) : null,
  174. Date.now()
  175. ])
  176. this.logInfo('taskLog', message || event, { taskId, clientId }, payload ? { event, ...this.sanitizeForLog(payload) } : { event })
  177. }
  178. async createTask(uuid, payload) {
  179. const courses = this.normalizeArray(payload.courses || payload.COURSES)
  180. const courseGroups = this.normalizeArray(payload.course_groups || payload.COURSE_GROUPS)
  181. const intervalMs = Number(payload.interval_ms || payload.INTERVAL_MS || 500)
  182. if (!courses.length && !courseGroups.length) {
  183. throw new Error('至少需要填写一门课程或一个课程分组')
  184. }
  185. if (!Number.isFinite(intervalMs) || intervalMs < 200 || intervalMs > 10000) {
  186. throw new Error('抢课间隔需在 200-10000ms 之间')
  187. }
  188. const time = Date.now()
  189. const sql = `INSERT INTO qk_task
  190. (create_user, name, jx0502zbid, student_num, password_enc, courses, course_groups, enable_ggxxk, interval_ms, status, create_time, update_time)
  191. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
  192. const result = await db.query(sql, [
  193. uuid,
  194. payload.name,
  195. payload.jx0502zbid || payload.id,
  196. payload.student_num || payload.user,
  197. this.encryptPassword(payload.password || payload.pass),
  198. JSON.stringify(courses),
  199. JSON.stringify(courseGroups),
  200. payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
  201. intervalMs,
  202. TASK_STATUS.PENDING,
  203. time,
  204. time
  205. ])
  206. if (!result || result.affectedRows <= 0) {
  207. throw new Error('创建抢课任务失败')
  208. }
  209. await this.logTask(result.insertId, null, 'created', '用户提交抢课任务')
  210. this.logInfo('createTask', '抢课任务已创建', { taskId: result.insertId, uuid }, {
  211. name: payload.name,
  212. student_num: payload.student_num || payload.user,
  213. jx0502zbid: payload.jx0502zbid || payload.id,
  214. courses_count: courses.length,
  215. course_groups_count: courseGroups.length,
  216. interval_ms: intervalMs,
  217. enable_ggxxk: !!(payload.enable_ggxxk || payload.ENABLE_GGXXK)
  218. })
  219. return result.insertId
  220. }
  221. async updateTask(uuid, taskId, payload) {
  222. const rows = await db.query('SELECT status FROM qk_task WHERE id = ? AND create_user = ?', [taskId, uuid])
  223. if (!rows || rows.length === 0) {
  224. throw new Error('任务不存在')
  225. }
  226. if (![TASK_STATUS.PENDING, TASK_STATUS.FAILED, TASK_STATUS.CANCELLED].includes(rows[0].status)) {
  227. throw new Error('任务已被客户端领取,暂不能修改')
  228. }
  229. const courses = this.normalizeArray(payload.courses || payload.COURSES)
  230. const courseGroups = this.normalizeArray(payload.course_groups || payload.COURSE_GROUPS)
  231. const intervalMs = Number(payload.interval_ms || payload.INTERVAL_MS || 500)
  232. if (!courses.length && !courseGroups.length) {
  233. throw new Error('至少需要填写一门课程或一个课程分组')
  234. }
  235. const passwordSql = payload.password || payload.pass ? ', password_enc = ?' : ''
  236. const params = [
  237. payload.name,
  238. payload.jx0502zbid || payload.id,
  239. payload.student_num || payload.user,
  240. JSON.stringify(courses),
  241. JSON.stringify(courseGroups),
  242. payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
  243. intervalMs,
  244. TASK_STATUS.PENDING,
  245. Date.now()
  246. ]
  247. if (passwordSql) {
  248. params.splice(3, 0, this.encryptPassword(payload.password || payload.pass))
  249. }
  250. params.push(taskId, uuid)
  251. const sql = `UPDATE qk_task SET name = ?, jx0502zbid = ?, student_num = ?${passwordSql}, courses = ?, course_groups = ?, enable_ggxxk = ?, interval_ms = ?, status = ?, update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL WHERE id = ? AND create_user = ?`
  252. const result = await db.query(sql, params)
  253. if (!result || result.affectedRows <= 0) {
  254. throw new Error('更新抢课任务失败')
  255. }
  256. await this.logTask(taskId, null, 'updated', '用户更新抢课任务')
  257. this.logInfo('updateTask', '抢课任务已更新并重新进入待分配队列', { taskId, uuid }, {
  258. name: payload.name,
  259. student_num: payload.student_num || payload.user,
  260. courses_count: courses.length,
  261. course_groups_count: courseGroups.length,
  262. interval_ms: intervalMs,
  263. password_changed: !!(payload.password || payload.pass)
  264. })
  265. }
  266. async listUserTasks(uuid) {
  267. const rows = await db.query('SELECT * FROM qk_task WHERE create_user = ? ORDER BY create_time DESC', [uuid])
  268. return (rows || []).map(row => this.serializeTask(row))
  269. }
  270. async getTaskDetail(uuid, taskId) {
  271. const rows = await db.query('SELECT * FROM qk_task WHERE id = ? AND create_user = ?', [taskId, uuid])
  272. if (!rows || rows.length === 0) {
  273. return null
  274. }
  275. 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])
  276. return {
  277. task: this.serializeTask(rows[0]),
  278. logs: (logs || []).map(log => {
  279. if (typeof log.payload_json === 'string' && log.payload_json) {
  280. try {
  281. log.payload_json = JSON.parse(log.payload_json)
  282. } catch (_) {}
  283. }
  284. return log
  285. })
  286. }
  287. }
  288. async cancelTask(uuid, taskId) {
  289. const result = await db.query(
  290. 'UPDATE qk_task SET status = ?, update_time = ?, finished_time = ? WHERE id = ? AND create_user = ? AND status IN (?, ?)',
  291. [TASK_STATUS.CANCELLED, Date.now(), Date.now(), taskId, uuid, TASK_STATUS.PENDING, TASK_STATUS.FAILED]
  292. )
  293. if (!result || result.affectedRows <= 0) {
  294. throw new Error('任务不存在或当前状态不可取消')
  295. }
  296. await this.logTask(taskId, null, 'cancelled', '用户取消抢课任务')
  297. this.logInfo('cancelTask', '用户已取消抢课任务', { taskId, uuid })
  298. }
  299. async getAdminTask(taskId) {
  300. const rows = await db.query(
  301. `SELECT t.*, u.username, u.avatar
  302. FROM qk_task t
  303. LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
  304. WHERE t.id = ?`,
  305. [taskId]
  306. )
  307. if (!rows || rows.length === 0) {
  308. return null
  309. }
  310. return this.serializeTask(rows[0], true)
  311. }
  312. async decrementClientSlots(clientId, now = Date.now()) {
  313. if (!clientId) return
  314. await db.query(
  315. 'UPDATE qk_client SET current_slots = IF(current_slots > 0, current_slots - 1, 0), update_time = ? WHERE client_id = ?',
  316. [now, clientId]
  317. )
  318. }
  319. async adminUpdateTask(taskId, payload) {
  320. const rows = await db.query('SELECT * FROM qk_task WHERE id = ?', [taskId])
  321. if (!rows || rows.length === 0) {
  322. throw new Error('任务不存在')
  323. }
  324. const task = rows[0]
  325. if (task.status === TASK_STATUS.SUCCESS) {
  326. throw new Error('已成功的任务不可编辑')
  327. }
  328. const courses = this.normalizeArray(payload.courses || payload.COURSES)
  329. const courseGroups = this.normalizeArray(payload.course_groups || payload.COURSE_GROUPS)
  330. const intervalMs = Number(payload.interval_ms || payload.INTERVAL_MS || task.interval_ms || 500)
  331. if (!courses.length && !courseGroups.length) {
  332. throw new Error('至少需要填写一门课程或一个课程分组')
  333. }
  334. if (!Number.isFinite(intervalMs) || intervalMs < 200 || intervalMs > 10000) {
  335. throw new Error('抢课间隔需在 200-10000ms 之间')
  336. }
  337. const now = Date.now()
  338. const wasAssigned = [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)
  339. if (wasAssigned) {
  340. await this.decrementClientSlots(task.assigned_client_id, now)
  341. }
  342. const passwordSql = payload.password || payload.pass ? ', password_enc = ?' : ''
  343. const params = [
  344. payload.name,
  345. payload.jx0502zbid || payload.id,
  346. payload.student_num || payload.user,
  347. JSON.stringify(courses),
  348. JSON.stringify(courseGroups),
  349. payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
  350. intervalMs,
  351. TASK_STATUS.PENDING,
  352. now
  353. ]
  354. if (passwordSql) {
  355. params.splice(3, 0, this.encryptPassword(payload.password || payload.pass))
  356. }
  357. params.push(taskId)
  358. const sql = `UPDATE qk_task SET name = ?, jx0502zbid = ?, student_num = ?${passwordSql}, courses = ?, course_groups = ?, enable_ggxxk = ?, interval_ms = ?, status = ?, 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 = ?`
  359. const result = await db.query(sql, params)
  360. if (!result || result.affectedRows <= 0) {
  361. throw new Error('更新抢课任务失败')
  362. }
  363. await this.logTask(taskId, task.assigned_client_id, 'admin_updated', wasAssigned ? '管理员更新任务并收回重新排队' : '管理员更新抢课任务')
  364. this.logInfo('adminUpdateTask', '管理员已更新抢课任务', { taskId }, {
  365. name: payload.name,
  366. student_num: payload.student_num || payload.user,
  367. released_from_client: wasAssigned ? task.assigned_client_id : null
  368. })
  369. }
  370. async adminCancelTask(taskId) {
  371. const rows = await db.query('SELECT id, status, assigned_client_id FROM qk_task WHERE id = ?', [taskId])
  372. if (!rows || rows.length === 0) {
  373. throw new Error('任务不存在')
  374. }
  375. const task = rows[0]
  376. if (![TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, TASK_STATUS.FAILED].includes(task.status)) {
  377. throw new Error('当前状态不可取消')
  378. }
  379. const now = Date.now()
  380. if ([TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)) {
  381. await this.decrementClientSlots(task.assigned_client_id, now)
  382. }
  383. const result = await db.query(
  384. `UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL,
  385. exclude_client_id = NULL, update_time = ?, finished_time = ? WHERE id = ? AND status IN (?, ?, ?, ?)`,
  386. [TASK_STATUS.CANCELLED, now, now, taskId, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, TASK_STATUS.FAILED]
  387. )
  388. if (!result || result.affectedRows <= 0) {
  389. throw new Error('取消抢课任务失败')
  390. }
  391. await this.logTask(taskId, task.assigned_client_id, 'admin_cancelled', '管理员取消抢课任务')
  392. this.logInfo('adminCancelTask', '管理员已取消抢课任务', { taskId })
  393. }
  394. async adminRetryTask(taskId) {
  395. const rows = await db.query('SELECT id, status FROM qk_task WHERE id = ?', [taskId])
  396. if (!rows || rows.length === 0) {
  397. throw new Error('任务不存在')
  398. }
  399. if (![TASK_STATUS.FAILED, TASK_STATUS.CANCELLED].includes(rows[0].status)) {
  400. throw new Error('仅失败或已取消的任务可重试')
  401. }
  402. const now = Date.now()
  403. const result = await db.query(
  404. `UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL,
  405. exclude_client_id = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL, update_time = ?
  406. WHERE id = ? AND status IN (?, ?)`,
  407. [TASK_STATUS.PENDING, now, taskId, TASK_STATUS.FAILED, TASK_STATUS.CANCELLED]
  408. )
  409. if (!result || result.affectedRows <= 0) {
  410. throw new Error('重试抢课任务失败')
  411. }
  412. await this.logTask(taskId, null, 'admin_retry', '管理员将任务重新加入待分配队列')
  413. this.logInfo('adminRetryTask', '管理员已重试抢课任务', { taskId })
  414. }
  415. async authenticateClient(clientId, clientSecret) {
  416. if (!clientId || !clientSecret) {
  417. this.logWarn('authClient', '客户端认证失败:缺少凭证', { clientId: clientId || 'unknown' })
  418. return null
  419. }
  420. const rows = await db.query('SELECT * FROM qk_client WHERE client_id = ? AND enabled = 1', [clientId])
  421. if (!rows || rows.length !== 1) {
  422. this.logWarn('authClient', '客户端认证失败:客户端不存在或已禁用', { clientId })
  423. return null
  424. }
  425. if (!bcryptjs.compareSync(String(clientSecret), rows[0].client_secret_hash)) {
  426. this.logWarn('authClient', '客户端认证失败:密钥不匹配', { clientId })
  427. return null
  428. }
  429. return rows[0]
  430. }
  431. async enrollOrAuthenticateClient(clientId, clientSecret, payload = {}) {
  432. const existing = await this.authenticateClient(clientId, clientSecret)
  433. if (existing) {
  434. return existing
  435. }
  436. if (!clientId || !clientSecret || !String(clientId).startsWith('qk-cli-')) {
  437. throw new Error('客户端凭证无效')
  438. }
  439. const rows = await db.query('SELECT * FROM qk_client WHERE client_id = ?', [clientId])
  440. if (rows && rows.length > 0) {
  441. throw new Error('客户端凭证无效')
  442. }
  443. const time = Date.now()
  444. const label = payload.label || payload.hostname || `auto-${clientId}`
  445. try {
  446. const result = await db.query(
  447. 'INSERT INTO qk_client (client_id, client_secret_hash, label, create_time, update_time) VALUES (?, ?, ?, ?, ?)',
  448. [clientId, bcryptjs.hashSync(String(clientSecret), 10), label, time, time]
  449. )
  450. if (!result || result.affectedRows <= 0) {
  451. throw new Error('客户端自动注册失败')
  452. }
  453. } catch (err) {
  454. if (err?.code === 'ER_DUP_ENTRY') {
  455. const raced = await this.authenticateClient(clientId, clientSecret)
  456. if (raced) {
  457. return raced
  458. }
  459. }
  460. throw err
  461. }
  462. const created = await db.query('SELECT * FROM qk_client WHERE client_id = ? AND enabled = 1', [clientId])
  463. if (!created || created.length !== 1) {
  464. throw new Error('客户端自动注册失败')
  465. }
  466. this.logInfo('enrollClient', '抢课客户端已自动注册', { clientId }, { label })
  467. return created[0]
  468. }
  469. async registerClient(clientId, clientSecret, payload = {}) {
  470. const client = await this.enrollOrAuthenticateClient(clientId, clientSecret, payload)
  471. const maxSlots = this.resolveClientMaxSlots(client, payload)
  472. const currentSlots = Math.max(0, Number(payload.current_slots || 0))
  473. const time = Date.now()
  474. await db.query(
  475. 'UPDATE qk_client SET label = COALESCE(NULLIF(?, \'\'), label), max_slots = ?, current_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 = ?',
  476. [
  477. payload.label || '',
  478. maxSlots,
  479. currentSlots,
  480. payload.hostname || null,
  481. payload.os_username || null,
  482. payload.cpu_model || null,
  483. payload.cpu_threads || null,
  484. payload.total_mem_mb || null,
  485. payload.free_mem_mb || null,
  486. payload.platform || null,
  487. time,
  488. time,
  489. clientId
  490. ]
  491. )
  492. await Redis.set(`qk:client:hb:${clientId}`, String(time), { EX: this.heartbeatTtlSeconds })
  493. this.logInfo('registerClient', '抢课客户端已注册/上线', { clientId }, {
  494. label: payload.label || client.label,
  495. max_slots: maxSlots,
  496. current_slots: currentSlots,
  497. hostname: payload.hostname,
  498. platform: payload.platform,
  499. cpu_threads: payload.cpu_threads,
  500. total_mem_mb: payload.total_mem_mb,
  501. free_mem_mb: payload.free_mem_mb
  502. })
  503. return { client_id: clientId, max_slots: maxSlots, current_slots: currentSlots }
  504. }
  505. async heartbeat(clientId, clientSecret, payload = {}) {
  506. const client = await this.authenticateClient(clientId, clientSecret)
  507. if (!client) {
  508. throw new Error('客户端凭证无效')
  509. }
  510. const runningTasks = Array.isArray(payload.running_tasks) ? payload.running_tasks.map(Number).filter(Boolean) : []
  511. const currentSlots = Math.max(0, Number(payload.current_slots ?? runningTasks.length))
  512. const maxSlots = this.resolveClientMaxSlots(client, payload)
  513. const now = Date.now()
  514. await db.query(
  515. 'UPDATE qk_client SET max_slots = ?, current_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 = ?',
  516. [
  517. maxSlots,
  518. currentSlots,
  519. payload.hostname || null,
  520. payload.os_username || null,
  521. payload.cpu_model || null,
  522. payload.cpu_threads || null,
  523. payload.total_mem_mb || null,
  524. payload.free_mem_mb || null,
  525. payload.platform || null,
  526. now,
  527. now,
  528. clientId
  529. ]
  530. )
  531. await Redis.set(`qk:client:hb:${clientId}`, String(now), { EX: this.heartbeatTtlSeconds })
  532. await Redis.set(`qk:client:slots:${clientId}`, String(currentSlots), { EX: this.heartbeatTtlSeconds })
  533. if (runningTasks.length > 0) {
  534. const leaseExpireAt = now + this.leaseMs
  535. const placeholders = runningTasks.map(() => '?').join(',')
  536. await db.query(
  537. `UPDATE qk_task SET status = ?, lease_expire_at = ?, update_time = ? WHERE assigned_client_id = ? AND id IN (${placeholders}) AND status IN (?, ?)`,
  538. [TASK_STATUS.RUNNING, leaseExpireAt, now, clientId, ...runningTasks, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  539. )
  540. this.logInfo('heartbeat', '客户端心跳续租运行中任务', { clientId }, {
  541. running_tasks: runningTasks,
  542. lease_expire_at: leaseExpireAt,
  543. current_slots: currentSlots,
  544. max_slots: maxSlots
  545. })
  546. }
  547. await this.releaseOrphanedClientTasks(clientId, runningTasks, now)
  548. return { client_id: clientId, current_slots: currentSlots, max_slots: maxSlots }
  549. }
  550. async releaseOrphanedClientTasks(clientId, runningTasks, now = Date.now()) {
  551. const assigned = await db.query(
  552. 'SELECT id FROM qk_task WHERE assigned_client_id = ? AND status IN (?, ?)',
  553. [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  554. )
  555. const runningSet = new Set((runningTasks || []).map(Number).filter(Boolean))
  556. let released = 0
  557. for (const row of assigned || []) {
  558. if (runningSet.has(row.id)) continue
  559. const result = await db.query(
  560. '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 (?, ?)',
  561. [TASK_STATUS.PENDING, now, row.id, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  562. )
  563. if (result && result.affectedRows > 0) {
  564. released += 1
  565. await this.logTask(row.id, clientId, 'released', '客户端未继续执行,任务已释放回队列')
  566. this.logInfo('releaseOrphaned', '释放未在运行的已分配任务', { taskId: row.id, clientId })
  567. }
  568. }
  569. return released
  570. }
  571. async reclaimTasks(clientId, clientSecret) {
  572. const client = await this.authenticateClient(clientId, clientSecret)
  573. if (!client) {
  574. throw new Error('客户端凭证无效')
  575. }
  576. const now = Date.now()
  577. const leaseExpireAt = now + this.leaseMs
  578. const rows = await db.query(
  579. 'SELECT * FROM qk_task WHERE assigned_client_id = ? AND status IN (?, ?) ORDER BY create_time ASC',
  580. [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  581. )
  582. if (!rows || rows.length === 0) {
  583. return []
  584. }
  585. for (const row of rows) {
  586. await db.query(
  587. 'UPDATE qk_task SET status = ?, lease_expire_at = ?, update_time = ? WHERE id = ? AND assigned_client_id = ? AND status IN (?, ?)',
  588. [TASK_STATUS.RUNNING, leaseExpireAt, now, row.id, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  589. )
  590. await this.logTask(row.id, clientId, 'reclaimed', '客户端重连后回收任务继续执行')
  591. }
  592. this.logInfo('reclaimTasks', `客户端回收 ${rows.length} 个进行中任务`, { clientId }, {
  593. task_ids: rows.map(row => row.id),
  594. lease_expire_at: leaseExpireAt
  595. })
  596. return rows.map(row => this.serializeTask({
  597. ...row,
  598. assigned_client_id: clientId,
  599. lease_expire_at: leaseExpireAt,
  600. status: TASK_STATUS.RUNNING
  601. }, true))
  602. }
  603. async releaseTasks(clientId, clientSecret, payload = {}) {
  604. const client = await this.authenticateClient(clientId, clientSecret)
  605. if (!client) {
  606. throw new Error('客户端凭证无效')
  607. }
  608. const now = Date.now()
  609. const taskIds = Array.isArray(payload.task_ids)
  610. ? payload.task_ids.map(Number).filter(Boolean)
  611. : []
  612. let rows = []
  613. if (taskIds.length > 0) {
  614. const placeholders = taskIds.map(() => '?').join(',')
  615. rows = await db.query(
  616. `SELECT id FROM qk_task WHERE assigned_client_id = ? AND id IN (${placeholders}) AND status IN (?, ?)`,
  617. [clientId, ...taskIds, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  618. )
  619. } else {
  620. rows = await db.query(
  621. 'SELECT id FROM qk_task WHERE assigned_client_id = ? AND status IN (?, ?)',
  622. [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  623. )
  624. }
  625. let released = 0
  626. for (const row of rows || []) {
  627. const result = await db.query(
  628. '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 (?, ?)',
  629. [TASK_STATUS.PENDING, now, row.id, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  630. )
  631. if (result && result.affectedRows > 0) {
  632. released += 1
  633. await this.logTask(row.id, clientId, 'released', '客户端主动释放任务')
  634. }
  635. }
  636. if (released > 0) {
  637. this.logInfo('releaseTasks', `客户端释放 ${released} 个任务`, { clientId }, {
  638. task_ids: (rows || []).map(row => row.id)
  639. })
  640. }
  641. return { released, task_ids: (rows || []).map(row => row.id) }
  642. }
  643. async pullTasks(clientId, clientSecret, count) {
  644. const client = await this.authenticateClient(clientId, clientSecret)
  645. if (!client) {
  646. throw new Error('客户端凭证无效')
  647. }
  648. const availableSlots = this.getClientAvailableSlots(client)
  649. const safeCount = Math.max(0, Math.min(50, Number(count || 0), availableSlots))
  650. if (safeCount <= 0) {
  651. this.logWarn('pullTasks', '客户端无可用槽位或拉取数量无效,已忽略', { clientId }, {
  652. count,
  653. available_slots: availableSlots,
  654. max_slots: this.resolveClientMaxSlots(client),
  655. current_slots: client.current_slots,
  656. free_mem_mb: client.free_mem_mb
  657. })
  658. return []
  659. }
  660. const lockKey = `qk:pull:lock:${clientId}`
  661. const locked = await Redis.set(lockKey, '1', { NX: true, EX: this.pullLockTtlSeconds })
  662. if (!locked) {
  663. this.logWarn('pullTasks', '拉取任务被并发锁拦截,本次跳过', { clientId }, { count: safeCount })
  664. return []
  665. }
  666. const conn = await db.connect()
  667. try {
  668. await conn.beginTransaction()
  669. const [rows] = await conn.execute(
  670. `SELECT * FROM qk_task
  671. WHERE status = ?
  672. AND (exclude_client_id IS NULL OR exclude_client_id <> ?)
  673. ORDER BY create_time ASC LIMIT ${safeCount} FOR UPDATE`,
  674. [TASK_STATUS.PENDING, clientId]
  675. )
  676. const now = Date.now()
  677. const leaseExpireAt = now + this.leaseMs
  678. for (const row of rows) {
  679. await conn.execute(
  680. 'UPDATE qk_task SET status = ?, assigned_client_id = ?, lease_expire_at = ?, assigned_at = ?, exclude_client_id = NULL, update_time = ? WHERE id = ?',
  681. [TASK_STATUS.ASSIGNED, clientId, leaseExpireAt, now, now, row.id]
  682. )
  683. }
  684. await conn.commit()
  685. if (rows.length > 0) {
  686. await db.query(
  687. 'UPDATE qk_client SET current_slots = current_slots + ?, update_time = ? WHERE client_id = ?',
  688. [rows.length, now, clientId]
  689. )
  690. }
  691. for (const row of rows) {
  692. await this.logTask(row.id, clientId, 'assigned', '任务已分配给客户端')
  693. }
  694. if (rows.length > 0) {
  695. this.logInfo('pullTasks', `已分配 ${rows.length} 个抢课任务`, { clientId }, {
  696. task_ids: rows.map(row => row.id),
  697. lease_expire_at: leaseExpireAt,
  698. requested_count: safeCount
  699. })
  700. }
  701. return rows.map(row => this.serializeTask({ ...row, assigned_client_id: clientId, lease_expire_at: leaseExpireAt, status: TASK_STATUS.ASSIGNED }, true))
  702. } catch (err) {
  703. await conn.rollback()
  704. this.logError('pullTasks', '拉取并分配任务失败,事务已回滚', { clientId }, err)
  705. throw err
  706. } finally {
  707. await Redis.del(lockKey)
  708. }
  709. }
  710. async reportResult(clientId, clientSecret, payload = {}) {
  711. const client = await this.authenticateClient(clientId, clientSecret)
  712. if (!client) {
  713. throw new Error('客户端凭证无效')
  714. }
  715. const taskId = Number(payload.task_id || payload.id)
  716. const success = payload.success === true || payload.status === TASK_STATUS.SUCCESS
  717. const status = success ? TASK_STATUS.SUCCESS : TASK_STATUS.FAILED
  718. const now = Date.now()
  719. const resultJson = payload.result ? JSON.stringify(payload.result) : JSON.stringify({
  720. course: payload.course || '',
  721. message: payload.message || ''
  722. })
  723. const result = await db.query(
  724. '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 (?, ?)',
  725. [status, resultJson, success ? null : (payload.error_msg || payload.message || '抢课失败'), now, now, taskId, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  726. )
  727. if (!result || result.affectedRows <= 0) {
  728. this.logWarn('reportResult', '任务结果上报被拒绝:任务不存在或不属于当前客户端', { taskId, clientId }, {
  729. success,
  730. status
  731. })
  732. throw new Error('任务不存在或不属于当前客户端')
  733. }
  734. await db.query(
  735. `UPDATE qk_client SET current_slots = IF(current_slots > 0, current_slots - 1, 0), total_completed = total_completed + 1, total_success = total_success + ?, update_time = ? WHERE client_id = ?`,
  736. [success ? 1 : 0, now, clientId]
  737. )
  738. await this.logTask(taskId, clientId, success ? 'grab_success' : 'grab_fail', payload.message || payload.error_msg || '', payload.result || payload)
  739. this.logInfo('reportResult', success ? '抢课成功' : '抢课失败', { taskId, clientId }, {
  740. success,
  741. message: payload.message || payload.error_msg || '',
  742. course: payload.course || payload.result?.course || '',
  743. result: payload.result || null
  744. })
  745. return { task_id: taskId, status }
  746. }
  747. async listClients() {
  748. 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')
  749. return rows || []
  750. }
  751. async deleteClient(clientId) {
  752. const result = await db.query('UPDATE qk_client SET enabled = 0, online = 0, update_time = ? WHERE client_id = ?', [Date.now(), clientId])
  753. if (!result || result.affectedRows <= 0) {
  754. throw new Error('客户端不存在')
  755. }
  756. this.logInfo('deleteClient', '抢课客户端已禁用', { clientId })
  757. }
  758. async listAdminTasks(filters = {}) {
  759. const pagesize = Math.max(1, Math.min(100, Number(filters.pagesize || 20)))
  760. const current = Math.max(1, Number(filters.current || 1))
  761. const where = ['1 = 1']
  762. const params = []
  763. const countParams = []
  764. if (filters.status) {
  765. where.push('t.status = ?')
  766. params.push(filters.status)
  767. countParams.push(filters.status)
  768. }
  769. if (filters.client_id) {
  770. where.push('t.assigned_client_id = ?')
  771. params.push(filters.client_id)
  772. countParams.push(filters.client_id)
  773. }
  774. if (filters.student_num) {
  775. where.push('t.student_num LIKE ?')
  776. params.push(`%${filters.student_num}%`)
  777. countParams.push(`%${filters.student_num}%`)
  778. }
  779. if (filters.username) {
  780. where.push('u.username COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
  781. params.push(`%${filters.username}%`)
  782. countParams.push(`%${filters.username}%`)
  783. }
  784. const whereSql = where.join(' AND ')
  785. const offset = (current - 1) * pagesize
  786. const countRows = await db.query(
  787. `SELECT COUNT(*) AS total FROM qk_task t
  788. LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
  789. WHERE ${whereSql}`,
  790. countParams
  791. )
  792. const rows = await db.query(
  793. `SELECT t.*, u.username, u.avatar
  794. FROM qk_task t
  795. LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
  796. WHERE ${whereSql}
  797. ORDER BY t.create_time DESC
  798. LIMIT ${pagesize} OFFSET ${offset}`,
  799. params
  800. )
  801. return {
  802. list: (rows || []).map(row => this.serializeTask(row, true)),
  803. total: countRows?.[0]?.total || 0,
  804. current,
  805. pagesize
  806. }
  807. }
  808. async reassignStaleRunningTasks() {
  809. const onlineCount = await this.countOnlineClients()
  810. if (onlineCount <= 1) {
  811. return 0
  812. }
  813. const now = Date.now()
  814. const staleBefore = now - this.staleTaskMs
  815. const rows = await db.query(
  816. `SELECT id, assigned_client_id FROM qk_task
  817. WHERE status IN (?, ?)
  818. AND assigned_at IS NOT NULL
  819. AND assigned_at < ?`,
  820. [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, staleBefore]
  821. )
  822. let count = 0
  823. for (const row of rows || []) {
  824. const result = await db.query(
  825. `UPDATE qk_task
  826. SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  827. assigned_at = NULL, exclude_client_id = ?, update_time = ?
  828. WHERE id = ? AND status IN (?, ?)`,
  829. [TASK_STATUS.PENDING, row.assigned_client_id, now, row.id, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  830. )
  831. if (result && result.affectedRows > 0) {
  832. count += 1
  833. if (row.assigned_client_id) {
  834. await db.query(
  835. 'UPDATE qk_client SET current_slots = IF(current_slots > 0, current_slots - 1, 0), update_time = ? WHERE client_id = ?',
  836. [now, row.assigned_client_id]
  837. )
  838. }
  839. await this.logTask(row.id, row.assigned_client_id, 'reassigned', '任务超过1小时未成功,已收回并等待分配给其他客户端')
  840. this.logWarn('reassignStale', '长时间未成功任务已收回', {
  841. taskId: row.id,
  842. clientId: row.assigned_client_id
  843. }, { online_clients: onlineCount })
  844. }
  845. }
  846. return count
  847. }
  848. async requeueExpiredTasks() {
  849. const now = Date.now()
  850. const rows = await db.query(
  851. 'SELECT id, assigned_client_id FROM qk_task WHERE status IN (?, ?) AND lease_expire_at IS NOT NULL AND lease_expire_at < ?',
  852. [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, now]
  853. )
  854. let count = 0
  855. for (const row of rows || []) {
  856. const result = await db.query(
  857. 'UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL, update_time = ? WHERE id = ? AND status IN (?, ?)',
  858. [TASK_STATUS.PENDING, now, row.id, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  859. )
  860. if (result && result.affectedRows > 0) {
  861. count++
  862. if (row.assigned_client_id) {
  863. await db.query(
  864. 'UPDATE qk_client SET current_slots = IF(current_slots > 0, current_slots - 1, 0), update_time = ? WHERE client_id = ?',
  865. [now, row.assigned_client_id]
  866. )
  867. }
  868. await this.logTask(row.id, row.assigned_client_id, 'reassigned', '租约过期,任务已重新进入待分配队列')
  869. this.logWarn('requeueExpired', '租约过期,任务已重新入队', {
  870. taskId: row.id,
  871. clientId: row.assigned_client_id
  872. })
  873. }
  874. }
  875. const offlineResult = await db.query(
  876. 'UPDATE qk_client SET online = 0, current_slots = 0, update_time = ? WHERE online = 1 AND (last_heartbeat_at IS NULL OR last_heartbeat_at < ?)',
  877. [now, now - this.heartbeatTtlSeconds * 1000]
  878. )
  879. const offlineCount = offlineResult?.affectedRows || 0
  880. const staleCount = await this.reassignStaleRunningTasks()
  881. if (count > 0 || offlineCount > 0 || staleCount > 0) {
  882. this.logInfo('requeueExpired', '租约巡检完成', {}, {
  883. expired_tasks: (rows || []).length,
  884. requeued: count,
  885. stale_reassigned: staleCount,
  886. clients_marked_offline: offlineCount
  887. })
  888. }
  889. return count + staleCount
  890. }
  891. }
  892. module.exports = {
  893. TaskScheduler,
  894. TASK_STATUS
  895. }