Browse Source

✨ feat: 增加微信对话能力

Pchen. 5 hours ago
parent
commit
e1dc8ab0be

+ 2 - 1
apis/AIChat/Admin/List.js

@@ -11,13 +11,14 @@ class AdminAIChatList extends API {
     }
 
     async onRequest(req, res) {
-        const { id, conversation_no, create_user, username, state = -1, queryTime, current = 1, pagesize = 20 } = req.query
+        const { id, conversation_no, create_user, username, state = -1, channel, queryTime, current = 1, pagesize = 20 } = req.query
         const result = await ConversationService.listAdminConversations({
             id,
             conversation_no,
             create_user,
             username,
             state,
+            channel,
             queryTime,
             current,
             pagesize

+ 19 - 0
apis/AIChat/Admin/WeixinBindingList.js

@@ -0,0 +1,19 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const WeixinBindingService = require('../../../lib/AIChat/WeixinBindingService')
+
+class AdminWeixinBindingList extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/AIChat/WeixinBinding/List')
+        this.setMethod('GET')
+        this.setPermissionCode('page.admin.aiChat')
+    }
+
+    async onRequest(req, res) {
+        const result = await WeixinBindingService.listAdminBindings(req.query || {})
+        return res.json({ ...BaseStdResponse.OK, ...result })
+    }
+}
+
+module.exports.AdminWeixinBindingList = AdminWeixinBindingList

+ 160 - 0
apis/AIChat/AssistantDashboard.js

@@ -0,0 +1,160 @@
+const API = require('../../lib/API')
+const db = require('../../plugin/DataBase/db')
+const AccessControl = require('../../lib/AccessControl')
+const { BaseStdResponse } = require('../../BaseStdResponse')
+const { checkQuota } = require('../../lib/AIChat/QuotaService')
+const { getUserVipInfo } = require('../../lib/VipService')
+const WeixinBindingService = require('../../lib/AIChat/WeixinBindingService')
+
+function getSemesterRange() {
+    const now = new Date()
+    const year = now.getFullYear()
+    const feb1 = new Date(year, 1, 1, 0, 0, 0, 0)
+    const aug31 = new Date(year, 7, 31, 0, 0, 0, 0)
+    const start = now >= feb1 && now < aug31
+        ? feb1
+        : new Date(now < feb1 ? year - 1 : year, 7, 31, 0, 0, 0, 0)
+    const end = new Date(now.getTime() + 24 * 60 * 60 * 1000)
+    return { start: start.getTime(), end: end.getTime() }
+}
+
+function compactQuota(quota) {
+    const configured = Boolean(quota?.configured)
+    const limit = Number(quota?.limit || 0)
+    const used = Number(quota?.used || 0)
+    const remaining = Number(quota?.remaining || Math.max(0, limit - used))
+    return {
+        configured,
+        limit,
+        used,
+        remaining,
+        percent: configured && limit > 0 ? Math.max(0, Math.min(100, Math.round((remaining / limit) * 100))) : 0
+    }
+}
+
+function dateKey(date) {
+    const month = String(date.getMonth() + 1).padStart(2, '0')
+    const day = String(date.getDate()).padStart(2, '0')
+    return `${month}-${day}`
+}
+
+function fillTrend(rows) {
+    const rowMap = new Map((rows || []).map(row => [row.day_label, row]))
+    const today = new Date()
+    const list = []
+    for (let i = 13; i >= 0; i--) {
+        const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() - i)
+        const key = dateKey(date)
+        const row = rowMap.get(key) || {}
+        list.push({
+            date: key,
+            delta: Number(row.delta || 0),
+            income: Number(row.income || 0),
+            expense: Math.abs(Number(row.expense || 0))
+        })
+    }
+    return list
+}
+
+class AssistantDashboard extends API {
+    constructor() {
+        super()
+        this.setPath('/AIChat/Assistant/Dashboard')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session } = req.query
+        if ([uuid, session].some(value => value === '' || value === null || value === undefined)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        }
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        }
+
+        const semester = getSemesterRange()
+        const [userRows, accountRows, ledgerRows, webQuota, wechatQuota, vipInfo, binding] = await Promise.all([
+            db.query('SELECT ic_count, lepao_count, vip, vip_expire_time FROM users WHERE uuid = ? LIMIT 1', [uuid]),
+            db.query(
+                `SELECT
+                    COUNT(*) AS total,
+                    COALESCE(SUM(CASE WHEN state = 1 THEN 1 ELSE 0 END), 0) AS normal,
+                    COALESCE(SUM(CASE WHEN state = 1 THEN 0 ELSE 1 END), 0) AS need_login,
+                    COALESCE(SUM(CASE WHEN auto_run = 1 THEN 1 ELSE 0 END), 0) AS auto_run
+                 FROM lepao_account
+                 WHERE create_user = ?
+                   AND ((update_time >= ? AND update_time < ?) OR (create_time >= ? AND create_time < ?))`,
+                [uuid, semester.start, semester.end, semester.start, semester.end]
+            ),
+            db.query(
+                `SELECT
+                    DATE_FORMAT(created_at, '%m-%d') AS day_label,
+                    COALESCE(SUM(delta), 0) AS delta,
+                    COALESCE(SUM(CASE WHEN delta > 0 THEN delta ELSE 0 END), 0) AS income,
+                    COALESCE(SUM(CASE WHEN delta < 0 THEN delta ELSE 0 END), 0) AS expense
+                 FROM lepao_count_ledger
+                 WHERE user_uuid = ? AND created_at >= DATE_SUB(CURDATE(), INTERVAL 13 DAY)
+                 GROUP BY DATE_FORMAT(created_at, '%m-%d')
+                 ORDER BY MIN(created_at) ASC`,
+                [uuid]
+            ),
+            checkQuota({ uuid, channel: 'web' }),
+            checkQuota({ uuid, channel: 'wechat' }),
+            getUserVipInfo(uuid),
+            WeixinBindingService.getBindingByUser(uuid)
+        ])
+
+        if (!userRows || !accountRows || !ledgerRows) {
+            return res.json({ ...BaseStdResponse.DATABASE_ERR })
+        }
+
+        const user = userRows[0] || {}
+        const account = accountRows[0] || {}
+        const total = Number(account.total || 0)
+        const normal = Number(account.normal || 0)
+        const needLogin = Number(account.need_login || 0)
+        const autoRun = Number(account.auto_run || 0)
+        const compactBinding = binding ? {
+            state: Number(binding.state),
+            bound: Number(binding.state) === 1,
+            last_user_message_time: Number(binding.last_user_message_time || 0),
+            bind_time: Number(binding.bind_time || 0),
+            last_error: binding.last_error || ''
+        } : null
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: {
+                user: {
+                    lepao_count: Number(user.lepao_count || 0),
+                    ic_count: Number(user.ic_count || 0),
+                    vip: Boolean(vipInfo.vip),
+                    vip_expire_time: Number(vipInfo.vip_expire_time || 0)
+                },
+                semester: {
+                    start: semester.start,
+                    end: semester.end
+                },
+                account: {
+                    total,
+                    normal,
+                    need_login: needLogin,
+                    auto_run: autoRun,
+                    manual: Math.max(0, total - autoRun),
+                    normal_percent: total > 0 ? Math.round((normal / total) * 100) : 0,
+                    auto_percent: total > 0 ? Math.round((autoRun / total) * 100) : 0
+                },
+                quota: {
+                    vip: Boolean(vipInfo.vip),
+                    vip_expire_time: Number(vipInfo.vip_expire_time || 0),
+                    web: compactQuota(webQuota),
+                    wechat: compactQuota(wechatQuota)
+                },
+                weixin: compactBinding,
+                ledger_trend: fillTrend(ledgerRows)
+            }
+        })
+    }
+}
+
+module.exports.AssistantDashboard = AssistantDashboard

+ 50 - 6
apis/AIChat/Message/Send.js

@@ -1,8 +1,10 @@
 const API = require('../../../lib/API')
 const { BaseStdResponse } = require('../../../BaseStdResponse')
 const AccessControl = require('../../../lib/AccessControl')
+const db = require('../../../plugin/DataBase/db')
 const OneBotV11 = require('../../../plugin/OneBot/OneBotV11')
 const { ConversationService } = require('../../../lib/AIChat/ConversationService')
+const { checkQuota, consumeQuota } = require('../../../lib/AIChat/QuotaService')
 
 class SendMessage extends API {
     constructor() {
@@ -20,14 +22,47 @@ class SendMessage extends API {
             return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
         }
 
+        const cleanContent = String(content || '').trim()
+        const cleanImages = Array.isArray(images) ? images.filter(Boolean) : []
+        if (!cleanContent && cleanImages.length === 0) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER, msg: '请输入消息或上传图片' })
+        }
+        if (conversation_id) {
+            const rows = await db.query(
+                'SELECT channel FROM ai_chat_conversation WHERE id = ? AND create_user = ? AND state = 1 LIMIT 1',
+                [conversation_id, uuid]
+            )
+            if (!rows || rows.length !== 1) return res.json({ ...BaseStdResponse.ERR, msg: '会话不存在或无权发送' })
+            if (rows[0].channel === 'wechat') return res.json({ ...BaseStdResponse.ERR, msg: '微信会话仅支持查看,请在微信内继续对话' })
+        }
+
+        const quotaCheck = await checkQuota({ uuid, channel: 'web' })
+        if (!quotaCheck.allowed) {
+            return res.json({ ...BaseStdResponse.ERR, msg: quotaCheck.message || '今日与小妍助理聊天次数已达上限,请明天再试' })
+        }
+
         const saved = await ConversationService.addUserMessage({
             uuid,
             conversationId: conversation_id,
-            content,
-            images
+            content: cleanContent,
+            images: cleanImages,
+            channel: 'web'
         })
         if (!saved) return res.json({ ...BaseStdResponse.ERR, msg: '会话不存在或无权发送' })
         if (saved.missingContent) return res.json({ ...BaseStdResponse.MISSING_PARAMETER, msg: '请输入消息或上传图片' })
+        if (saved.readonly) return res.json({ ...BaseStdResponse.ERR, msg: '微信会话仅支持查看,请在微信内继续对话' })
+
+        const quota = await consumeQuota({ uuid, channel: 'web' })
+        if (!quota.allowed) {
+            await ConversationService.addSystemMessage({
+                conversationId: saved.conversationId,
+                content: quota.message || '今日与小妍助理聊天次数已达上限,请明天再试',
+                status: 'error',
+                errorMsg: 'quota exceeded',
+                channel: 'web'
+            })
+            return res.json({ ...BaseStdResponse.ERR, msg: quota.message || '今日与小妍助理聊天次数已达上限,请明天再试' })
+        }
 
         res.json({
             ...BaseStdResponse.OK,
@@ -36,7 +71,14 @@ class SendMessage extends API {
                 conversation_id: saved.conversationId,
                 conversation_no: saved.conversationNo,
                 conversation: saved.conversation,
-                message: saved.message
+                message: saved.message,
+                quota: {
+                    limit: quota.limit,
+                    used: quota.used,
+                    remaining: quota.remaining,
+                    channel: 'web',
+                    vip: quota.vip
+                }
             }
         })
 
@@ -46,15 +88,17 @@ class SendMessage extends API {
                 conversationNo: saved.conversationNo,
                 senderUuid: uuid,
                 content: saved.content,
-                images: saved.images
+                images: saved.images,
+                channel: 'web'
             })
         } catch (err) {
-            this.logger.error(`AIChat OneBot 转发失败: ${err.stack || err}`)
+            this.logger.error(`AIChat OneBot forward failed: ${err.stack || err}`)
             await ConversationService.addSystemMessage({
                 conversationId: saved.conversationId,
                 content: '消息已保存,但暂时无法连接小妍助理,请稍后再试。',
                 status: 'error',
-                errorMsg: err.message || 'OneBot send failed'
+                errorMsg: err.message || 'OneBot send failed',
+                channel: 'web'
             })
         }
     }

+ 34 - 0
apis/AIChat/Quota.js

@@ -0,0 +1,34 @@
+const API = require('../../lib/API')
+const { BaseStdResponse } = require('../../BaseStdResponse')
+const AccessControl = require('../../lib/AccessControl')
+const { checkQuota } = require('../../lib/AIChat/QuotaService')
+
+class GetAIChatQuota extends API {
+    constructor() {
+        super()
+        this.setPath('/AIChat/Quota')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session } = req.query
+        if ([uuid, session].some(v => v === '' || v === null || v === undefined)) return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        if (!await AccessControl.checkSession(uuid, session)) return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        const [web, wechat] = await Promise.all([
+            checkQuota({ uuid, channel: 'web' }),
+            checkQuota({ uuid, channel: 'wechat' })
+        ])
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: {
+                web: { configured: web.configured, allowed: web.allowed, limit: web.limit, used: web.used, remaining: web.remaining, vip: web.vip },
+                wechat: { configured: wechat.configured, allowed: wechat.allowed, limit: wechat.limit, used: wechat.used, remaining: wechat.remaining, vip: wechat.vip },
+                vip: Boolean(web.vip || wechat.vip),
+                vip_expire_time: Math.max(Number(web.vip_expire_time || 0), Number(wechat.vip_expire_time || 0)),
+                message: web.message || wechat.message
+            }
+        })
+    }
+}
+
+module.exports.GetAIChatQuota = GetAIChatQuota

+ 60 - 0
apis/AIChat/Weixin/Binding.js

@@ -0,0 +1,60 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const AccessControl = require('../../../lib/AccessControl')
+const WeixinBindingService = require('../../../lib/AIChat/WeixinBindingService')
+
+class GetWeixinBinding extends API {
+    constructor() {
+        super()
+        this.setPath('/AIChat/Weixin/Binding')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session } = req.query
+        if ([uuid, session].some(v => v === '' || v === null || v === undefined)) return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        if (!await AccessControl.checkSession(uuid, session)) return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        const binding = await WeixinBindingService.refreshQrStatus(uuid)
+            || await WeixinBindingService.getBindingByUser(uuid)
+        return res.json({ ...BaseStdResponse.OK, data: binding })
+    }
+}
+
+class CreateWeixinBinding extends API {
+    constructor() {
+        super()
+        this.setPath('/AIChat/Weixin/Binding')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session } = req.body
+        if ([uuid, session].some(v => v === '' || v === null || v === undefined)) return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        if (!await AccessControl.checkSession(uuid, session)) return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        const result = await WeixinBindingService.createBinding(uuid)
+        if (!result.ok) return res.json({ ...BaseStdResponse.ERR, msg: result.msg })
+        return res.json({ ...BaseStdResponse.OK, data: result.data })
+    }
+}
+
+class DeleteWeixinBinding extends API {
+    constructor() {
+        super()
+        this.setPath('/AIChat/Weixin/Binding')
+        this.setMethod('DELETE')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session } = req.body
+        if ([uuid, session].some(v => v === '' || v === null || v === undefined)) return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        if (!await AccessControl.checkSession(uuid, session)) return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        await WeixinBindingService.disableBinding(uuid)
+        return res.json({ ...BaseStdResponse.OK })
+    }
+}
+
+module.exports = {
+    GetWeixinBinding,
+    CreateWeixinBinding,
+    DeleteWeixinBinding
+}

+ 16 - 6
apis/Goods/Admin/AddGoods.js

@@ -27,7 +27,12 @@ class AddProduct extends API {
             ic_count,
             icon,
             description,
-            features
+            features,
+            vip = 0,
+            vip_validity_type = 'none',
+            vip_valid_days = 0,
+            vip_fixed_expire_time = 0,
+            allow_refund = 1
         } = req.body
 
         const goodsIcon = (icon && String(icon).trim()) ? String(icon).trim().slice(0, 16) : '🏃'
@@ -42,6 +47,11 @@ class AddProduct extends API {
                 goodsFeatures = '[]'
             }
         }
+        const safeVip = Number(vip) === 1 ? 1 : 0
+        const safeVipType = ['none', 'days', 'fixed'].includes(String(vip_validity_type)) ? String(vip_validity_type) : 'none'
+        const safeVipDays = Math.max(0, Number(vip_valid_days || 0))
+        const safeVipFixedExpire = Math.max(0, Number(vip_fixed_expire_time || 0))
+        const safeAllowRefund = Number(allow_refund) === 0 ? 0 : 1
 
         if ([uuid, session, name, state, content, price, num, lepao_count, ic_count].some(value => value === '' || value === null || value === undefined))
             return res.json({
@@ -65,11 +75,11 @@ class AddProduct extends API {
         const time = new Date().getTime()
 
         if (!id) {
-            sql = 'INSERT INTO goods (name, create_user, create_time, update_time, state, content, price, lepao_count, ic_count, num, icon, description, features) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
-            r = await db.query(sql, [name, uuid, time, time, state, content, price, lepao_count, ic_count, num, goodsIcon, goodsDesc, goodsFeatures])
+            sql = 'INSERT INTO goods (name, create_user, create_time, update_time, state, content, price, lepao_count, ic_count, num, icon, description, features, vip, vip_validity_type, vip_valid_days, vip_fixed_expire_time, allow_refund) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
+            r = await db.query(sql, [name, uuid, time, time, state, content, price, lepao_count, ic_count, num, goodsIcon, goodsDesc, goodsFeatures, safeVip, safeVipType, safeVipDays, safeVipFixedExpire, safeAllowRefund])
         } else {
-            sql = 'UPDATE goods SET name = ?, update_user = ?, update_time = ?, state = ?, content = ?, price = ?, lepao_count = ?, ic_count = ?, num = ?, icon = ?, description = ?, features = ? WHERE id = ?'
-            r = await db.query(sql, [name, uuid, time, state, content, price, lepao_count, ic_count, num, goodsIcon, goodsDesc, goodsFeatures, id])
+            sql = 'UPDATE goods SET name = ?, update_user = ?, update_time = ?, state = ?, content = ?, price = ?, lepao_count = ?, ic_count = ?, num = ?, icon = ?, description = ?, features = ?, vip = ?, vip_validity_type = ?, vip_valid_days = ?, vip_fixed_expire_time = ?, allow_refund = ? WHERE id = ?'
+            r = await db.query(sql, [name, uuid, time, state, content, price, lepao_count, ic_count, num, goodsIcon, goodsDesc, goodsFeatures, safeVip, safeVipType, safeVipDays, safeVipFixedExpire, safeAllowRefund, id])
         }
 
         try {
@@ -90,4 +100,4 @@ class AddProduct extends API {
     }
 }
 
-module.exports.AddProduct = AddProduct;
+module.exports.AddProduct = AddProduct;

+ 6 - 1
apis/Goods/Admin/GetGoods.js

@@ -48,6 +48,11 @@ class GetGoods extends API {
                 a.num,
                 a.ic_count,
                 a.lepao_count,
+                a.vip,
+                a.vip_validity_type,
+                a.vip_valid_days,
+                a.vip_fixed_expire_time,
+                a.allow_refund,
                 a.icon,
                 a.description,
                 a.features,
@@ -84,4 +89,4 @@ class GetGoods extends API {
     }
 }
 
-module.exports.GetGoods = GetGoods
+module.exports.GetGoods = GetGoods

+ 6 - 1
apis/Goods/Admin/GetGoodsList.js

@@ -52,6 +52,11 @@ class GetGoodsList extends API {
                 a.num,
                 a.ic_count,
                 a.lepao_count,
+                a.vip,
+                a.vip_validity_type,
+                a.vip_valid_days,
+                a.vip_fixed_expire_time,
+                a.allow_refund,
                 a.icon,
                 a.views,
                 a.create_time,
@@ -116,4 +121,4 @@ class GetGoodsList extends API {
     }
 }
 
-module.exports.GetGoodsList = GetGoodsList;
+module.exports.GetGoodsList = GetGoodsList;

+ 2 - 2
apis/Goods/GetCount.js

@@ -28,7 +28,7 @@ class GetCount extends API {
                 ...BaseStdResponse.ACCESS_DENIED
             })
 
-        let sql = 'SELECT ic_count, lepao_count, vip FROM users WHERE uuid = ? '
+        let sql = 'SELECT ic_count, lepao_count, vip, vip_expire_time FROM users WHERE uuid = ? '
         let rows = await db.query(sql, [uuid])
 
         if (!rows || rows.length === 0)
@@ -44,4 +44,4 @@ class GetCount extends API {
     }
 }
 
-module.exports.GetCount = GetCount;
+module.exports.GetCount = GetCount;

+ 9 - 2
apis/Goods/GetGoods.js

@@ -31,7 +31,14 @@ class GetProduct extends API {
                 isHot,
                 description,
                 category,
-                features
+                features,
+                lepao_count,
+                ic_count,
+                vip,
+                vip_validity_type,
+                vip_valid_days,
+                vip_fixed_expire_time,
+                allow_refund
             FROM
                 goods 
             WHERE id = ? AND state = 1
@@ -55,4 +62,4 @@ class GetProduct extends API {
     }
 }
 
-module.exports.GetProduct = GetProduct;
+module.exports.GetProduct = GetProduct;

+ 9 - 2
apis/Goods/GetGoodsList.js

@@ -25,7 +25,14 @@ class GetGoodsList extends API {
                 isHot,
                 description,
                 category,
-                features
+                features,
+                lepao_count,
+                ic_count,
+                vip,
+                vip_validity_type,
+                vip_valid_days,
+                vip_fixed_expire_time,
+                allow_refund
             FROM
                 goods
             WHERE state = 1 
@@ -57,4 +64,4 @@ class GetGoodsList extends API {
     }
 }
 
-module.exports.GetGoodsList = GetGoodsList;
+module.exports.GetGoodsList = GetGoodsList;

+ 12 - 5
apis/Lepao/Account/AddAccount.js

@@ -66,10 +66,17 @@ class AddAccount extends API {
     async onRequest(req, res) {
         let { uuid, session, student_num, email, id, area, auto_time, auto_run, target_count, auto_day, notice_type, notes } = req.body
 
-        if ([uuid, session, student_num, auto_time, target_count, auto_day].some(value => value === '' || value === null || value === undefined))
-            return res.json({
-                ...BaseStdResponse.MISSING_PARAMETER
-            })
+	        if ([uuid, session, student_num, auto_time, target_count, auto_day].some(value => value === '' || value === null || value === undefined))
+	            return res.json({
+	                ...BaseStdResponse.MISSING_PARAMETER
+	            })
+
+	        if (notice_type && !['email', 'none', 'bot', 'wechat'].includes(notice_type)) {
+	            return res.json({
+	                ...BaseStdResponse.ERR,
+	                msg: '通知方式不合法'
+	            })
+	        }
 
         if (isNaN(target_count) || target_count < 0 || target_count > 99) {
             return res.json({
@@ -240,4 +247,4 @@ class AddAccount extends API {
     }
 }
 
-module.exports.AddAccount = AddAccount;
+module.exports.AddAccount = AddAccount;

+ 5 - 1
apis/Lepao/Account/UpdateAccount/UpdateAccount.js

@@ -7,6 +7,8 @@ const { mq: mqName } = require('../../../../plugin/mq/mqPrefix')
 const { BaseStdResponse } = require("../../../../BaseStdResponse.js")
 const { dataDecrypt } = require('../../../../plugin/Lepao/Crypto')
 const { enqueueLepaoSyncAccountInfo } = require('../../../../plugin/mq/enqueueLepaoSyncAccountInfo')
+const WeixinBindingService = require('../../../../lib/AIChat/WeixinBindingService')
+const LepaoWechatTemplate = require('../../../../plugin/Wechat/lepaoWechatTemplate')
 
 // 客户端上传数据接口
 class UpdateAccount extends API {
@@ -142,7 +144,9 @@ class UpdateAccount extends API {
                     )
 
                     this.logger.info(`${student_num}乐跑更新Bot通知发送完成`)
-                } else if (findRows[0].notice_type === 'email' && findRows[0].email) {
+	                } else if (findRows[0].notice_type === 'wechat' && findRows[0].create_user) {
+	                    await WeixinBindingService.sendTextToUser(findRows[0].create_user, LepaoWechatTemplate.buildUpdateSuccess(emailData))
+	                } else if (findRows[0].notice_type === 'email' && findRows[0].email) {
                     await EmailTemplate.updateSuccess(findRows[0].email, emailData)
                     this.logger.info(`${student_num}乐跑更新邮件发送完成`)
                 }

+ 5 - 1
apis/Lepao/Account/UpdateAccount/UpdateAccountAndroidApp.js

@@ -7,6 +7,8 @@ const { mq: mqName } = require('../../../../plugin/mq/mqPrefix')
 const { BaseStdResponse } = require("../../../../BaseStdResponse.js")
 const { dataDecrypt } = require('../../../../plugin/Lepao/Crypto')
 const { enqueueLepaoSyncAccountInfo } = require('../../../../plugin/mq/enqueueLepaoSyncAccountInfo')
+const WeixinBindingService = require('../../../../lib/AIChat/WeixinBindingService')
+const LepaoWechatTemplate = require('../../../../plugin/Wechat/lepaoWechatTemplate')
 
 // 客户端上传数据接口
 class UpdateAccountAndroidApp extends API {
@@ -141,7 +143,9 @@ class UpdateAccountAndroidApp extends API {
                     )
 
                     this.logger.info(`${student_num}乐跑更新Bot通知发送完成`)
-                } else if (findRows[0].notice_type === 'email' && findRows[0].email) {
+	                } else if (findRows[0].notice_type === 'wechat' && findRows[0].create_user) {
+	                    await WeixinBindingService.sendTextToUser(findRows[0].create_user, LepaoWechatTemplate.buildUpdateSuccess(emailData))
+	                } else if (findRows[0].notice_type === 'email' && findRows[0].email) {
                     await EmailTemplate.updateSuccess(findRows[0].email, emailData)
                     this.logger.info(`${student_num}乐跑更新邮件发送完成`)
                 }

+ 5 - 1
apis/Lepao/Account/UpdateAccount/UpdateAccountiPhone.js

@@ -7,6 +7,8 @@ const { mq: mqName } = require('../../../../plugin/mq/mqPrefix')
 const { BaseStdResponse } = require("../../../../BaseStdResponse.js")
 const { dataDecrypt } = require('../../../../plugin/Lepao/Crypto')
 const { enqueueLepaoSyncAccountInfo } = require('../../../../plugin/mq/enqueueLepaoSyncAccountInfo')
+const WeixinBindingService = require('../../../../lib/AIChat/WeixinBindingService')
+const LepaoWechatTemplate = require('../../../../plugin/Wechat/lepaoWechatTemplate')
 
 // 客户端上传数据接口
 class UpdateAccountiPhone extends API {
@@ -142,7 +144,9 @@ class UpdateAccountiPhone extends API {
                     )
 
                     this.logger.info(`${student_num}乐跑更新Bot通知发送完成`)
-                } else if (findRows[0].notice_type === 'email' && findRows[0].email) {
+	                } else if (findRows[0].notice_type === 'wechat' && findRows[0].create_user) {
+	                    await WeixinBindingService.sendTextToUser(findRows[0].create_user, LepaoWechatTemplate.buildUpdateSuccess(emailData))
+	                } else if (findRows[0].notice_type === 'email' && findRows[0].email) {
                     await EmailTemplate.updateSuccess(findRows[0].email, emailData)
                     this.logger.info(`${student_num}乐跑更新邮件发送完成`)
                 }

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

@@ -113,7 +113,7 @@ const tools = [
                 auto_run: { type: 'integer', enum: [0, 1], description: '自动乐跑开关:1 开启,0 关闭。' },
                 target_count: { type: 'number', minimum: 0, maximum: 99, description: '目标总乐跑次数,范围 0-99。开启自动乐跑时,目标次数不能小于或等于账号已累计完成次数,除非设置为 0,0为不限次。' },
                 auto_day: { type: 'array', items: { type: 'integer', minimum: 0, maximum: 6 }, description: '自动乐跑星期。0 周日,1 周一,依此类推,6 周六。' },
-                notice_type: { type: 'string', enum: ['email', 'none', 'bot'], description: '乐跑后的通知渠道:email 邮件,bot 机器人,none 不通知。' },
+                notice_type: { type: 'string', enum: ['email', 'none', 'wechat'], description: '乐跑后的通知渠道:email 邮件,wechat 微信,none 不通知。' },
                 notes: { type: 'string', description: '该账号的用户可见备注。' }
             },
             required: ['sender', 'student_num']
@@ -154,7 +154,7 @@ const tools = [
     },
     {
         name: 'list_power_bill_records',
-        description: '查询某个寝室/房间的电费账单或余额变动记录。必须提供 sender、area、building、room 精确定位当前用户拥有的电费任务,建议先调用 list_power_bills 获取准确的寝室信息;返回匹配任务和分页 records。不暴露内部任务 id 或记录 id。',
+        description: '查询某个寝室/房间的电费账单或余额变动记录。必须提供 sender、area、building、room 精确定位当前用户拥有的电费任务,必须先调用 list_power_bills 获取准确的寝室信息,因为用户提供的信息不能精确匹配;返回匹配任务和分页 records。不暴露内部任务 id 或记录 id。',
         inputSchema: {
             type: 'object',
             properties: {

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

@@ -83,8 +83,8 @@ class McpRpc extends API {
                                     },
                                     "mode": {
                                         "type": "string",
-                                        "enum": ["bot", "email", "none"],
-                                        "description": "Notification mode: bot (chat bot), email (email notification), none (disable notifications)"
+                                        "enum": ["bot", "email", "wechat", "none"],
+                                        "description": "Notification mode: bot (old chat bot), email (email notification), wechat (bound WeChat), none (disable notifications)"
                                     }
                                 },
                                 "required": ["sender", "mode"]
@@ -266,4 +266,4 @@ class McpRpc extends API {
     }
 }
 
-module.exports.McpRpc = McpRpc
+module.exports.McpRpc = McpRpc

+ 5 - 0
apis/Order/Admin/GetOrderDetail.js

@@ -58,6 +58,10 @@ class GetOrderDetail extends API {
                 g.lepao_count,
                 g.ic_count,
                 g.vip,
+                g.vip_validity_type,
+                g.vip_valid_days,
+                g.vip_fixed_expire_time,
+                g.allow_refund,
                 u.username,
                 u.avatar,
                 u.email AS user_email
@@ -90,6 +94,7 @@ class GetOrderDetail extends API {
             payTime: order.pay_time,
             userLepaoCount,
             goodsLepaoCount: order.lepao_count,
+            allowRefund: 1,
             skipTimeLimit: true
         })
         order.canRefund = refundEligibility.canRefund

+ 7 - 1
apis/Order/GetOrderDetail.js

@@ -37,7 +37,12 @@ class GetAccount extends API {
                 g.category,
                 g.features,
                 g.lepao_count,
-                g.ic_count
+                g.ic_count,
+                g.vip,
+                g.vip_validity_type,
+                g.vip_valid_days,
+                g.vip_fixed_expire_time,
+                g.allow_refund
             FROM 
                 orders a
             LEFT JOIN 
@@ -88,6 +93,7 @@ class GetAccount extends API {
             payTime: order.pay_time,
             userLepaoCount,
             goodsLepaoCount: order.lepao_count,
+            allowRefund: order.allow_refund,
             skipTimeLimit: false
         });
         order.canRefund = refundEligibility.canRefund;

+ 7 - 0
config.example.json

@@ -71,5 +71,12 @@
         "selfId": "your-chat-bot-id",
         "botName": "Xiaoyan Assistant",
         "botUuid": "your-chat-bot-uuid"
+    },
+    "weixinBot": {
+        "enabled": false,
+        "apiBaseUrl": "https://ilinkai.weixin.qq.com",
+        "tokenAesKey": "CHANGE_ME_TO_A_RANDOM_32_BYTE_SECRET",
+        "pollIntervalMs": 5000,
+        "pollBatchSize": 5
     }
 }

+ 1 - 1
lib/AIChat/AiAssistantMcp.js

@@ -487,7 +487,7 @@ class AiAssistantMcp {
             fields.push('area = ?')
             params.push(area)
         }

-        if (isProvided(args.notice_type) && !['email', 'none', 'bot'].includes(args.notice_type)) return '通知方式不合法'
+        if (isProvided(args.notice_type) && !['email', 'none', 'bot', 'wechat'].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 '自动乐跑时间不合法'

+ 92 - 39
lib/AIChat/ConversationService.js

@@ -1,11 +1,13 @@
 const crypto = require('crypto')
 const db = require('../../plugin/DataBase/db')
+const Logger = require('../Logger')
 
 const ACTIVE_STATE = 1
 const DELETED_STATE = 2
 const ASSISTANT_UUID = 'e4fe0277-0b1a-41a1-b25f-8b6e4cec3281'
 const DEFAULT_TITLE = '新对话'
 const MAX_MESSAGE_IMAGES = 3
+const logger = new Logger()
 
 function toPositiveInt(value, fallback = null) {
     const n = Number(value)
@@ -23,6 +25,10 @@ function normalizeText(value, max = 2000) {
     return String(value || '').trim().slice(0, max)
 }
 
+function normalizeChannel(channel) {
+    return channel === 'wechat' ? 'wechat' : 'web'
+}
+
 function normalizeImages(images) {
     if (!Array.isArray(images)) return []
     return images
@@ -55,10 +61,21 @@ function parseImages(value) {
 function normalizeMessage(row) {
     return {
         ...row,
+        channel: normalizeChannel(row.channel),
         images: parseImages(row.images)
     }
 }
 
+function normalizeConversation(row) {
+    if (!row) return row
+    const channel = normalizeChannel(row.channel)
+    return {
+        ...row,
+        channel,
+        readonly: channel === 'wechat'
+    }
+}
+
 async function generateConversationNo() {
     for (let i = 0; i < 8; i++) {
         const conversationNo = String(crypto.randomInt(100000000000, 999999999999))
@@ -72,17 +89,17 @@ async function assertUserConversation(conversationId, uuid) {
     const id = toPositiveInt(conversationId)
     if (!id) return null
     const rows = await db.query(
-        'SELECT id, conversation_no, create_user, title, state FROM ai_chat_conversation WHERE id = ? AND create_user = ? AND state = ? LIMIT 1',
+        'SELECT id, conversation_no, create_user, title, channel, state, last_message_preview, last_message_time, create_time, update_time FROM ai_chat_conversation WHERE id = ? AND create_user = ? AND state = ? LIMIT 1',
         [id, uuid, ACTIVE_STATE]
     )
-    return rows && rows.length === 1 ? rows[0] : null
+    return rows && rows.length === 1 ? normalizeConversation(rows[0]) : null
 }
 
 class ConversationService {
     async listUserConversations({ uuid, current, pagesize }) {
         const page = normalizePage(current, pagesize)
         const rows = await db.query(
-            `SELECT id, conversation_no, title, state, last_message_preview, last_message_time, create_time, update_time
+            `SELECT id, conversation_no, title, channel, state, last_message_preview, last_message_time, create_time, update_time
              FROM ai_chat_conversation
              WHERE create_user = ? AND state = ?
              ORDER BY update_time DESC
@@ -94,7 +111,7 @@ class ConversationService {
             [uuid, ACTIVE_STATE]
         )
         return {
-            data: rows || [],
+            data: (rows || []).map(normalizeConversation),
             pagination: {
                 current: page.current,
                 pagesize: page.pagesize,
@@ -103,17 +120,18 @@ class ConversationService {
         }
     }
 
-    async createConversation({ uuid, title }) {
+    async createConversation({ uuid, title, channel = 'web' }) {
+        const safeChannel = normalizeChannel(channel)
         const emptyRows = await db.query(
-            `SELECT id, conversation_no, title, state, last_message_preview, last_message_time, create_time, update_time
+            `SELECT id, conversation_no, title, channel, state, last_message_preview, last_message_time, create_time, update_time
              FROM ai_chat_conversation
-             WHERE create_user = ? AND state = ? AND last_message_time = 0
+             WHERE create_user = ? AND state = ? AND last_message_time = 0 AND channel = ?
              ORDER BY update_time DESC
              LIMIT 1`,
-            [uuid, ACTIVE_STATE]
+            [uuid, ACTIVE_STATE, safeChannel]
         )
         if (emptyRows && emptyRows.length === 1) {
-            return { ...emptyRows[0], reused: true }
+            return normalizeConversation({ ...emptyRows[0], reused: true })
         }
 
         const now = Date.now()
@@ -121,21 +139,36 @@ class ConversationService {
         const conversationNo = await generateConversationNo()
         const result = await db.query(
             `INSERT INTO ai_chat_conversation
-             (conversation_no, create_user, title, state, last_message_preview, last_message_time, create_time, update_time)
-             VALUES (?, ?, ?, ?, '', 0, ?, ?)`,
-            [conversationNo, uuid, safeTitle, ACTIVE_STATE, now, now]
+             (conversation_no, create_user, title, channel, state, last_message_preview, last_message_time, create_time, update_time)
+             VALUES (?, ?, ?, ?, ?, '', 0, ?, ?)`,
+            [conversationNo, uuid, safeTitle, safeChannel, ACTIVE_STATE, now, now]
         )
-        return {
+        return normalizeConversation({
             id: result?.insertId,
             conversation_no: conversationNo,
             title: safeTitle,
+            channel: safeChannel,
             state: ACTIVE_STATE,
             last_message_preview: '',
             last_message_time: 0,
             create_time: now,
             update_time: now,
             reused: false
-        }
+        })
+    }
+
+    async getOrCreateChannelConversation({ uuid, channel, title }) {
+        const safeChannel = normalizeChannel(channel)
+        const rows = await db.query(
+            `SELECT id, conversation_no, title, channel, state, last_message_preview, last_message_time, create_time, update_time
+             FROM ai_chat_conversation
+             WHERE create_user = ? AND state = ? AND channel = ?
+             ORDER BY create_time ASC
+             LIMIT 1`,
+            [uuid, ACTIVE_STATE, safeChannel]
+        )
+        if (rows && rows.length === 1) return normalizeConversation(rows[0])
+        return this.createConversation({ uuid, title, channel: safeChannel })
     }
 
     async deleteConversation({ uuid, conversationId }) {
@@ -154,7 +187,7 @@ class ConversationService {
         const after = Number(afterId || 0)
         if (Number.isFinite(after) && after > 0) {
             const rows = await db.query(
-                `SELECT id, conversation_id, role, content, images, status, error_msg, create_time, update_time
+                `SELECT id, conversation_id, role, channel, content, images, status, error_msg, create_time, update_time
                  FROM ai_chat_message
                  WHERE conversation_id = ? AND create_user = ? AND id > ?
                  ORDER BY id ASC
@@ -166,7 +199,7 @@ class ConversationService {
 
         const page = normalizePage(current, pagesize, 100)
         const rows = await db.query(
-            `SELECT id, conversation_id, role, content, images, status, error_msg, create_time, update_time
+            `SELECT id, conversation_id, role, channel, content, images, status, error_msg, create_time, update_time
              FROM ai_chat_message
              WHERE conversation_id = ? AND create_user = ?
              ORDER BY id DESC
@@ -187,23 +220,25 @@ class ConversationService {
         }
     }
 
-    async addUserMessage({ uuid, conversationId, content, images }) {
+    async addUserMessage({ uuid, conversationId, content, images, channel = 'web' }) {
         const text = normalizeText(content, 2000)
         const imgs = normalizeImages(images)
         if (!text && imgs.length === 0) return { missingContent: true }
 
         const preview = buildPreview(text, imgs)
+        const safeChannel = normalizeChannel(channel)
         const conv = conversationId
             ? await assertUserConversation(conversationId, uuid)
-            : await this.createConversation({ uuid, title: preview ? preview.slice(0, 40) : DEFAULT_TITLE })
+            : await this.createConversation({ uuid, title: preview ? preview.slice(0, 40) : DEFAULT_TITLE, channel: safeChannel })
         if (!conv) return null
+        if (conv.channel === 'wechat' && safeChannel !== 'wechat') return { readonly: true }
 
         const now = Date.now()
         const result = await db.query(
             `INSERT INTO ai_chat_message
-             (conversation_id, create_user, role, content, images, status, error_msg, create_time, update_time)
-             VALUES (?, ?, 'user', ?, ?, 'done', '', ?, ?)`,
-            [conv.id, uuid, text, JSON.stringify(imgs), now, now]
+             (conversation_id, create_user, role, channel, content, images, status, error_msg, create_time, update_time)
+             VALUES (?, ?, 'user', ?, ?, ?, 'done', '', ?, ?)`,
+            [conv.id, uuid, safeChannel, text, JSON.stringify(imgs), now, now]
         )
         const title = (!conv.title || conv.title === DEFAULT_TITLE || Number(conv.last_message_time || 0) === 0) && preview
             ? preview.slice(0, 40)
@@ -218,20 +253,23 @@ class ConversationService {
             messageId: result?.insertId,
             content: text,
             images: imgs,
-            conversation: {
+            channel: safeChannel,
+            conversation: normalizeConversation({
                 id: conv.id,
                 conversation_no: conv.conversation_no,
                 title,
+                channel: conv.channel,
                 state: ACTIVE_STATE,
                 last_message_preview: preview,
                 last_message_time: now,
                 create_time: conv.create_time || now,
                 update_time: now
-            },
+            }),
             message: {
                 id: result?.insertId,
                 conversation_id: conv.id,
                 role: 'user',
+                channel: safeChannel,
                 content: text,
                 images: imgs,
                 status: 'done',
@@ -242,11 +280,11 @@ class ConversationService {
         }
     }
 
-    async addAssistantMessage({ conversationId, conversationNo, content, images = [], status = 'done', errorMsg = '' }) {
+    async addAssistantMessage({ conversationId, conversationNo, content, images = [], status = 'done', errorMsg = '', channel = null }) {
         const whereSql = conversationNo ? 'conversation_no = ?' : 'id = ?'
         const whereValue = conversationNo || conversationId
         const rows = await db.query(
-            `SELECT id, create_user FROM ai_chat_conversation WHERE ${whereSql} AND state = ? LIMIT 1`,
+            `SELECT id, create_user, channel FROM ai_chat_conversation WHERE ${whereSql} AND state = ? LIMIT 1`,
             [whereValue, ACTIVE_STATE]
         )
         if (!rows || rows.length !== 1) return false
@@ -258,36 +296,45 @@ class ConversationService {
         if (!text && imgs.length === 0 && status !== 'error') return false
 
         const now = Date.now()
+        const safeChannel = normalizeChannel(channel || rows[0].channel)
         await db.query(
             `INSERT INTO ai_chat_message
-             (conversation_id, create_user, role, content, images, status, error_msg, create_time, update_time)
-             VALUES (?, ?, 'assistant', ?, ?, ?, ?, ?, ?)`,
-            [targetConversationId, uuid, text, JSON.stringify(imgs), status, normalizeText(errorMsg, 200), now, now]
+             (conversation_id, create_user, role, channel, content, images, status, error_msg, create_time, update_time)
+             VALUES (?, ?, 'assistant', ?, ?, ?, ?, ?, ?, ?)`,
+            [targetConversationId, uuid, safeChannel, text, JSON.stringify(imgs), status, normalizeText(errorMsg, 200), now, now]
         )
         await db.query(
             'UPDATE ai_chat_conversation SET last_message_preview = ?, last_message_time = ?, update_time = ? WHERE id = ?',
             [buildPreview(text, imgs), now, now, targetConversationId]
         )
+        if (safeChannel === 'wechat' && text && status === 'done') {
+            try {
+                const { sendTextToUser } = require('./WeixinBindingService')
+                await sendTextToUser(uuid, text)
+            } catch (err) {
+                logger.error(`WeChat AIChat reply send failed user=${uuid} conversation=${targetConversationId}: ${err.stack || err.message || err}`)
+            }
+        }
         return true
     }
 
-    async addSystemMessage({ conversationId, content, status = 'done', errorMsg = '' }) {
+    async addSystemMessage({ conversationId, content, status = 'done', errorMsg = '', channel = null }) {
         const rows = await db.query(
-            'SELECT id, create_user FROM ai_chat_conversation WHERE id = ? AND state = ? LIMIT 1',
+            'SELECT id, create_user, channel FROM ai_chat_conversation WHERE id = ? AND state = ? LIMIT 1',
             [conversationId, ACTIVE_STATE]
         )
         if (!rows || rows.length !== 1) return false
         const now = Date.now()
         await db.query(
             `INSERT INTO ai_chat_message
-             (conversation_id, create_user, role, content, images, status, error_msg, create_time, update_time)
-             VALUES (?, ?, 'system', ?, JSON_ARRAY(), ?, ?, ?, ?)`,
-            [conversationId, rows[0].create_user, normalizeText(content, 1000), status, normalizeText(errorMsg, 200), now, now]
+             (conversation_id, create_user, role, channel, content, images, status, error_msg, create_time, update_time)
+             VALUES (?, ?, 'system', ?, ?, JSON_ARRAY(), ?, ?, ?, ?)`,
+            [conversationId, rows[0].create_user, normalizeChannel(channel || rows[0].channel), normalizeText(content, 1000), status, normalizeText(errorMsg, 200), now, now]
         )
         return true
     }
 
-    async listAdminConversations({ id, conversation_no, create_user, username, state, queryTime, current, pagesize }) {
+    async listAdminConversations({ id, conversation_no, create_user, username, state, channel, queryTime, current, pagesize }) {
         const page = normalizePage(current, pagesize)
         const where = ['1 = 1']
         const params = []
@@ -318,6 +365,11 @@ class ConversationService {
             params.push(state)
             countParams.push(state)
         }
+        if (channel && ['web', 'wechat'].includes(channel)) {
+            where.push('c.channel = ?')
+            params.push(channel)
+            countParams.push(channel)
+        }
         if (Array.isArray(queryTime) && queryTime.length === 2) {
             where.push('c.update_time >= ? AND c.update_time < ?')
             params.push(queryTime[0], queryTime[1])
@@ -326,7 +378,7 @@ class ConversationService {
 
         const whereSql = where.join(' AND ')
         const rows = await db.query(
-            `SELECT c.id, c.conversation_no, c.create_user, c.title, c.state, c.last_message_preview, c.last_message_time,
+            `SELECT c.id, c.conversation_no, c.create_user, c.title, c.channel, c.state, c.last_message_preview, c.last_message_time,
                     c.create_time, c.update_time, u.username
              FROM ai_chat_conversation c
              LEFT JOIN users u ON u.uuid = c.create_user
@@ -343,7 +395,7 @@ class ConversationService {
             countParams
         )
         return {
-            data: rows || [],
+            data: (rows || []).map(normalizeConversation),
             pagination: {
                 current: page.current,
                 pagesize: page.pagesize,
@@ -354,7 +406,7 @@ class ConversationService {
 
     async getAdminConversationDetail({ conversationId }) {
         const convRows = await db.query(
-            `SELECT c.id, c.conversation_no, c.create_user, c.title, c.state, c.last_message_preview, c.last_message_time,
+            `SELECT c.id, c.conversation_no, c.create_user, c.title, c.channel, c.state, c.last_message_preview, c.last_message_time,
                     c.create_time, c.update_time, u.username
              FROM ai_chat_conversation c
              LEFT JOIN users u ON u.uuid = c.create_user
@@ -364,14 +416,14 @@ class ConversationService {
         )
         if (!convRows || convRows.length !== 1) return null
         const msgRows = await db.query(
-            `SELECT id, conversation_id, role, content, images, status, error_msg, create_time, update_time
+            `SELECT id, conversation_id, role, channel, content, images, status, error_msg, create_time, update_time
              FROM ai_chat_message
              WHERE conversation_id = ?
              ORDER BY id ASC`,
             [conversationId]
         )
         return {
-            ...convRows[0],
+            ...normalizeConversation(convRows[0]),
             messages: (msgRows || []).map(normalizeMessage),
             assistantUuid: ASSISTANT_UUID
         }
@@ -382,5 +434,6 @@ module.exports = {
     ConversationService: new ConversationService(),
     normalizeImages,
     normalizeText,
+    normalizeChannel,
     ASSISTANT_UUID
 }

+ 94 - 0
lib/AIChat/QuotaService.js

@@ -0,0 +1,94 @@
+const db = require('../../plugin/DataBase/db')
+const { getRuntimeConfig } = require('../RuntimeConfig')
+const { getUserVipInfo } = require('../VipService')
+
+const CONFIG_KEY = 'aiChatQuota'
+const DEFAULT_NOT_CONFIGURED_MSG = '聊天限额未配置'
+
+function ymd(date = new Date()) {
+    const yyyy = date.getFullYear()
+    const mm = String(date.getMonth() + 1).padStart(2, '0')
+    const dd = String(date.getDate()).padStart(2, '0')
+    return `${yyyy}${mm}${dd}`
+}
+
+function normalizeChannel(channel) {
+    return channel === 'wechat' ? 'wechat' : 'web'
+}
+
+async function getQuotaConfig() {
+    const config = await getRuntimeConfig(CONFIG_KEY, { required: false, defaultValue: null })
+    if (!config || config.enabled !== true) {
+        return { enabled: false, message: DEFAULT_NOT_CONFIGURED_MSG }
+    }
+    return config
+}
+
+async function getDailyLimit({ uuid, channel }) {
+    const normalizedChannel = normalizeChannel(channel)
+    const config = await getQuotaConfig()
+    if (!config.enabled) return { configured: false, limit: 0, message: config.message || DEFAULT_NOT_CONFIGURED_MSG }
+    const vipInfo = await getUserVipInfo(uuid)
+    const bucket = vipInfo.vip ? config.vip : config.non_vip
+    const key = `${normalizedChannel}_daily`
+    const limit = Number(bucket?.[key])
+    if (!Number.isFinite(limit) || limit < 0) {
+        return { configured: false, limit: 0, message: DEFAULT_NOT_CONFIGURED_MSG, vip: vipInfo.vip }
+    }
+    return {
+        configured: true,
+        limit,
+        vip: vipInfo.vip,
+        vip_expire_time: vipInfo.vip_expire_time,
+        message: config.limit_message || '今日与小妍助理聊天次数已达上限,请明天再试'
+    }
+}
+
+async function getUsage({ uuid, channel, usageDate = ymd() }) {
+    const rows = await db.query(
+        'SELECT used_count FROM ai_chat_quota_usage WHERE user_uuid = ? AND channel = ? AND usage_date = ? LIMIT 1',
+        [uuid, normalizeChannel(channel), usageDate]
+    )
+    return Number(rows?.[0]?.used_count || 0)
+}
+
+async function checkQuota({ uuid, channel }) {
+    const quota = await getDailyLimit({ uuid, channel })
+    if (!quota.configured) return { allowed: false, ...quota, used: 0, remaining: 0 }
+    const used = await getUsage({ uuid, channel })
+    const remaining = Math.max(0, quota.limit - used)
+    return {
+        allowed: used < quota.limit,
+        ...quota,
+        used,
+        remaining
+    }
+}
+
+async function consumeQuota({ uuid, channel }) {
+    const normalizedChannel = normalizeChannel(channel)
+    const quota = await checkQuota({ uuid, channel: normalizedChannel })
+    if (!quota.allowed) return quota
+    const now = Date.now()
+    const usageDate = ymd()
+    await db.query(
+        `INSERT INTO ai_chat_quota_usage (user_uuid, channel, usage_date, used_count, create_time, update_time)
+         VALUES (?, ?, ?, 1, ?, ?)
+         ON DUPLICATE KEY UPDATE used_count = used_count + 1, update_time = VALUES(update_time)`,
+        [uuid, normalizedChannel, usageDate, now, now]
+    )
+    return {
+        ...quota,
+        used: quota.used + 1,
+        remaining: Math.max(0, quota.limit - quota.used - 1)
+    }
+}
+
+module.exports = {
+    CONFIG_KEY,
+    normalizeChannel,
+    getDailyLimit,
+    getUsage,
+    checkQuota,
+    consumeQuota
+}

+ 384 - 0
lib/AIChat/WeixinBindingService.js

@@ -0,0 +1,384 @@
+const crypto = require('crypto')
+const db = require('../../plugin/DataBase/db')
+const Logger = require('../Logger')
+const config = require('../../config.json')
+const { ConversationService, normalizeText } = require('./ConversationService')
+const { consumeQuota } = require('./QuotaService')
+const WeixinBotClient = require('./WeixinBotClient')
+const OneBotV11 = require('../../plugin/OneBot/OneBotV11')
+const { getUserVipInfo } = require('../VipService')
+
+const STATE_PENDING = 0
+const STATE_ACTIVE = 1
+const STATE_DISABLED = 2
+const STATE_EXPIRED = 3
+const WECHAT_EXPIRE_MS = 24 * 60 * 60 * 1000
+const WECHAT_REMIND_MS = 23 * 60 * 60 * 1000
+
+const logger = new Logger()
+let pollTimer = null
+let running = false
+
+function keyBuffer() {
+    const raw = config.weixinBot?.tokenAesKey || config.qk?.passwordAesKey || 'runforge-weixin-token-key'
+    return crypto.createHash('sha256').update(String(raw)).digest()
+}
+
+function encryptToken(text) {
+    if (!text) return ''
+    const iv = crypto.randomBytes(12)
+    const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer(), iv)
+    const encrypted = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()])
+    const tag = cipher.getAuthTag()
+    return `${iv.toString('base64')}.${tag.toString('base64')}.${encrypted.toString('base64')}`
+}
+
+function decryptToken(value) {
+    if (!value) return ''
+    const [ivRaw, tagRaw, encryptedRaw] = String(value).split('.')
+    const decipher = crypto.createDecipheriv('aes-256-gcm', keyBuffer(), Buffer.from(ivRaw, 'base64'))
+    decipher.setAuthTag(Buffer.from(tagRaw, 'base64'))
+    return Buffer.concat([
+        decipher.update(Buffer.from(encryptedRaw, 'base64')),
+        decipher.final()
+    ]).toString('utf8')
+}
+
+async function assertVip(uuid) {
+    const vip = await getUserVipInfo(uuid)
+    return vip.vip
+}
+
+function sanitizeBinding(row) {
+    if (!row) return null
+    return {
+        user_uuid: row.user_uuid,
+        state: Number(row.state),
+        qrcode_url: row.qrcode_url,
+        wechat_last_user_id: row.wechat_last_user_id ? 'bound' : '',
+        last_user_message_time: Number(row.last_user_message_time || 0),
+        expire_remind_time: Number(row.expire_remind_time || 0),
+        last_error: row.last_error || '',
+        bind_time: Number(row.bind_time || 0),
+        create_time: Number(row.create_time || 0),
+        update_time: Number(row.update_time || 0)
+    }
+}
+
+async function getBindingByUser(uuid, includeSecret = false) {
+    const rows = await db.query(
+        `SELECT * FROM weixin_bot_binding WHERE user_uuid = ? LIMIT 1`,
+        [uuid]
+    )
+    const row = rows?.[0] || null
+    return includeSecret ? row : sanitizeBinding(row)
+}
+
+async function createBinding(uuid) {
+    if (!await assertVip(uuid)) return { ok: false, msg: '微信绑定仅限 VIP 用户使用' }
+    let qr
+    try {
+        qr = await WeixinBotClient.getQrCode()
+    } catch (err) {
+        logger.error(`Weixin get qrcode failed: ${err.stack || err.message || err}`)
+        return { ok: false, msg: err.userMessage || '获取微信二维码失败' }
+    }
+    const qrcode = qr.qrcode || qr.data?.qrcode || ''
+    let qrcodeUrl = qr.url || qr.qrcode_url || qr.qrcode_img_content || qr.data?.url || qr.data?.qrcode_url || qr.data?.qrcode_img_content || ''
+    if (qrcodeUrl && !/^https?:\/\//i.test(qrcodeUrl) && !String(qrcodeUrl).startsWith('data:image/')) {
+        qrcodeUrl = `data:image/png;base64,${qrcodeUrl}`
+    }
+    if (!qrcode) return { ok: false, msg: '获取微信二维码失败' }
+    const now = Date.now()
+    await db.query(
+        `INSERT INTO weixin_bot_binding
+         (user_uuid, state, qrcode, qrcode_url, bot_token_enc, base_url, get_updates_buf, last_error, create_time, update_time)
+         VALUES (?, ?, ?, ?, '', '', '', '', ?, ?)
+         ON DUPLICATE KEY UPDATE state = VALUES(state), qrcode = VALUES(qrcode), qrcode_url = VALUES(qrcode_url),
+         bot_token_enc = '', base_url = '', get_updates_buf = '', last_error = '', update_time = VALUES(update_time)`,
+        [uuid, STATE_PENDING, qrcode, qrcodeUrl, now, now]
+    )
+    return { ok: true, data: await getBindingByUser(uuid) }
+}
+
+async function refreshQrStatus(uuid) {
+    const row = await getBindingByUser(uuid, true)
+    if (!row) return null
+    if (Number(row.state) !== STATE_PENDING || !row.qrcode) return sanitizeBinding(row)
+    const status = await WeixinBotClient.getQrCodeStatus(row.qrcode)
+    const confirmed = status.status === 'confirmed' || status.data?.status === 'confirmed'
+    if (!confirmed) return sanitizeBinding(row)
+    const botToken = status.bot_token || status.data?.bot_token
+    const baseUrl = status.baseurl || status.base_url || status.data?.baseurl || status.data?.base_url || WeixinBotClient.DEFAULT_BASE_URL
+    if (!botToken) return sanitizeBinding(row)
+    const now = Date.now()
+    await db.query(
+        `UPDATE weixin_bot_binding
+         SET state = ?, bot_token_enc = ?, base_url = ?, bind_time = ?, last_user_message_time = ?, last_error = '', update_time = ?
+         WHERE user_uuid = ?`,
+        [STATE_ACTIVE, encryptToken(botToken), baseUrl, now, now, now, uuid]
+    )
+    return getBindingByUser(uuid)
+}
+
+async function disableBinding(uuid) {
+    const now = Date.now()
+    await db.query(
+        'UPDATE weixin_bot_binding SET state = ?, update_time = ? WHERE user_uuid = ?',
+        [STATE_DISABLED, now, uuid]
+    )
+    return true
+}
+
+function extractInbound(msg) {
+    const items = Array.isArray(msg?.item_list) ? msg.item_list : []
+    const texts = []
+    const images = []
+    for (const item of items) {
+        if (Number(item.type) === 1 && item.text_item?.text) texts.push(String(item.text_item.text))
+        if (Number(item.type) === 2) {
+            const image = item.image_item || item.image || {}
+            const url = image.url || image.cdn_url || image.download_url || image.file_url || ''
+            if (url) images.push(String(url))
+            else texts.push('[微信图片]')
+        }
+    }
+    return {
+        text: normalizeText(texts.join('\n'), 2000),
+        images,
+        fromUserId: msg?.from_user_id || '',
+        toUserId: msg?.to_user_id || '',
+        contextToken: msg?.context_token || ''
+    }
+}
+
+async function getOrCreateWechatConversation(uuid) {
+    return ConversationService.getOrCreateChannelConversation({
+        uuid,
+        channel: 'wechat',
+        title: '微信对话'
+    })
+}
+
+async function handleInboundMessage(binding, msg) {
+    const inbound = extractInbound(msg)
+    if (!inbound.fromUserId || (!inbound.text && inbound.images.length === 0)) return
+    const now = Date.now()
+    await db.query(
+        `UPDATE weixin_bot_binding
+         SET wechat_bot_user_id = ?, wechat_last_user_id = ?, last_context_token = ?, last_user_message_time = ?, expire_remind_time = 0, update_time = ?
+         WHERE user_uuid = ?`,
+        [inbound.toUserId, inbound.fromUserId, inbound.contextToken, now, now, binding.user_uuid]
+    )
+
+    const quota = await consumeQuota({ uuid: binding.user_uuid, channel: 'wechat' })
+    if (!quota.allowed) {
+        await sendTextToUser(binding.user_uuid, quota.message || '今日与小妍助理聊天次数已达上限,请明天再试')
+        return
+    }
+
+    const conversation = await getOrCreateWechatConversation(binding.user_uuid)
+    const saved = await ConversationService.addUserMessage({
+        uuid: binding.user_uuid,
+        conversationId: conversation.id,
+        content: inbound.text,
+        images: inbound.images,
+        channel: 'wechat'
+    })
+    if (!saved || saved.missingContent) return
+    try {
+        await OneBotV11.sendAiChatMessage({
+            conversationId: saved.conversationId,
+            conversationNo: saved.conversationNo,
+            senderUuid: binding.user_uuid,
+            content: saved.content,
+            images: saved.images,
+            channel: 'wechat'
+        })
+    } catch (err) {
+        logger.error(`WeChat AIChat OneBot forward failed: ${err.stack || err}`)
+        await ConversationService.addSystemMessage({
+            conversationId: saved.conversationId,
+            content: '消息已保存,但暂时无法连接小妍助理。',
+            status: 'error',
+            errorMsg: err.message || 'OneBot send failed',
+            channel: 'wechat'
+        })
+        await sendTextToUser(binding.user_uuid, '消息已保存,但暂时无法连接小妍助理,请稍后再试。')
+    }
+}
+
+async function pollOneBinding(row) {
+    const now = Date.now()
+    if (!await assertVip(row.user_uuid)) {
+        await db.query('UPDATE weixin_bot_binding SET state = ?, update_time = ?, last_error = ? WHERE user_uuid = ?', [STATE_DISABLED, now, 'VIP expired', row.user_uuid])
+        return
+    }
+    if (Number(row.last_user_message_time || 0) > 0 && now - Number(row.last_user_message_time) >= WECHAT_EXPIRE_MS) {
+        await db.query('UPDATE weixin_bot_binding SET state = ?, update_time = ? WHERE user_uuid = ?', [STATE_EXPIRED, now, row.user_uuid])
+        return
+    }
+    const botToken = decryptToken(row.bot_token_enc)
+    const result = await WeixinBotClient.getUpdates({
+        baseUrl: row.base_url,
+        botToken,
+        getUpdatesBuf: row.get_updates_buf || ''
+    })
+    const nextBuf = result.get_updates_buf ?? result.data?.get_updates_buf ?? row.get_updates_buf ?? ''
+    await db.query(
+        'UPDATE weixin_bot_binding SET get_updates_buf = ?, last_poll_time = ?, last_error = ?, update_time = ? WHERE user_uuid = ?',
+        [nextBuf, Date.now(), '', Date.now(), row.user_uuid]
+    )
+    const msgs = result.msgs || result.data?.msgs || []
+    for (const msg of msgs) {
+        if (Number(msg.message_type) === 1) await handleInboundMessage(row, msg)
+    }
+}
+
+async function pollActiveBindings() {
+    if (running) return
+    running = true
+    try {
+        const rows = await db.query(
+            `SELECT * FROM weixin_bot_binding
+             WHERE state = ?
+             ORDER BY last_poll_time ASC
+             LIMIT ?`,
+            [STATE_ACTIVE, String(Number(config.weixinBot?.pollBatchSize || 5))]
+        ) || []
+        for (const row of rows) {
+            try {
+                await pollOneBinding(row)
+            } catch (err) {
+                logger.error(`WeChat binding poll failed user=${row.user_uuid}: ${err.stack || err}`)
+                await db.query(
+                    'UPDATE weixin_bot_binding SET last_error = ?, last_poll_time = ?, update_time = ? WHERE user_uuid = ?',
+                    [String(err.message || err).slice(0, 255), Date.now(), Date.now(), row.user_uuid]
+                )
+            }
+        }
+        await sendExpireReminders()
+    } finally {
+        running = false
+    }
+}
+
+async function sendExpireReminders() {
+    const threshold = Date.now() - WECHAT_REMIND_MS
+    const rows = await db.query(
+        `SELECT user_uuid FROM weixin_bot_binding
+         WHERE state = ? AND last_user_message_time > 0 AND last_user_message_time <= ? AND expire_remind_time = 0
+         LIMIT 30`,
+        [STATE_ACTIVE, threshold]
+    ) || []
+    for (const row of rows) {
+        try {
+            await sendTextToUser(row.user_uuid, '微信登录即将过期,请在 1 小时内向小妍助理发送任意消息以保持登录。')
+            await db.query('UPDATE weixin_bot_binding SET expire_remind_time = ?, update_time = ? WHERE user_uuid = ?', [Date.now(), Date.now(), row.user_uuid])
+        } catch (err) {
+            logger.error(`WeChat expire reminder failed user=${row.user_uuid}: ${err.stack || err}`)
+        }
+    }
+}
+
+async function sendTextToUser(uuid, text) {
+    if (!await assertVip(uuid)) return false
+    const row = await getBindingByUser(uuid, true)
+    if (!row || Number(row.state) !== STATE_ACTIVE) return false
+    if (!row.wechat_last_user_id || !row.last_context_token) return false
+    try {
+        await WeixinBotClient.sendText({
+            baseUrl: row.base_url,
+            botToken: decryptToken(row.bot_token_enc),
+            toUserId: row.wechat_last_user_id,
+            contextToken: row.last_context_token,
+            text
+        })
+        await db.query(
+            'UPDATE weixin_bot_binding SET last_error = ?, update_time = ? WHERE user_uuid = ?',
+            ['', Date.now(), uuid]
+        )
+    } catch (err) {
+        await db.query(
+            'UPDATE weixin_bot_binding SET last_error = ?, update_time = ? WHERE user_uuid = ?',
+            [String(err.message || err).slice(0, 255), Date.now(), uuid]
+        )
+        throw err
+    }
+    return true
+}
+
+async function listAdminBindings({ user_uuid, username, state, vip, expired, current = 1, pagesize = 20 }) {
+    const c = Math.max(1, Number(current || 1))
+    const p = Math.min(100, Math.max(1, Number(pagesize || 20)))
+    const where = ['1=1']
+    const params = []
+    const countParams = []
+    if (user_uuid) {
+        where.push('b.user_uuid LIKE ?')
+        params.push(`%${user_uuid}%`)
+        countParams.push(`%${user_uuid}%`)
+    }
+    if (username) {
+        where.push('u.username LIKE ?')
+        params.push(`%${username}%`)
+        countParams.push(`%${username}%`)
+    }
+    if (state !== undefined && state !== null && state !== '' && Number(state) !== -1) {
+        where.push('b.state = ?')
+        params.push(Number(state))
+        countParams.push(Number(state))
+    }
+    if (String(vip) === '1') where.push('(u.vip_expire_time > UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 OR (u.vip = 1 AND u.vip_expire_time = 0))')
+    if (String(vip) === '0') where.push('NOT (u.vip_expire_time > UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 OR (u.vip = 1 AND u.vip_expire_time = 0))')
+    if (String(expired) === '1') where.push('(b.state = 3 OR (b.last_user_message_time > 0 AND b.last_user_message_time <= UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 - 86400000))')
+    const whereSql = where.join(' AND ')
+    const rows = await db.query(
+        `SELECT b.user_uuid, b.state, b.wechat_last_user_id, b.last_user_message_time, b.expire_remind_time,
+                b.last_error, b.last_poll_time, b.bind_time, b.create_time, b.update_time,
+                u.username, u.vip, u.vip_expire_time
+         FROM weixin_bot_binding b
+         LEFT JOIN users u ON u.uuid = b.user_uuid
+         WHERE ${whereSql}
+         ORDER BY b.update_time DESC
+         LIMIT ? OFFSET ?`,
+        [...params, String(p), String((c - 1) * p)]
+    )
+    const countRows = await db.query(
+        `SELECT COUNT(*) AS total FROM weixin_bot_binding b LEFT JOIN users u ON u.uuid = b.user_uuid WHERE ${whereSql}`,
+        countParams
+    )
+    return {
+        data: (rows || []).map(row => ({
+            ...row,
+            wechat_last_user_id: row.wechat_last_user_id ? 'bound' : '',
+            token: undefined,
+            bot_token_enc: undefined
+        })),
+        pagination: { current: c, pagesize: p, total: Number(countRows?.[0]?.total || 0) }
+    }
+}
+
+function startPolling() {
+    if (config.weixinBot?.enabled !== true) return
+    if (pollTimer) return
+    const interval = Math.max(5000, Number(config.weixinBot?.pollIntervalMs || 5000))
+    pollTimer = setInterval(() => {
+        pollActiveBindings().catch(err => logger.error(`WeChat poll loop failed: ${err.stack || err}`))
+    }, interval)
+    pollActiveBindings().catch(err => logger.error(`WeChat initial poll failed: ${err.stack || err}`))
+}
+
+module.exports = {
+    STATE_PENDING,
+    STATE_ACTIVE,
+    STATE_DISABLED,
+    STATE_EXPIRED,
+    getBindingByUser,
+    createBinding,
+    refreshQrStatus,
+    disableBinding,
+    sendTextToUser,
+    listAdminBindings,
+    startPolling
+}

+ 251 - 0
lib/AIChat/WeixinBotClient.js

@@ -0,0 +1,251 @@
+const crypto = require('crypto')
+const axios = require('axios')
+const config = require('../../config.json')
+
+const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
+const CHANNEL_VERSION = '1.0.2'
+const MAX_REDIRECTS = 3
+
+const COMMON_HEADERS = {
+    Accept: 'application/json, text/plain, */*',
+    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) LepaoBackend/1.0 Chrome/127.0.0.0 Safari/537.36'
+}
+
+const COMMON_AXIOS_OPTIONS = {
+    proxy: false
+}
+
+function randomUinHeader() {
+    const n = crypto.randomInt(1, 0xffffffff)
+    return Buffer.from(String(n)).toString('base64')
+}
+
+function randomClientId() {
+    return `openclaw-weixin-${crypto.randomBytes(4).toString('hex')}`
+}
+
+function baseInfo() {
+    return { channel_version: config.weixinBot?.channelVersion || CHANNEL_VERSION }
+}
+
+function authHeaders(botToken) {
+    return {
+        ...COMMON_HEADERS,
+        'Content-Type': 'application/json',
+        AuthorizationType: 'ilink_bot_token',
+        'X-WECHAT-UIN': randomUinHeader(),
+        Authorization: `Bearer ${botToken}`
+    }
+}
+
+function normalizeBaseUrl(baseUrl) {
+    const raw = String(baseUrl || DEFAULT_BASE_URL).trim()
+    const value = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`
+    try {
+        const url = new URL(value)
+        if (url.protocol === 'http:' && url.hostname === 'ilinkai.weixin.qq.com') url.protocol = 'https:'
+        url.search = ''
+        url.hash = ''
+        url.pathname = url.pathname.replace(/\/ilink\/bot\/.*$/i, '').replace(/\/+$/, '')
+        return url.toString().replace(/\/+$/, '')
+    } catch (_) {
+        return DEFAULT_BASE_URL
+    }
+}
+
+function buildUrl(baseUrl, path, params = {}) {
+    const url = new URL(`${normalizeBaseUrl(baseUrl)}${path}`)
+    Object.entries(params).forEach(([key, value]) => {
+        if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value))
+    })
+    return url.toString()
+}
+
+function createWeixinError(message, userMessage) {
+    const err = new Error(message)
+    err.userMessage = userMessage || message
+    return err
+}
+
+function responseMeta(res) {
+    const headers = res.headers || {}
+    return JSON.stringify({
+        status: res.status,
+        location: headers.location || '',
+        server: headers.server || '',
+        via: headers.via || '',
+        contentType: headers['content-type'] || '',
+        contentLength: headers['content-length'] || '',
+        setCookieCount: Array.isArray(headers['set-cookie']) ? headers['set-cookie'].length : 0
+    })
+}
+
+function assertIlinkResult(action, data) {
+    if (!data || typeof data !== 'object') return data
+    if (data.ret !== undefined && Number(data.ret) !== 0) {
+        const msg = data.err_msg || data.errmsg || data.message || data.wording || JSON.stringify(data)
+        throw new Error(`Weixin iLink ${action} failed: ret=${data.ret}, ${msg}`)
+    }
+    return data
+}
+
+async function getWithRedirectGuard(url, userMessage) {
+    let currentUrl = url
+    const visited = new Set()
+    const cookies = new Map()
+    for (let i = 0; i <= MAX_REDIRECTS; i++) {
+        const cookieHeader = Array.from(cookies.entries()).map(([key, value]) => `${key}=${value}`).join('; ')
+        const res = await axios.get(currentUrl, {
+            ...COMMON_AXIOS_OPTIONS,
+            timeout: 15000,
+            maxRedirects: 0,
+            validateStatus: () => true,
+            headers: cookieHeader ? { ...COMMON_HEADERS, Cookie: cookieHeader } : COMMON_HEADERS
+        })
+        let cookieChanged = false
+        const setCookies = Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'] : []
+        for (const item of setCookies) {
+            const pair = String(item).split(';')[0]
+            const index = pair.indexOf('=')
+            if (index <= 0) continue
+            const key = pair.slice(0, index).trim()
+            const value = pair.slice(index + 1)
+            if (cookies.get(key) !== value) {
+                cookies.set(key, value)
+                cookieChanged = true
+            }
+        }
+        if (res.status >= 300 && res.status < 400 && res.headers.location) {
+            const nextUrl = new URL(res.headers.location, currentUrl).toString()
+            const stateKey = `${nextUrl}|${Array.from(cookies.entries()).map(([key, value]) => `${key}=${value}`).join(';')}`
+            if (visited.has(stateKey) || (nextUrl === currentUrl && !cookieChanged)) {
+                throw createWeixinError(
+                    `Weixin iLink redirect loop: ${currentUrl} -> ${nextUrl}, meta=${responseMeta(res)}`,
+                    userMessage
+                )
+            }
+            visited.add(stateKey)
+            currentUrl = nextUrl
+            continue
+        }
+        if (res.status < 200 || res.status >= 300) {
+            throw createWeixinError(
+                `Weixin iLink request failed: ${currentUrl}, meta=${responseMeta(res)}`,
+                userMessage
+            )
+        }
+        return res.data
+    }
+    throw createWeixinError(`Weixin iLink redirects exceeded: ${url}`, userMessage)
+}
+
+async function getQrCode() {
+    return getWithRedirectGuard(
+        buildUrl(config.weixinBot?.apiBaseUrl, '/ilink/bot/get_bot_qrcode', { bot_type: 3 }),
+        '获取微信二维码失败,请检查微信服务配置'
+    )
+}
+
+async function getQrCodeStatus(qrcode) {
+    return getWithRedirectGuard(
+        buildUrl(config.weixinBot?.apiBaseUrl, '/ilink/bot/get_qrcode_status', { qrcode }),
+        '获取微信扫码状态失败,请检查微信服务配置'
+    )
+}
+
+async function getUpdates({ baseUrl, botToken, getUpdatesBuf = '' }) {
+    const res = await axios.post(
+        `${normalizeBaseUrl(baseUrl)}/ilink/bot/getupdates`,
+        {
+            get_updates_buf: getUpdatesBuf || '',
+            base_info: baseInfo()
+        },
+        {
+            ...COMMON_AXIOS_OPTIONS,
+            headers: authHeaders(botToken),
+            timeout: 40000
+        }
+    )
+    return assertIlinkResult('getupdates', res.data)
+}
+
+async function getConfig({ baseUrl, botToken, toUserId, contextToken }) {
+    const res = await axios.post(
+        `${normalizeBaseUrl(baseUrl)}/ilink/bot/getconfig`,
+        {
+            ilink_user_id: toUserId,
+            context_token: contextToken || '',
+            base_info: baseInfo()
+        },
+        {
+            ...COMMON_AXIOS_OPTIONS,
+            headers: authHeaders(botToken),
+            timeout: 15000
+        }
+    )
+    return assertIlinkResult('getconfig', res.data)
+}
+
+async function sendTyping({ baseUrl, botToken, toUserId, typingTicket, status }) {
+    if (!typingTicket) return null
+    const res = await axios.post(
+        `${normalizeBaseUrl(baseUrl)}/ilink/bot/sendtyping`,
+        {
+            ilink_user_id: toUserId,
+            typing_ticket: typingTicket,
+            status,
+            base_info: baseInfo()
+        },
+        {
+            ...COMMON_AXIOS_OPTIONS,
+            headers: authHeaders(botToken),
+            timeout: 10000
+        }
+    )
+    return assertIlinkResult('sendtyping', res.data)
+}
+
+async function sendText({ baseUrl, botToken, toUserId, contextToken, text }) {
+    const content = String(text || '').trim()
+    if (!content) return null
+    const configData = await getConfig({ baseUrl, botToken, toUserId, contextToken })
+    const typingTicket = configData.typing_ticket || configData.data?.typing_ticket || ''
+    if (typingTicket) await sendTyping({ baseUrl, botToken, toUserId, typingTicket, status: 1 })
+    let sendResult
+    try {
+        const res = await axios.post(
+            `${normalizeBaseUrl(baseUrl)}/ilink/bot/sendmessage`,
+            {
+                msg: {
+                    from_user_id: '',
+                    to_user_id: toUserId,
+                    client_id: randomClientId(),
+                    message_type: 2,
+                    message_state: 2,
+                    context_token: contextToken || '',
+                    item_list: [{ type: 1, text_item: { text: content.slice(0, 1800) } }]
+                },
+                base_info: baseInfo()
+            },
+            {
+                ...COMMON_AXIOS_OPTIONS,
+                headers: authHeaders(botToken),
+                timeout: 15000
+            }
+        )
+        sendResult = assertIlinkResult('sendmessage', res.data)
+    } finally {
+        if (typingTicket) {
+            await sendTyping({ baseUrl, botToken, toUserId, typingTicket, status: 2 }).catch(() => null)
+        }
+    }
+    return sendResult
+}
+
+module.exports = {
+    DEFAULT_BASE_URL,
+    getQrCode,
+    getQrCodeStatus,
+    getUpdates,
+    sendText
+}

+ 2 - 2
lib/Lepao/Mcp.js

@@ -250,7 +250,7 @@ class Mcp {
                 return '缺少参数'
 
             this.logger.info(`MCP接收设置通知请求:${sender},mode:${mode}`)
-            if (mode !== 'email' && mode !== 'bot' && mode !== 'none') return '通知type不合法,仅支持 email, bot, none三种模式'
+            if (mode !== 'email' && mode !== 'bot' && mode !== 'wechat' && mode !== 'none') return '通知type不合法,仅支持 email, bot, wechat, none'
 
             let sql = `
                 SELECT 
@@ -512,4 +512,4 @@ class Mcp {
 }
 
 const MCP = new Mcp()
-module.exports.MCP = MCP
+module.exports.MCP = MCP

+ 55 - 10
lib/Lepao/Worker.js

@@ -23,6 +23,8 @@ const { postLepaoSchool } = require('./lepaoSchoolHttp')
 const { putOssWithQgOutbound } = require('./qgOssPut')
 const { isProxyForwardEnabled } = require('../ProxyForwardClient')
 const { insertLedgerRecord } = require('./CountLedger')
+const WeixinBindingService = require('../AIChat/WeixinBindingService')
+const LepaoWechatTemplate = require('../../plugin/Wechat/lepaoWechatTemplate')
 
 const Logger = require('../Logger')
 
@@ -613,8 +615,38 @@ class Worker {
                 this.logger.error(`lepao_over Bot 通知失败: ${e.message || e}`)
             }
         }
+
+        if (noticeType === 'wechat' && user.create_user) {
+            try {
+                await WeixinBindingService.sendTextToUser(
+                    user.create_user,
+                    LepaoWechatTemplate.buildTargetComplete({
+                        name: user.name,
+                        account,
+                        total_num: total,
+                        target_count: target,
+                        traceId
+                    })
+                )
+            } catch (e) {
+                this.logger.error(`lepao_over WeChat notice failed: ${e.message || e}`)
+            }
+        }
     }
 
+    buildWechatNoticeText({ success, account, payload, reason }) {
+        if (success) {
+            return LepaoWechatTemplate.buildRunSuccess({
+                ...(payload || {}),
+                account
+            })
+        }
+        return LepaoWechatTemplate.buildRunFail({
+            ...(payload || {}),
+            account,
+            reason
+        })
+    }
     register(type, handler) {
         this.handlers[type] = handler
         this.logger.info(`注册任务: ${type}`)
@@ -876,9 +908,10 @@ class Worker {
                 SELECT 
                     a.name, 
                     a.email, 
-                    a.target_count,
-                    a.notice_type,
-                    e.bot_umo,
+	                    a.target_count,
+	                    a.notice_type,
+	                    a.create_user,
+	                    e.bot_umo,
                     a.update_type
                 FROM 
                     lepao_account a
@@ -942,13 +975,25 @@ class Worker {
                 }
             }
 
-            if (noticeType === 'bot' && user.bot_umo) {
-                await this.publishJson(this.noticeQueue, payload, {
-                    channelName: 'lepao_worker_publish_notice'
-                })
-                await afterSuccessNotify()
-                return { delivered: true, via: 'bot' }
-            }
+	            if (noticeType === 'bot' && user.bot_umo) {
+	                await this.publishJson(this.noticeQueue, payload, {
+	                    channelName: 'lepao_worker_publish_notice'
+	                })
+	                await afterSuccessNotify()
+	                return { delivered: true, via: 'bot' }
+	            }
+
+	            if (noticeType === 'wechat' && user.create_user) {
+	                try {
+	                    await WeixinBindingService.sendTextToUser(user.create_user, this.buildWechatNoticeText({ success, account, payload, reason }))
+	                    await afterSuccessNotify()
+	                    return { delivered: true, via: 'wechat' }
+	                } catch (e) {
+	                    this.logger.error(`lepao WeChat notice failed: ${e.message || e}`)
+	                    await afterSuccessNotify()
+	                    return { delivered: false, via: 'wechat' }
+	                }
+	            }
 
             if (noticeType === 'email' && user.email) {
                 if (success) {

+ 18 - 31
lib/OrderRefundService.js

@@ -11,20 +11,14 @@ function evaluateRefundEligibility({
     payTime,
     userLepaoCount,
     goodsLepaoCount,
+    allowRefund = 1,
     skipTimeLimit = false
 }) {
-    if (Number(state) === ORDER_STATE_REFUNDED) {
-        return { canRefund: false, reason: '订单已退款' }
-    }
-    if (Number(state) !== ORDER_STATE_COMPLETED) {
-        return { canRefund: false, reason: '仅已完成订单可申请退款' }
-    }
-    if (!payTime) {
-        return { canRefund: false, reason: '订单支付时间异常' }
-    }
-    if (!skipTimeLimit && Date.now() - Number(payTime) > REFUND_WINDOW_MS) {
-        return { canRefund: false, reason: '已超过7天退款期限' }
-    }
+    if (Number(state) === ORDER_STATE_REFUNDED) return { canRefund: false, reason: '订单已退款' }
+    if (Number(state) !== ORDER_STATE_COMPLETED) return { canRefund: false, reason: '仅已完成订单可申请退款' }
+    if (!skipTimeLimit && Number(allowRefund) === 0) return { canRefund: false, reason: '该商品不支持自助退款' }
+    if (!payTime) return { canRefund: false, reason: '订单支付时间异常' }
+    if (!skipTimeLimit && Date.now() - Number(payTime) > REFUND_WINDOW_MS) return { canRefund: false, reason: '已超过 7 天退款期限' }
 
     const purchasedCount = Number(goodsLepaoCount || 0)
     const remainingCount = Number(userLepaoCount || 0)
@@ -45,26 +39,22 @@ async function loadRefundContext(orderId) {
             o.pay_time,
             o.create_user,
             g.lepao_count,
-            g.ic_count
+            g.ic_count,
+            g.allow_refund
         FROM orders o
         LEFT JOIN goods g ON o.goods_id = g.id
         WHERE o.orderId = ?
         LIMIT 1`,
         [orderId]
     )
-    if (!rows || rows.length !== 1) {
-        return null
-    }
+    if (!rows || rows.length !== 1) return null
 
     const order = rows[0]
     const userRows = await db.query(
         'SELECT lepao_count, ic_count FROM users WHERE uuid = ? LIMIT 1',
         [order.create_user]
     )
-    if (!userRows || userRows.length !== 1) {
-        return null
-    }
-
+    if (!userRows || userRows.length !== 1) return null
     return { order, user: userRows[0] }
 }
 
@@ -76,9 +66,7 @@ async function executeOrderRefund({
 }) {
     try {
         const context = await loadRefundContext(orderId)
-        if (!context) {
-            return { ok: false, msg: '订单或用户不存在' }
-        }
+        if (!context) return { ok: false, msg: '订单或用户不存在' }
 
         const { order, user } = context
         const eligibility = evaluateRefundEligibility({
@@ -86,11 +74,10 @@ async function executeOrderRefund({
             payTime: order.pay_time,
             userLepaoCount: user.lepao_count,
             goodsLepaoCount: order.lepao_count,
+            allowRefund: order.allow_refund,
             skipTimeLimit
         })
-        if (!eligibility.canRefund) {
-            return { ok: false, msg: eligibility.reason }
-        }
+        if (!eligibility.canRefund) return { ok: false, msg: eligibility.reason }
 
         const deductLepao = Number(order.lepao_count || 0)
         const deductIc = Number(order.ic_count || 0)
@@ -150,7 +137,7 @@ async function executeOrderRefund({
 
             if (lockedDeductLepao > 0 && beforeLepao < lockedDeductLepao) {
                 await conn.rollback()
-                logger?.error?.(`退款支付已成功但扣次失败,需人工处理,订单号:${orderId}`)
+                logger?.error?.(`Refund paid but count deduction failed, orderId=${orderId}`)
                 return { ok: false, msg: '支付已退款但扣减次数失败,请联系客服处理' }
             }
 
@@ -163,7 +150,7 @@ async function executeOrderRefund({
             )
             if (!updateUserRes || updateUserRes.affectedRows !== 1) {
                 await conn.rollback()
-                logger?.error?.(`退款支付已成功但更新用户失败,需人工处理,订单号:${orderId}`)
+                logger?.error?.(`Refund paid but user update failed, orderId=${orderId}`)
                 return { ok: false, msg: '支付已退款但更新账户失败,请联系客服处理' }
             }
 
@@ -173,7 +160,7 @@ async function executeOrderRefund({
             )
             if (!updateOrderRes || updateOrderRes.affectedRows !== 1) {
                 await conn.rollback()
-                logger?.error?.(`退款支付已成功但更新订单失败,需人工处理,订单号:${orderId}`)
+                logger?.error?.(`Refund paid but order update failed, orderId=${orderId}`)
                 return { ok: false, msg: '支付已退款但更新订单失败,请联系客服处理' }
             }
 
@@ -195,11 +182,11 @@ async function executeOrderRefund({
             return { ok: true, msg: '退款成功' }
         } catch (dbError) {
             try { await conn.rollback() } catch (_) { }
-            logger?.error?.(`退款入账失败 ${orderId}: ${dbError.stack || dbError}`)
+            logger?.error?.(`Refund ledger failed ${orderId}: ${dbError.stack || dbError}`)
             return { ok: false, msg: '支付已退款但入账失败,请联系客服处理' }
         }
     } catch (error) {
-        logger?.error?.(`订单退款失败 ${orderId}: ${error.stack || error}`)
+        logger?.error?.(`Order refund failed ${orderId}: ${error.stack || error}`)
         return { ok: false, msg: error.message || '退款失败,请稍后再试' }
     }
 }

+ 27 - 13
lib/OrderSettlement.js

@@ -2,6 +2,7 @@ const db = require('../plugin/DataBase/db')
 const Redis = require('../plugin/DataBase/Redis')
 const { releaseUsageForOrder } = require('./CouponService')
 const { insertLedgerRecord } = require('./Lepao/CountLedger')
+const { calculateNextVipExpire } = require('./VipService')
 
 const ORDER_STATE_PENDING = 0
 const ORDER_STATE_PROCESSING = 1
@@ -105,12 +106,15 @@ async function completePaidOrder({ orderId, payType, payId, payTime = Date.now()
         }
 
         const [orderRows] = await conn.execute(
-            `SELECT
-                o.create_user,
-                g.lepao_count,
-                g.ic_count,
-                g.vip
-             FROM orders o
+	            `SELECT
+	                o.create_user,
+	                g.lepao_count,
+	                g.ic_count,
+	                g.vip,
+	                g.vip_validity_type,
+	                g.vip_valid_days,
+	                g.vip_fixed_expire_time
+	             FROM orders o
              LEFT JOIN goods g ON o.goods_id = g.id
              WHERE o.orderId = ?
              LIMIT 1`,
@@ -126,7 +130,7 @@ async function completePaidOrder({ orderId, payType, payId, payTime = Date.now()
         const addLepao = Number(order.lepao_count || 0)
         const addIc = Number(order.ic_count || 0)
         const [userRows] = await conn.execute(
-            'SELECT lepao_count FROM users WHERE uuid = ? FOR UPDATE',
+	            'SELECT lepao_count, vip, vip_expire_time FROM users WHERE uuid = ? FOR UPDATE',
             [order.create_user]
         )
         if (!userRows || userRows.length !== 1) {
@@ -135,12 +139,22 @@ async function completePaidOrder({ orderId, payType, payId, payTime = Date.now()
             return { completed: false, reason: 'user_not_found' }
         }
 
-        const beforeLepao = Number(userRows[0].lepao_count || 0)
-        const afterLepao = beforeLepao + addLepao
-        const [updateUserRes] = await conn.execute(
-            'UPDATE users SET lepao_count = lepao_count + ?, ic_count = ic_count + ?, vip = ? WHERE uuid = ?',
-            [addLepao, addIc, order.vip, order.create_user]
-        )
+	        const beforeLepao = Number(userRows[0].lepao_count || 0)
+	        const afterLepao = beforeLepao + addLepao
+	        const nextVipExpireTime = Number(order.vip || 0) === 1
+	            ? calculateNextVipExpire({
+	                currentExpireTime: userRows[0].vip_expire_time,
+	                payTime,
+	                validityType: order.vip_validity_type,
+	                validDays: order.vip_valid_days,
+	                fixedExpireTime: order.vip_fixed_expire_time
+	            })
+	            : Number(userRows[0].vip_expire_time || 0)
+	        const nextVip = nextVipExpireTime > Date.now() || (Number(userRows[0].vip || 0) === 1 && nextVipExpireTime === 0) || (Number(order.vip || 0) === 1 && nextVipExpireTime === 0) ? 1 : 0
+	        const [updateUserRes] = await conn.execute(
+	            'UPDATE users SET lepao_count = lepao_count + ?, ic_count = ic_count + ?, vip = ?, vip_expire_time = ? WHERE uuid = ?',
+	            [addLepao, addIc, nextVip, nextVipExpireTime, order.create_user]
+	        )
         if (!updateUserRes || updateUserRes.affectedRows !== 1) {
             await conn.execute('UPDATE orders SET state = ? WHERE orderId = ?', [ORDER_STATE_ERROR, orderId])
             await conn.commit()

+ 6 - 0
lib/PermissionCatalog.js

@@ -63,6 +63,12 @@ const DEFAULT_PERMISSION_RESOURCE_RULES = [
     { resource_type: 'page', resource_key: 'admin.service.orderList', required_codes: ['page.admin.service.orderList'] },
     { resource_type: 'page', resource_key: 'admin.aiChat.list', required_codes: ['page.admin.aiChat'] },
     { resource_type: 'page', resource_key: 'admin.aiChat.detail', required_codes: ['page.admin.aiChat'] },
+    { resource_type: 'page', resource_key: 'aiManage.workOrder.orderList', required_codes: ['page.admin.service.orderList'] },
+    { resource_type: 'page', resource_key: 'aiManage.workOrder.orderDetail', required_codes: ['page.admin.service.orderList'] },
+    { resource_type: 'page', resource_key: 'aiManage.aiChat.list', required_codes: ['page.admin.aiChat'] },
+    { resource_type: 'page', resource_key: 'aiManage.aiChat.detail', required_codes: ['page.admin.aiChat'] },
+    { resource_type: 'page', resource_key: 'aiManage.weixinBinding.list', required_codes: ['page.admin.aiChat'] },
+    { resource_type: 'page', resource_key: 'aiManage.quota', required_codes: ['page.admin.aiChat'] },
     { resource_type: 'page', resource_key: 'admin.goods.sendCountRequestList', required_codes: ['page.admin.goods.sendCountRequestList'] },
     { resource_type: 'page', resource_key: 'service.createOrder', required_codes: ['page.service.createOrder'] },
     { resource_type: 'page', resource_key: 'lepao.accountList', required_codes: ['page.lepao.accountList'] },

+ 1 - 1
lib/RuntimeConfig.js

@@ -1,7 +1,7 @@
 const db = require('../plugin/DataBase/db')
 const Redis = require('../plugin/DataBase/Redis')
 
-const CONFIG_KEYS = ['pay', 'email', 'unilogin', 'proxyForwardServer']
+const CONFIG_KEYS = ['pay', 'email', 'unilogin', 'proxyForwardServer', 'aiChatQuota']
 const CACHE_TTL_SECONDS = 300
 
 class RuntimeConfigNotFoundError extends Error {

+ 6 - 0
lib/Server.js

@@ -10,6 +10,7 @@ const mq = require('../plugin/mq')
 const { mq: mqName } = require('../plugin/mq/mqPrefix')
 const { startLepaoSchedulePublisher } = require('../plugin/mq/lepaoSchedulePublisher')
 const OneBotV11 = require('../plugin/OneBot/OneBotV11')
+const WeixinBindingService = require('./AIChat/WeixinBindingService')
 const AccessControl = require('./AccessControl')
 const LeaseWatcher = require('./QK/LeaseWatcher')
 const { TaskScheduler } = require('./QK/TaskScheduler')
@@ -92,6 +93,11 @@ class SERVER {
             } else if (shouldServeApi(this.serverRole)) {
                 this.logger.info('serverRole=api,跳过订单支付 MQ 消费者(由 all 进程消费)')
             }
+
+            if (shouldRunLepaoWorker(this.serverRole)) {
+                WeixinBindingService.startPolling()
+                this.logger.info('WeChat AIChat binding poller started')
+            }
         } catch (e) {
             this.logger.error('❌ RabbitMQ 初始化失败')
             process.exit(1)

+ 47 - 0
lib/VipService.js

@@ -0,0 +1,47 @@
+const db = require('../plugin/DataBase/db')
+
+function nowMs() {
+    return Date.now()
+}
+
+function isVipByUser(user) {
+    const expire = Number(user?.vip_expire_time || 0)
+    if (expire > nowMs()) return true
+    return Number(user?.vip || 0) === 1 && expire === 0
+}
+
+async function getUserVipInfo(uuid, executor = db) {
+    const rows = await executor.query(
+        'SELECT vip, vip_expire_time FROM users WHERE uuid = ? LIMIT 1',
+        [uuid]
+    )
+    const user = rows?.[0] || {}
+    const vipExpireTime = Number(user.vip_expire_time || 0)
+    return {
+        vip: isVipByUser(user),
+        vip_expire_time: vipExpireTime,
+        legacy_vip: Number(user.vip || 0)
+    }
+}
+
+function calculateNextVipExpire({ currentExpireTime = 0, payTime = nowMs(), validityType, validDays = 0, fixedExpireTime = 0 }) {
+    const current = Number(currentExpireTime || 0)
+    const paidAt = Number(payTime || nowMs())
+    const type = String(validityType || 'none')
+    if (type === 'days') {
+        const days = Math.max(0, Number(validDays || 0))
+        if (days <= 0) return current
+        const base = current > paidAt ? current : paidAt
+        return base + days * 24 * 60 * 60 * 1000
+    }
+    if (type === 'fixed') {
+        return Math.max(current, Number(fixedExpireTime || 0))
+    }
+    return current
+}
+
+module.exports = {
+    isVipByUser,
+    getUserVipInfo,
+    calculateNextVipExpire
+}

+ 123 - 0
plugin/Wechat/lepaoWechatTemplate.js

@@ -0,0 +1,123 @@
+function display(value, fallback = '-') {
+    if (value === undefined || value === null || value === '') return fallback
+    return String(value)
+}
+
+function formatTime(time = Date.now()) {
+    return new Date(Number(time) || Date.now()).toLocaleString('zh-CN', {
+        year: 'numeric',
+        month: '2-digit',
+        day: '2-digit',
+        hour: '2-digit',
+        minute: '2-digit'
+    })
+}
+
+function formatSecondsToMinSec(totalSeconds) {
+    const seconds = Number(totalSeconds) || 0
+    if (seconds <= 0) return '-'
+    const minutes = Math.floor(seconds / 60)
+    const remain = Math.floor(seconds % 60)
+    return `${minutes}分${String(remain).padStart(2, '0')}秒`
+}
+
+function calculatePace(seconds, kilometers) {
+    const totalSeconds = Number(seconds) || 0
+    const distance = Number(kilometers) || 0
+    if (totalSeconds <= 0 || distance <= 0) return '-'
+    const paceInSeconds = totalSeconds / distance
+    const minutes = Math.floor(paceInSeconds / 60)
+    const remain = Math.round(paceInSeconds % 60)
+    return `${minutes}'${String(remain).padStart(2, '0')}''`
+}
+
+function divider() {
+    return '\n---\n'
+}
+
+function kv(label, value) {
+    return `> **${label}**:${display(value)}`
+}
+
+function buildUpdateSuccess(data = {}) {
+    const autoNote = Number(data.auto_run) === 0
+        ? '当前未开启自动乐跑。如需跑步,请登录 RunForge 后手动发起。'
+        : '已为您开启自动乐跑,系统将按计划代为完成跑步任务,请留意后续提醒。'
+
+    return [
+        `## ✅ 乐跑账号信息已更新`,
+        '',
+        `${display(data.name, '同学')},您的乐跑账号登录信息已更新成功。`,
+        divider(),
+        kv('学号', data.account),
+        kv('年级', data.grade_id),
+        kv('学院', data.academy_name),
+        kv('更新时间', formatTime()),
+        divider(),
+        `**自动乐跑状态**  \n${autoNote}`,
+        '',
+        `> 请在当前登录乐跑账号的设备上使用「智慧体育」小程序;请勿在其他设备登录该小程序,以免登录状态失效并需重新绑定。`
+    ].join('\n')
+}
+
+function buildRunSuccess(data = {}) {
+    const targetCount = Number(data.target_count) || 0
+    const totalNum = Number(data.total_num) || 0
+    const timeSec = Number(data.time ?? data.used_time) || 0
+    const distanceKm = Number(data.distance) || 0
+    const goalLines = targetCount > 0
+        ? [kv('目标次数', `${targetCount} 次`), kv('累计次数', `${totalNum} 次`)]
+        : [kv('累计次数', `${totalNum} 次`)]
+
+    return [
+        `## ✅ 乐跑成功通知`,
+        '',
+        `${display(data.name, '同学')},系统已成功代您完成一次乐跑。`,
+        divider(),
+        kv('学号', data.account),
+        kv('跑区', data.pass_tit || data.run_zone_name),
+        kv('用时', formatSecondsToMinSec(timeSec)),
+        kv('平均配速', calculatePace(timeSec, distanceKm)),
+        kv('距离', distanceKm > 0 ? `${distanceKm} km` : '-'),
+        ...goalLines,
+        kv('完成时间', formatTime()),
+        divider(),
+        `> 若已开启自动乐跑,请勿在除更新乐跑账号信息以外的其他设备登录「智慧体育」小程序,以免登录状态失效。`
+    ].join('\n')
+}
+
+function buildRunFail(data = {}) {
+    return [
+        `## ❌ 乐跑未能完成`,
+        '',
+        `${display(data.name || data.account, '同学')},系统在尝试执行乐跑任务时未成功。`,
+        divider(),
+        kv('学号', data.account),
+        kv('时间', formatTime()),
+        kv('原因', data.reason || '系统繁忙,请稍后再试'),
+        divider(),
+        `> 若为登录失效,请在 RunForge 乐跑登录器中重新完成登录后再试。若问题持续存在,请联系 RunForge 客服并说明学号与失败时间。`
+    ].join('\n')
+}
+
+function buildTargetComplete(data = {}) {
+    return [
+        `## 🏁 乐跑目标已全部完成`,
+        '',
+        `${display(data.name || data.account, '同学')},您设定的乐跑目标已全部完成,系统已为您关闭自动乐跑功能。`,
+        divider(),
+        kv('学号', data.account),
+        kv('目标次数', `${display(data.target_count, 0)} 次`),
+        kv('累计次数', `${display(data.total_num, 0)} 次`),
+        kv('完成时间', formatTime()),
+        divider(),
+        `如仍需跑步,可在 RunForge 中按需重新开启相关功能。`
+    ].join('\n')
+}
+
+module.exports = {
+    buildUpdateSuccess,
+    buildRunSuccess,
+    buildRunFail,
+    buildTargetComplete
+}