AiAssistantMcp.js 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077
  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 get_lepao_count_balance(args) {
  312. const { sender } = args
  313. if (required([sender])) return '缺少 sender'
  314. const rows = await db.query(
  315. 'SELECT username, lepao_count FROM users WHERE uuid = ? LIMIT 1',
  316. [sender]
  317. )
  318. if (!rows || rows.length !== 1) return '获取用户乐跑次数余额失败'
  319. return asJson({
  320. username: rows[0].username,
  321. lepao_count: Number(rows[0].lepao_count || 0)
  322. })
  323. }
  324. async list_goods(args) {
  325. const { keyword, category, current, pagesize } = args
  326. const page = pageArgs({ current, pagesize })
  327. const where = ['state = 1']
  328. const params = []
  329. if (keyword) {
  330. where.push('name LIKE ?')
  331. params.push(`%${keyword}%`)
  332. }
  333. if (category) {
  334. where.push('category = ?')
  335. params.push(category)
  336. }
  337. const totalRows = await db.query(
  338. `SELECT COUNT(*) AS total
  339. FROM goods
  340. WHERE ${where.join(' AND ')}`,
  341. params
  342. )
  343. const rows = await db.query(
  344. `SELECT name, price, num, \`limit\`, description, category, features
  345. FROM goods
  346. WHERE ${where.join(' AND ')}
  347. ORDER BY id DESC
  348. LIMIT ? OFFSET ?`,
  349. [...params, String(page.pagesize), String(page.offset)]
  350. )
  351. if (!rows || !totalRows) return '查询失败'
  352. return asJson({
  353. list: rows.map(r => ({ ...r, features: parseJson(r.features, []) })),
  354. pagination: pagination(page, totalRows)
  355. })
  356. }
  357. async list_my_orders(args) {
  358. const { sender, state, order_id, current, pagesize } = args
  359. if (required([sender])) return '缺少 sender'
  360. const page = pageArgs({ current, pagesize })
  361. const where = ['o.create_user = ?']
  362. const params = [sender]
  363. if (state !== undefined && state !== null && state !== '' && Number(state) !== -1) {
  364. where.push('o.state = ?')
  365. params.push(state)
  366. }
  367. if (order_id) {
  368. where.push('o.orderId = ?')
  369. params.push(order_id)
  370. }
  371. const totalRows = await db.query(
  372. `SELECT COUNT(*) AS total
  373. FROM orders o
  374. JOIN goods g ON o.goods_id = g.id
  375. WHERE ${where.join(' AND ')}`,
  376. params
  377. )
  378. const rows = await db.query(
  379. `SELECT o.orderId, o.state, o.create_time, o.price, o.pay_type, g.name AS goods_name
  380. FROM orders o
  381. JOIN goods g ON o.goods_id = g.id
  382. WHERE ${where.join(' AND ')}
  383. ORDER BY o.id DESC
  384. LIMIT ? OFFSET ?`,
  385. [...params, String(page.pagesize), String(page.offset)]
  386. )
  387. if (!rows || !totalRows) return '查询失败'
  388. return asJson({
  389. list: rows.map(mapOrder),
  390. pagination: pagination(page, totalRows)
  391. })
  392. }
  393. async save_lepao_account(args) {
  394. const { sender, student_num } = args
  395. if (required([sender, student_num])) return '缺少 sender 或 student_num'
  396. const existRows = await db.query(
  397. `SELECT id, create_user, student_num, email, area, auto_time, auto_run, target_count, total_num, auto_day, notice_type, notes
  398. FROM lepao_account
  399. WHERE student_num = ?
  400. LIMIT 1`,
  401. [student_num]
  402. )
  403. if (!existRows) return '查询账号失败'
  404. if (existRows.length > 0 && existRows[0].create_user && existRows[0].create_user !== sender) {
  405. return '该乐跑账号已被其他用户绑定'
  406. }
  407. const existing = existRows[0] || {}
  408. const targetCount = isProvided(args.target_count)
  409. ? normalizeNumber(args.target_count)
  410. : (existRows.length > 0 ? normalizeNumber(existing.target_count) : 30)
  411. if (targetCount === null || targetCount < 0 || targetCount > 99) return '乐跑目标次数不在合法范围内'
  412. args.target_count = targetCount
  413. const noticeType = isProvided(args.notice_type) ? args.notice_type : (existing.notice_type || 'email')
  414. const email = isProvided(args.email) ? String(args.email).trim() : (existing.email || '')
  415. if (noticeType === 'email') {
  416. if (!this.emailRegex.test(email)) return '邮箱格式不正确'
  417. const emailDomain = email.split('@')[1].toLowerCase()
  418. if (this.banEmailList.includes(emailDomain)) return `暂不支持使用 ${emailDomain} 域名的邮箱,请更换其他邮箱后重试`
  419. } else if (email && !this.emailRegex.test(email)) {
  420. return '邮箱格式不正确'
  421. }
  422. const autoRun = isProvided(args.auto_run) ? Number(args.auto_run) : (existRows.length > 0 ? Number(existing.auto_run) : 1)
  423. if (![0, 1].includes(autoRun)) return 'auto_run 只能为 0 或 1'
  424. args.auto_run = autoRun
  425. const autoDay = isProvided(args.auto_day)
  426. ? args.auto_day
  427. : (existRows.length > 0 ? parseJson(existing.auto_day, []) : [0, 1, 2, 3, 4, 5, 6])
  428. if (autoRun === 1 && (!Array.isArray(autoDay) || !autoDay.every(v => Number.isInteger(v) && v >= 0 && v <= 6))) return '自动乐跑星期不合法'
  429. if (isProvided(args.auto_day)) args.auto_day = autoDay
  430. const totalNum = normalizeNumber(existing.total_num) || 0
  431. if (autoRun === 1 && totalNum >= targetCount && targetCount !== 0) return '该账号累计跑步次数已达到预设目标次数,请尝试增大目标次数后再试'
  432. const fields = []
  433. const params = []
  434. const mutable = [
  435. ['email', 'email'],
  436. ['auto_time', 'auto_time'],
  437. ['auto_run', 'auto_run'],
  438. ['target_count', 'target_count'],
  439. ['notice_type', 'notice_type'],
  440. ['notes', 'notes']
  441. ]
  442. if (isProvided(args.area)) {
  443. const area = normalizeRunArea(args.area)
  444. if (area === null) return '跑区不合法'
  445. fields.push('area = ?')
  446. params.push(area)
  447. }
  448. if (isProvided(args.notice_type) && !['email', 'none', 'bot'].includes(args.notice_type)) return '通知方式不合法'
  449. if (isProvided(args.auto_time)) {
  450. const autoTime = Number(args.auto_time)
  451. if (!Number.isInteger(autoTime) || autoTime < -1 || autoTime > 23) return '自动乐跑时间不合法'
  452. args.auto_time = autoTime
  453. }
  454. if (isProvided(args.auto_day)) {
  455. fields.push('auto_day = ?')
  456. params.push(JSON.stringify(args.auto_day))
  457. }
  458. for (const [argName, column] of mutable) {
  459. if (isProvided(args[argName])) {
  460. fields.push(`${column} = ?`)
  461. params.push(args[argName])
  462. }
  463. }
  464. const now = Date.now()
  465. let result
  466. if (existRows.length > 0) {
  467. if (fields.length === 0) return '没有需要修改的字段'
  468. fields.push('update_time = ?', 'create_user = ?')
  469. params.push(now, sender)
  470. result = await db.query(
  471. `UPDATE lepao_account SET ${fields.join(', ')} WHERE student_num = ? AND (create_user = ? OR create_user IS NULL)`,
  472. [...params, student_num, sender]
  473. )
  474. } else {
  475. const area = isProvided(args.area) ? normalizeRunArea(args.area) : ''
  476. if (area === null) return '跑区不合法'
  477. const autoDay = isProvided(args.auto_day) ? args.auto_day : [0, 1, 2, 3, 4, 5, 6]
  478. if (!Array.isArray(autoDay) || !autoDay.every(v => Number.isInteger(v) && v >= 0 && v <= 6)) return '自动乐跑星期不合法'
  479. const email = args.email || ''
  480. if (email && !this.emailRegex.test(email)) return '邮箱格式不正确'
  481. result = await db.query(
  482. `INSERT INTO lepao_account
  483. (student_num, email, area, auto_time, auto_run, target_count, create_user, create_time, update_time, notes, auto_day, notice_type)
  484. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  485. [
  486. student_num,
  487. email,
  488. area,
  489. isProvided(args.auto_time) ? Number(args.auto_time) : -1,
  490. isProvided(args.auto_run) ? Number(args.auto_run) : 1,
  491. isProvided(args.target_count) ? args.target_count : 30,
  492. sender,
  493. now,
  494. now,
  495. args.notes || '',
  496. JSON.stringify(autoDay),
  497. args.notice_type || 'email'
  498. ]
  499. )
  500. const bindCode = Math.random().toString(36).slice(2, 8).toUpperCase()
  501. await db.query('INSERT IGNORE INTO lepao_extra (student_num, bind_code) VALUES (?, ?)', [student_num, bindCode])
  502. }
  503. if (!result || result.affectedRows < 1) return '保存失败或无权修改'
  504. return asJson({ success: true, student_num })
  505. }
  506. async save_power_task(args) {
  507. const { sender, task_id, area, building, room } = args
  508. if (required([sender])) return '缺少 sender'
  509. const now = Date.now()
  510. let existing = []
  511. if (task_id) {
  512. existing = await db.query('SELECT id, area, building, room FROM power_task WHERE id = ? AND create_user = ? LIMIT 1', [task_id, sender])
  513. } else if ([area, building, room].every(isProvided)) {
  514. existing = await db.query(
  515. 'SELECT id, area, building, room FROM power_task WHERE create_user = ? AND area = ? AND building = ? AND room = ? LIMIT 1',
  516. [sender, area, building, room]
  517. )
  518. }
  519. if (!existing) return '查询电费任务失败'
  520. const fields = []
  521. const params = []
  522. for (const key of ['area', 'building', 'room', 'email', 'lowest', 'notes']) {
  523. if (isProvided(args[key])) {
  524. fields.push(`${key} = ?`)
  525. params.push(args[key])
  526. }
  527. }
  528. let result
  529. if (existing.length > 0) {
  530. if (fields.length === 0) return '没有需要修改的字段'
  531. fields.push('is_notice = 0', 'update_time = ?')
  532. params.push(now)
  533. result = await db.query(
  534. `UPDATE power_task SET ${fields.join(', ')} WHERE id = ? AND create_user = ?`,
  535. [...params, existing[0].id, sender]
  536. )
  537. } else {
  538. if (required([area, building, room, args.lowest])) return '新增电费任务需提供 area、building、room、lowest'
  539. let balance = ''
  540. let koufeiDate = ''
  541. try {
  542. const endpoint = `https://hqpay.ctbu.edu.cn/weixin/ashx/frmuser.ashx?test=lastlist&pid=${room}&dyid=${building}`
  543. const apiRes = await axiosWithQgOutbound({ method: 'get', url: endpoint, timeout: 15000, logger: this.logger, scene: 'AiAssistantPowerAccount' })
  544. if (!apiRes || !apiRes.data || !apiRes.data[0]) return '获取电费信息失败,请稍后再试'
  545. balance = apiRes.data[0][1]
  546. koufeiDate = apiRes.data[0][2]
  547. } catch (_) {
  548. return '获取电费信息失败,请稍后再试'
  549. }
  550. result = await db.query(
  551. 'INSERT INTO power_task (create_user, create_time, update_time, area, building, room, email, lowest, notes, balance, koufei_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
  552. [sender, now, now, area, building, room, args.email || '', args.lowest, args.notes || '', balance, koufeiDate]
  553. )
  554. }
  555. if (!result || result.affectedRows < 1) return '保存失败或无权修改'
  556. return asJson({ success: true, area, building, room })
  557. }
  558. async list_power_bills(args) {
  559. const { sender, area, building, room, current, pagesize } = args
  560. if (required([sender])) return 'missing sender'
  561. const page = pageArgs({ current, pagesize })
  562. const taskWhere = ['create_user = ?']
  563. const taskParams = [sender]
  564. if (area) {
  565. taskWhere.push('area = ?')
  566. taskParams.push(area)
  567. }
  568. if (building) {
  569. taskWhere.push('building = ?')
  570. taskParams.push(building)
  571. }
  572. if (room) {
  573. taskWhere.push('room = ?')
  574. taskParams.push(room)
  575. }
  576. const totalRows = await db.query(
  577. `SELECT COUNT(*) AS total
  578. FROM power_task
  579. WHERE ${taskWhere.join(' AND ')}`,
  580. taskParams
  581. )
  582. const tasks = await db.query(
  583. `SELECT id, area, building, room, balance, lowest, koufei_date, update_time, notes
  584. FROM power_task
  585. WHERE ${taskWhere.join(' AND ')}
  586. ORDER BY id DESC
  587. LIMIT ? OFFSET ?`,
  588. [...taskParams, String(page.pagesize), String(page.offset)]
  589. )
  590. if (!tasks || !totalRows) return 'query failed'
  591. return asJson({
  592. tasks: tasks.map(mapPowerTask),
  593. pagination: pagination(page, totalRows)
  594. })
  595. }
  596. async list_power_bill_records(args) {
  597. const { sender, area, building, room, current, pagesize } = args
  598. if (required([sender, area, building, room])) return 'missing sender, area, building or room'
  599. const page = pageArgs({ current, pagesize })
  600. const tasks = await db.query(
  601. `SELECT id, area, building, room, balance, lowest, koufei_date, update_time, notes
  602. FROM power_task
  603. WHERE create_user = ? AND area = ? AND building = ? AND room = ?
  604. ORDER BY id DESC
  605. LIMIT 1`,
  606. [sender, area, building, room]
  607. )
  608. if (!tasks) return 'query failed'
  609. if (tasks.length === 0) return asJson({ task: null, records: [], pagination: pagination(page, [{ total: 0 }]) })
  610. const totalRows = await db.query(
  611. `SELECT COUNT(*) AS total
  612. FROM power_change_record
  613. WHERE task_id = ?`,
  614. [tasks[0].id]
  615. )
  616. const records = await db.query(
  617. `SELECT time, balance, old_balance, change_time
  618. FROM power_change_record
  619. WHERE task_id = ?
  620. ORDER BY id DESC
  621. LIMIT ? OFFSET ?`,
  622. [tasks[0].id, String(page.pagesize), String(page.offset)]
  623. )
  624. if (!records || !totalRows) return 'query failed'
  625. return asJson({
  626. task: mapPowerTask(tasks[0]),
  627. records: records.map(mapPowerRecord),
  628. pagination: pagination(page, totalRows)
  629. })
  630. }
  631. async send_lepao_count(args) {
  632. let { sender, username, count } = args
  633. username = typeof username === 'string' ? username.trim() : username
  634. count = Number(count)
  635. if (required([sender, username]) || Number.isNaN(count)) return '缺少 sender、username 或 count'
  636. if (!Number.isInteger(count) || count < 1 || count > 9999) return '超出赠送的次数范围,请重新选择赠送次数'
  637. const conn = await db.connect()
  638. try {
  639. await conn.beginTransaction()
  640. const [senderRows] = await conn.execute(
  641. 'SELECT id, username, lepao_count, COALESCE(send_count_auto_approve, 0) AS send_count_auto_approve FROM users WHERE uuid = ?',
  642. [sender]
  643. )
  644. if (!senderRows || senderRows.length !== 1) {
  645. await conn.rollback()
  646. return '获取用户信息失败'
  647. }
  648. const [targetRows] = await conn.execute(
  649. 'SELECT id, uuid FROM users WHERE username = ?',
  650. [username]
  651. )
  652. if (!targetRows || targetRows.length !== 1) {
  653. await conn.rollback()
  654. return '未找到接收用户,请检查用户名是否正确'
  655. }
  656. if (targetRows[0].uuid === sender) {
  657. await conn.rollback()
  658. return '不能给自己赠送次数'
  659. }
  660. const [decResult] = await conn.execute(
  661. 'UPDATE users SET lepao_count = lepao_count - ? WHERE uuid = ? AND lepao_count >= ?',
  662. [count, sender, count]
  663. )
  664. if (!decResult || decResult.affectedRows !== 1) {
  665. await conn.rollback()
  666. return '剩余乐跑次数不足,请购买后再赠送'
  667. }
  668. const senderUsername = senderRows[0].username
  669. const senderLepaoBefore = Number(senderRows[0].lepao_count || 0)
  670. const autoApprove = Number(senderRows[0].send_count_auto_approve) === 1
  671. if (autoApprove) {
  672. const [recvRows] = await conn.execute(
  673. 'SELECT uuid, lepao_count FROM users WHERE id = ? FOR UPDATE',
  674. [targetRows[0].id]
  675. )
  676. if (!recvRows || recvRows.length !== 1) {
  677. await conn.rollback()
  678. return '未找到接收用户,请检查用户名是否正确'
  679. }
  680. const receiverUuid = recvRows[0].uuid
  681. const beforeRecv = Number(recvRows[0].lepao_count || 0)
  682. const [incResult] = await conn.execute(
  683. 'UPDATE users SET lepao_count = lepao_count + ? WHERE id = ?',
  684. [count, targetRows[0].id]
  685. )
  686. if (!incResult || incResult.affectedRows !== 1) {
  687. await conn.rollback()
  688. return '接收方入账失败,请稍后再试'
  689. }
  690. const [insertResult] = await conn.execute(
  691. `INSERT INTO lepao_send_count_request
  692. (sender_uuid, receiver_user_id, count, status, created_at, reviewed_at, reviewer_uuid)
  693. VALUES (?, ?, ?, 'approved', NOW(), NOW(), NULL)`,
  694. [sender, targetRows[0].id, count]
  695. )
  696. if (!insertResult || insertResult.affectedRows !== 1) {
  697. await conn.rollback()
  698. return '记录赠送失败,请稍后再试'
  699. }
  700. const requestId = insertResult.insertId
  701. await insertLedgerRecord({
  702. executor: conn,
  703. userUuid: sender,
  704. delta: -count,
  705. balanceBefore: senderLepaoBefore,
  706. balanceAfter: senderLepaoBefore - count,
  707. bizType: 'gift_send_lock',
  708. bizId: `send_request:${requestId}`,
  709. remark: `向${username}赠送${count}次`
  710. })
  711. await insertLedgerRecord({
  712. executor: conn,
  713. userUuid: receiverUuid,
  714. delta: count,
  715. balanceBefore: beforeRecv,
  716. balanceAfter: beforeRecv + count,
  717. bizType: 'gift_receive',
  718. bizId: `send_request:${requestId}`,
  719. operatorUuid: null,
  720. remark: `${senderUsername}赠送${count}次`
  721. })
  722. await conn.commit()
  723. const reviewTime = Date.now()
  724. Promise.resolve().then(async () => {
  725. try {
  726. const infoRows = await db.query('SELECT email FROM users WHERE id = ?', [targetRows[0].id])
  727. if (infoRows && infoRows.length === 1 && infoRows[0].email) {
  728. await EmailTemplate.sendCountRequestApproved(infoRows[0].email, {
  729. requestId,
  730. senderUsername,
  731. count,
  732. reviewTime
  733. })
  734. }
  735. } catch (mailErr) {
  736. this.logger.error(`[AiAssistantMcp][SendCount][auto][${requestId}] notify failed: ${mailErr.message || mailErr}`)
  737. }
  738. })
  739. return asJson({ success: true, status: 'approved', request_id: requestId, message: '赠送成功,对方已到账' })
  740. }
  741. const [insertResult] = await conn.execute(
  742. `INSERT INTO lepao_send_count_request
  743. (sender_uuid, receiver_user_id, count, status, created_at)
  744. VALUES (?, ?, ?, 'pending', NOW())`,
  745. [sender, targetRows[0].id, count]
  746. )
  747. if (!insertResult || insertResult.affectedRows !== 1) {
  748. await conn.rollback()
  749. return '提交赠送审核失败,请稍后再试'
  750. }
  751. const requestId = insertResult.insertId
  752. await insertLedgerRecord({
  753. executor: conn,
  754. userUuid: sender,
  755. delta: -count,
  756. balanceBefore: senderLepaoBefore,
  757. balanceAfter: senderLepaoBefore - count,
  758. bizType: 'gift_send_lock',
  759. bizId: `send_request:${requestId}`,
  760. remark: `向${username}赠送${count}次`
  761. })
  762. await conn.commit()
  763. const createTime = Date.now()
  764. Promise.resolve().then(async () => {
  765. try {
  766. const adminRows = await db.query(`
  767. SELECT email
  768. FROM users
  769. WHERE email IS NOT NULL
  770. AND email <> ''
  771. AND (JSON_CONTAINS(permission, '"admin"') OR JSON_CONTAINS(permission, '"service"'))
  772. `)
  773. const emails = [...new Set((adminRows || []).map(row => row.email).filter(Boolean))]
  774. for (const email of emails) {
  775. await EmailTemplate.sendCountRequestNotifyAdmins(email, {
  776. requestId,
  777. senderUsername,
  778. receiverUsername: username,
  779. count,
  780. createTime
  781. })
  782. }
  783. } catch (mailErr) {
  784. this.logger.error(`[AiAssistantMcp][SendCount][pending][${requestId}] notify failed: ${mailErr.message || mailErr}`)
  785. }
  786. })
  787. return asJson({ success: true, status: 'pending', request_id: requestId, message: '已提交审核,审核通过后接收方将到账' })
  788. } catch (err) {
  789. try { await conn.rollback() } catch (_) { }
  790. this.logger.error(`MCP赠送乐跑次数失败:${err.stack || err}`)
  791. return '赠送次数失败,请稍后再试'
  792. } finally {
  793. if (conn?.connection && typeof conn.connection.release === 'function' && typeof conn?.release === 'function') {
  794. conn.release()
  795. }
  796. }
  797. }
  798. async list_send_count_requests(args) {
  799. const { sender, direction, status, current, pagesize } = args
  800. if (required([sender])) return '缺少 sender'
  801. if (direction && !['sent', 'received'].includes(direction)) return 'direction 参数错误'
  802. if (status && !['pending', 'approved', 'rejected'].includes(status)) return 'status 参数错误'
  803. const page = pageArgs({ current, pagesize })
  804. const userRows = await db.query('SELECT id FROM users WHERE uuid = ?', [sender])
  805. if (!userRows || userRows.length !== 1) return '获取用户信息失败'
  806. const userId = userRows[0].id
  807. let sql = `
  808. SELECT
  809. r.id,
  810. r.sender_uuid,
  811. su.username AS sender_username,
  812. su.avatar AS sender_avatar,
  813. r.receiver_user_id,
  814. ru.username AS receiver_username,
  815. ru.avatar AS receiver_avatar,
  816. CASE WHEN r.sender_uuid = ? THEN 'sent' ELSE 'received' END AS direction,
  817. CASE WHEN r.sender_uuid = ? THEN ru.username ELSE su.username END AS counterparty_username,
  818. CASE WHEN r.sender_uuid = ? THEN ru.avatar ELSE su.avatar END AS counterparty_avatar,
  819. r.count,
  820. r.status,
  821. r.created_at,
  822. r.reviewed_at,
  823. r.reject_reason
  824. FROM lepao_send_count_request r
  825. LEFT JOIN users su ON su.uuid = r.sender_uuid
  826. LEFT JOIN users ru ON ru.id = r.receiver_user_id
  827. WHERE (r.sender_uuid = ? OR r.receiver_user_id = ?)
  828. `
  829. let countSql = `
  830. SELECT COUNT(*) AS total
  831. FROM lepao_send_count_request r
  832. WHERE (r.sender_uuid = ? OR r.receiver_user_id = ?)
  833. `
  834. const params = [sender, sender, sender, sender, userId]
  835. const countParams = [sender, userId]
  836. if (direction === 'sent') {
  837. sql += ' AND r.sender_uuid = ?'
  838. countSql += ' AND r.sender_uuid = ?'
  839. params.push(sender)
  840. countParams.push(sender)
  841. }
  842. if (direction === 'received') {
  843. sql += ' AND r.receiver_user_id = ?'
  844. countSql += ' AND r.receiver_user_id = ?'
  845. params.push(userId)
  846. countParams.push(userId)
  847. }
  848. if (status) {
  849. sql += ' AND r.status = ?'
  850. countSql += ' AND r.status = ?'
  851. params.push(status)
  852. countParams.push(status)
  853. }
  854. sql += ' ORDER BY r.id DESC LIMIT ? OFFSET ?'
  855. params.push(String(page.pagesize), String(page.offset))
  856. const rows = await db.query(sql, params)
  857. const totalRows = await db.query(countSql, countParams)
  858. if (!rows || !totalRows) return '获取赠送记录失败'
  859. return asJson({
  860. list: rows.map(mapSendCountRequest),
  861. pagination: pagination(page, totalRows)
  862. })
  863. }
  864. async list_work_orders(args) {
  865. const { sender, current, pagesize } = args
  866. if (required([sender])) return '缺少 sender'
  867. const page = pageArgs({ current, pagesize })
  868. const rows = await db.query(
  869. `SELECT id, title, state, email, create_time, update_time
  870. FROM work_order
  871. WHERE create_user = ?
  872. ORDER BY update_time DESC
  873. LIMIT ? OFFSET ?`,
  874. [sender, String(page.pagesize), String(page.offset)]
  875. )
  876. const totalRows = await db.query(
  877. `SELECT COUNT(*) AS total
  878. FROM work_order
  879. WHERE create_user = ?`,
  880. [sender]
  881. )
  882. if (!rows || !totalRows) return '查询工单列表失败'
  883. return asJson({
  884. list: rows.map(mapWorkOrder),
  885. pagination: pagination(page, totalRows)
  886. })
  887. }
  888. async get_work_order_detail(args) {
  889. const { sender, work_order_id } = args
  890. if (required([sender, work_order_id])) return '缺少 sender 或 work_order_id'
  891. const rows = await db.query(
  892. `SELECT id, title, msg, state, email, create_time, update_time
  893. FROM work_order
  894. WHERE create_user = ? AND id = ?`,
  895. [sender, work_order_id]
  896. )
  897. if (!rows || rows.length !== 1) return '工单异常,请提交新工单'
  898. const order = mapWorkOrder(rows[0])
  899. const msgList = Array.isArray(rows[0].msg) ? rows[0].msg : parseJson(rows[0].msg, [])
  900. const uuidList = [...new Set(msgList.map(msg => msg?.uuid).filter(Boolean))]
  901. let userInfo = {}
  902. if (uuidList.length > 0) {
  903. const placeholders = uuidList.map(() => '?').join(', ')
  904. const userRows = await db.query(`SELECT uuid, username, avatar FROM users WHERE uuid IN (${placeholders})`, uuidList)
  905. if (!userRows || userRows.length === 0) return '用户信息获取失败'
  906. userRows.forEach(user => {
  907. userInfo[user.uuid] = {
  908. username: user.username,
  909. avatar: user.avatar
  910. }
  911. })
  912. }
  913. return asJson({
  914. ...order,
  915. msg: msgList.map(mapWorkOrderMessage),
  916. userInfo
  917. })
  918. }
  919. async query_qxs_book_list(args) {
  920. const { sender, username, password, current, pagesize } = args
  921. if (required([sender, username, password])) return '缺少 sender、username 或 password'
  922. try {
  923. const page = pageArgs({ current, pagesize })
  924. const qxs = new QxsBookList()
  925. const { accessToken } = await qxs.qxsLogin(username, password)
  926. const { userInfo } = await qxs.qsxUserInfo(accessToken)
  927. const { bookList } = await qxs.qsxGetList(accessToken, userInfo.termCode)
  928. const list = bookList || []
  929. return asJson({
  930. user: {
  931. realname: userInfo.userName,
  932. termName: userInfo.termName
  933. },
  934. list: list.slice(page.offset, page.offset + page.pagesize).map(book => ({
  935. title: book.title || book.bookName || '',
  936. isbn: book.isbn || '',
  937. author: book.author || '',
  938. publisher: book.publisher || '',
  939. className: book.className || ''
  940. })),
  941. pagination: pagination(page, [{ total: list.length }])
  942. })
  943. } catch (err) {
  944. return err.message || '查询趣选书书单失败'
  945. }
  946. }
  947. async submit_work_order(args) {
  948. const { sender, title, content, email = '', files = [] } = args
  949. if (required([sender, title, content])) return '缺少 sender、title 或 content'
  950. if (email && !this.emailRegex.test(email)) return '邮箱格式不正确'
  951. if (email && this.banEmailList.includes(email.split('@')[1].toLowerCase())) return '暂不支持该邮箱域名'
  952. const safeTitle = normalizeText(title, 80)
  953. const safeContent = normalizeText(content, 4000)
  954. const safeFiles = Array.isArray(files) ? files.map(item => String(item || '').trim()).filter(Boolean).slice(0, 6) : []
  955. if (!safeTitle || !safeContent) return '标题或内容不能为空'
  956. const time = Date.now()
  957. const msg = [
  958. {
  959. time,
  960. content: safeContent,
  961. files: safeFiles,
  962. uuid: sender,
  963. type: 'user'
  964. },
  965. {
  966. time,
  967. content: '您的问题已收到,我们将尽快处理,请耐心等待。',
  968. uuid: ASSISTANT_UUID,
  969. type: 'system'
  970. }
  971. ]
  972. const result = await db.query(
  973. 'INSERT INTO work_order (title, email, msg, create_user, create_time, update_time) VALUES (?, ?, ?, ?, ?, ?)',
  974. [safeTitle, email, msg, sender, time, time]
  975. )
  976. if (!result || result.affectedRows !== 1) return '提交工单失败'
  977. const kefuRows = await db.query(`
  978. SELECT email FROM users
  979. WHERE JSON_CONTAINS(permission, '"admin"') OR JSON_CONTAINS(permission, '"service"')
  980. `)
  981. const emails = [...new Set((kefuRows || []).map(row => row.email).filter(Boolean))]
  982. for (const targetEmail of emails) {
  983. await EmailTemplate.orderNewReply(targetEmail, { id: result.insertId, content: safeContent, files: safeFiles })
  984. }
  985. return asJson({ success: true, message: '工单提交成功,可在工单列表查看处理进度' })
  986. }
  987. }
  988. module.exports.AI_ASSISTANT_MCP = new AiAssistantMcp()