Browse Source

增强mcp校验

cq.tianyong 4 hours ago
parent
commit
9e179deb6a
2 changed files with 379 additions and 345 deletions
  1. 3 3
      apis/MCP/aiAssistantMcp/McpRPC.js
  2. 376 342
      lib/AIChat/AiAssistantMcp.js

+ 3 - 3
apis/MCP/aiAssistantMcp/McpRPC.js

@@ -96,13 +96,13 @@ const tools = [
             properties: {
             properties: {
                 sender,
                 sender,
                 student_num: { type: 'string', description: '乐跑学号/账号。必填,用于识别账号。' },
                 student_num: { type: 'string', description: '乐跑学号/账号。必填,用于识别账号。' },
-                email: { type: 'string', description: '该乐跑账号的通知邮箱。' },
+                email: { type: 'string', description: '该乐跑账号的通知邮箱。notice_type 为 email 时必填,且不能使用 icloud.com 域名。' },
                 area: { type: 'string', description: '要使用的跑区。用户要求随机跑区时使用空值。' },
                 area: { type: 'string', description: '要使用的跑区。用户要求随机跑区时使用空值。' },
                 auto_time: { type: 'integer', minimum: -1, maximum: 23, description: '自动乐跑开始小时,范围 7-21;使用 -1 表示随机时间。' },
                 auto_time: { type: 'integer', minimum: -1, maximum: 23, description: '自动乐跑开始小时,范围 7-21;使用 -1 表示随机时间。' },
                 auto_run: { type: 'integer', enum: [0, 1], description: '自动乐跑开关:1 开启,0 关闭。' },
                 auto_run: { type: 'integer', enum: [0, 1], description: '自动乐跑开关:1 开启,0 关闭。' },
-                target_count: { type: 'number', description: '目标总乐跑次数。达到目标后后端逻辑可能自动关闭自动乐跑。' },
+                target_count: { type: 'number', minimum: 0, maximum: 99, description: '目标总乐跑次数,范围 0-99。开启自动乐跑时,目标次数不能小于或等于账号已累计完成次数,除非设置为 0。' },
                 auto_day: { type: 'array', items: { type: 'integer', minimum: 0, maximum: 6 }, description: '自动乐跑星期。0 周日,1 周一,依此类推,6 周六。' },
                 auto_day: { type: 'array', items: { type: 'integer', minimum: 0, maximum: 6 }, description: '自动乐跑星期。0 周日,1 周一,依此类推,6 周六。' },
-                notice_type: { type: 'string', enum: ['email', 'none'], description: '乐跑后的通知渠道:email 邮件,none 不通知。' },
+                notice_type: { type: 'string', enum: ['email', 'none', 'bot'], description: '乐跑后的通知渠道:email 邮件,bot 机器人,none 不通知。' },
                 notes: { type: 'string', description: '该账号的用户可见备注。' }
                 notes: { type: 'string', description: '该账号的用户可见备注。' }
             },
             },
             required: ['sender', 'student_num']
             required: ['sender', 'student_num']

+ 376 - 342
lib/AIChat/AiAssistantMcp.js

@@ -1,20 +1,20 @@
-const db = require('../../plugin/DataBase/db')
-const { axiosWithQgOutbound } = require('../Lepao/qgOutboundAxios')
-const Logger = require('../Logger')
-const path = require('path')
-const QxsBookList = require('../../apis/QuXuanShu/GetBookList').GetBookList
-const EmailTemplate = require('../../plugin/Email/emailTemplate')
-
-const ASSISTANT_UUID = 'e4fe0277-0b1a-41a1-b25f-8b6e4cec3281'
-
-function required(values) {
-    return values.some(v => v === '' || v === null || v === undefined)
-}
-
-function isProvided(value) {
-    return value !== '' && value !== null && value !== undefined
-}
-
+const db = require('../../plugin/DataBase/db')
+const { axiosWithQgOutbound } = require('../Lepao/qgOutboundAxios')
+const Logger = require('../Logger')
+const path = require('path')
+const QxsBookList = require('../../apis/QuXuanShu/GetBookList').GetBookList
+const EmailTemplate = require('../../plugin/Email/emailTemplate')
+
+const ASSISTANT_UUID = 'e4fe0277-0b1a-41a1-b25f-8b6e4cec3281'
+
+function required(values) {
+    return values.some(v => v === '' || v === null || v === undefined)
+}
+
+function isProvided(value) {
+    return value !== '' && value !== null && value !== undefined
+}
+
 function pageArgs({ current = 1, pagesize = 10 } = {}, max = 20) {
 function pageArgs({ current = 1, pagesize = 10 } = {}, max = 20) {
     const c = Math.max(1, Number.parseInt(current, 10) || 1)
     const c = Math.max(1, Number.parseInt(current, 10) || 1)
     const p = Math.min(Math.max(1, Number.parseInt(pagesize, 10) || 10), max)
     const p = Math.min(Math.max(1, Number.parseInt(pagesize, 10) || 10), max)
@@ -65,52 +65,58 @@ function formatBeijingTime(value) {
 function parseJson(value, fallback = []) {
 function parseJson(value, fallback = []) {
     if (!value) return fallback
     if (!value) return fallback
     if (Array.isArray(value)) return value
     if (Array.isArray(value)) return value
-    try {
-        const parsed = JSON.parse(value)
-        return parsed == null ? fallback : parsed
-    } catch (_) {
-        return fallback
-    }
-}
-
-function stateText(value, map) {
-    return map[value] || String(value)
-}
-
+    try {
+        const parsed = JSON.parse(value)
+        return parsed == null ? fallback : parsed
+    } catch (_) {
+        return fallback
+    }
+}
+
+function stateText(value, map) {
+    return map[value] || String(value)
+}
+
 function normalizeText(value, max = 2000) {
 function normalizeText(value, max = 2000) {
     return String(value || '').trim().slice(0, max)
     return String(value || '').trim().slice(0, max)
 }
 }
 
 
-function normalizeRunArea(area) {
-    if (!isProvided(area)) return ''
-    const value = String(area).trim()
-    if (value === '随机分配') return ''
-    const allowed = new Set([
-        '兰花湖校区跑区',
-        '主校区北跑区',
-        '主校区南跑区',
-        '重庆工商大学茶园校区',
-        ''
-    ])
-    return allowed.has(value) ? value : null
+function normalizeNumber(value) {
+    if (!isProvided(value)) return null
+    const numberValue = Number(value)
+    return Number.isFinite(numberValue) ? numberValue : null
 }
 }
 
 
+function normalizeRunArea(area) {
+    if (!isProvided(area)) return ''
+    const value = String(area).trim()
+    if (value === '随机分配') return ''
+    const allowed = new Set([
+        '兰花湖校区跑区',
+        '主校区北跑区',
+        '主校区南跑区',
+        '重庆工商大学茶园校区',
+        ''
+    ])
+    return allowed.has(value) ? value : null
+}
+
 function mapLepaoAccount(row) {
 function mapLepaoAccount(row) {
-    return {
-        student_num: row.student_num,
-        name: row.name || '',
-        state: stateText(row.state, { 0: '需登录', 1: '正常', 2: '异常' }),
-        area: row.area || '随机分配',
-        auto_run: row.auto_run === 1,
-        auto_time: row.auto_time,
-        today_auto_time: row.today_auto_time,
-        target_count: row.target_count,
-        total_num: row.total_num,
-        term_num: row.term_num,
-        auto_day: parseJson(row.auto_day, []),
-        notice_type: row.notice_type,
-        email: row.email || '',
-        notes: row.notes || ''
+    return {
+        student_num: row.student_num,
+        name: row.name || '',
+        state: stateText(row.state, { 0: '需登录', 1: '正常', 2: '异常' }),
+        area: row.area || '随机分配',
+        auto_run: row.auto_run === 1,
+        auto_time: row.auto_time,
+        today_auto_time: row.today_auto_time,
+        target_count: row.target_count,
+        total_num: row.total_num,
+        term_num: row.term_num,
+        auto_day: parseJson(row.auto_day, []),
+        notice_type: row.notice_type,
+        email: row.email || '',
+        notes: row.notes || ''
     }
     }
 }
 }
 
 
@@ -162,38 +168,38 @@ function mapPowerRecord(row) {
         change_time: formatBeijingTime(row.change_time)
         change_time: formatBeijingTime(row.change_time)
     }
     }
 }
 }
-
-class AiAssistantMcp {
-    constructor() {
-        this.logger = new Logger(path.join(__dirname, '../../logs/AiAssistantMcp.log'), 'INFO')
-        this.emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
-        this.banEmailList = ['icloud.com']
-    }
-
-    async list_lepao_accounts(args) {
-        const { sender, student_num, name, area, state, auto_run, current, pagesize } = args
-        if (required([sender])) return '缺少 sender'
-        const page = pageArgs({ current, pagesize })
-        const where = ['create_user = ?']
-        const params = [sender]
-        if (student_num) {
-            where.push('student_num LIKE ?')
-            params.push(`%${student_num}%`)
-        }
-        if (name) {
-            where.push('name LIKE ?')
-            params.push(`%${name}%`)
-        }
-        if (area !== undefined && area !== null && area !== '') {
-            const normalizedArea = normalizeRunArea(area)
-            if (normalizedArea === null) return '跑区不合法'
-            where.push('area = ?')
-            params.push(normalizedArea)
-        }
-        if (state !== undefined && state !== null && state !== '' && Number(state) !== -1) {
-            where.push('state = ?')
-            params.push(state)
-        }
+
+class AiAssistantMcp {
+    constructor() {
+        this.logger = new Logger(path.join(__dirname, '../../logs/AiAssistantMcp.log'), 'INFO')
+        this.emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
+        this.banEmailList = ['icloud.com']
+    }
+
+    async list_lepao_accounts(args) {
+        const { sender, student_num, name, area, state, auto_run, current, pagesize } = args
+        if (required([sender])) return '缺少 sender'
+        const page = pageArgs({ current, pagesize })
+        const where = ['create_user = ?']
+        const params = [sender]
+        if (student_num) {
+            where.push('student_num LIKE ?')
+            params.push(`%${student_num}%`)
+        }
+        if (name) {
+            where.push('name LIKE ?')
+            params.push(`%${name}%`)
+        }
+        if (area !== undefined && area !== null && area !== '') {
+            const normalizedArea = normalizeRunArea(area)
+            if (normalizedArea === null) return '跑区不合法'
+            where.push('area = ?')
+            params.push(normalizedArea)
+        }
+        if (state !== undefined && state !== null && state !== '' && Number(state) !== -1) {
+            where.push('state = ?')
+            params.push(state)
+        }
         if (auto_run !== undefined && auto_run !== null && auto_run !== '') {
         if (auto_run !== undefined && auto_run !== null && auto_run !== '') {
             where.push('auto_run = ?')
             where.push('auto_run = ?')
             params.push(Number(auto_run))
             params.push(Number(auto_run))
@@ -207,38 +213,38 @@ class AiAssistantMcp {
         const rows = await db.query(
         const rows = await db.query(
             `SELECT student_num, name, state, area, auto_run, auto_time, today_auto_time,
             `SELECT student_num, name, state, area, auto_run, auto_time, today_auto_time,
                     target_count, total_num, term_num, auto_day, notice_type, email, notes
                     target_count, total_num, term_num, auto_day, notice_type, email, notes
-             FROM lepao_account
-             WHERE ${where.join(' AND ')}
-             ORDER BY update_time DESC
+             FROM lepao_account
+             WHERE ${where.join(' AND ')}
+             ORDER BY update_time DESC
              LIMIT ? OFFSET ?`,
              LIMIT ? OFFSET ?`,
             [...params, String(page.pagesize), String(page.offset)]
             [...params, String(page.pagesize), String(page.offset)]
         )
         )
         if (!rows || !totalRows) return '查询失败'
         if (!rows || !totalRows) return '查询失败'
         return asJson({ list: rows.map(mapLepaoAccount), pagination: pagination(page, totalRows) })
         return asJson({ list: rows.map(mapLepaoAccount), pagination: pagination(page, totalRows) })
     }
     }
-
-    async list_lepao_records(args) {
-        const { sender, lepao_account, name, start_time, end_time, result, current, pagesize } = args
-        if (required([sender])) return '缺少 sender'
-        const page = pageArgs({ current, pagesize })
-        const where = ['(r.uuid = ? OR a.create_user = ?)']
-        const params = [sender, sender]
-        if (lepao_account) {
-            where.push('r.lepao_account LIKE ?')
-            params.push(`%${lepao_account}%`)
-        }
-        if (name) {
-            where.push('a.name LIKE ?')
-            params.push(`%${name}%`)
-        }
-        if (result) {
-            where.push('r.result LIKE ?')
-            params.push(`%${result}%`)
-        }
-        if (start_time) {
-            where.push('r.time >= ?')
-            params.push(start_time)
-        }
+
+    async list_lepao_records(args) {
+        const { sender, lepao_account, name, start_time, end_time, result, current, pagesize } = args
+        if (required([sender])) return '缺少 sender'
+        const page = pageArgs({ current, pagesize })
+        const where = ['(r.uuid = ? OR a.create_user = ?)']
+        const params = [sender, sender]
+        if (lepao_account) {
+            where.push('r.lepao_account LIKE ?')
+            params.push(`%${lepao_account}%`)
+        }
+        if (name) {
+            where.push('a.name LIKE ?')
+            params.push(`%${name}%`)
+        }
+        if (result) {
+            where.push('r.result LIKE ?')
+            params.push(`%${result}%`)
+        }
+        if (start_time) {
+            where.push('r.time >= ?')
+            params.push(start_time)
+        }
         if (end_time) {
         if (end_time) {
             where.push('r.time < ?')
             where.push('r.time < ?')
             params.push(end_time)
             params.push(end_time)
@@ -253,34 +259,34 @@ class AiAssistantMcp {
         const rows = await db.query(
         const rows = await db.query(
             `SELECT r.public_id, r.time, r.result, r.run_mode, r.lepao_account, a.name
             `SELECT r.public_id, r.time, r.result, r.run_mode, r.lepao_account, a.name
              FROM lepao_record r
              FROM lepao_record r
-             LEFT JOIN lepao_account a ON r.lepao_account = a.student_num
-             WHERE ${where.join(' AND ')}
-             ORDER BY r.id DESC
+             LEFT JOIN lepao_account a ON r.lepao_account = a.student_num
+             WHERE ${where.join(' AND ')}
+             ORDER BY r.id DESC
              LIMIT ? OFFSET ?`,
              LIMIT ? OFFSET ?`,
             [...params, String(page.pagesize), String(page.offset)]
             [...params, String(page.pagesize), String(page.offset)]
         )
         )
         if (!rows || !totalRows) return '查询失败'
         if (!rows || !totalRows) return '查询失败'
         return asJson({ list: rows.map(mapLepaoRecord), pagination: pagination(page, totalRows) })
         return asJson({ list: rows.map(mapLepaoRecord), pagination: pagination(page, totalRows) })
     }
     }
-
-    async list_lepao_count_ledger(args) {
-        const { sender, biz_type, remark, start_time, end_time, current, pagesize } = args
-        if (required([sender])) return '缺少 sender'
-        const page = pageArgs({ current, pagesize })
-        const where = ['user_uuid = ?']
-        const params = [sender]
-        if (biz_type) {
-            where.push('biz_type = ?')
-            params.push(biz_type)
-        }
-        if (remark) {
-            where.push('remark LIKE ?')
-            params.push(`%${remark}%`)
-        }
-        if (start_time) {
-            where.push('created_at >= FROM_UNIXTIME(? / 1000)')
-            params.push(start_time)
-        }
+
+    async list_lepao_count_ledger(args) {
+        const { sender, biz_type, remark, start_time, end_time, current, pagesize } = args
+        if (required([sender])) return '缺少 sender'
+        const page = pageArgs({ current, pagesize })
+        const where = ['user_uuid = ?']
+        const params = [sender]
+        if (biz_type) {
+            where.push('biz_type = ?')
+            params.push(biz_type)
+        }
+        if (remark) {
+            where.push('remark LIKE ?')
+            params.push(`%${remark}%`)
+        }
+        if (start_time) {
+            where.push('created_at >= FROM_UNIXTIME(? / 1000)')
+            params.push(start_time)
+        }
         if (end_time) {
         if (end_time) {
             where.push('created_at <= FROM_UNIXTIME(? / 1000)')
             where.push('created_at <= FROM_UNIXTIME(? / 1000)')
             params.push(end_time)
             params.push(end_time)
@@ -294,25 +300,25 @@ class AiAssistantMcp {
         const rows = await db.query(
         const rows = await db.query(
             `SELECT delta, balance_before, balance_after, biz_type, remark,
             `SELECT delta, balance_before, balance_after, biz_type, remark,
                     UNIX_TIMESTAMP(created_at) * 1000 AS created_at
                     UNIX_TIMESTAMP(created_at) * 1000 AS created_at
-             FROM lepao_count_ledger
-             WHERE ${where.join(' AND ')}
-             ORDER BY id DESC
+             FROM lepao_count_ledger
+             WHERE ${where.join(' AND ')}
+             ORDER BY id DESC
              LIMIT ? OFFSET ?`,
              LIMIT ? OFFSET ?`,
             [...params, String(page.pagesize), String(page.offset)]
             [...params, String(page.pagesize), String(page.offset)]
         )
         )
         if (!rows || !totalRows) return '查询失败'
         if (!rows || !totalRows) return '查询失败'
         return asJson({ list: rows.map(mapCountLedger), pagination: pagination(page, totalRows) })
         return asJson({ list: rows.map(mapCountLedger), pagination: pagination(page, totalRows) })
     }
     }
-
-    async list_goods(args) {
-        const { keyword, category, current, pagesize } = args
-        const page = pageArgs({ current, pagesize })
-        const where = ['state = 1']
-        const params = []
-        if (keyword) {
-            where.push('name LIKE ?')
-            params.push(`%${keyword}%`)
-        }
+
+    async list_goods(args) {
+        const { keyword, category, current, pagesize } = args
+        const page = pageArgs({ current, pagesize })
+        const where = ['state = 1']
+        const params = []
+        if (keyword) {
+            where.push('name LIKE ?')
+            params.push(`%${keyword}%`)
+        }
         if (category) {
         if (category) {
             where.push('category = ?')
             where.push('category = ?')
             params.push(category)
             params.push(category)
@@ -326,8 +332,8 @@ class AiAssistantMcp {
         const rows = await db.query(
         const rows = await db.query(
             `SELECT name, price, num, \`limit\`, description, category, features
             `SELECT name, price, num, \`limit\`, description, category, features
              FROM goods
              FROM goods
-             WHERE ${where.join(' AND ')}
-             ORDER BY id DESC
+             WHERE ${where.join(' AND ')}
+             ORDER BY id DESC
              LIMIT ? OFFSET ?`,
              LIMIT ? OFFSET ?`,
             [...params, String(page.pagesize), String(page.offset)]
             [...params, String(page.pagesize), String(page.offset)]
         )
         )
@@ -376,155 +382,183 @@ class AiAssistantMcp {
     }
     }
 
 
     async save_lepao_account(args) {
     async save_lepao_account(args) {
-        const { sender, student_num } = args
-        if (required([sender, student_num])) return '缺少 sender 或 student_num'
-
-        const existRows = await db.query(
-            `SELECT id, create_user, student_num, email, area, auto_time, auto_run, target_count, auto_day, notice_type, notes
+        const { sender, student_num } = args
+        if (required([sender, student_num])) return '缺少 sender 或 student_num'
+
+        const existRows = await db.query(
+            `SELECT id, create_user, student_num, email, area, auto_time, auto_run, target_count, total_num, auto_day, notice_type, notes
              FROM lepao_account
              FROM lepao_account
-             WHERE student_num = ?
-             LIMIT 1`,
-            [student_num]
-        )
-        if (!existRows) return '查询账号失败'
-        if (existRows.length > 0 && existRows[0].create_user && existRows[0].create_user !== sender) {
-            return '该乐跑账号已被其他用户绑定'
-        }
+             WHERE student_num = ?
+             LIMIT 1`,
+            [student_num]
+        )
+        if (!existRows) return '查询账号失败'
+        if (existRows.length > 0 && existRows[0].create_user && existRows[0].create_user !== sender) {
+            return '该乐跑账号已被其他用户绑定'
+        }
+
+        const existing = existRows[0] || {}
+        const targetCount = isProvided(args.target_count)
+            ? normalizeNumber(args.target_count)
+            : (existRows.length > 0 ? normalizeNumber(existing.target_count) : 30)
+        if (targetCount === null || targetCount < 0 || targetCount > 99) return '乐跑目标次数不在合法范围内'
+        args.target_count = targetCount
+        const noticeType = isProvided(args.notice_type) ? args.notice_type : (existing.notice_type || 'email')
+        const email = isProvided(args.email) ? String(args.email).trim() : (existing.email || '')
+        if (noticeType === 'email') {
+            if (!this.emailRegex.test(email)) return '邮箱格式不正确'
+            const emailDomain = email.split('@')[1].toLowerCase()
+            if (this.banEmailList.includes(emailDomain)) return `暂不支持使用 ${emailDomain} 域名的邮箱,请更换其他邮箱后重试`
+        } else if (email && !this.emailRegex.test(email)) {
+            return '邮箱格式不正确'
+        }
+
+        const autoRun = isProvided(args.auto_run) ? Number(args.auto_run) : (existRows.length > 0 ? Number(existing.auto_run) : 1)
+        if (![0, 1].includes(autoRun)) return 'auto_run 只能为 0 或 1'
+        args.auto_run = autoRun
+
+        const autoDay = isProvided(args.auto_day)
+            ? args.auto_day
+            : (existRows.length > 0 ? parseJson(existing.auto_day, []) : [0, 1, 2, 3, 4, 5, 6])
+        if (autoRun === 1 && (!Array.isArray(autoDay) || !autoDay.every(v => Number.isInteger(v) && v >= 0 && v <= 6))) return '自动乐跑星期不合法'
+        if (isProvided(args.auto_day)) args.auto_day = autoDay
+
+        const totalNum = normalizeNumber(existing.total_num) || 0
+        if (autoRun === 1 && totalNum >= targetCount && targetCount !== 0) return '该账号累计跑步次数已达到预设目标次数,请尝试增大目标次数后再试'
 
 
         const fields = []
         const fields = []
-        const params = []
-        const mutable = [
-            ['email', 'email'],
-            ['auto_time', 'auto_time'],
-            ['auto_run', 'auto_run'],
-            ['target_count', 'target_count'],
-            ['notice_type', 'notice_type'],
-            ['notes', 'notes']
-        ]
-
-        if (isProvided(args.area)) {
-            const area = normalizeRunArea(args.area)
-            if (area === null) return '跑区不合法'
-            fields.push('area = ?')
-            params.push(area)
-        }
-
-        if (isProvided(args.email) && !this.emailRegex.test(args.email)) return '邮箱格式不正确'
-        if (isProvided(args.notice_type) && !['email', 'none', 'bot'].includes(args.notice_type)) return '通知方式不合法'
-        if (isProvided(args.auto_time)) {
-            const autoTime = Number(args.auto_time)
-            if (!Number.isInteger(autoTime) || autoTime < -1 || autoTime > 23) return '自动乐跑时间不合法'
-            args.auto_time = autoTime
-        }
-        if (isProvided(args.auto_run) && ![0, 1].includes(Number(args.auto_run))) return 'auto_run 只能为 0 或 1'
-        if (isProvided(args.auto_day)) {
-            if (!Array.isArray(args.auto_day) || !args.auto_day.every(v => Number.isInteger(v) && v >= 0 && v <= 6)) return '自动乐跑星期不合法'
-            fields.push('auto_day = ?')
-            params.push(JSON.stringify(args.auto_day))
-        }
-        for (const [argName, column] of mutable) {
-            if (isProvided(args[argName])) {
-                fields.push(`${column} = ?`)
-                params.push(args[argName])
-            }
-        }
-
-        const now = Date.now()
-        let result
-        if (existRows.length > 0) {
-            if (fields.length === 0) return '没有需要修改的字段'
-            fields.push('update_time = ?', 'create_user = ?')
-            params.push(now, sender)
-            result = await db.query(
-                `UPDATE lepao_account SET ${fields.join(', ')} WHERE student_num = ? AND (create_user = ? OR create_user IS NULL)`,
-                [...params, student_num, sender]
-            )
-        } else {
-            const area = isProvided(args.area) ? normalizeRunArea(args.area) : ''
-            if (area === null) return '跑区不合法'
-            const autoDay = isProvided(args.auto_day) ? args.auto_day : [0, 1, 2, 3, 4, 5, 6]
-            if (!Array.isArray(autoDay) || !autoDay.every(v => Number.isInteger(v) && v >= 0 && v <= 6)) return '自动乐跑星期不合法'
-            const email = args.email || ''
-            if (email && !this.emailRegex.test(email)) return '邮箱格式不正确'
-            result = await db.query(
-                `INSERT INTO lepao_account
-                 (student_num, email, area, auto_time, auto_run, target_count, create_user, create_time, update_time, notes, auto_day, notice_type)
-                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
-                [
-                    student_num,
-                    email,
-                    area,
-                    isProvided(args.auto_time) ? Number(args.auto_time) : -1,
-                    isProvided(args.auto_run) ? Number(args.auto_run) : 1,
-                    isProvided(args.target_count) ? args.target_count : 30,
-                    sender,
-                    now,
-                    now,
-                    args.notes || '',
-                    JSON.stringify(autoDay),
-                    args.notice_type || 'email'
-                ]
-            )
-            const bindCode = Math.random().toString(36).slice(2, 8).toUpperCase()
-            await db.query('INSERT IGNORE INTO lepao_extra (student_num, bind_code) VALUES (?, ?)', [student_num, bindCode])
-        }
-        if (!result || result.affectedRows < 1) return '保存失败或无权修改'
-        return asJson({ success: true, student_num })
-    }
-
-    async save_power_task(args) {
-        const { sender, task_id, area, building, room } = args
-        if (required([sender])) return '缺少 sender'
-
-        const now = Date.now()
-        let existing = []
-        if (task_id) {
-            existing = await db.query('SELECT id, area, building, room FROM power_task WHERE id = ? AND create_user = ? LIMIT 1', [task_id, sender])
-        } else if ([area, building, room].every(isProvided)) {
-            existing = await db.query(
-                'SELECT id, area, building, room FROM power_task WHERE create_user = ? AND area = ? AND building = ? AND room = ? LIMIT 1',
-                [sender, area, building, room]
-            )
-        }
-        if (!existing) return '查询电费任务失败'
-
-        const fields = []
-        const params = []
-        for (const key of ['area', 'building', 'room', 'email', 'lowest', 'notes']) {
-            if (isProvided(args[key])) {
-                fields.push(`${key} = ?`)
-                params.push(args[key])
-            }
-        }
-
-        let result
-        if (existing.length > 0) {
-            if (fields.length === 0) return '没有需要修改的字段'
-            fields.push('is_notice = 0', 'update_time = ?')
-            params.push(now)
-            result = await db.query(
-                `UPDATE power_task SET ${fields.join(', ')} WHERE id = ? AND create_user = ?`,
-                [...params, existing[0].id, sender]
-            )
-        } else {
-            if (required([area, building, room, args.lowest])) return '新增电费任务需提供 area、building、room、lowest'
-            let balance = ''
-            let koufeiDate = ''
+        const params = []
+        const mutable = [
+            ['email', 'email'],
+            ['auto_time', 'auto_time'],
+            ['auto_run', 'auto_run'],
+            ['target_count', 'target_count'],
+            ['notice_type', 'notice_type'],
+            ['notes', 'notes']
+        ]
+
+        if (isProvided(args.area)) {
+            const area = normalizeRunArea(args.area)
+            if (area === null) return '跑区不合法'
+            fields.push('area = ?')
+            params.push(area)
+        }

+        if (isProvided(args.notice_type) && !['email', 'none', 'bot'].includes(args.notice_type)) return '通知方式不合法'
+        if (isProvided(args.auto_time)) {
+            const autoTime = Number(args.auto_time)
+            if (!Number.isInteger(autoTime) || autoTime < -1 || autoTime > 23) return '自动乐跑时间不合法'
+            args.auto_time = autoTime
+        }

+        if (isProvided(args.auto_day)) {

+            fields.push('auto_day = ?')
+            params.push(JSON.stringify(args.auto_day))
+        }
+        for (const [argName, column] of mutable) {
+            if (isProvided(args[argName])) {
+                fields.push(`${column} = ?`)
+                params.push(args[argName])
+            }
+        }
+
+        const now = Date.now()
+        let result
+        if (existRows.length > 0) {
+            if (fields.length === 0) return '没有需要修改的字段'
+            fields.push('update_time = ?', 'create_user = ?')
+            params.push(now, sender)
+            result = await db.query(
+                `UPDATE lepao_account SET ${fields.join(', ')} WHERE student_num = ? AND (create_user = ? OR create_user IS NULL)`,
+                [...params, student_num, sender]
+            )
+        } else {
+            const area = isProvided(args.area) ? normalizeRunArea(args.area) : ''
+            if (area === null) return '跑区不合法'
+            const autoDay = isProvided(args.auto_day) ? args.auto_day : [0, 1, 2, 3, 4, 5, 6]
+            if (!Array.isArray(autoDay) || !autoDay.every(v => Number.isInteger(v) && v >= 0 && v <= 6)) return '自动乐跑星期不合法'
+            const email = args.email || ''
+            if (email && !this.emailRegex.test(email)) return '邮箱格式不正确'
+            result = await db.query(
+                `INSERT INTO lepao_account
+                 (student_num, email, area, auto_time, auto_run, target_count, create_user, create_time, update_time, notes, auto_day, notice_type)
+                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+                [
+                    student_num,
+                    email,
+                    area,
+                    isProvided(args.auto_time) ? Number(args.auto_time) : -1,
+                    isProvided(args.auto_run) ? Number(args.auto_run) : 1,
+                    isProvided(args.target_count) ? args.target_count : 30,
+                    sender,
+                    now,
+                    now,
+                    args.notes || '',
+                    JSON.stringify(autoDay),
+                    args.notice_type || 'email'
+                ]
+            )
+            const bindCode = Math.random().toString(36).slice(2, 8).toUpperCase()
+            await db.query('INSERT IGNORE INTO lepao_extra (student_num, bind_code) VALUES (?, ?)', [student_num, bindCode])
+        }
+        if (!result || result.affectedRows < 1) return '保存失败或无权修改'
+        return asJson({ success: true, student_num })
+    }
+
+    async save_power_task(args) {
+        const { sender, task_id, area, building, room } = args
+        if (required([sender])) return '缺少 sender'
+
+        const now = Date.now()
+        let existing = []
+        if (task_id) {
+            existing = await db.query('SELECT id, area, building, room FROM power_task WHERE id = ? AND create_user = ? LIMIT 1', [task_id, sender])
+        } else if ([area, building, room].every(isProvided)) {
+            existing = await db.query(
+                'SELECT id, area, building, room FROM power_task WHERE create_user = ? AND area = ? AND building = ? AND room = ? LIMIT 1',
+                [sender, area, building, room]
+            )
+        }
+        if (!existing) return '查询电费任务失败'
+
+        const fields = []
+        const params = []
+        for (const key of ['area', 'building', 'room', 'email', 'lowest', 'notes']) {
+            if (isProvided(args[key])) {
+                fields.push(`${key} = ?`)
+                params.push(args[key])
+            }
+        }
+
+        let result
+        if (existing.length > 0) {
+            if (fields.length === 0) return '没有需要修改的字段'
+            fields.push('is_notice = 0', 'update_time = ?')
+            params.push(now)
+            result = await db.query(
+                `UPDATE power_task SET ${fields.join(', ')} WHERE id = ? AND create_user = ?`,
+                [...params, existing[0].id, sender]
+            )
+        } else {
+            if (required([area, building, room, args.lowest])) return '新增电费任务需提供 area、building、room、lowest'
+            let balance = ''
+            let koufeiDate = ''
             try {
             try {
                 const endpoint = `https://hqpay.ctbu.edu.cn/weixin/ashx/frmuser.ashx?test=lastlist&pid=${room}&dyid=${building}`
                 const endpoint = `https://hqpay.ctbu.edu.cn/weixin/ashx/frmuser.ashx?test=lastlist&pid=${room}&dyid=${building}`
                 const apiRes = await axiosWithQgOutbound({ method: 'get', url: endpoint, timeout: 15000, logger: this.logger, scene: 'AiAssistantPowerAccount' })
                 const apiRes = await axiosWithQgOutbound({ method: 'get', url: endpoint, timeout: 15000, logger: this.logger, scene: 'AiAssistantPowerAccount' })
-                balance = apiRes?.data?.[0]?.[1] ?? ''
-                koufeiDate = apiRes?.data?.[0]?.[2] ?? ''
-            } catch (_) { }
-            result = await db.query(
-                'INSERT INTO power_task (create_user, create_time, update_time, area, building, room, email, lowest, notes, balance, koufei_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
-                [sender, now, now, area, building, room, args.email || '', args.lowest, args.notes || '', balance, koufeiDate]
-            )
-        }
-        if (!result || result.affectedRows < 1) return '保存失败或无权修改'
-        return asJson({ success: true, area, building, room })
-    }
-
+                if (!apiRes || !apiRes.data || !apiRes.data[0]) return '获取电费信息失败,请稍后再试'
+                balance = apiRes.data[0][1]
+                koufeiDate = apiRes.data[0][2]
+            } catch (_) {
+                return '获取电费信息失败,请稍后再试'
+            }
+            result = await db.query(
+                'INSERT INTO power_task (create_user, create_time, update_time, area, building, room, email, lowest, notes, balance, koufei_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
+                [sender, now, now, area, building, room, args.email || '', args.lowest, args.notes || '', balance, koufeiDate]
+            )
+        }
+        if (!result || result.affectedRows < 1) return '保存失败或无权修改'
+        return asJson({ success: true, area, building, room })
+    }
+
     async list_power_bills(args) {
     async list_power_bills(args) {
         const { sender, area, building, room, current, pagesize } = args
         const { sender, area, building, room, current, pagesize } = args
         if (required([sender])) return 'missing sender'
         if (required([sender])) return 'missing sender'
@@ -629,51 +663,51 @@ class AiAssistantMcp {
         } catch (err) {
         } catch (err) {
             return err.message || '查询趣选书书单失败'
             return err.message || '查询趣选书书单失败'
         }
         }
-    }
-
-    async submit_work_order(args) {
-        const { sender, title, content, email = '', files = [] } = args
-        if (required([sender, title, content])) return '缺少 sender、title 或 content'
-        if (email && !this.emailRegex.test(email)) return '邮箱格式不正确'
-        if (email && this.banEmailList.includes(email.split('@')[1].toLowerCase())) return '暂不支持该邮箱域名'
-
-        const safeTitle = normalizeText(title, 80)
-        const safeContent = normalizeText(content, 4000)
-        const safeFiles = Array.isArray(files) ? files.map(item => String(item || '').trim()).filter(Boolean).slice(0, 6) : []
-        if (!safeTitle || !safeContent) return '标题或内容不能为空'
-
-        const time = Date.now()
-        const msg = [
-            {
-                time,
-                content: safeContent,
-                files: safeFiles,
-                uuid: sender,
-                type: 'user'
-            },
-            {
-                time,
-                content: '您的问题已收到,我们将尽快处理,请耐心等待。',
-                uuid: ASSISTANT_UUID,
-                type: 'system'
-            }
-        ]
-        const result = await db.query(
-            'INSERT INTO work_order (title, email, msg, create_user, create_time, update_time) VALUES (?, ?, ?, ?, ?, ?)',
-            [safeTitle, email, msg, sender, time, time]
-        )
-        if (!result || result.affectedRows !== 1) return '提交工单失败'
-
-        const kefuRows = await db.query(`
-            SELECT email FROM users
-            WHERE JSON_CONTAINS(permission, '"admin"') OR JSON_CONTAINS(permission, '"service"')
-        `)
-        const emails = [...new Set((kefuRows || []).map(row => row.email).filter(Boolean))]
-        for (const targetEmail of emails) {
-            await EmailTemplate.orderNewReply(targetEmail, { id: result.insertId, content: safeContent, files: safeFiles })
-        }
-        return asJson({ success: true, message: '工单提交成功,可在工单列表查看处理进度' })
-    }
-}
-
-module.exports.AI_ASSISTANT_MCP = new AiAssistantMcp()
+    }
+
+    async submit_work_order(args) {
+        const { sender, title, content, email = '', files = [] } = args
+        if (required([sender, title, content])) return '缺少 sender、title 或 content'
+        if (email && !this.emailRegex.test(email)) return '邮箱格式不正确'
+        if (email && this.banEmailList.includes(email.split('@')[1].toLowerCase())) return '暂不支持该邮箱域名'
+
+        const safeTitle = normalizeText(title, 80)
+        const safeContent = normalizeText(content, 4000)
+        const safeFiles = Array.isArray(files) ? files.map(item => String(item || '').trim()).filter(Boolean).slice(0, 6) : []
+        if (!safeTitle || !safeContent) return '标题或内容不能为空'
+
+        const time = Date.now()
+        const msg = [
+            {
+                time,
+                content: safeContent,
+                files: safeFiles,
+                uuid: sender,
+                type: 'user'
+            },
+            {
+                time,
+                content: '您的问题已收到,我们将尽快处理,请耐心等待。',
+                uuid: ASSISTANT_UUID,
+                type: 'system'
+            }
+        ]
+        const result = await db.query(
+            'INSERT INTO work_order (title, email, msg, create_user, create_time, update_time) VALUES (?, ?, ?, ?, ?, ?)',
+            [safeTitle, email, msg, sender, time, time]
+        )
+        if (!result || result.affectedRows !== 1) return '提交工单失败'
+
+        const kefuRows = await db.query(`
+            SELECT email FROM users
+            WHERE JSON_CONTAINS(permission, '"admin"') OR JSON_CONTAINS(permission, '"service"')
+        `)
+        const emails = [...new Set((kefuRows || []).map(row => row.email).filter(Boolean))]
+        for (const targetEmail of emails) {
+            await EmailTemplate.orderNewReply(targetEmail, { id: result.insertId, content: safeContent, files: safeFiles })
+        }
+        return asJson({ success: true, message: '工单提交成功,可在工单列表查看处理进度' })
+    }
+}
+
+module.exports.AI_ASSISTANT_MCP = new AiAssistantMcp()