'use strict'; const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const qiniu = require('qiniu'); const superagent = require('superagent'); const PROJECT_NAME = '飞小尚'; const CATEGORY = 'other'; const SUPPORTED_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp']); function getCurrentUserId(ctx) { const value = ctx.fs?.curUser?.userInfo?.id; if (!/^\d+$/.test(String(value || ''))) { const error = new Error('当前登录用户缺少 ai-center user id'); error.status = 401; throw error; } return Number(value); } function getFileExtension(fileName) { return path.extname(String(fileName || '')).slice(1).toLowerCase(); } function toVectorLiteral(vector) { if (!Array.isArray(vector) || vector.length !== 1024 || vector.some(value => !Number.isFinite(value))) { throw new Error('embedding 返回的向量不是有效的 1024 维数值数组'); } return `[${vector.join(',')}]`; } function normalizeDescription(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } function getDescriptionMode(value) { return value === 'ai' ? 'ai' : 'manual'; } function assertConfiguration(ctx, { needsFastGpt = false, needsQiniu = false, needsEmbedding = false } = {}) { const { qiniu: qiniuConfig, imageAssets, fastGpt } = ctx.config; if (needsQiniu && (!qiniuConfig?.dmn || !qiniuConfig?.bkt || !qiniuConfig?.ak || !qiniuConfig?.sk)) { throw new Error('七牛配置不完整'); } if (needsEmbedding && (!imageAssets?.databaseUrl || !imageAssets?.dashscopeApiKey || imageAssets.embeddingDimensions !== 1024)) { throw new Error('图片资源库 embedding 配置不完整'); } if (needsFastGpt && (!fastGpt?.apiUrl || !fastGpt?.v2TenderImageAppKey)) { throw new Error('图片 AI 描述 FastGPT 配置不完整'); } } function buildQiniuKey(fileName, userId) { const extension = getFileExtension(fileName); const safeName = path.basename(String(fileName || 'image')) .replace(/[^a-zA-Z0-9._-]/g, '_') .slice(0, 80); return `ai-query/resource-library/${userId}/${Date.now()}-${crypto.randomUUID()}-${safeName || `image.${extension}`}`; } async function uploadFileToQiniu(ctx, file, userId, fileName) { const { dmn: domain, bkt: bucket, ak: accessKey, sk: secretKey } = ctx.config.qiniu; const mac = new qiniu.auth.digest.Mac(accessKey, secretKey); const putPolicy = new qiniu.rs.PutPolicy({ scope: bucket }); const uploaderConfig = new qiniu.conf.Config(); uploaderConfig.zone = qiniu.zone.Zone_z0; const uploader = new qiniu.form_up.FormUploader(uploaderConfig); const key = buildQiniuKey(fileName || file.originalname, userId); const result = await new Promise((resolve, reject) => { uploader.putFile(putPolicy.uploadToken(mac), key, file.path, new qiniu.form_up.PutExtra(), (error, body) => { if (error) reject(error); else resolve(body); }); }); return `${String(domain).replace(/\/+$/, '')}/${result.key}`; } async function generateAiDescription(ctx, imageUrl) { const { apiUrl, v2TenderImageAppKey } = ctx.config.fastGpt; const response = await superagent .post(`${String(apiUrl).replace(/\/+$/, '')}/api/v1/chat/completions`) .send({ stream: false, detail: false, messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: imageUrl } }], }], }) .set({ Authorization: `Bearer ${v2TenderImageAppKey}`, 'Content-Type': 'application/json' }); const description = normalizeDescription(response.body?.choices?.[0]?.message?.content); if (!description) throw new Error('AI 未生成有效图片描述'); return description; } async function generateEmbedding(ctx, summary) { const { dashscopeBaseUrl, dashscopeApiKey, embeddingModel, requestTimeout } = ctx.config.imageAssets; const response = await superagent .post(`${String(dashscopeBaseUrl).replace(/\/+$/, '')}/embeddings`) .send({ model: embeddingModel, input: summary, dimensions: 1024 }) .set({ Authorization: `Bearer ${dashscopeApiKey}`, 'Content-Type': 'application/json' }) .timeout(requestTimeout); return toVectorLiteral(response.body?.data?.[0]?.embedding); } function ensureImageFile(file, fileName = file?.originalname) { if (!file) throw new Error('请选择图片文件'); const extension = getFileExtension(fileName); if (!SUPPORTED_EXTENSIONS.has(extension) || !String(file.mimetype || '').startsWith('image/')) { throw new Error('仅支持 JPG、JPEG、PNG、WEBP 图片'); } } function toAsset(row) { return { id: row.id, imageName: row.image_name, summary: row.summary, url: row.url, fileFormat: row.file_format, createdAt: row.created_at, updatedAt: row.updated_at, }; } async function withUploadCleanup(file, callback) { try { return await callback(); } finally { if (file?.path) fs.promises.unlink(file.path).catch(() => {}); } } async function resolveAssetPayload(ctx, userId, existing) { const file = ctx.file || ctx.request.file; // multipart 的 filename 头可能被服务端按 Latin-1 解码;前端额外提交的 // fileName 字段保持 UTF-8,优先使用它作为资源显示名和扩展名来源。 const uploadedFileName = file ? normalizeFileName(ctx.request.body?.fileName || file.originalname) : null; const mode = getDescriptionMode(ctx.request.body?.descriptionMode); const manualDescription = normalizeDescription(ctx.request.body?.description); if (file) ensureImageFile(file, uploadedFileName); if (!existing && !file) throw new Error('请选择图片文件'); if (mode === 'manual' && !manualDescription) throw new Error('请输入图片描述'); assertConfiguration(ctx, { needsFastGpt: mode === 'ai', needsQiniu: Boolean(file), needsEmbedding: true }); const url = file ? await uploadFileToQiniu(ctx, file, userId, uploadedFileName) : existing.url; const summary = mode === 'ai' ? await generateAiDescription(ctx, url) : manualDescription; const vector = await generateEmbedding(ctx, summary); return { imageName: file ? uploadedFileName : existing.image_name, url, fileFormat: file ? getFileFormat(uploadedFileName) : existing.file_format, summary, vector, }; } module.exports.listImages = async (ctx) => { try { const userId = getCurrentUserId(ctx); const result = await ctx.app.fs.imageAssetsDb.query( `SELECT id, image_name, summary, url, file_format, created_at, updated_at FROM image_assets WHERE project_name = $1 AND category = $2 AND ai_center_user_id = $3 ORDER BY created_at DESC`, [PROJECT_NAME, CATEGORY, userId], ); ctx.body = { list: result.rows.map(toAsset) }; } catch (error) { ctx.logger.error(error); ctx.throw(error.status || 400, error.message || '获取资源库图片失败'); } }; module.exports.getImage = async (ctx) => { try { const userId = getCurrentUserId(ctx); const result = await ctx.app.fs.imageAssetsDb.query( `SELECT id, image_name, summary, url, file_format, created_at, updated_at FROM image_assets WHERE id = $1 AND project_name = $2 AND category = $3 AND ai_center_user_id = $4`, [ctx.params.id, PROJECT_NAME, CATEGORY, userId], ); if (!result.rows[0]) ctx.throw(404, '图片资源不存在'); ctx.body = toAsset(result.rows[0]); } catch (error) { ctx.logger.error(error); ctx.throw(error.status || 400, error.message || '获取资源库图片失败'); } }; module.exports.createImage = async (ctx) => withUploadCleanup(ctx.file || ctx.request.file, async () => { try { const userId = getCurrentUserId(ctx); const asset = await resolveAssetPayload(ctx, userId, null); const result = await ctx.app.fs.imageAssetsDb.query( `INSERT INTO image_assets ( image_name, summary, summary_vector, embedding_status, url, file_format, category, project_name, ai_center_user_id, last_synced_at, created_at, updated_at ) VALUES ($1, $2, $3::vector, 'completed', $4, $5, $6, $7, $8, NOW(), NOW(), NOW()) RETURNING id, image_name, summary, url, file_format, created_at, updated_at`, [asset.imageName, asset.summary, asset.vector, asset.url, asset.fileFormat, CATEGORY, PROJECT_NAME, userId], ); ctx.status = 201; ctx.body = toAsset(result.rows[0]); } catch (error) { ctx.logger.error(error); ctx.throw(error.status || 400, error.message || '创建资源库图片失败'); } }); module.exports.updateImage = async (ctx) => withUploadCleanup(ctx.file || ctx.request.file, async () => { try { const userId = getCurrentUserId(ctx); const existingResult = await ctx.app.fs.imageAssetsDb.query( `SELECT id, image_name, summary, url, file_format FROM image_assets WHERE id = $1 AND project_name = $2 AND category = $3 AND ai_center_user_id = $4`, [ctx.params.id, PROJECT_NAME, CATEGORY, userId], ); const existing = existingResult.rows[0]; if (!existing) ctx.throw(404, '图片资源不存在'); const asset = await resolveAssetPayload(ctx, userId, existing); const result = await ctx.app.fs.imageAssetsDb.query( `UPDATE image_assets SET image_name = $1, summary = $2, summary_vector = $3::vector, embedding_status = 'completed', url = $4, file_format = $5, embedding_error = NULL, last_synced_at = NOW(), updated_at = NOW() WHERE id = $6 AND project_name = $7 AND category = $8 AND ai_center_user_id = $9 RETURNING id, image_name, summary, url, file_format, created_at, updated_at`, [asset.imageName, asset.summary, asset.vector, asset.url, asset.fileFormat, existing.id, PROJECT_NAME, CATEGORY, userId], ); ctx.body = toAsset(result.rows[0]); } catch (error) { ctx.logger.error(error); ctx.throw(error.status || 400, error.message || '更新资源库图片失败'); } }); module.exports.deleteImage = async (ctx) => { try { const userId = getCurrentUserId(ctx); const result = await ctx.app.fs.imageAssetsDb.query( `DELETE FROM image_assets WHERE id = $1 AND project_name = $2 AND category = $3 AND ai_center_user_id = $4 RETURNING id`, [ctx.params.id, PROJECT_NAME, CATEGORY, userId], ); if (!result.rows[0]) ctx.throw(404, '图片资源不存在'); ctx.status = 204; } catch (error) { ctx.logger.error(error); ctx.throw(error.status || 400, error.message || '删除资源库图片失败'); } }; const FILE_PROJECT_NAME = '飞小尚'; const FILE_SOURCE_TYPES = new Set(['chat_upload', 'tender_export']); const FILE_MAX_SIZE = 100 * 1024 * 1024; function getResourceFileModel(ctx) { const model = ctx.app.fs.dc?.models?.ResourceLibraryFile; if (!model) throw new Error('资源库文件模型未加载'); return model; } function normalizeFileName(value) { const name = path.basename(String(value || '').trim()); return name || '未命名文件'; } function normalizeSha256(value) { const sha256 = String(value || '').trim().toLowerCase(); if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error('文件 SHA-256 格式不正确'); return sha256; } function getFileFormat(fileName) { const extension = getFileExtension(fileName); return extension || null; } function toFileAsset(row) { const value = row?.get ? row.get({ plain: true }) : row; return { id: value.id, fileName: value.fileName || value.file_name, fileFormat: value.fileFormat || value.file_format, mimeType: value.mimeType || value.mime_type, fileSize: value.fileSize ?? value.file_size, url: value.url, objectKey: value.objectKey || value.object_key, storageProvider: value.storageProvider || value.storage_provider, sourceType: value.sourceType || value.source_type, createdAt: value.createdAt || value.created_at, updatedAt: value.updatedAt || value.updated_at, }; } function assertFileSourceType(sourceType) { if (!FILE_SOURCE_TYPES.has(sourceType)) throw new Error('文件来源类型不正确'); return sourceType; } async function findOrCreateFileAsset(ctx, userId, payload) { const ResourceLibraryFile = getResourceFileModel(ctx); const sourceType = assertFileSourceType(payload.sourceType); const normalizedPayload = { ...payload, sourceType }; const where = { aiCenterUserId: userId, projectName: FILE_PROJECT_NAME, sha256: normalizedPayload.sha256, }; const existing = await ResourceLibraryFile.findOne({ where }); if (existing) return { asset: existing, created: false }; try { const [asset, created] = await ResourceLibraryFile.findOrCreate({ where: { aiCenterUserId: userId, sha256: normalizedPayload.sha256 }, defaults: { ...normalizedPayload, aiCenterUserId: userId, projectName: FILE_PROJECT_NAME }, }); return { asset, created }; } catch (error) { if (error?.name === 'SequelizeUniqueConstraintError') { const duplicate = await ResourceLibraryFile.findOne({ where }); if (duplicate) return { asset: duplicate, created: false }; } throw error; } } function buildResourceFileKey(fileName, userId) { const safeName = normalizeFileName(fileName) .replace(/[^a-zA-Z0-9._-]/g, '_') .slice(0, 100) || 'file'; return `ai-query/resource-library/files/${userId}/${Date.now()}-${crypto.randomUUID()}-${safeName}`; } async function uploadResourceFileToQiniu(ctx, file, userId, fileName) { const { dmn: domain, bkt: bucket, ak: accessKey, sk: secretKey } = ctx.config.qiniu || {}; if (!domain || !bucket || !accessKey || !secretKey) throw new Error('七牛配置不完整'); const mac = new qiniu.auth.digest.Mac(accessKey, secretKey); const putPolicy = new qiniu.rs.PutPolicy({ scope: bucket }); const uploaderConfig = new qiniu.conf.Config(); uploaderConfig.zone = qiniu.zone.Zone_z0; const uploader = new qiniu.form_up.FormUploader(uploaderConfig); const key = buildResourceFileKey(fileName || file.originalname, userId); const result = await new Promise((resolve, reject) => { uploader.putFile( putPolicy.uploadToken(mac), key, file.path, new qiniu.form_up.PutExtra(), (error, body) => error ? reject(error) : resolve(body), ); }); return { url: `${String(domain).replace(/\/+$/, '')}/${result.key}`, objectKey: result.key, }; } async function hashFile(filePath) { return new Promise((resolve, reject) => { const hash = crypto.createHash('sha256'); const stream = fs.createReadStream(filePath); stream.on('data', (chunk) => hash.update(chunk)); stream.on('error', reject); stream.on('end', () => resolve(hash.digest('hex'))); }); } function parseUploadPayload(ctx) { const body = ctx.request.body || {}; const url = String(body.path || body.url || '').trim(); const fileName = normalizeFileName(body.original_filename || body.originalFilename || body.file_name); if (!url) throw new Error('上传结果缺少文件链接'); return { fileName, fileFormat: getFileFormat(fileName), mimeType: String(body.content_type || body.contentType || '').trim() || null, fileSize: body.size_bytes == null ? null : Number(body.size_bytes), url, objectKey: String(body.object_key || body.objectKey || '').trim() || null, storageProvider: String(body.storage_provider || body.storageProvider || 'qiniu').trim() || 'qiniu', sha256: normalizeSha256(body.sha256), sourceType: 'chat_upload', sourceUploadId: String(body.id || '').trim() || null, sourceTaskId: String(body.task_id || body.taskId || '').trim() || null, sourceSessionId: String(body.uploaded_in_session_id || body.sessionId || '').trim() || null, }; } module.exports.listFiles = async (ctx) => { try { const userId = getCurrentUserId(ctx); const ResourceLibraryFile = getResourceFileModel(ctx); const rows = await ResourceLibraryFile.findAll({ where: { aiCenterUserId: userId, projectName: FILE_PROJECT_NAME }, order: [['createdAt', 'DESC']], }); ctx.body = { list: rows.map(toFileAsset) }; } catch (error) { ctx.logger.error(error); ctx.throw(error.status || 400, error.message || '获取资源库文件失败'); } }; module.exports.createFileFromUpload = async (ctx) => { try { const userId = getCurrentUserId(ctx); const payload = parseUploadPayload(ctx); const result = await findOrCreateFileAsset(ctx, userId, payload); ctx.status = result.created ? 201 : 200; ctx.body = { ...toFileAsset(result.asset), created: result.created }; } catch (error) { ctx.logger.error(error); ctx.throw(error.status || 400, error.message || '保存资源库文件失败'); } }; module.exports.uploadFile = async (ctx) => withUploadCleanup(ctx.file || ctx.request.file, async () => { try { const userId = getCurrentUserId(ctx); const file = ctx.file || ctx.request.file; if (!file?.path) throw new Error('请上传文件'); if (!file.size || file.size > FILE_MAX_SIZE) throw new Error('文件大小必须在 1B 至 100MB 之间'); const fileName = normalizeFileName(ctx.request.body?.fileName || file.originalname); const sha256 = await hashFile(file.path); const ResourceLibraryFile = getResourceFileModel(ctx); const existing = await ResourceLibraryFile.findOne({ where: { aiCenterUserId: userId, projectName: FILE_PROJECT_NAME, sha256 }, }); if (existing) { ctx.body = { ...toFileAsset(existing), created: false }; return; } const uploaded = await uploadResourceFileToQiniu(ctx, file, userId, fileName); const result = await findOrCreateFileAsset(ctx, userId, { fileName, fileFormat: getFileFormat(fileName), mimeType: String(file.mimetype || '').trim() || null, fileSize: Number(file.size), url: uploaded.url, objectKey: uploaded.objectKey, storageProvider: 'qiniu', sha256, sourceType: 'tender_export', sourceUploadId: null, sourceTaskId: String(ctx.request.body?.taskId || '').trim() || null, sourceSessionId: String(ctx.request.body?.sessionId || '').trim() || null, }); ctx.status = result.created ? 201 : 200; ctx.body = { ...toFileAsset(result.asset), created: result.created }; } catch (error) { ctx.logger.error(error); ctx.throw(error.status || 400, error.message || '上传资源库文件失败'); } }); module.exports.deleteFile = async (ctx) => { try { const userId = getCurrentUserId(ctx); const ResourceLibraryFile = getResourceFileModel(ctx); const count = await ResourceLibraryFile.destroy({ where: { id: ctx.params.id, aiCenterUserId: userId, projectName: FILE_PROJECT_NAME, }, }); if (!count) ctx.throw(404, '文件资源不存在'); ctx.status = 204; } catch (error) { ctx.logger.error(error); ctx.throw(error.status || 400, error.message || '删除资源库文件失败'); } };