AiAssistantMcp.js 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061
  1. const db = require('../../plugin/DataBase/db')
  2. const { axiosWithQgOutbound } = require('../Lepao/qgOutboundAxios')
  3. const Logger = require('../Logger')
  4. const path = require('path')
  5. const QxsBookList = require('../../apis/QuXuanShu/GetBookList').GetBookList
  6. const EmailTemplate = require('../../plugin/Email/emailTemplate')
  7. const { insertLedgerRecord } = require('../Lepao/CountLedger')
  8. const ASSISTANT_UUID = 'e4fe0277-0b1a-41a1-b25f-8b6e4cec3281'
  9. function required(values) {
  10. return values.some(v => v === '' || v === null || v === undefined)
  11. }
  12. function isProvided(value) {
  13. return value !== '' && value !== null && value !== undefined
  14. }
  15. function pageArgs({ current = 1, pagesize = 10 } = {}, max = 20) {
  16. const c = Math.max(1, Number.parseInt(current, 10) || 1)
  17. const p = Math.min(Math.max(1, Number.parseInt(pagesize, 10) || 10), max)
  18. return { current: c, pagesize: p, offset: (c - 1) * p }
  19. }
  20. function pagination(page, totalRows) {
  21. const total = Number(totalRows?.[0]?.total || 0)
  22. return {
  23. current: page.current,
  24. pagesize: page.pagesize,
  25. total,
  26. total_pages: Math.ceil(total / page.pagesize)
  27. }
  28. }
  29. function asJson(data) {
  30. return JSON.stringify(data)
  31. }
  32. function pad2(value) {
  33. return String(value).padStart(2, '0')
  34. }
  35. function formatBeijingTime(value) {
  36. if (value === '' || value === null || value === undefined) return ''
  37. if (typeof value === 'string' && !/^\d+$/.test(value.trim())) {
  38. const match = value.trim().match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/)
  39. if (match && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(value.trim())) {
  40. const [, y, m, d, h = '0', min = '0', s = '0'] = match
  41. return `${y}-${pad2(m)}-${pad2(d)} ${pad2(h)}:${pad2(min)}:${pad2(s)}`
  42. }
  43. }
  44. const numberValue = typeof value === 'number' || /^\d+$/.test(String(value).trim())
  45. ? Number(value)
  46. : null
  47. const millis = numberValue !== null
  48. ? (numberValue < 1000000000000 ? numberValue * 1000 : numberValue)
  49. : new Date(value).getTime()
  50. if (!Number.isFinite(millis)) return String(value)
  51. const date = new Date(millis + 8 * 60 * 60 * 1000)
  52. return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}`
  53. }
  54. function parseJson(value, fallback = []) {
  55. if (!value) return fallback
  56. if (Array.isArray(value)) return value
  57. try {
  58. const parsed = JSON.parse(value)
  59. return parsed == null ? fallback : parsed
  60. } catch (_) {
  61. return fallback
  62. }
  63. }
  64. function stateText(value, map) {
  65. return map[value] || String(value)
  66. }
  67. function normalizeText(value, max = 2000) {
  68. return String(value || '').trim().slice(0, max)
  69. }
  70. function normalizeNumber(value) {
  71. if (!isProvided(value)) return null
  72. const numberValue = Number(value)
  73. return Number.isFinite(numberValue) ? numberValue : null
  74. }
  75. function normalizeRunArea(area) {
  76. if (!isProvided(area)) return ''
  77. const value = String(area).trim()
  78. if (value === '随机分配') return ''
  79. const allowed = new Set([
  80. '兰花湖校区跑区',
  81. '主校区北跑区',
  82. '主校区南跑区',
  83. '重庆工商大学茶园校区',
  84. ''
  85. ])
  86. return allowed.has(value) ? value : null
  87. }
  88. function mapLepaoAccount(row) {
  89. return {
  90. student_num: row.student_num,
  91. name: row.name || '',
  92. state: stateText(row.state, { 0: '需登录', 1: '正常', 2: '异常' }),
  93. area: row.area || '随机分配',
  94. auto_run: row.auto_run === 1,
  95. auto_time: row.auto_time,
  96. today_auto_time: row.today_auto_time,
  97. target_count: row.target_count,
  98. total_num: row.total_num,
  99. term_num: row.term_num,
  100. auto_day: parseJson(row.auto_day, []),
  101. notice_type: row.notice_type,
  102. email: row.email || '',
  103. notes: row.notes || ''
  104. }
  105. }
  106. function mapLepaoRecord(row) {
  107. return {
  108. ...row,
  109. time: formatBeijingTime(row.time)
  110. }
  111. }
  112. function mapCountLedger(row) {
  113. return {
  114. ...row,
  115. created_at: formatBeijingTime(row.created_at)
  116. }
  117. }
  118. function mapOrder(row) {
  119. const orderStateMap = {
  120. 0: '待支付',
  121. 1: '已支付',
  122. 2: '已取消',
  123. 3: '异常',
  124. 4: '已退款'
  125. }
  126. return {
  127. order_no: row.orderId,
  128. state: stateText(row.state, orderStateMap),
  129. create_time: formatBeijingTime(row.create_time),
  130. price: row.price,
  131. pay_type: row.pay_type,
  132. goods_name: row.goods_name
  133. }
  134. }
  135. function mapPowerTask(row) {
  136. const { id, ...task } = row
  137. return {
  138. ...task,
  139. koufei_date: formatBeijingTime(task.koufei_date),
  140. update_time: formatBeijingTime(task.update_time)
  141. }
  142. }
  143. function mapPowerRecord(row) {
  144. return {
  145. ...row,
  146. time: formatBeijingTime(row.time),
  147. change_time: formatBeijingTime(row.change_time)
  148. }
  149. }
  150. function mapSendCountRequest(row) {
  151. const { id, sender_uuid, receiver_user_id, ...item } = row
  152. return {
  153. request_id: id,
  154. ...item,
  155. created_at: formatBeijingTime(item.created_at),
  156. reviewed_at: formatBeijingTime(item.reviewed_at)
  157. }
  158. }
  159. function mapWorkOrder(row) {
  160. const { id, ...item } = row
  161. return {
  162. work_order_id: id,
  163. ...item,
  164. create_time: formatBeijingTime(item.create_time),
  165. update_time: formatBeijingTime(item.update_time)
  166. }
  167. }
  168. function mapWorkOrderMessage(msg) {
  169. return {
  170. ...msg,
  171. time: formatBeijingTime(msg.time)
  172. }
  173. }
  174. class AiAssistantMcp {
  175. constructor() {
  176. this.logger = new Logger(path.join(__dirname, '../../logs/AiAssistantMcp.log'), 'INFO')
  177. this.emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
  178. this.banEmailList = ['icloud.com']
  179. }
  180. async list_lepao_accounts(args) {
  181. const { sender, student_num, name, area, state, auto_run, current, pagesize } = args
  182. if (required([sender])) return '缺少 sender'
  183. const page = pageArgs({ current, pagesize })
  184. const where = ['create_user = ?']
  185. const params = [sender]
  186. if (student_num) {
  187. where.push('student_num LIKE ?')
  188. params.push(`%${student_num}%`)
  189. }
  190. if (name) {
  191. where.push('name LIKE ?')
  192. params.push(`%${name}%`)
  193. }
  194. if (area !== undefined && area !== null && area !== '') {
  195. const normalizedArea = normalizeRunArea(area)
  196. if (normalizedArea === null) return '跑区不合法'
  197. where.push('area = ?')
  198. params.push(normalizedArea)
  199. }
  200. if (state !== undefined && state !== null && state !== '' && Number(state) !== -1) {
  201. where.push('state = ?')
  202. params.push(state)
  203. }
  204. if (auto_run !== undefined && auto_run !== null && auto_run !== '') {
  205. where.push('auto_run = ?')
  206. params.push(Number(auto_run))
  207. }
  208. const totalRows = await db.query(
  209. `SELECT COUNT(*) AS total
  210. FROM lepao_account
  211. WHERE ${where.join(' AND ')}`,
  212. params
  213. )
  214. const rows = await db.query(
  215. `SELECT student_num, name, state, area, auto_run, auto_time, today_auto_time,
  216. target_count, total_num, term_num, auto_day, notice_type, email, notes
  217. FROM lepao_account
  218. WHERE ${where.join(' AND ')}
  219. ORDER BY update_time DESC
  220. LIMIT ? OFFSET ?`,
  221. [...params, String(page.pagesize), String(page.offset)]
  222. )
  223. if (!rows || !totalRows) return '查询失败'
  224. return asJson({ list: rows.map(mapLepaoAccount), pagination: pagination(page, totalRows) })
  225. }
  226. async list_lepao_records(args) {
  227. const { sender, lepao_account, name, start_time, end_time, result, current, pagesize } = args
  228. if (required([sender])) return '缺少 sender'
  229. const page = pageArgs({ current, pagesize })
  230. const where = ['(r.uuid = ? OR a.create_user = ?)']
  231. const params = [sender, sender]
  232. if (lepao_account) {
  233. where.push('r.lepao_account LIKE ?')
  234. params.push(`%${lepao_account}%`)
  235. }
  236. if (name) {
  237. where.push('a.name LIKE ?')
  238. params.push(`%${name}%`)
  239. }
  240. if (result) {
  241. where.push('r.result LIKE ?')
  242. params.push(`%${result}%`)
  243. }
  244. if (start_time) {
  245. where.push('r.time >= ?')
  246. params.push(start_time)
  247. }
  248. if (end_time) {
  249. where.push('r.time < ?')
  250. params.push(end_time)
  251. }
  252. const totalRows = await db.query(
  253. `SELECT COUNT(*) AS total
  254. FROM lepao_record r
  255. LEFT JOIN lepao_account a ON r.lepao_account = a.student_num
  256. WHERE ${where.join(' AND ')}`,
  257. params
  258. )
  259. const rows = await db.query(
  260. `SELECT r.public_id, r.time, r.result, r.run_mode, r.lepao_account, a.name
  261. FROM lepao_record r
  262. LEFT JOIN lepao_account a ON r.lepao_account = a.student_num
  263. WHERE ${where.join(' AND ')}
  264. ORDER BY r.id DESC
  265. LIMIT ? OFFSET ?`,
  266. [...params, String(page.pagesize), String(page.offset)]
  267. )
  268. if (!rows || !totalRows) return '查询失败'
  269. return asJson({ list: rows.map(mapLepaoRecord), pagination: pagination(page, totalRows) })
  270. }
  271. async list_lepao_count_ledger(args) {
  272. const { sender, biz_type, remark, start_time, end_time, current, pagesize } = args
  273. if (required([sender])) return '缺少 sender'
  274. const page = pageArgs({ current, pagesize })
  275. const where = ['user_uuid = ?']
  276. const params = [sender]
  277. if (biz_type) {
  278. where.push('biz_type = ?')
  279. params.push(biz_type)
  280. }
  281. if (remark) {
  282. where.push('remark LIKE ?')
  283. params.push(`%${remark}%`)
  284. }
  285. if (start_time) {
  286. where.push('created_at >= FROM_UNIXTIME(? / 1000)')
  287. params.push(start_time)
  288. }
  289. if (end_time) {
  290. where.push('created_at <= FROM_UNIXTIME(? / 1000)')
  291. params.push(end_time)
  292. }
  293. const totalRows = await db.query(
  294. `SELECT COUNT(*) AS total
  295. FROM lepao_count_ledger
  296. WHERE ${where.join(' AND ')}`,
  297. params
  298. )
  299. const rows = await db.query(
  300. `SELECT delta, balance_before, balance_after, biz_type, remark,
  301. UNIX_TIMESTAMP(created_at) * 1000 AS created_at
  302. FROM lepao_count_ledger
  303. WHERE ${where.join(' AND ')}
  304. ORDER BY id DESC
  305. LIMIT ? OFFSET ?`,
  306. [...params, String(page.pagesize), String(page.offset)]
  307. )
  308. if (!rows || !totalRows) return '查询失败'
  309. return asJson({ list: rows.map(mapCountLedger), pagination: pagination(page, totalRows) })
  310. }
  311. async list_goods(args) {
  312. const { keyword, category, current, pagesize } = args
  313. const page = pageArgs({ current, pagesize })
  314. const where = ['state = 1']
  315. const params = []
  316. if (keyword) {
  317. where.push('name LIKE ?')
  318. params.push(`%${keyword}%`)
  319. }
  320. if (category) {
  321. where.push('category = ?')
  322. params.push(category)
  323. }
  324. const totalRows = await db.query(
  325. `SELECT COUNT(*) AS total
  326. FROM goods
  327. WHERE ${where.join(' AND ')}`,
  328. params
  329. )
  330. const rows = await db.query(
  331. `SELECT name, price, num, \`limit\`, description, category, features
  332. FROM goods
  333. WHERE ${where.join(' AND ')}
  334. ORDER BY id DESC
  335. LIMIT ? OFFSET ?`,
  336. [...params, String(page.pagesize), String(page.offset)]
  337. )
  338. if (!rows || !totalRows) return '查询失败'
  339. return asJson({
  340. list: rows.map(r => ({ ...r, features: parseJson(r.features, []) })),
  341. pagination: pagination(page, totalRows)
  342. })
  343. }
  344. async list_my_orders(args) {
  345. const { sender, state, order_id, current, pagesize } = args
  346. if (required([sender])) return '缺少 sender'
  347. const page = pageArgs({ current, pagesize })
  348. const where = ['o.create_user = ?']
  349. const params = [sender]
  350. if (state !== undefined && state !== null && state !== '' && Number(state) !== -1) {
  351. where.push('o.state = ?')
  352. params.push(state)
  353. }
  354. if (order_id) {
  355. where.push('o.orderId = ?')
  356. params.push(order_id)
  357. }
  358. const totalRows = await db.query(
  359. `SELECT COUNT(*) AS total
  360. FROM orders o
  361. JOIN goods g ON o.goods_id = g.id
  362. WHERE ${where.join(' AND ')}`,
  363. params
  364. )
  365. const rows = await db.query(
  366. `SELECT o.orderId, o.state, o.create_time, o.price, o.pay_type, g.name AS goods_name
  367. FROM orders o
  368. JOIN goods g ON o.goods_id = g.id
  369. WHERE ${where.join(' AND ')}
  370. ORDER BY o.id DESC
  371. LIMIT ? OFFSET ?`,
  372. [...params, String(page.pagesize), String(page.offset)]
  373. )
  374. if (!rows || !totalRows) return '查询失败'
  375. return asJson({
  376. list: rows.map(mapOrder),
  377. pagination: pagination(page, totalRows)
  378. })
  379. }
  380. async save_lepao_account(args) {
  381. const { sender, student_num } = args
  382. if (required([sender, student_num])) return '缺少 sender 或 student_num'
  383. const existRows = await db.query(
  384. `SELECT id, create_user, student_num, email, area, auto_time, auto_run, target_count, total_num, auto_day, notice_type, notes
  385. FROM lepao_account
  386. WHERE student_num = ?
  387. LIMIT 1`,
  388. [student_num]
  389. )
  390. if (!existRows) return '查询账号失败'
  391. if (existRows.length > 0 && existRows[0].create_user && existRows[0].create_user !== sender) {
  392. return '该乐跑账号已被其他用户绑定'
  393. }
  394. const existing = existRows[0] || {}
  395. const targetCount = isProvided(args.target_count)
  396. ? normalizeNumber(args.target_count)
  397. : (existRows.length > 0 ? normalizeNumber(existing.target_count) : 30)
  398. if (targetCount === null || targetCount < 0 || targetCount > 99) return '乐跑目标次数不在合法范围内'
  399. args.target_count = targetCount
  400. const noticeType = isProvided(args.notice_type) ? args.notice_type : (existing.notice_type || 'email')
  401. const email = isProvided(args.email) ? String(args.email).trim() : (existing.email || '')
  402. if (noticeType === 'email') {
  403. if (!this.emailRegex.test(email)) return '邮箱格式不正确'
  404. const emailDomain = email.split('@')[1].toLowerCase()
  405. if (this.banEmailList.includes(emailDomain)) return `暂不支持使用 ${emailDomain} 域名的邮箱,请更换其他邮箱后重试`
  406. } else if (email && !this.emailRegex.test(email)) {
  407. return '邮箱格式不正确'
  408. }
  409. const autoRun = isProvided(args.auto_run) ? Number(args.auto_run) : (existRows.length > 0 ? Number(existing.auto_run) : 1)
  410. if (![0, 1].includes(autoRun)) return 'auto_run 只能为 0 或 1'
  411. args.auto_run = autoRun
  412. const autoDay = isProvided(args.auto_day)
  413. ? args.auto_day
  414. : (existRows.length > 0 ? parseJson(existing.auto_day, []) : [0, 1, 2, 3, 4, 5, 6])
  415. if (autoRun === 1 && (!Array.isArray(autoDay) || !autoDay.every(v => Number.isInteger(v) && v >= 0 && v <= 6))) return '自动乐跑星期不合法'
  416. if (isProvided(args.auto_day)) args.auto_day = autoDay
  417. const totalNum = normalizeNumber(existing.total_num) || 0
  418. if (autoRun === 1 && totalNum >= targetCount && targetCount !== 0) return '该账号累计跑步次数已达到预设目标次数,请尝试增大目标次数后再试'
  419. const fields = []
  420. const params = []
  421. const mutable = [
  422. ['email', 'email'],
  423. ['auto_time', 'auto_time'],
  424. ['auto_run', 'auto_run'],
  425. ['target_count', 'target_count'],
  426. ['notice_type', 'notice_type'],
  427. ['notes', 'notes']
  428. ]
  429. if (isProvided(args.area)) {
  430. const area = normalizeRunArea(args.area)
  431. if (area === null) return '跑区不合法'
  432. fields.push('area = ?')
  433. params.push(area)
  434. }
  435. if (isProvided(args.notice_type) && !['email', 'none', 'bot'].includes(args.notice_type)) return '通知方式不合法'
  436. if (isProvided(args.auto_time)) {
  437. const autoTime = Number(args.auto_time)
  438. if (!Number.isInteger(autoTime) || autoTime < -1 || autoTime > 23) return '自动乐跑时间不合法'
  439. args.auto_time = autoTime
  440. }
  441. if (isProvided(args.auto_day)) {
  442. fields.push('auto_day = ?')
  443. params.push(JSON.stringify(args.auto_day))
  444. }
  445. for (const [argName, column] of mutable) {
  446. if (isProvided(args[argName])) {
  447. fields.push(`${column} = ?`)
  448. params.push(args[argName])
  449. }
  450. }
  451. const now = Date.now()
  452. let result
  453. if (existRows.length > 0) {
  454. if (fields.length === 0) return '没有需要修改的字段'
  455. fields.push('update_time = ?', 'create_user = ?')
  456. params.push(now, sender)
  457. result = await db.query(
  458. `UPDATE lepao_account SET ${fields.join(', ')} WHERE student_num = ? AND (create_user = ? OR create_user IS NULL)`,
  459. [...params, student_num, sender]
  460. )
  461. } else {
  462. const area = isProvided(args.area) ? normalizeRunArea(args.area) : ''
  463. if (area === null) return '跑区不合法'
  464. const autoDay = isProvided(args.auto_day) ? args.auto_day : [0, 1, 2, 3, 4, 5, 6]
  465. if (!Array.isArray(autoDay) || !autoDay.every(v => Number.isInteger(v) && v >= 0 && v <= 6)) return '自动乐跑星期不合法'
  466. const email = args.email || ''
  467. if (email && !this.emailRegex.test(email)) return '邮箱格式不正确'
  468. result = await db.query(
  469. `INSERT INTO lepao_account
  470. (student_num, email, area, auto_time, auto_run, target_count, create_user, create_time, update_time, notes, auto_day, notice_type)
  471. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  472. [
  473. student_num,
  474. email,
  475. area,
  476. isProvided(args.auto_time) ? Number(args.auto_time) : -1,
  477. isProvided(args.auto_run) ? Number(args.auto_run) : 1,
  478. isProvided(args.target_count) ? args.target_count : 30,
  479. sender,
  480. now,
  481. now,
  482. args.notes || '',
  483. JSON.stringify(autoDay),
  484. args.notice_type || 'email'
  485. ]
  486. )
  487. const bindCode = Math.random().toString(36).slice(2, 8).toUpperCase()
  488. await db.query('INSERT IGNORE INTO lepao_extra (student_num, bind_code) VALUES (?, ?)', [student_num, bindCode])
  489. }
  490. if (!result || result.affectedRows < 1) return '保存失败或无权修改'
  491. return asJson({ success: true, student_num })
  492. }
  493. async save_power_task(args) {
  494. const { sender, task_id, area, building, room } = args
  495. if (required([sender])) return '缺少 sender'
  496. const now = Date.now()
  497. let existing = []
  498. if (task_id) {
  499. existing = await db.query('SELECT id, area, building, room FROM power_task WHERE id = ? AND create_user = ? LIMIT 1', [task_id, sender])
  500. } else if ([area, building, room].every(isProvided)) {
  501. existing = await db.query(
  502. 'SELECT id, area, building, room FROM power_task WHERE create_user = ? AND area = ? AND building = ? AND room = ? LIMIT 1',
  503. [sender, area, building, room]
  504. )
  505. }
  506. if (!existing) return '查询电费任务失败'
  507. const fields = []
  508. const params = []
  509. for (const key of ['area', 'building', 'room', 'email', 'lowest', 'notes']) {
  510. if (isProvided(args[key])) {
  511. fields.push(`${key} = ?`)
  512. params.push(args[key])
  513. }
  514. }
  515. let result
  516. if (existing.length > 0) {
  517. if (fields.length === 0) return '没有需要修改的字段'
  518. fields.push('is_notice = 0', 'update_time = ?')
  519. params.push(now)
  520. result = await db.query(
  521. `UPDATE power_task SET ${fields.join(', ')} WHERE id = ? AND create_user = ?`,
  522. [...params, existing[0].id, sender]
  523. )
  524. } else {
  525. if (required([area, building, room, args.lowest])) return '新增电费任务需提供 area、building、room、lowest'
  526. let balance = ''
  527. let koufeiDate = ''
  528. try {
  529. const endpoint = `https://hqpay.ctbu.edu.cn/weixin/ashx/frmuser.ashx?test=lastlist&pid=${room}&dyid=${building}`
  530. const apiRes = await axiosWithQgOutbound({ method: 'get', url: endpoint, timeout: 15000, logger: this.logger, scene: 'AiAssistantPowerAccount' })
  531. if (!apiRes || !apiRes.data || !apiRes.data[0]) return '获取电费信息失败,请稍后再试'
  532. balance = apiRes.data[0][1]
  533. koufeiDate = apiRes.data[0][2]
  534. } catch (_) {
  535. return '获取电费信息失败,请稍后再试'
  536. }
  537. result = await db.query(
  538. 'INSERT INTO power_task (create_user, create_time, update_time, area, building, room, email, lowest, notes, balance, koufei_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
  539. [sender, now, now, area, building, room, args.email || '', args.lowest, args.notes || '', balance, koufeiDate]
  540. )
  541. }
  542. if (!result || result.affectedRows < 1) return '保存失败或无权修改'
  543. return asJson({ success: true, area, building, room })
  544. }
  545. async list_power_bills(args) {
  546. const { sender, area, building, room, current, pagesize } = args
  547. if (required([sender])) return 'missing sender'
  548. const page = pageArgs({ current, pagesize })
  549. const taskWhere = ['create_user = ?']
  550. const taskParams = [sender]
  551. if (area) {
  552. taskWhere.push('area = ?')
  553. taskParams.push(area)
  554. }
  555. if (building) {
  556. taskWhere.push('building = ?')
  557. taskParams.push(building)
  558. }
  559. if (room) {
  560. taskWhere.push('room = ?')
  561. taskParams.push(room)
  562. }
  563. const totalRows = await db.query(
  564. `SELECT COUNT(*) AS total
  565. FROM power_task
  566. WHERE ${taskWhere.join(' AND ')}`,
  567. taskParams
  568. )
  569. const tasks = await db.query(
  570. `SELECT id, area, building, room, balance, lowest, koufei_date, update_time, notes
  571. FROM power_task
  572. WHERE ${taskWhere.join(' AND ')}
  573. ORDER BY id DESC
  574. LIMIT ? OFFSET ?`,
  575. [...taskParams, String(page.pagesize), String(page.offset)]
  576. )
  577. if (!tasks || !totalRows) return 'query failed'
  578. return asJson({
  579. tasks: tasks.map(mapPowerTask),
  580. pagination: pagination(page, totalRows)
  581. })
  582. }
  583. async list_power_bill_records(args) {
  584. const { sender, area, building, room, current, pagesize } = args
  585. if (required([sender, area, building, room])) return 'missing sender, area, building or room'
  586. const page = pageArgs({ current, pagesize })
  587. const tasks = await db.query(
  588. `SELECT id, area, building, room, balance, lowest, koufei_date, update_time, notes
  589. FROM power_task
  590. WHERE create_user = ? AND area = ? AND building = ? AND room = ?
  591. ORDER BY id DESC
  592. LIMIT 1`,
  593. [sender, area, building, room]
  594. )
  595. if (!tasks) return 'query failed'
  596. if (tasks.length === 0) return asJson({ task: null, records: [], pagination: pagination(page, [{ total: 0 }]) })
  597. const totalRows = await db.query(
  598. `SELECT COUNT(*) AS total
  599. FROM power_change_record
  600. WHERE task_id = ?`,
  601. [tasks[0].id]
  602. )
  603. const records = await db.query(
  604. `SELECT time, balance, old_balance, change_time
  605. FROM power_change_record
  606. WHERE task_id = ?
  607. ORDER BY id DESC
  608. LIMIT ? OFFSET ?`,
  609. [tasks[0].id, String(page.pagesize), String(page.offset)]
  610. )
  611. if (!records || !totalRows) return 'query failed'
  612. return asJson({
  613. task: mapPowerTask(tasks[0]),
  614. records: records.map(mapPowerRecord),
  615. pagination: pagination(page, totalRows)
  616. })
  617. }
  618. async send_lepao_count(args) {
  619. let { sender, username, count } = args
  620. username = typeof username === 'string' ? username.trim() : username
  621. count = Number(count)
  622. if (required([sender, username]) || Number.isNaN(count)) return '缺少 sender、username 或 count'
  623. if (!Number.isInteger(count) || count < 1 || count > 9999) return '超出赠送的次数范围,请重新选择赠送次数'
  624. const conn = await db.connect()
  625. try {
  626. await conn.beginTransaction()
  627. const [senderRows] = await conn.execute(
  628. 'SELECT id, username, lepao_count, COALESCE(send_count_auto_approve, 0) AS send_count_auto_approve FROM users WHERE uuid = ?',
  629. [sender]
  630. )
  631. if (!senderRows || senderRows.length !== 1) {
  632. await conn.rollback()
  633. return '获取用户信息失败'
  634. }
  635. const [targetRows] = await conn.execute(
  636. 'SELECT id, uuid FROM users WHERE username = ?',
  637. [username]
  638. )
  639. if (!targetRows || targetRows.length !== 1) {
  640. await conn.rollback()
  641. return '未找到接收用户,请检查用户名是否正确'
  642. }
  643. if (targetRows[0].uuid === sender) {
  644. await conn.rollback()
  645. return '不能给自己赠送次数'
  646. }
  647. const [decResult] = await conn.execute(
  648. 'UPDATE users SET lepao_count = lepao_count - ? WHERE uuid = ? AND lepao_count >= ?',
  649. [count, sender, count]
  650. )
  651. if (!decResult || decResult.affectedRows !== 1) {
  652. await conn.rollback()
  653. return '剩余乐跑次数不足,请购买后再赠送'
  654. }
  655. const senderUsername = senderRows[0].username
  656. const senderLepaoBefore = Number(senderRows[0].lepao_count || 0)
  657. const autoApprove = Number(senderRows[0].send_count_auto_approve) === 1
  658. if (autoApprove) {
  659. const [recvRows] = await conn.execute(
  660. 'SELECT uuid, lepao_count FROM users WHERE id = ? FOR UPDATE',
  661. [targetRows[0].id]
  662. )
  663. if (!recvRows || recvRows.length !== 1) {
  664. await conn.rollback()
  665. return '未找到接收用户,请检查用户名是否正确'
  666. }
  667. const receiverUuid = recvRows[0].uuid
  668. const beforeRecv = Number(recvRows[0].lepao_count || 0)
  669. const [incResult] = await conn.execute(
  670. 'UPDATE users SET lepao_count = lepao_count + ? WHERE id = ?',
  671. [count, targetRows[0].id]
  672. )
  673. if (!incResult || incResult.affectedRows !== 1) {
  674. await conn.rollback()
  675. return '接收方入账失败,请稍后再试'
  676. }
  677. const [insertResult] = await conn.execute(
  678. `INSERT INTO lepao_send_count_request
  679. (sender_uuid, receiver_user_id, count, status, created_at, reviewed_at, reviewer_uuid)
  680. VALUES (?, ?, ?, 'approved', NOW(), NOW(), NULL)`,
  681. [sender, targetRows[0].id, count]
  682. )
  683. if (!insertResult || insertResult.affectedRows !== 1) {
  684. await conn.rollback()
  685. return '记录赠送失败,请稍后再试'
  686. }
  687. const requestId = insertResult.insertId
  688. await insertLedgerRecord({
  689. executor: conn,
  690. userUuid: sender,
  691. delta: -count,
  692. balanceBefore: senderLepaoBefore,
  693. balanceAfter: senderLepaoBefore - count,
  694. bizType: 'gift_send_lock',
  695. bizId: `send_request:${requestId}`,
  696. remark: `向${username}赠送${count}次`
  697. })
  698. await insertLedgerRecord({
  699. executor: conn,
  700. userUuid: receiverUuid,
  701. delta: count,
  702. balanceBefore: beforeRecv,
  703. balanceAfter: beforeRecv + count,
  704. bizType: 'gift_receive',
  705. bizId: `send_request:${requestId}`,
  706. operatorUuid: null,
  707. remark: `${senderUsername}赠送${count}次`
  708. })
  709. await conn.commit()
  710. const reviewTime = Date.now()
  711. Promise.resolve().then(async () => {
  712. try {
  713. const infoRows = await db.query('SELECT email FROM users WHERE id = ?', [targetRows[0].id])
  714. if (infoRows && infoRows.length === 1 && infoRows[0].email) {
  715. await EmailTemplate.sendCountRequestApproved(infoRows[0].email, {
  716. requestId,
  717. senderUsername,
  718. count,
  719. reviewTime
  720. })
  721. }
  722. } catch (mailErr) {
  723. this.logger.error(`[AiAssistantMcp][SendCount][auto][${requestId}] notify failed: ${mailErr.message || mailErr}`)
  724. }
  725. })
  726. return asJson({ success: true, status: 'approved', request_id: requestId, message: '赠送成功,对方已到账' })
  727. }
  728. const [insertResult] = await conn.execute(
  729. `INSERT INTO lepao_send_count_request
  730. (sender_uuid, receiver_user_id, count, status, created_at)
  731. VALUES (?, ?, ?, 'pending', NOW())`,
  732. [sender, targetRows[0].id, count]
  733. )
  734. if (!insertResult || insertResult.affectedRows !== 1) {
  735. await conn.rollback()
  736. return '提交赠送审核失败,请稍后再试'
  737. }
  738. const requestId = insertResult.insertId
  739. await insertLedgerRecord({
  740. executor: conn,
  741. userUuid: sender,
  742. delta: -count,
  743. balanceBefore: senderLepaoBefore,
  744. balanceAfter: senderLepaoBefore - count,
  745. bizType: 'gift_send_lock',
  746. bizId: `send_request:${requestId}`,
  747. remark: `向${username}赠送${count}次`
  748. })
  749. await conn.commit()
  750. const createTime = Date.now()
  751. Promise.resolve().then(async () => {
  752. try {
  753. const adminRows = await db.query(`
  754. SELECT email
  755. FROM users
  756. WHERE email IS NOT NULL
  757. AND email <> ''
  758. AND (JSON_CONTAINS(permission, '"admin"') OR JSON_CONTAINS(permission, '"service"'))
  759. `)
  760. const emails = [...new Set((adminRows || []).map(row => row.email).filter(Boolean))]
  761. for (const email of emails) {
  762. await EmailTemplate.sendCountRequestNotifyAdmins(email, {
  763. requestId,
  764. senderUsername,
  765. receiverUsername: username,
  766. count,
  767. createTime
  768. })
  769. }
  770. } catch (mailErr) {
  771. this.logger.error(`[AiAssistantMcp][SendCount][pending][${requestId}] notify failed: ${mailErr.message || mailErr}`)
  772. }
  773. })
  774. return asJson({ success: true, status: 'pending', request_id: requestId, message: '已提交审核,审核通过后接收方将到账' })
  775. } catch (err) {
  776. try { await conn.rollback() } catch (_) { }
  777. this.logger.error(`MCP赠送乐跑次数失败:${err.stack || err}`)
  778. return '赠送次数失败,请稍后再试'
  779. } finally {
  780. if (conn?.connection && typeof conn.connection.release === 'function' && typeof conn?.release === 'function') {
  781. conn.release()
  782. }
  783. }
  784. }
  785. async list_send_count_requests(args) {
  786. const { sender, direction, status, current, pagesize } = args
  787. if (required([sender])) return '缺少 sender'
  788. if (direction && !['sent', 'received'].includes(direction)) return 'direction 参数错误'
  789. if (status && !['pending', 'approved', 'rejected'].includes(status)) return 'status 参数错误'
  790. const page = pageArgs({ current, pagesize })
  791. const userRows = await db.query('SELECT id FROM users WHERE uuid = ?', [sender])
  792. if (!userRows || userRows.length !== 1) return '获取用户信息失败'
  793. const userId = userRows[0].id
  794. let sql = `
  795. SELECT
  796. r.id,
  797. r.sender_uuid,
  798. su.username AS sender_username,
  799. su.avatar AS sender_avatar,
  800. r.receiver_user_id,
  801. ru.username AS receiver_username,
  802. ru.avatar AS receiver_avatar,
  803. CASE WHEN r.sender_uuid = ? THEN 'sent' ELSE 'received' END AS direction,
  804. CASE WHEN r.sender_uuid = ? THEN ru.username ELSE su.username END AS counterparty_username,
  805. CASE WHEN r.sender_uuid = ? THEN ru.avatar ELSE su.avatar END AS counterparty_avatar,
  806. r.count,
  807. r.status,
  808. r.created_at,
  809. r.reviewed_at,
  810. r.reject_reason
  811. FROM lepao_send_count_request r
  812. LEFT JOIN users su ON su.uuid = r.sender_uuid
  813. LEFT JOIN users ru ON ru.id = r.receiver_user_id
  814. WHERE (r.sender_uuid = ? OR r.receiver_user_id = ?)
  815. `
  816. let countSql = `
  817. SELECT COUNT(*) AS total
  818. FROM lepao_send_count_request r
  819. WHERE (r.sender_uuid = ? OR r.receiver_user_id = ?)
  820. `
  821. const params = [sender, sender, sender, sender, userId]
  822. const countParams = [sender, userId]
  823. if (direction === 'sent') {
  824. sql += ' AND r.sender_uuid = ?'
  825. countSql += ' AND r.sender_uuid = ?'
  826. params.push(sender)
  827. countParams.push(sender)
  828. }
  829. if (direction === 'received') {
  830. sql += ' AND r.receiver_user_id = ?'
  831. countSql += ' AND r.receiver_user_id = ?'
  832. params.push(userId)
  833. countParams.push(userId)
  834. }
  835. if (status) {
  836. sql += ' AND r.status = ?'
  837. countSql += ' AND r.status = ?'
  838. params.push(status)
  839. countParams.push(status)
  840. }
  841. sql += ' ORDER BY r.id DESC LIMIT ? OFFSET ?'
  842. params.push(String(page.pagesize), String(page.offset))
  843. const rows = await db.query(sql, params)
  844. const totalRows = await db.query(countSql, countParams)
  845. if (!rows || !totalRows) return '获取赠送记录失败'
  846. return asJson({
  847. list: rows.map(mapSendCountRequest),
  848. pagination: pagination(page, totalRows)
  849. })
  850. }
  851. async list_work_orders(args) {
  852. const { sender, current, pagesize } = args
  853. if (required([sender])) return '缺少 sender'
  854. const page = pageArgs({ current, pagesize })
  855. const rows = await db.query(
  856. `SELECT id, title, state, email, create_time, update_time
  857. FROM work_order
  858. WHERE create_user = ?
  859. ORDER BY update_time DESC
  860. LIMIT ? OFFSET ?`,
  861. [sender, String(page.pagesize), String(page.offset)]
  862. )
  863. const totalRows = await db.query(
  864. `SELECT COUNT(*) AS total
  865. FROM work_order
  866. WHERE create_user = ?`,
  867. [sender]
  868. )
  869. if (!rows || !totalRows) return '查询工单列表失败'
  870. return asJson({
  871. list: rows.map(mapWorkOrder),
  872. pagination: pagination(page, totalRows)
  873. })
  874. }
  875. async get_work_order_detail(args) {
  876. const { sender, work_order_id } = args
  877. if (required([sender, work_order_id])) return '缺少 sender 或 work_order_id'
  878. const rows = await db.query(
  879. `SELECT id, title, msg, state, email, create_time, update_time
  880. FROM work_order
  881. WHERE create_user = ? AND id = ?`,
  882. [sender, work_order_id]
  883. )
  884. if (!rows || rows.length !== 1) return '工单异常,请提交新工单'
  885. const order = mapWorkOrder(rows[0])
  886. const msgList = Array.isArray(rows[0].msg) ? rows[0].msg : parseJson(rows[0].msg, [])
  887. const uuidList = [...new Set(msgList.map(msg => msg?.uuid).filter(Boolean))]
  888. let userInfo = {}
  889. if (uuidList.length > 0) {
  890. const placeholders = uuidList.map(() => '?').join(', ')
  891. const userRows = await db.query(`SELECT uuid, username, avatar FROM users WHERE uuid IN (${placeholders})`, uuidList)
  892. if (!userRows || userRows.length === 0) return '用户信息获取失败'
  893. userRows.forEach(user => {
  894. userInfo[user.uuid] = {
  895. username: user.username,
  896. avatar: user.avatar
  897. }
  898. })
  899. }
  900. return asJson({
  901. ...order,
  902. msg: msgList.map(mapWorkOrderMessage),
  903. userInfo
  904. })
  905. }
  906. async query_qxs_book_list(args) {
  907. const { sender, username, password, current, pagesize } = args
  908. if (required([sender, username, password])) return '缺少 sender、username 或 password'
  909. try {
  910. const page = pageArgs({ current, pagesize })
  911. const qxs = new QxsBookList()
  912. const { accessToken } = await qxs.qxsLogin(username, password)
  913. const { userInfo } = await qxs.qsxUserInfo(accessToken)
  914. const { bookList } = await qxs.qsxGetList(accessToken, userInfo.termCode)
  915. const list = bookList || []
  916. return asJson({
  917. user: {
  918. realname: userInfo.userName,
  919. termName: userInfo.termName
  920. },
  921. list: list.slice(page.offset, page.offset + page.pagesize).map(book => ({
  922. title: book.title || book.bookName || '',
  923. isbn: book.isbn || '',
  924. author: book.author || '',
  925. publisher: book.publisher || '',
  926. className: book.className || ''
  927. })),
  928. pagination: pagination(page, [{ total: list.length }])
  929. })
  930. } catch (err) {
  931. return err.message || '查询趣选书书单失败'
  932. }
  933. }
  934. async submit_work_order(args) {
  935. const { sender, title, content, email = '', files = [] } = args
  936. if (required([sender, title, content])) return '缺少 sender、title 或 content'
  937. if (email && !this.emailRegex.test(email)) return '邮箱格式不正确'
  938. if (email && this.banEmailList.includes(email.split('@')[1].toLowerCase())) return '暂不支持该邮箱域名'
  939. const safeTitle = normalizeText(title, 80)
  940. const safeContent = normalizeText(content, 4000)
  941. const safeFiles = Array.isArray(files) ? files.map(item => String(item || '').trim()).filter(Boolean).slice(0, 6) : []
  942. if (!safeTitle || !safeContent) return '标题或内容不能为空'
  943. const time = Date.now()
  944. const msg = [
  945. {
  946. time,
  947. content: safeContent,
  948. files: safeFiles,
  949. uuid: sender,
  950. type: 'user'
  951. },
  952. {
  953. time,
  954. content: '您的问题已收到,我们将尽快处理,请耐心等待。',
  955. uuid: ASSISTANT_UUID,
  956. type: 'system'
  957. }
  958. ]
  959. const result = await db.query(
  960. 'INSERT INTO work_order (title, email, msg, create_user, create_time, update_time) VALUES (?, ?, ?, ?, ?, ?)',
  961. [safeTitle, email, msg, sender, time, time]
  962. )
  963. if (!result || result.affectedRows !== 1) return '提交工单失败'
  964. const kefuRows = await db.query(`
  965. SELECT email FROM users
  966. WHERE JSON_CONTAINS(permission, '"admin"') OR JSON_CONTAINS(permission, '"service"')
  967. `)
  968. const emails = [...new Set((kefuRows || []).map(row => row.email).filter(Boolean))]
  969. for (const targetEmail of emails) {
  970. await EmailTemplate.orderNewReply(targetEmail, { id: result.insertId, content: safeContent, files: safeFiles })
  971. }
  972. return asJson({ success: true, message: '工单提交成功,可在工单列表查看处理进度' })
  973. }
  974. }
  975. module.exports.AI_ASSISTANT_MCP = new AiAssistantMcp()