Przeglądaj źródła

查询助手相关

MaiXinRong 6 dni temu
rodzic
commit
0d02cbbd16
2 zmienionych plików z 187 dodań i 20 usunięć
  1. 114 0
      app/controller/assistant_controller.js
  2. 73 20
      app/service/assistant.js

+ 114 - 0
app/controller/assistant_controller.js

@@ -0,0 +1,114 @@
+'use strict';
+
+const AiDify = require('../lib/ai_dify');
+
+/**
+ * 全局业务查询助手
+ *
+ * @param {Object} app Egg应用
+ * @return {Object} 助手Controller
+ * @author Codex
+ */
+module.exports = app => {
+    class AssistantController extends app.BaseController {
+        /**
+         * 查询业务数据
+         *
+         * @param {Object} ctx - egg全局页面
+         * @return {void}
+         */
+        async query(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data || '{}');
+                const question = data.question ? data.question.trim() : '';
+                if (!question) throw '请输入要查询的内容';
+                if (question.length > 200) throw '问题不能超过200个字符';
+                ctx.body = {
+                    err: 0,
+                    msg: '',
+                    data: await ctx.service.assistant.query(question, data.context || {}),
+                };
+            } catch (err) {
+                console.log(err);
+                this.log(err);
+                ctx.body = this.ajaxErrorBody(err, '助手查询失败');
+            }
+        }
+
+        /** v2:Chatflow 分类和解析,共用 assistant Service 的业务查询。 */
+        async queryV2(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data || '{}');
+                const question = typeof data.question === 'string' ? data.question.trim() : '';
+                if (!question) throw '请输入要查询的内容';
+                if (question.length > 200) throw '问题不能超过200个字符';
+                const sessionUser = ctx.session.sessionUser;
+                if (sessionUser.loginType === 4) throw '游客账号暂不支持使用查询助手';
+                const context = data.context && typeof data.context === 'object' ? data.context : {};
+                const path = typeof context.path === 'string' ? context.path.split(/[?#]/)[0] : '';
+                if (path.length > 1024 || (path && !/^\/(?!\/)/.test(path))) throw '页面路径不正确';
+                const conversationId = data.conversationId || '';
+                if (typeof conversationId !== 'string' || (conversationId && !/^[a-f0-9-]{36}$/i.test(conversationId))) {
+                    throw '助手会话参数不正确,请清空对话后重试';
+                }
+                const config = app.config.difyInfo;
+                if (!config || !config.apiKey || !config.apiKey.assistant || !config.baseUrl) throw '尚未配置查询助手 Chatflow';
+                const dify = new AiDify({ baseUrl: config.baseUrl, apiKey: config.apiKey.assistant });
+                // 会话归属由 Dify 按服务端生成的 user 校验,不能使用客户端传入的用户 ID。
+                const user = `assistant-${app.config.env}-${ctx.session.sessionProject.id}-${sessionUser.accountId}`;
+                let response;
+                try {
+                    response = await dify.askAssistant({ question, path, user, conversationId });
+                } catch (err) {
+                    // ai_dify 已将网络错误转换为不含密钥、请求正文的安全提示。
+                    throw err.message;
+                }
+                const parsed = response.result;
+                let result = {
+                    answer: parsed.answer,
+                    summary: [],
+                    items: parsed.sources.filter(source => source && typeof source.title === 'string' &&
+                        typeof source.url === 'string' && /^https?:\/\//i.test(source.url))
+                        .slice(0, 20).map(source => ({ title: source.title, url: source.url })),
+                    suggestions: [],
+                };
+                if (parsed.type === 'business' && parsed.status === 'query') {
+                    try {
+                        result = await ctx.service.assistant.queryParameters(parsed.query, Object.assign({}, context, { path }));
+                    } catch (err) {
+                        this.log(err);
+                        // 保留已完成解析的会话,让用户能补充标段、期号等条件。
+                        result = { answer: typeof err === 'string' ? err : '业务查询失败,请稍后重试。', summary: [], items: [], suggestions: [] };
+                    }
+                }
+                result.conversationId = response.conversationId;
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (err) {
+                console.log(err);
+                this.log(err);
+                ctx.body = this.ajaxErrorBody(err, '助手查询失败');
+            }
+        }
+
+        /** 下载查询助手返回的统一附件。 */
+        async attachment(ctx) {
+            try {
+                const file = await ctx.service.assistant.getAttachmentFile(ctx.params.source, ctx.params.id);
+                const disposition = `attachment; filename*=UTF-8''${encodeURIComponent(file.filename)}`;
+                const headers = {
+                    'Content-Type': 'application/octet-stream',
+                    'Content-Disposition': disposition,
+                };
+                if (/^[1-9]\d*$/.test(String(file.filesize))) headers['Content-Length'] = file.filesize;
+                ctx.response.set(headers);
+                ctx.body = await ctx.helper.ossFileGet(file.filepath);
+            } catch (err) {
+                this.log(err);
+                ctx.status = 404;
+                ctx.body = this.ajaxErrorBody(err, '附件下载失败');
+            }
+        }
+    }
+
+    return AssistantController;
+};

+ 73 - 20
app/service/assistant.js

@@ -1845,26 +1845,52 @@ module.exports = app => {
             const result = [];
             const contractIds = new Set((await this._loadContracts(scope)).map(item => String(item.id)));
             for (const source of this._attachmentSources()) {
-                const where = { [source.tenderField]: tenderId };
-                if (source.notDeleted) where.is_deleted = 0;
-                const rows = await this.ctx.service[source.service].getAllDataByCondition({ where });
+                let rows = [];
+                if (source.parent) {
+                    const parents = source.parent.permission === 'financialPay'
+                        ? await this.ctx.service.financialPay.getListByStatus(scope.subProjects[0].id, null, null, 0, [tenderId])
+                        : await this.ctx.service[source.parent.service].getAllDataByCondition({ where: { tid: tenderId } });
+                    for (const parent of parents) {
+                        rows.push(...await this.ctx.service[source.service].getAllDataByCondition({
+                            where: { [source.parent.foreignKey]: parent.id },
+                        }));
+                    }
+                } else {
+                    const where = { [source.tenderField]: tenderId };
+                    if (source.notDeleted) where.is_deleted = 0;
+                    rows = await this.ctx.service[source.service].getAllDataByCondition({ where });
+                }
                 for (const row of rows) {
                     if (source.contractScoped && !contractIds.has(String(row.cid))) continue;
-                    const filename = String(row.file_name || row.filename || '').trim();
-                    const fileext = String(row.fileext || '').trim();
-                    const fullName = filename && fileext && !filename.toLowerCase().endsWith(fileext.toLowerCase())
-                        ? filename + fileext : filename;
-                    if (!fullName || !row.filepath) continue;
-                    if (keyword && `${fullName} ${source.label}`.toLowerCase().indexOf(keyword) === -1) continue;
-                    const uploadTime = row.create_time || row.upload_time || row.update_time || row.in_time || '';
-                    result.push({
-                        source: source.key,
-                        id: String(row.id),
-                        filename: fullName,
-                        module: source.label,
-                        uploadTime,
-                        filesize: row.filesize || row.file_size || '',
-                    });
+                    let files = [row];
+                    if (source.embedded) {
+                        try {
+                            const attachment = JSON.parse(row.attachment || '[]');
+                            files = Array.isArray(attachment) ? attachment.map(file => Object.assign({}, file, {
+                                id: `${row.id}:${file.file_id || file.id}`,
+                                create_time: file.in_time || row.in_time,
+                            })) : [];
+                        } catch (err) {
+                            files = [];
+                        }
+                    }
+                    for (const file of files) {
+                        const filename = String(file.file_name || file.filename || '').trim();
+                        const fileext = String(file.fileext || '').trim();
+                        const fullName = filename && fileext && !filename.toLowerCase().endsWith(fileext.toLowerCase())
+                            ? filename + fileext : filename;
+                        if (!fullName || !file.filepath) continue;
+                        if (keyword && `${fullName} ${source.label}`.toLowerCase().indexOf(keyword) === -1) continue;
+                        const uploadTime = file.create_time || file.upload_time || file.update_time || file.in_time || '';
+                        result.push({
+                            source: source.key,
+                            id: String(file.id),
+                            filename: fullName,
+                            module: source.label,
+                            uploadTime,
+                            filesize: file.filesize || file.file_size || '',
+                        });
+                    }
                 }
             }
             result.sort((a, b) => new Date(b.uploadTime || 0).getTime() - new Date(a.uploadTime || 0).getTime());
@@ -1891,10 +1917,35 @@ module.exports = app => {
         async getAttachmentFile(sourceKey, id) {
             const source = this._attachmentSources().find(item => item.key === sourceKey);
             if (!source || !id) throw '附件参数不正确';
-            const file = await this.ctx.service[source.service].getDataById(id);
+            let file;
+            let tenderId;
+            if (source.embedded) {
+                const separator = id.indexOf(':');
+                if (separator <= 0) throw '附件参数不正确';
+                const row = await this.ctx.service[source.service].getDataById(id.slice(0, separator));
+                let attachment = [];
+                try {
+                    attachment = row && JSON.parse(row.attachment || '[]');
+                } catch (err) {
+                    attachment = [];
+                }
+                file = Array.isArray(attachment) ? attachment.find(item => String(item.file_id || item.id) === id.slice(separator + 1)) : null;
+                tenderId = row && String(row.tid || '');
+            } else {
+                file = await this.ctx.service[source.service].getDataById(id);
+                if (file && source.parent) {
+                    const parent = await this.ctx.service[source.parent.service].getDataById(file[source.parent.foreignKey]);
+                    tenderId = parent && String(parent.tid || '');
+                    if (parent && source.parent.permission === 'financialPay') {
+                        const allowed = await this.ctx.service.financialPay.getListByStatus(parent.spid, null, null, 0, [parent.tid]);
+                        if (!allowed.some(item => String(item.id) === String(parent.id))) throw '您无权查看该附件';
+                    }
+                } else {
+                    tenderId = file && String(file[source.tenderField] || '');
+                }
+            }
             if (!file || (source.notDeleted && Number(file.is_deleted) !== 0) || !file.filepath) throw '附件不存在或已删除';
             const scope = await this._loadScope('', {});
-            const tenderId = String(file[source.tenderField] || '');
             const tender = scope.tenders.find(item => String(item.id) === tenderId);
             if (!tenderId || !tender) throw '您无权查看该附件';
             if (source.contractScoped) {
@@ -1921,6 +1972,7 @@ module.exports = app => {
             return [
                 { key: 'ledger', service: 'ledgerAtt', tenderField: 'tid', label: '台账' },
                 { key: 'stage', service: 'stageAtt', tenderField: 'tid', label: '计量' },
+                { key: 'stage_detail', service: 'stageDetailAtt', tenderField: 'tid', label: '计量清单附件', embedded: true },
                 { key: 'stage_shoufang', service: 'stageShoufangAtt', tenderField: 'tid', label: '收方记录' },
                 { key: 'material', service: 'materialFile', tenderField: 'tid', label: '材料调差' },
                 { key: 'advance', service: 'advanceFile', tenderField: 'tid', label: '预付款' },
@@ -1935,6 +1987,7 @@ module.exports = app => {
                 { key: 'contract_supplement', service: 'contractSupplementAtt', tenderField: 'tid', label: '合同补充协议', contractScoped: true },
                 { key: 'contract_pay', service: 'contractPayAtt', tenderField: 'tid', label: '合同支付', contractScoped: true },
                 { key: 'construction', service: 'constructionAtt', tenderField: 'tid', label: '施工日志' },
+                { key: 'financial_pay', service: 'financialPayAtt', label: '资金支付', parent: { service: 'financialPay', foreignKey: 'fpid', permission: 'financialPay' } },
                 { key: 'cost', service: 'costStageFile', tenderField: 'tid', label: '成本管理', notDeleted: true },
                 { key: 'quality', service: 'qualityFile', tenderField: 'tid', label: '质量管理', notDeleted: true },
                 { key: 'quality_inspection', service: 'qualityInspectionAtt', tenderField: 'tid', label: '质量巡检' },