"use strict"; const superagent = require("superagent"); const qiniu = require("qiniu"); const { Readable } = require("stream"); const fsPromises = require("fs").promises; const fs = require("fs"); const path = require("path"); const os = require("os"); const { randomUUID } = require("crypto"); const { convertPdfToImages } = require("../utils/pdfToImages"); const { getFastGptToken } = require("../utils/fastgptToken"); const pepTokenCache = { token: "", expiresAt: 0, }; const knowledgeTokenCache = { token: "", expiresAt: 0, }; const PEP_TOKEN_TTL_MS = 1000 * 60 * 60; // 50分钟 const KNOWLEDGE_TOKEN_TTL_MS = 1000 * 60 * 60; const getPepToken = async (ctx) => { const now = Date.now(); if (pepTokenCache.token && pepTokenCache.expiresAt > now) { return pepTokenCache.token; } const { emisApi, username, password } = ctx.config.pep; const loginRes = await superagent .post(`${emisApi}/login`) .send({ username, password }); if (loginRes.status !== 200 || !loginRes.body?.token) { throw new Error("登录失败"); } pepTokenCache.token = loginRes.body.token; pepTokenCache.expiresAt = now + PEP_TOKEN_TTL_MS; return pepTokenCache.token; }; const getKnowledgeBaseUrl = (ctx) => { const baseUrl = ctx.config?.pep?.knowledgeUrl || process.env.FS_KNOWLEDGE_URL || ""; return String(baseUrl || "").replace(/\/+$/, ""); }; const resetKnowledgeTokenCache = () => { knowledgeTokenCache.token = ""; knowledgeTokenCache.expiresAt = 0; }; const getKnowledgeToken = async (ctx, { forceRefresh = false } = {}) => { const now = Date.now(); if ( !forceRefresh && knowledgeTokenCache.token && knowledgeTokenCache.expiresAt > now ) { return knowledgeTokenCache.token; } const knowledgeBaseUrl = getKnowledgeBaseUrl(ctx); if (!knowledgeBaseUrl) { throw new Error("未配置知识产权系统地址"); } const { username, password } = ctx.config.pep || {}; const loginRes = await superagent .post(`${knowledgeBaseUrl}/_api/login`) .send({ phone: username, password }); if (loginRes.status !== 200 || !loginRes.body?.token) { throw new Error("知识产权系统登录失败"); } knowledgeTokenCache.token = loginRes.body.token; knowledgeTokenCache.expiresAt = now + KNOWLEDGE_TOKEN_TTL_MS; return knowledgeTokenCache.token; }; const isKnowledgeTokenExpired = (error) => { const status = error?.status || error?.response?.status; if (status === 401) return true; const message = String( error?.response?.body?.message || error?.message || "", ).toLowerCase(); return ( status === 403 && (message.includes("token") || message.includes("登录") || message.includes("认证") || message.includes("unauthorized")) ); }; const requestKnowledgeApi = async ( ctx, { method = "get", path: requestPath, query, body, buffer = false, responseType, } = {}, ) => { const knowledgeBaseUrl = getKnowledgeBaseUrl(ctx); if (!knowledgeBaseUrl) { throw new Error("未配置知识产权系统地址"); } const execute = async (forceRefresh = false) => { const token = await getKnowledgeToken(ctx, { forceRefresh }); let req = superagent[method](`${knowledgeBaseUrl}${requestPath}`).set( "Token", token, ); if (query) req = req.query(query); if (body) req = req.send(body); if (buffer) req = req.buffer(true); if (responseType) req = req.responseType(responseType); return req; }; try { return await execute(false); } catch (error) { if (!isKnowledgeTokenExpired(error)) { throw error; } resetKnowledgeTokenCache(); return execute(true); } }; const parseUserId = (value) => { if (value == null) return null; const str = String(value); const match = str.match(/pep_(\d+)_/i); const id = match ? match[1] : str; const num = Number(id); return Number.isNaN(num) ? null : num; }; const normalizeVisibleUsers = (list) => { if (!Array.isArray(list)) return []; const ids = list .map((item) => parseUserId(item)) .filter((val) => val != null); return Array.from(new Set(ids)); }; const ensureCreatorVisible = (visibleUsers, creator) => { const creatorId = parseUserId(creator); if (!creatorId) return visibleUsers; if (visibleUsers.includes(creatorId)) return visibleUsers; return [...visibleUsers, creatorId]; }; const uploadFileToQiniu = async (ctx, filePath, key) => { const { qiniu: { dmn: domain, bkt: bucket, ak: accessKey, sk: secretKey }, } = ctx.config; const resolvedDomain = domain || process.env.FS_QINIU_DOMAIN || ""; const mac = new qiniu.auth.digest.Mac(accessKey, secretKey); const putPolicy = new qiniu.rs.PutPolicy({ scope: bucket }); const uploadToken = putPolicy.uploadToken(mac); const config = new qiniu.conf.Config(); config.zone = qiniu.zone.Zone_z0; const formUploader = new qiniu.form_up.FormUploader(config); const putExtra = new qiniu.form_up.PutExtra(); const uploadResult = await new Promise((resolve, reject) => { formUploader.putFile( uploadToken, key, filePath, putExtra, (respErr, respBody) => { if (respErr) { reject(respErr); return; } resolve(respBody); }, ); }); return resolvedDomain ? `${resolvedDomain}/${uploadResult.key}` : uploadResult.key; }; const LIBRARY_TYPE_META = { patent: { label: "专利证书库", type: "patentImages" }, software: { label: "软著证书库", type: "softwareImages" }, patentImages: { label: "专利证书库", type: "patentImages" }, softwareImages: { label: "软著证书库", type: "softwareImages" }, }; const deriveBaseNameFromUrl = (url) => { if (!url) return ""; const cleanUrl = decodeURIComponent(String(url).split("#")[0].split("?")[0]); const base = path.basename(cleanUrl); const ext = path.extname(base); return base.slice(0, base.length - ext.length); }; const ensureUserLibraryByType = async (ctx, { userId, libraryType, departmentId }) => { const { models } = ctx.app.fs.dc; const uid = parseUserId(userId); const creatorValue = String(userId); if (!uid) { throw new Error("无效的userId"); } const meta = LIBRARY_TYPE_META[libraryType]; if (!meta) { throw new Error( "libraryType仅支持 patent/software 或 patentImages/softwareImages", ); } const { label, type: normalizedType } = meta; const libraryName = `${label}-${uid}`; // 优先使用 libraryType + creator 定位库,避免名称变更影响判断;兼容旧数据再回退到 name。 let existing = await models.TenderImageLibrary.findOne({ where: { creator: creatorValue, libraryType: normalizedType }, }); if (!existing) { existing = await models.TenderImageLibrary.findOne({ where: { name: libraryName }, }); } if (existing) { return existing; } if (!departmentId) { throw new Error("创建专利/软著图库需要departmentId"); } const visibleUserIds = ensureCreatorVisible([], userId); return models.TenderImageLibrary.create( { name: libraryName, description: `${label}(用户${uid})`, creator: creatorValue, libraryType: normalizedType, departmentId, visibilityScope: "custom", visibleUserIds, }, { returning: true }, ); }; const buildImageTitle = ({ baseName, page, index }) => { const safeBaseName = baseName || "未命名文件"; const pageNumber = Number(page) || index + 1; return `${safeBaseName}-page-${pageNumber}`; }; const deriveGroupFromTitle = (title) => { const raw = String(title || "").trim(); if (!raw) return { groupKey: "", page: null, isPaged: false }; const match = raw.match(/^(.*?)-page-(\d+)$/i); if (!match) { return { groupKey: raw, page: null, isPaged: false }; } return { groupKey: match[1].trim() || raw, page: Number(match[2]) || null, isPaged: true, }; }; const buildStableGroupKey = (baseName, sourceUrl) => { const fromBase = String(baseName || "").trim(); if (fromBase) return fromBase; const fromUrl = deriveBaseNameFromUrl(sourceUrl); return fromUrl || ""; }; // ==================== 图片库相关接口 ==================== /** * 获取图片库列表 */ module.exports.getLibraryList = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { page, pageSize, keyword, departmentId, userId } = ctx.request.query; const where = {}; if (departmentId) { where.departmentId = departmentId; } // 支持按名称模糊搜索 if (keyword) { const Op = ctx.app.fs.dc.ORM.Op; where.name = { [Op.like]: `%${keyword}%` }; } const options = { where, order: [["updatedAt", "DESC"]], raw: true, }; // 分页处理 if (page && pageSize) { options.offset = (page - 1) * pageSize; options.limit = parseInt(pageSize); } const libraryList = await models.TenderImageLibrary.findAndCountAll(options); const uid = parseUserId(userId); const rows = (libraryList.rows || []).filter((item) => { const scope = item.visibilityScope || "all"; const isCreator = uid ? parseUserId(item.creator) === uid : false; if (scope === "all") { // 未传部门时,避免“公开库”误返回全量数据,只保留本人创建。 if (!departmentId) return isCreator; return true; } const visibleUsers = Array.isArray(item.visibleUserIds) ? item.visibleUserIds : []; // 可见人员为空时,表示“全部门可见”;但未传部门时仅返回本人创建。 if (!visibleUsers.length) { if (!departmentId) return isCreator; return true; } if (!uid) return false; if (isCreator) return true; return visibleUsers.includes(uid); }); ctx.body = { ...libraryList, rows:rows || [], count: rows.length || 0 }; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "获取图片库列表失败", }; } }; /** * 获取图片库详情 */ module.exports.getLibraryDetail = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { libraryId } = ctx.params; if (!libraryId) { throw "缺少参数"; } const library = await models.TenderImageLibrary.findOne({ where: { id: libraryId }, include: [ { model: models.TenderImages, }, ], }); if (!library) { throw "图片库不存在"; } ctx.body = library; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "获取图片库详情失败", }; } }; /** * 获取图片库分组(用于专利/软著库的“管理文件夹”展示) * 规则:标题形如 xxx-page-N 的图片按 xxx 分组,且仅当分组内数量>1时作为文件夹返回 */ module.exports.getLibraryGroups = async (ctx, next) => { try { const { models, ORM } = ctx.app.fs.dc; const { libraryId } = ctx.params; const { keyword, departmentId } = ctx.request.query || {}; if (!libraryId) { throw new Error("缺少参数:libraryId"); } const libraryWhere = { id: libraryId }; if (departmentId) { libraryWhere.departmentId = departmentId; } const library = await models.TenderImageLibrary.findOne({ where: libraryWhere, raw: true, }); if (!library) { throw new Error("图片库不存在或无权限"); } const where = { libraryId }; if (keyword) { where.title = { [ORM.Op.like]: `%${keyword}%` }; } const images = await models.TenderImages.findAll({ where, order: [["createdAt", "DESC"]], raw: true, }); const groupMap = new Map(); images.forEach((img) => { const derived = deriveGroupFromTitle(img.title); const stableGroupKey = (img.groupKey && String(img.groupKey).trim()) || derived.groupKey; const stablePage = img.pageNo ?? derived.page; if (!stableGroupKey || stablePage == null) return; if (!groupMap.has(stableGroupKey)) groupMap.set(stableGroupKey, []); groupMap.get(stableGroupKey).push({ ...img, groupKey: stableGroupKey, page: Number(stablePage) || null, }); }); const groups = Array.from(groupMap.entries()) .map(([groupKey, items]) => ({ groupKey, count: items.length, items: items.sort((a, b) => (a.page || 0) - (b.page || 0)), })) .filter((group) => group.count > 1) .sort((a, b) => b.count - a.count); const groupedKeys = new Set(groups.map((g) => g.groupKey)); const singles = images .filter((img) => { const derived = deriveGroupFromTitle(img.title); const stableGroupKey = (img.groupKey && String(img.groupKey).trim()) || derived.groupKey; const stablePage = img.pageNo ?? derived.page; if (!stableGroupKey || stablePage == null) return true; return !groupedKeys.has(stableGroupKey); }) .map((img) => { const derived = deriveGroupFromTitle(img.title); return { ...img, groupKey: img.groupKey || derived.groupKey, page: img.pageNo ?? derived.page, isPaged: derived.isPaged, }; }); ctx.status = 200; ctx.body = { libraryId: Number(libraryId), groups, singles, }; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: error?.message || "获取图片库分组失败", }; } }; /** * 创建图片库 */ module.exports.addLibrary = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { name, description, creator, departmentId, visibleUsers, } = ctx.request.body; if (!name) { throw "缺少参数:name"; } const normalizedVisible = normalizeVisibleUsers(visibleUsers); const isAllVisible = !normalizedVisible.length; const library = await models.TenderImageLibrary.create( { name, description, creator, departmentId, visibilityScope: isAllVisible ? "all" : "custom", visibleUserIds: isAllVisible ? [] : normalizedVisible, }, { returning: true }, ); ctx.body = library; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "创建图片库失败", }; } }; /** * 修改图片库 */ module.exports.updateLibrary = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { libraryId } = ctx.params; const body = ctx.request.body; if (!libraryId) { throw "缺少参数"; } const library = await models.TenderImageLibrary.findOne({ where: { id: libraryId }, raw: true, }); if (!library) { throw "图片库不存在"; } let payload = { ...body }; if (Array.isArray(body.visibleUsers)) { const normalizedVisible = normalizeVisibleUsers(body.visibleUsers); const isAllVisible = !normalizedVisible.length; payload.visibleUserIds = isAllVisible ? [] : normalizedVisible; payload.visibilityScope = isAllVisible ? "all" : "custom"; } await models.TenderImageLibrary.update( payload, { where: { id: libraryId } }, ); ctx.status = 204; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "修改图片库失败", }; } }; /** * 删除图片库(级联删除关联的图片) */ module.exports.deleteLibrary = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models, ORM: { Op }, } = ctx.app.fs.dc; const { libraryId } = ctx.params; if (!libraryId) { throw "缺少参数"; } // 查询图片库里所有有collectionId的图片 const images = await models.TenderImages.findAll({ attributes: ["id", "datasetCollectionId"], where: { libraryId, datasetCollectionId: { [Op.ne]: null } }, transaction, }); // 由于数据库设置了级联删除,直接删除图片库即可 await models.TenderImageLibrary.destroy({ where: { id: libraryId }, transaction, }); // 从FastGPT知识库删除图片数据 for (const image of images) { if (image.datasetCollectionId) { deleteCollectionToFastGpt(ctx, image.datasetCollectionId); } } ctx.status = 204; await transaction.commit(); } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "删除图片库失败", }; } }; // ==================== 图片相关接口 ==================== /** * 获取图片列表 */ module.exports.getImageList = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { page, pageSize, libraryId, keyword, departmentId, webSearch } = ctx.request.query; const where = {}; const Op = ctx.app.fs.dc.ORM.Op; // 按图片库ID筛选 if (libraryId) { where.libraryId = libraryId; } // 支持按标题模糊搜索 if (keyword) { where.title = { [Op.like]: `%${keyword}%` }; } const tenderImageLibraryWhere = {}; if (departmentId) { tenderImageLibraryWhere.departmentId = departmentId; } const options = { where, order: [["createdAt", "DESC"]], include: [ { model: models.TenderImageLibrary, attributes: ["id", "name"], where: tenderImageLibraryWhere, }, { model: models.TenderImageTagRelation, include: [ { model: models.TenderImageTags, attributes: ["id", "tagName"], }, ], }, ], }; // 分页处理 if (page && pageSize) { options.offset = (page - 1) * pageSize; options.limit = parseInt(pageSize); } const imageList = await models.TenderImages.findAndCountAll(options); let returnList = imageList; if (webSearch === "true" && keyword) { try { const { apiUrl: apiHzUrl, id: apiHzId, key: apiHzKey } = ctx.app.fs.config.apihz; const webSearchResults = await superagent .get(`${apiHzUrl}/api/img/apihzimgbaidu.php`) .query({ id: apiHzId, key: apiHzKey, words: keyword, // 搜索关键词 page: 1, // 页码(默认1) limit: 20, // 返回数量(1-100,默认1) type: 1 // 返回源类型:1=百度预览图(默认),2=原始图(可能失效) }); if (webSearchResults && webSearchResults.body && webSearchResults.body.res && webSearchResults.body.res.length > 0 ) { returnList.webSearchRows = webSearchResults.body.res; } else if (webSearchResults?.body?.code === 400 && webSearchResults?.body?.msg?.startsWith("调用频次过快")) { returnList.webSearchRows = []; returnList.webSearchMsg = "联网搜索调用频次过快,请稍后再试"; } } catch (error) { console.log("[webSearchImages] Error:", error); } } ctx.body = returnList; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "获取图片列表失败", }; } }; /** * 获取图片详情 */ module.exports.getImageDetail = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { imageId } = ctx.params; if (!imageId) { throw "缺少参数"; } const image = await models.TenderImages.findOne({ where: { id: imageId }, include: [ { model: models.TenderImageLibrary, attributes: ["id", "name"], }, { model: models.TenderImageTagRelation, include: [ { model: models.TenderImageTags, attributes: ["id", "tagName"], }, ], }, ], }); if (!image) { throw "图片不存在"; } ctx.body = image; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "获取图片详情失败", }; } }; /** * 添加图片 */ module.exports.addImage = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models } = ctx.app.fs.dc; const { libraryId, title, imageUrl, images } = ctx.request.body || {}; if (!libraryId) { throw "缺少必要参数"; } const sourceImages = Array.isArray(images) && images.length ? images : [{ title, imageUrl }]; const normalizedImages = sourceImages .map((item) => { const itemTitle = item?.title; const itemImageUrl = item?.imageUrl; if (!itemTitle || !itemImageUrl) return null; return { libraryId, title: itemTitle, description: item?.description, imageUrl: itemImageUrl, status: "training", }; }) .filter(Boolean); if (!normalizedImages.length) { throw "缺少必要参数"; } const createdImages = []; for (const item of normalizedImages) { const image = await models.TenderImages.create(item, { returning: true, transaction, }); createdImages.push(image); } await transaction.commit(); ctx.body = Array.isArray(images) ? createdImages : createdImages[0]; ctx.status = 200; createdImages.forEach((image) => { uploadImageToFastGpt(ctx, image); }); } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "添加图片失败", }; } }; /** * 解析图片数据并上传到FastGPT知识库 */ async function uploadImageToFastGpt(ctx, image) { const { models } = ctx.app.fs.dc; try { const { apiUrl, v2TenderImageAppKey, imageDatasetId } = ctx.app.fs.config.fastGpt; // 获取FastGPT Token(使用缓存) let fastgptToken = await getFastGptToken(ctx.app.fs.config); // 分析图片特征 const imageFeatureRes = await superagent .post(`${apiUrl}/api/v1/chat/completions`) .send({ stream: false, detail: false, messages: [ { role: "user", content: [ { type: "image_url", image_url: { url: image.imageUrl }, }, ], }, ], }) .set({ Authorization: `Bearer ${v2TenderImageAppKey}`, "Content-Type": "application/json", }); const imageFeature = imageFeatureRes.body.choices[0].message.content; // 构建txt文件内容 const txtContent = `图片URL:${image.imageUrl}\n图片标题:${image.title}\n图片描述:${imageFeature}`; // 调用 FastGPT 上传文件接口(带重试逻辑) //修改部分 // const fileName = encodeURIComponent(`${image.id}-${image.title}.txt`); const fileName = `${image.id}-${image.title}`; let fastgptFileId = null; let retryCount = 0; const maxRetries = 1; // 确认参数开始训练 const qaPrompt = ` 标记中是一段文本,学习和分析它,并整理学习成果: - 提出问题并给出每个问题的答案。 - 答案需详细完整,尽可能保留原文描述 - 答案可以包含普通文字、链接、代码、表格、公示、媒体链接等 Markdown 元素。 - 生成的问题和答案和源文本语言相同。 - 如果实在找不到问题的答案则留空,不要胡乱填写 - 问题1:图片URL是什么? - 问题2:请从文本中"图片标题:"后面提取图片标题是什么? - 问题3:图片描述是什么?`; const trainRes = await superagent //修改部分 // .post(`${apiUrl}/api/core/dataset/collection/create/fileId`) .post(`${apiUrl}/api/core/dataset/collection/create/text`)//修改部分 .set("Content-Type", "application/json") .set("Accept", "application/json, text/plain, */*") .set("Cookie", `fastgpt_token=${fastgptToken}`) //修改部分,传参修改成qa .send({ datasetId: imageDatasetId, text: txtContent, name: fileName, trainingType: "qa", chunkSettingMode: "auto", qaPrompt, }); const datasetCollectionId = trainRes.body.data.collectionId; // 更新图片表 await models.TenderImages.update( { description: imageFeature, datasetCollectionId: datasetCollectionId, status: "trainingSuccess", }, { where: { id: image.id } }, ); } catch (error) { console.error(`[uploadImageToFastGpt imageId: ${image.id}]: ${error}`); try { await models.TenderImages.update( { status: "trainingFailure" }, { where: { id: image.id } }, ); } catch (error) { console.error( `[uploadImageToFastGpt imageId: ${image.id}]: 更新状态失败`, ); } } } //修改部分// * 从FastGPT知识库删除图片数据 async function deleteCollectionToFastGpt(ctx, collectionId) { const { apiUrl } = ctx.app.fs.config.fastGpt; let fastgptToken = await getFastGptToken(ctx.app.fs.config); let retryCount = 0; const maxRetries = 1; while (retryCount <= maxRetries) { try { await superagent .delete(`${apiUrl}/api/core/dataset/collection/delete?id=${collectionId}`) .set("Cookie", `fastgpt_token=${fastgptToken}`); break; } catch (error) { const isAuthError = error?.response?.text?.includes("unAuthorization") || error?.response?.statusCode === 401 || error?.status === 401; if (isAuthError && retryCount < maxRetries) { console.log(`[deleteCollectionToFastGpt] Token认证失败,尝试重新获取token`); fastgptToken = await getFastGptToken(ctx.app.fs.config, true); retryCount++; } else if (error?.response?.body?.code === 501003) { // collection 不存在,不需要再删 break; } else { throw error; } } } } /** * 修改图片 */ module.exports.updateImage = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { imageId } = ctx.params; const body = ctx.request.body; if (!imageId) { throw "缺少参数"; } await models.TenderImages.update( { ...body, status: "waitSync" }, { where: { id: imageId } }, ); ctx.status = 204; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "修改图片失败", }; } }; /** * 删除图片 */ module.exports.deleteImage = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models } = ctx.app.fs.dc; const { imageId } = ctx.params; if (!imageId) { throw "缺少参数"; } const image = await models.TenderImages.findByPk(imageId); if (!image) { throw "图片不存在"; } // 级联删除会自动删除关联的标签关系 await models.TenderImages.destroy({ where: { id: imageId }, transaction, }); // 从FastGPT知识库删除图片数据 if (image.datasetCollectionId) { await deleteCollectionToFastGpt(ctx, image.datasetCollectionId); } ctx.status = 204; await transaction.commit(); } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "删除图片失败", }; } }; // ==================== 标签相关接口 ==================== /** * 获取标签列表 */ module.exports.getTagList = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { page, pageSize, keyword } = ctx.request.query; const where = {}; // 支持按标签名模糊搜索 if (keyword) { const Op = ctx.app.fs.dc.ORM.Op; where.tagName = { [Op.like]: `%${keyword}%` }; } const options = { where, order: [["createdAt", "DESC"]], raw: true, }; // 分页处理 if (page && pageSize) { options.offset = (page - 1) * pageSize; options.limit = parseInt(pageSize); } const tagList = await models.TenderImageTags.findAndCountAll(options); ctx.body = tagList; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "获取标签列表失败", }; } }; /** * 创建标签 */ module.exports.addTag = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { tagName } = ctx.request.body; if (!tagName) { throw "缺少参数:tagName"; } const tag = await models.TenderImageTags.create( { tagName }, { returning: true }, ); ctx.body = tag; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "创建标签失败", }; } }; /** * 修改标签 */ module.exports.updateTag = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { tagId } = ctx.params; const { tagName } = ctx.request.body; if (!tagId || !tagName) { throw "缺少参数"; } await models.TenderImageTags.update( { tagName }, { where: { id: tagId } }, ); ctx.status = 204; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "修改标签失败", }; } }; /** * 删除标签 */ module.exports.deleteTag = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models } = ctx.app.fs.dc; const { tagId } = ctx.params; if (!tagId) { throw "缺少参数"; } // 级联删除会自动删除关联关系 await models.TenderImageTags.destroy({ where: { id: tagId }, transaction, }); ctx.status = 204; await transaction.commit(); } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "删除标签失败", }; } }; // ==================== 图片标签关联接口 ==================== /** * 为图片添加标签 */ module.exports.addImageTag = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models } = ctx.app.fs.dc; const { imageId } = ctx.params; const { tagId } = ctx.request.body; if (!imageId || !tagId) { throw "缺少参数"; } const relation = await models.TenderImageTagRelation.create( { imageId, tagId }, { returning: true, transaction, }, ); await models.TenderImages.update( { status: "waitSync" }, { where: { id: imageId }, transaction }, ); await transaction.commit(); ctx.body = relation; ctx.status = 200; } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "添加图片标签失败", }; } }; /** * 批量为图片添加标签 */ module.exports.batchAddImageTags = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models } = ctx.app.fs.dc; const { imageId } = ctx.params; const { tagIds } = ctx.request.body; if (!imageId || !tagIds || !Array.isArray(tagIds)) { throw "缺少参数"; } const relations = tagIds.map((tagId) => ({ imageId, tagId })); const createdRelations = await models.TenderImageTagRelation.bulkCreate( relations, { transaction, returning: true }, ); await models.TenderImages.update( { status: "waitSync" }, { where: { id: imageId }, transaction }, ); ctx.body = createdRelations; ctx.status = 200; await transaction.commit(); } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "批量添加图片标签失败", }; } }; /** * 删除图片标签关联 */ module.exports.deleteImageTag = async (ctx, next) => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const { models } = ctx.app.fs.dc; const { imageId, tagId } = ctx.params; if (!imageId || !tagId) { throw "缺少参数"; } await models.TenderImageTagRelation.destroy({ where: { imageId: Number(imageId), tagId: Number(tagId) }, transaction, }); await models.TenderImages.update( { status: "waitSync" }, { where: { id: Number(imageId) }, transaction }, ); await transaction.commit(); ctx.status = 204; } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "删除图片标签关联失败", }; } }; /** * 按标签搜索图片 */ module.exports.getImagesByTag = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { tagId } = ctx.params; const { page, pageSize, departmentId } = ctx.request.query; if (!tagId) { throw "缺少参数"; } const tenderImageLibraryWhere = {}; if (departmentId) { tenderImageLibraryWhere.departmentId = departmentId; } const options = { where: { tagId }, include: [ { model: models.TenderImages, include: [ { model: models.TenderImageLibrary, attributes: ["id", "name"], where: tenderImageLibraryWhere, }, ], required: true, }, ], order: [["createdAt", "DESC"]], }; // 分页处理 if (page && pageSize) { options.offset = (page - 1) * pageSize; options.limit = parseInt(pageSize); } const relations = await models.TenderImageTagRelation.findAndCountAll(options); ctx.body = relations; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error == "string" ? error : "按标签搜索图片失败", }; } }; //获取项企信息+部门 module.exports.getDepartment = async (ctx, next) => { try { const { emisApi } = ctx.config.pep; const { departmentId } = ctx.request.query; const token = await getPepToken(ctx); const userRes = await superagent.get( `${emisApi}/dept/user?token=${token}`, ); const departments = Array.isArray(userRes._body || userRes.body ) ? userRes._body || userRes.body : []; // 保留 departmentId 查询的兼容能力;未传时返回完整部门树,供人员选择器使用。 ctx.body = departmentId ? departments.find((item) => item.id === Number(departmentId)) : departments; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: error?.message || "获取部门失败" }; } }; //专利接口 module.exports.getPatents = async (ctx, next) => { try { const params = ctx.request.query; const res = await requestKnowledgeApi(ctx, { method: "get", path: "/_api/patents", query: params, }); const status = res?.status || res?.statusCode || 200; if (status < 200 || status >= 300) { ctx.status = status; ctx.body = res?.body || { message: "获取知识产权失败", }; return; } ctx.body = res.body; ctx.status = status; } catch (error) { ctx.logger.log(error); const status = error?.response?.status || 400; ctx.status = status; ctx.body = error?.response?.body || { message: error?.message || "获取知识产权失败" }; } }; //软著搜索 module.exports.software = async (ctx, next) => { try { const params = ctx.request.query; const res = await requestKnowledgeApi(ctx, { method: "get", path: "/_api/software-copyrights", query: params, }); const status = res?.status || res?.statusCode || 200; if (status < 200 || status >= 300) { ctx.status = status; ctx.body = res?.body || { message: "获取知识产权失败", }; return; } ctx.body = res.body; ctx.status = status; } catch (error) { ctx.logger.log(error); const status = error?.response?.status || 400; ctx.status = status; ctx.body = error?.response?.body || { message: error?.message || "获取知识产权失败" }; } }; //知识产权文件详情 module.exports.getKnowledgeFiles = async (ctx, next) => { try { const params = ctx.request.query; const res = await requestKnowledgeApi(ctx, { method: "get", path: "/_api/files", query: params, }); ctx.body = res.body; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: error?.message || "获取知识产权文件失败" }; } }; //pdf转图片 module.exports.pdfToImage = async (ctx, next) => { let tempDir = null; try { const { pdfUrl, dpi, startPage, endPage } = ctx.request.body || ctx.request.query || {}; if (!pdfUrl) { ctx.status = 400; ctx.body = { message: "缺少pdfUrl" }; return; } tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "pdf2image-")); const inputPath = path.join(tempDir, `input_${Date.now()}.pdf`); const outputDir = tempDir; const token = await getPepToken(ctx); const res = await superagent .get(pdfUrl) .set("Authorization", token) .buffer(true); await fsPromises.writeFile(inputPath, res.body); const images = await convertPdfToImages( inputPath, outputDir, Number(dpi) || 150, Number(startPage) || 1, Number(endPage) || undefined, ); const cleanPdfUrl = decodeURIComponent( String(pdfUrl).split("#")[0].split("?")[0], ); const pdfBaseName = path.basename(cleanPdfUrl); const pdfNameWithoutExt = pdfBaseName.slice(0, pdfBaseName.length - path.extname(pdfBaseName).length) || String(Date.now()); const fileName = pdfNameWithoutExt; const uuidPrefix = randomUUID(); const uploadedImages = []; for (let i = 0; i < images.length; i++) { const imagePath = images[i]; const ext = path.extname(imagePath) || ".png"; const match = path.basename(imagePath).match(/page-(\d+)\.png$/i); const pageNum = match ? Number(match[1]) : i + 1; const key = `${uuidPrefix}/${fileName}/page-${pageNum}${ext}`; const url = await uploadFileToQiniu(ctx, imagePath, key); uploadedImages.push(url); } ctx.status = 200; ctx.body = { message: "文件转换成功", data: { images: uploadedImages, startPage: Number(startPage) || 1, endPage: Number(endPage) || null, }, }; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: error?.message || "pdf转图片失败" }; } finally { if (tempDir) { try { await fsPromises.rm(tempDir, { recursive: true, force: true }); } catch (cleanupError) { ctx.logger.log(cleanupError); } } } }; module.exports.getKnowledgeQualificationPermission = async (ctx, next) => { try { const userId = Number(ctx.request.query?.userId); if (!Number.isInteger(userId) || userId <= 0) { ctx.status = 400; ctx.body = { message: "userId无效" }; return; } const res = await requestKnowledgeApi(ctx, { method: "get", path: `/_api/system/users/${userId}/qualification-permission`, }); ctx.status = res?.status || 200; ctx.body = res.body; } catch (error) { ctx.logger.log(error); ctx.status = error?.response?.status || 400; ctx.body = error?.response?.body || { message: error?.message || "查询资质权限失败" }; } }; module.exports.getQualificationCerts = async (ctx, next) => { try { const params = { ...ctx.request.query, }; const res = await requestKnowledgeApi(ctx, { method: "get", path: "/_api/qualification/certs", query: params, }); ctx.status = res?.status || 200; ctx.body = res.body; } catch (error) { ctx.logger.log(error); ctx.status = error?.response?.status || 400; ctx.body = error?.response?.body || { message: error?.message || "获取资质证照失败" }; } }; /** * 将知识产权系统选中的图片入库(用户专属专利/软著库) */ module.exports.addKnowledgeImagesToLibrary = async (ctx, next) => { try { const { models } = ctx.app.fs.dc; const { userId, libraryType, departmentId, baseName, sourceUrl, images, } = ctx.request.body || {}; if ( !userId || !libraryType || !departmentId || !Array.isArray(images) || !images.length ) { throw new Error("缺少必要参数:userId, libraryType, departmentId, images"); } const library = await ensureUserLibraryByType(ctx, { userId, libraryType, departmentId, }); const resolvedBaseName = baseName || deriveBaseNameFromUrl(sourceUrl); const stableGroupKey = buildStableGroupKey(resolvedBaseName, sourceUrl); const normalizedImages = images .map((item, index) => { const url = typeof item === "string" ? item : item?.url; if (!url) return null; const page = typeof item === "string" ? undefined : item?.page; const title = buildImageTitle({ baseName: resolvedBaseName, page, index }); const pageNo = Number(page) || index + 1; return { url, page: pageNo, pageNo, title, groupKey: stableGroupKey, sourceUrl: sourceUrl || null, }; }) .filter(Boolean); if (!normalizedImages.length) { throw new Error("images中没有可用的图片url"); } const existingImages = await models.TenderImages.findAll({ where: { libraryId: library.id, groupKey: stableGroupKey, pageNo: normalizedImages.map((item) => item.pageNo), }, raw: true, }); const existingPageSet = new Set(existingImages.map((item) => item.pageNo)); const inserted = []; const skipped = []; for (const item of normalizedImages) { if (existingPageSet.has(item.pageNo)) { skipped.push(item); continue; } const created = await models.TenderImages.create( { libraryId: library.id, title: item.title, groupKey: item.groupKey, pageNo: item.pageNo, sourceUrl: item.sourceUrl, imageUrl: item.url, status: "training", }, { returning: true }, ); inserted.push(created); uploadImageToFastGpt(ctx, created); } ctx.status = 200; ctx.body = { message: "入库完成", data: { libraryId: library.id, libraryName: library.name, insertedCount: inserted.length, skippedCount: skipped.length, inserted, skipped, }, }; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: error?.message || "知识产权图片入库失败", }; } };