forked from lunanting/cangku
2224 lines
72 KiB
JavaScript
2224 lines
72 KiB
JavaScript
const { isRetiredSystemConfigKey, pruneRetiredSidebarConfig } = require('../services/retiredFeatures');
|
||
// 管理员 API
|
||
const express = require('express');
|
||
const router = express.Router();
|
||
const bcrypt = require('bcryptjs');
|
||
const crypto = require('crypto');
|
||
const dayjs = require('dayjs');
|
||
const fs = require('fs');
|
||
const multer = require('multer');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const { v4: uuidv4 } = require('uuid');
|
||
const { requireRole } = require('../middleware/auth');
|
||
const database = require('../config/database');
|
||
const cloudDb = require('../config/cloudDatabase');
|
||
const { dailyRefreshJob } = require('../jobs/dailyRefresh');
|
||
const { manualJxStockSelectionSync } = require('../jobs/jxStockSelectionSyncJob');
|
||
const { getAuthConfig, saveAuthConfig } = require('../services/authConfig');
|
||
const {
|
||
ensureObjectStorageSchema,
|
||
getDefaultS3Config,
|
||
normalizeEndpoint,
|
||
normalizeProtocol,
|
||
normalizePublicDomain,
|
||
normalizeStorageClass,
|
||
testObjectStorageConnection,
|
||
uploadBase64ImageToDefaultS3,
|
||
uploadFileToDefaultS3,
|
||
} = require('../services/objectStorage');
|
||
const {
|
||
compareVersions,
|
||
getLatestDesktopRelease,
|
||
listDesktopReleases,
|
||
normalizeArchitecture,
|
||
normalizePlatform,
|
||
publishDesktopRelease,
|
||
validateVersion,
|
||
} = require('../services/desktopReleaseService');
|
||
const { listOnlineDesktopUsers } = require('../services/desktopPresenceService');
|
||
const {
|
||
getSystemDbConfig,
|
||
saveSystemDbConfig,
|
||
testSystemDbConnection,
|
||
} = require('../services/systemDbConfig');
|
||
const {
|
||
getEffectiveRedisConfig,
|
||
getRedisConfig,
|
||
getRedisRuntimeProfile,
|
||
getRedisRuntimeProfiles,
|
||
getStoredRedisConfigs,
|
||
saveRedisConfig,
|
||
setRedisRuntimeProfile,
|
||
testRedisConnection,
|
||
} = require('../services/redisConfig');
|
||
const {
|
||
getWorkOrderConfig,
|
||
saveWorkOrderConfig,
|
||
testWorkOrderConnection,
|
||
} = require('../services/workOrderIntegration');
|
||
const redisRuntime = require('../config/redis');
|
||
const {
|
||
getPageDataCacheStats,
|
||
resetPageDataCacheStats,
|
||
} = require('../middleware/pageDataCache');
|
||
const {
|
||
getSetupStatus,
|
||
markSetupCompleted,
|
||
} = require('../services/setupWizard');
|
||
const { syncDingTalkUsers } = require('../services/socialAuth');
|
||
const {
|
||
SIDEBAR_PAGES,
|
||
getDepartmentPagePermissions,
|
||
getSidebarRolePermissions,
|
||
getUserPagePermissions,
|
||
saveDepartmentPagePermissions,
|
||
saveUserPagePermissions,
|
||
} = require('../services/pageAccess');
|
||
const { getStatsSnapshot, resetStats } = require('../services/dataPageCache');
|
||
const { getHeavyDatabaseTaskStats } = require('../services/databaseLoadGuard');
|
||
const { batchUpsertAiImageFeatures } = require('../services/aiImageFeatureLibrary');
|
||
const {
|
||
buildProductCodeImportTemplateBuffer,
|
||
buildProductCodeBatchImportTemplateBuffer,
|
||
importProductCodeWorkbook,
|
||
deleteAllProductCodeItems,
|
||
deleteProductCodeItemsByCodes,
|
||
} = require('../services/productCodeBoard');
|
||
const { clearPublicSystemConfigCache } = require('../services/publicSystemConfigCache');
|
||
const {
|
||
filterPublicSystemConfigs,
|
||
isProtectedSystemConfigKey,
|
||
} = require('../services/systemConfigVisibility');
|
||
const {
|
||
createSelectionFissionApiKey,
|
||
deleteSelectionFissionApiKey,
|
||
listSelectionFissionApiKeys,
|
||
updateSelectionFissionApiKey,
|
||
} = require('../services/selectionFissionApiKeyService');
|
||
const {
|
||
createExternalAutomationApp,
|
||
deleteExternalAutomationApp,
|
||
listExternalAutomationApps,
|
||
updateExternalAutomationApp,
|
||
} = require('../services/externalAutomationAppService');
|
||
const {
|
||
getSelectionFissionAiAdminSettings,
|
||
saveSelectionFissionAiConfig,
|
||
testSelectionFissionAiConfig,
|
||
} = require('../services/selectionFissionAiSettings');
|
||
const { testSelectionImageGenerationConfig } = require('../services/selectionImageGenerationService');
|
||
const {
|
||
getProductTagConfig,
|
||
saveProductTagConfig,
|
||
} = require('../services/productTagConfigService');
|
||
|
||
const backfillJobs = new Map();
|
||
const productCodeImportUpload = multer({
|
||
storage: multer.memoryStorage(),
|
||
limits: {
|
||
fileSize: 20 * 1024 * 1024,
|
||
},
|
||
fileFilter: (_req, file, callback) => {
|
||
const lowerName = String(file.originalname || '').toLowerCase();
|
||
if (lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')) {
|
||
callback(null, true);
|
||
return;
|
||
}
|
||
callback(new Error('仅支持上传 Excel 文件(.xlsx / .xls)'));
|
||
},
|
||
});
|
||
|
||
const desktopReleaseTempDir = path.join(os.tmpdir(), 'maixiang-desktop-releases');
|
||
fs.mkdirSync(desktopReleaseTempDir, { recursive: true });
|
||
const desktopReleaseUpload = multer({
|
||
dest: desktopReleaseTempDir,
|
||
limits: {
|
||
fileSize: 300 * 1024 * 1024,
|
||
files: 1,
|
||
},
|
||
fileFilter: (_req, file, callback) => {
|
||
if (/\.(exe|dmg)$/i.test(String(file.originalname || ''))) {
|
||
callback(null, true);
|
||
return;
|
||
}
|
||
callback(new Error('仅支持上传 Windows(.exe)或 macOS(.dmg)安装包'));
|
||
},
|
||
});
|
||
|
||
const uploadDesktopReleaseInstaller = (req, res, next) => {
|
||
desktopReleaseUpload.single('installer')(req, res, (error) => {
|
||
if (!error) return next();
|
||
if (error.code === 'LIMIT_FILE_SIZE') {
|
||
return res.status(413).json({ success: false, error: '安装包不能超过 300 MB' });
|
||
}
|
||
return res.status(400).json({ success: false, error: error.message || '安装包上传失败' });
|
||
});
|
||
};
|
||
|
||
async function sha256File(filePath) {
|
||
const hash = crypto.createHash('sha256');
|
||
await new Promise((resolve, reject) => {
|
||
const stream = fs.createReadStream(filePath);
|
||
stream.on('data', (chunk) => hash.update(chunk));
|
||
stream.on('error', reject);
|
||
stream.on('end', resolve);
|
||
});
|
||
return hash.digest('hex');
|
||
}
|
||
|
||
const runBackfill = async (job) => {
|
||
try {
|
||
const totalStepsPerDay = 21;
|
||
job.totalTasks = job.totalDays * totalStepsPerDay;
|
||
job.completedTasks = 0;
|
||
job.remainingTasks = job.totalTasks;
|
||
|
||
for (let i = 0; i < job.totalDays; i++) {
|
||
const date = dayjs(job.startDate).add(i, 'day').format('YYYY-MM-DD');
|
||
job.currentDate = date;
|
||
await dailyRefreshJob(date, (step) => {
|
||
job.currentTask = step.name;
|
||
job.currentStep = step.currentStep;
|
||
job.totalStepsPerDay = step.totalSteps;
|
||
job.completedTasks = i * totalStepsPerDay + step.currentStep;
|
||
job.remainingTasks = Math.max(0, job.totalTasks - job.completedTasks);
|
||
});
|
||
job.completedDays = i + 1;
|
||
job.remainingDays = job.totalDays - job.completedDays;
|
||
}
|
||
job.status = 'success';
|
||
job.finishedAt = new Date().toISOString();
|
||
} catch (error) {
|
||
job.status = 'failed';
|
||
job.error = error.message;
|
||
job.finishedAt = new Date().toISOString();
|
||
}
|
||
};
|
||
|
||
router.use(requireRole('admin'));
|
||
|
||
router.get('/desktop-presence', async (_req, res) => {
|
||
try {
|
||
const data = await listOnlineDesktopUsers();
|
||
res.json({ success: true, data });
|
||
} catch (error) {
|
||
console.error('获取桌面客户端在线用户失败:', error);
|
||
res.status(500).json({ success: false, error: '获取桌面客户端在线用户失败' });
|
||
}
|
||
});
|
||
|
||
router.get('/desktop-releases', async (req, res) => {
|
||
try {
|
||
const releases = await listDesktopReleases(req.query.limit);
|
||
res.json({
|
||
success: true,
|
||
data: releases.map((release) => ({
|
||
...release,
|
||
downloadUrl: `/api/desktop-client/releases/${release.id}/download`,
|
||
})),
|
||
});
|
||
} catch (error) {
|
||
console.error('获取桌面客户端发布记录失败:', error);
|
||
res.status(500).json({ success: false, error: '获取桌面客户端发布记录失败' });
|
||
}
|
||
});
|
||
|
||
router.post('/desktop-releases', uploadDesktopReleaseInstaller, async (req, res) => {
|
||
const tempFilePath = req.file?.path;
|
||
try {
|
||
if (!req.file) {
|
||
return res.status(400).json({ success: false, error: '请选择需要发布的桌面客户端安装包' });
|
||
}
|
||
const version = validateVersion(req.body?.version);
|
||
const originalName = String(req.file.originalname || '');
|
||
const platform = normalizePlatform(req.body?.platform, originalName);
|
||
const architecture = normalizeArchitecture(req.body?.architecture, originalName, platform);
|
||
const expectedExtension = platform === 'darwin' ? '.dmg' : '.exe';
|
||
if (!originalName.toLowerCase().endsWith(expectedExtension)) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: platform === 'darwin' ? 'macOS 安装包必须为 .dmg 格式' : 'Windows 安装包必须为 .exe 格式',
|
||
});
|
||
}
|
||
const latest = await getLatestDesktopRelease(platform, architecture);
|
||
if (latest && compareVersions(version, latest.version) < 0) {
|
||
return res.status(409).json({
|
||
success: false,
|
||
error: `发布版本 v${version} 低于当前平台最新版本 v${latest.version}`,
|
||
});
|
||
}
|
||
|
||
const fileName = platform === 'darwin'
|
||
? `pdd-auto-sale-${version}-${architecture}.dmg`
|
||
: `pdd-auto-sale-${version}-setup.exe`;
|
||
const objectKey = `desktop-client/${platform}/${version}/${Date.now()}-${fileName}`;
|
||
const mimeType = platform === 'darwin'
|
||
? 'application/x-apple-diskimage'
|
||
: 'application/vnd.microsoft.portable-executable';
|
||
const [sha256, uploadResult] = await Promise.all([
|
||
sha256File(tempFilePath),
|
||
uploadFileToDefaultS3(tempFilePath, {
|
||
key: objectKey,
|
||
mimeType,
|
||
}),
|
||
]);
|
||
const release = await publishDesktopRelease({
|
||
version,
|
||
platform,
|
||
architecture,
|
||
releaseNotes: String(req.body?.releaseNotes || '').trim(),
|
||
objectKey: uploadResult.key,
|
||
fileName,
|
||
fileSize: req.file.size,
|
||
sha256,
|
||
publishedByUserId: Number(req.user?.sub || req.user?.id || 0) || null,
|
||
publishedByName: req.user?.name || req.user?.username || req.user?.email || '',
|
||
});
|
||
|
||
res.status(201).json({
|
||
success: true,
|
||
message: `${platform === 'darwin' ? 'macOS' : 'Windows'} 客户端 v${version} 已发布`,
|
||
data: {
|
||
...release,
|
||
downloadUrl: `/api/desktop-client/releases/${release.id}/download`,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('发布桌面客户端失败:', error);
|
||
const status = /版本号格式/.test(error.message) ? 400 : 500;
|
||
res.status(status).json({ success: false, error: error.message || '发布桌面客户端失败' });
|
||
} finally {
|
||
if (tempFilePath) {
|
||
await fs.promises.unlink(tempFilePath).catch(() => {});
|
||
}
|
||
}
|
||
});
|
||
|
||
router.get('/cache-performance', async (_req, res) => {
|
||
const dataPayloadStats = getStatsSnapshot();
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
...dataPayloadStats,
|
||
pageResponseCache: getPageDataCacheStats(),
|
||
heavyDatabaseTasks: getHeavyDatabaseTaskStats(),
|
||
},
|
||
});
|
||
});
|
||
|
||
router.post('/cache-performance/reset', async (_req, res) => {
|
||
resetStats();
|
||
resetPageDataCacheStats();
|
||
const dataPayloadStats = getStatsSnapshot();
|
||
res.json({
|
||
success: true,
|
||
message: '缓存性能统计已重置',
|
||
data: {
|
||
...dataPayloadStats,
|
||
pageResponseCache: getPageDataCacheStats(),
|
||
heavyDatabaseTasks: getHeavyDatabaseTaskStats(),
|
||
},
|
||
});
|
||
});
|
||
|
||
router.get('/product-code-board/import-template', async (_req, res) => {
|
||
try {
|
||
const buffer = buildProductCodeImportTemplateBuffer();
|
||
const fileName = encodeURIComponent('测款数据导入模板.xlsx');
|
||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${fileName}`);
|
||
res.send(buffer);
|
||
} catch (error) {
|
||
console.error('生成测款导入模板失败:', error);
|
||
res.status(500).json({ error: '生成测款导入模板失败' });
|
||
}
|
||
});
|
||
|
||
router.get('/product-code-board/batch-template', async (_req, res) => {
|
||
try {
|
||
const buffer = buildProductCodeBatchImportTemplateBuffer();
|
||
const fileName = encodeURIComponent('测款批量新增模板.xlsx');
|
||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${fileName}`);
|
||
res.send(buffer);
|
||
} catch (error) {
|
||
console.error('生成测款批量新增模板失败:', error);
|
||
res.status(500).json({ error: '生成测款批量新增模板失败' });
|
||
}
|
||
});
|
||
|
||
router.post('/product-code-board/import', (req, res, next) => {
|
||
productCodeImportUpload.single('file')(req, res, (error) => {
|
||
if (error) {
|
||
return res.status(400).json({ success: false, error: error.message || '上传文件失败' });
|
||
}
|
||
return next();
|
||
});
|
||
}, async (req, res) => {
|
||
try {
|
||
if (!req.file?.buffer) {
|
||
return res.status(400).json({ success: false, error: '请上传测款数据 Excel 文件' });
|
||
}
|
||
|
||
const data = await importProductCodeWorkbook(req.file.buffer, req.user || {});
|
||
res.json({
|
||
success: true,
|
||
message: '测款数据导入成功',
|
||
data,
|
||
});
|
||
} catch (error) {
|
||
console.error('导入测款数据失败:', error);
|
||
res.status(error.status || error.statusCode || 500).json({
|
||
success: false,
|
||
error: error.message || '导入测款数据失败',
|
||
details: error.details || null,
|
||
});
|
||
}
|
||
});
|
||
|
||
async function handleDeleteProductCodeItems(_req, res) {
|
||
try {
|
||
const data = await deleteAllProductCodeItems();
|
||
res.json({
|
||
success: true,
|
||
message: `已删除 ${data.deletedRows} 条测款数据,清理 ${data.deletedStyleRows || 0} 条款式记录`,
|
||
data,
|
||
});
|
||
} catch (error) {
|
||
console.error('删除测款数据失败:', error);
|
||
res.status(error.status || error.statusCode || 500).json({
|
||
success: false,
|
||
error: error.message || '删除测款数据失败',
|
||
});
|
||
}
|
||
}
|
||
|
||
async function handleDeleteSelectedProductCodeItems(req, res) {
|
||
try {
|
||
const data = await deleteProductCodeItemsByCodes(req.body?.productCodes);
|
||
const missingText = data.notFoundCodes?.length
|
||
? `,未找到 ${data.notFoundCodes.length} 个编码`
|
||
: '';
|
||
res.json({
|
||
success: true,
|
||
message: `已删除 ${data.deletedRows} 条测款数据,清理 ${data.deletedStyleRows || 0} 条款式记录${missingText}`,
|
||
data,
|
||
});
|
||
} catch (error) {
|
||
console.error('按编码删除测款数据失败:', error);
|
||
res.status(error.status || error.statusCode || 500).json({
|
||
success: false,
|
||
error: error.message || '按编码删除测款数据失败',
|
||
});
|
||
}
|
||
}
|
||
|
||
router.post('/product-code-board/items/delete-all', handleDeleteProductCodeItems);
|
||
router.post('/product-code-board/items/delete-selected', handleDeleteSelectedProductCodeItems);
|
||
router.delete('/product-code-board/items', handleDeleteProductCodeItems);
|
||
|
||
// ==================== 首次安装向导 ====================
|
||
|
||
router.get('/setup/status', async (req, res) => {
|
||
try {
|
||
const status = await getSetupStatus(req.user.sub);
|
||
res.json(status);
|
||
} catch (error) {
|
||
console.error('获取首次安装状态失败:', error);
|
||
res.status(500).json({ error: '获取首次安装状态失败' });
|
||
}
|
||
});
|
||
|
||
router.post('/setup/password', async (req, res) => {
|
||
try {
|
||
const { currentPassword, newPassword } = req.body || {};
|
||
|
||
if (!currentPassword || !newPassword) {
|
||
return res.status(400).json({ error: '请填写当前密码和新密码' });
|
||
}
|
||
|
||
if (String(newPassword).length < 8) {
|
||
return res.status(400).json({ error: '新密码长度不能少于 8 位' });
|
||
}
|
||
|
||
const users = await database.local.query(
|
||
'SELECT id, password_hash FROM users WHERE id = ? LIMIT 1',
|
||
[req.user.sub]
|
||
);
|
||
const currentUser = users[0];
|
||
|
||
if (!currentUser) {
|
||
return res.status(404).json({ error: '当前账号不存在' });
|
||
}
|
||
|
||
const matched = await bcrypt.compare(currentPassword, currentUser.password_hash || '');
|
||
if (!matched) {
|
||
return res.status(400).json({ error: '当前密码不正确' });
|
||
}
|
||
|
||
const sameAsCurrent = await bcrypt.compare(newPassword, currentUser.password_hash || '');
|
||
if (sameAsCurrent) {
|
||
return res.status(400).json({ error: '新密码不能与当前密码相同' });
|
||
}
|
||
|
||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||
await database.local.update('users', currentUser.id, { password_hash: passwordHash });
|
||
|
||
const status = await getSetupStatus(req.user.sub);
|
||
res.json({
|
||
message: '管理员密码已更新',
|
||
setup: status,
|
||
});
|
||
} catch (error) {
|
||
console.error('更新管理员密码失败:', error);
|
||
res.status(500).json({ error: '更新管理员密码失败' });
|
||
}
|
||
});
|
||
|
||
router.post('/setup/complete', async (req, res) => {
|
||
try {
|
||
const status = await markSetupCompleted(req.user.sub);
|
||
res.json({
|
||
message: '首次安装向导已完成,请重启后端服务后重新登录',
|
||
needRestart: true,
|
||
...status,
|
||
});
|
||
} catch (error) {
|
||
console.error('完成首次安装向导失败:', error);
|
||
res.status(400).json({ error: error.message || '完成首次安装向导失败' });
|
||
}
|
||
});
|
||
|
||
// ==================== 京喜现货选品同步 ====================
|
||
|
||
router.post('/jx/stock-selection/sync', async (_req, res) => {
|
||
try {
|
||
const result = await manualJxStockSelectionSync();
|
||
res.json({
|
||
success: true,
|
||
data: result,
|
||
});
|
||
} catch (error) {
|
||
console.error('京喜现货选品同步失败:', error);
|
||
res.status(500).json({ success: false, error: error.message || '京喜现货选品同步失败' });
|
||
}
|
||
});
|
||
|
||
// ==================== 用户管理 ====================
|
||
|
||
async function getManagedUser(userId) {
|
||
const users = await database.local.query(
|
||
'SELECT id, username, name, role, status FROM users WHERE id = ? LIMIT 1',
|
||
[userId]
|
||
);
|
||
|
||
return users[0] || null;
|
||
}
|
||
|
||
async function countOtherActiveAdmins(userId) {
|
||
const result = await database.local.query(
|
||
"SELECT COUNT(*) AS total FROM users WHERE role = 'admin' AND status = 'active' AND id <> ?",
|
||
[userId]
|
||
);
|
||
|
||
return result[0]?.total || 0;
|
||
}
|
||
|
||
async function ensureAdminRoleChangeAllowed(req, targetUser, nextRole) {
|
||
if (!targetUser) {
|
||
return { allowed: false, status: 404, error: '用户不存在' };
|
||
}
|
||
|
||
if (targetUser.role !== 'admin' || nextRole === 'admin') {
|
||
return { allowed: true };
|
||
}
|
||
|
||
if (String(req.user.sub) === String(targetUser.id)) {
|
||
return { allowed: false, status: 400, error: '不能取消当前登录账号的管理员权限' };
|
||
}
|
||
|
||
const otherAdminCount = await countOtherActiveAdmins(targetUser.id);
|
||
if (otherAdminCount === 0) {
|
||
return { allowed: false, status: 400, error: '至少保留一位启用状态的管理员' };
|
||
}
|
||
|
||
return { allowed: true };
|
||
}
|
||
|
||
// 获取用户列表
|
||
router.get('/users', async (req, res) => {
|
||
try {
|
||
const { page = 1, size = 20, keyword = '' } = req.query;
|
||
const offset = (page - 1) * size;
|
||
|
||
let sql = `
|
||
SELECT
|
||
u.id,
|
||
u.username,
|
||
u.name,
|
||
u.email,
|
||
u.role,
|
||
u.status,
|
||
u.department_id,
|
||
u.created_at,
|
||
u.auth_source AS authSource,
|
||
u.avatar_url AS avatarUrl,
|
||
u.mobile,
|
||
u.job_title AS jobTitle,
|
||
u.dingtalk_user_id AS dingtalkUserId,
|
||
u.dingtalk_union_id AS dingtalkUnionId,
|
||
u.google_sub AS googleSub,
|
||
u.department_names AS departmentNames,
|
||
u.last_login_at AS lastLoginAt,
|
||
d.name AS departmentName,
|
||
d.source AS departmentSource
|
||
FROM users u
|
||
LEFT JOIN departments d ON u.department_id = d.id
|
||
`;
|
||
const params = [];
|
||
|
||
if (keyword) {
|
||
sql += `
|
||
WHERE u.username LIKE ? OR u.name LIKE ? OR u.email LIKE ?
|
||
OR COALESCE(u.mobile, '') LIKE ?
|
||
OR COALESCE(u.department_names, '') LIKE ?
|
||
OR COALESCE(d.name, '') LIKE ?
|
||
`;
|
||
params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
|
||
}
|
||
|
||
sql += ' ORDER BY u.created_at DESC LIMIT ? OFFSET ?';
|
||
params.push(parseInt(size), parseInt(offset));
|
||
|
||
const users = await database.local.query(sql, params);
|
||
|
||
// 获取总数
|
||
let countSql = `
|
||
SELECT COUNT(*) as total
|
||
FROM users u
|
||
LEFT JOIN departments d ON u.department_id = d.id
|
||
`;
|
||
if (keyword) {
|
||
countSql += `
|
||
WHERE u.username LIKE ? OR u.name LIKE ? OR u.email LIKE ?
|
||
OR COALESCE(u.mobile, '') LIKE ?
|
||
OR COALESCE(u.department_names, '') LIKE ?
|
||
OR COALESCE(d.name, '') LIKE ?
|
||
`;
|
||
}
|
||
const countResult = await database.local.query(
|
||
countSql,
|
||
keyword ? [`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`] : []
|
||
);
|
||
const total = countResult[0]?.total || 0;
|
||
|
||
res.json({
|
||
data: users,
|
||
total,
|
||
page: parseInt(page),
|
||
size: parseInt(size),
|
||
});
|
||
} catch (error) {
|
||
console.error('获取用户列表失败:', error);
|
||
res.status(500).json({ error: '获取用户列表失败' });
|
||
}
|
||
});
|
||
|
||
// 获取单个用户
|
||
router.get('/users/:id', async (req, res) => {
|
||
try {
|
||
const users = await database.local.query(
|
||
`SELECT
|
||
u.id,
|
||
u.username,
|
||
u.name,
|
||
u.email,
|
||
u.role,
|
||
u.status,
|
||
u.department_id,
|
||
u.created_at,
|
||
u.auth_source AS authSource,
|
||
u.avatar_url AS avatarUrl,
|
||
u.mobile,
|
||
u.job_title AS jobTitle,
|
||
u.dingtalk_user_id AS dingtalkUserId,
|
||
u.dingtalk_union_id AS dingtalkUnionId,
|
||
u.google_sub AS googleSub,
|
||
u.department_names AS departmentNames,
|
||
u.last_login_at AS lastLoginAt,
|
||
d.name AS departmentName
|
||
FROM users u
|
||
LEFT JOIN departments d ON u.department_id = d.id
|
||
WHERE u.id = ?`,
|
||
[req.params.id]
|
||
);
|
||
if (users.length === 0) {
|
||
return res.status(404).json({ error: '用户不存在' });
|
||
}
|
||
res.json(users[0]);
|
||
} catch (error) {
|
||
console.error('获取用户失败:', error);
|
||
res.status(500).json({ error: '获取用户失败' });
|
||
}
|
||
});
|
||
|
||
router.get('/users/:id/page-permissions', async (req, res) => {
|
||
try {
|
||
const data = await getUserPagePermissions(req.params.id);
|
||
if (!data) {
|
||
return res.status(404).json({ error: '用户不存在' });
|
||
}
|
||
|
||
res.json(data);
|
||
} catch (error) {
|
||
console.error('获取用户页面权限失败:', error);
|
||
res.status(500).json({ error: '获取用户页面权限失败' });
|
||
}
|
||
});
|
||
|
||
router.put('/users/:id/page-permissions', async (req, res) => {
|
||
try {
|
||
const targetUser = await getManagedUser(req.params.id);
|
||
if (!targetUser) {
|
||
return res.status(404).json({ error: '用户不存在' });
|
||
}
|
||
|
||
const permissions = Array.isArray(req.body?.permissions) ? req.body.permissions : [];
|
||
const result = await saveUserPagePermissions(req.params.id, permissions);
|
||
res.json({
|
||
message: '用户可见页面已更新',
|
||
user: result.user,
|
||
permissions: result.permissions,
|
||
});
|
||
} catch (error) {
|
||
console.error('更新用户页面权限失败:', error);
|
||
res.status(500).json({ error: '更新用户页面权限失败' });
|
||
}
|
||
});
|
||
|
||
// 创建用户
|
||
router.post('/users', async (req, res) => {
|
||
try {
|
||
const { username, name, email, password, departmentId, role, mobile, jobTitle } = req.body;
|
||
|
||
// 检查用户名是否存在
|
||
const existing = await database.local.query('SELECT id FROM users WHERE username = ?', [username]);
|
||
if (existing.length > 0) {
|
||
return res.status(400).json({ error: '用户名已存在' });
|
||
}
|
||
|
||
const existingEmail = await database.local.query('SELECT id FROM users WHERE email = ?', [email]);
|
||
if (existingEmail.length > 0) {
|
||
return res.status(400).json({ error: '邮箱已存在' });
|
||
}
|
||
|
||
const hashedPassword = await bcrypt.hash(password, 10);
|
||
const result = await database.local.insert('users', {
|
||
username,
|
||
password_hash: hashedPassword,
|
||
name,
|
||
email,
|
||
department_id: departmentId || null,
|
||
role: role || 'user',
|
||
status: 'active',
|
||
auth_source: 'local',
|
||
mobile: mobile || null,
|
||
job_title: jobTitle || null,
|
||
});
|
||
|
||
res.status(201).json({
|
||
id: result.insertId,
|
||
username,
|
||
name,
|
||
email,
|
||
role: role || 'user',
|
||
status: 'active',
|
||
authSource: 'local',
|
||
mobile: mobile || null,
|
||
jobTitle: jobTitle || null,
|
||
});
|
||
} catch (error) {
|
||
console.error('创建用户失败:', error);
|
||
res.status(500).json({ error: '创建用户失败' });
|
||
}
|
||
});
|
||
|
||
// 更新用户
|
||
router.put('/users/:id', async (req, res) => {
|
||
try {
|
||
const { name, email, departmentId, role, status, mobile, jobTitle } = req.body;
|
||
const targetUser = await getManagedUser(req.params.id);
|
||
if (!targetUser) {
|
||
return res.status(404).json({ error: '用户不存在' });
|
||
}
|
||
|
||
const existingEmail = await database.local.query(
|
||
'SELECT id FROM users WHERE email = ? AND id <> ? LIMIT 1',
|
||
[email, req.params.id]
|
||
);
|
||
if (existingEmail.length > 0) {
|
||
return res.status(400).json({ error: '邮箱已存在' });
|
||
}
|
||
|
||
const roleCheck = await ensureAdminRoleChangeAllowed(req, targetUser, role);
|
||
if (!roleCheck.allowed) {
|
||
return res.status(roleCheck.status).json({ error: roleCheck.error });
|
||
}
|
||
|
||
await database.local.update('users', req.params.id, {
|
||
name,
|
||
email,
|
||
department_id: departmentId || null,
|
||
role,
|
||
status,
|
||
mobile: mobile || null,
|
||
job_title: jobTitle || null,
|
||
});
|
||
|
||
res.json({ id: req.params.id, name, email, role, status, mobile: mobile || null, jobTitle: jobTitle || null });
|
||
} catch (error) {
|
||
console.error('更新用户失败:', error);
|
||
res.status(500).json({ error: '更新用户失败' });
|
||
}
|
||
});
|
||
|
||
router.patch('/users/:id/admin-role', async (req, res) => {
|
||
try {
|
||
const { isAdmin } = req.body || {};
|
||
if (typeof isAdmin !== 'boolean') {
|
||
return res.status(400).json({ error: '请提供是否授予管理员权限' });
|
||
}
|
||
|
||
const targetUser = await getManagedUser(req.params.id);
|
||
if (!targetUser) {
|
||
return res.status(404).json({ error: '用户不存在' });
|
||
}
|
||
|
||
const nextRole = isAdmin ? 'admin' : 'user';
|
||
const roleCheck = await ensureAdminRoleChangeAllowed(req, targetUser, nextRole);
|
||
if (!roleCheck.allowed) {
|
||
return res.status(roleCheck.status).json({ error: roleCheck.error });
|
||
}
|
||
|
||
if (targetUser.role !== nextRole) {
|
||
await database.local.update('users', req.params.id, {
|
||
role: nextRole,
|
||
});
|
||
}
|
||
|
||
res.json({
|
||
id: Number(req.params.id),
|
||
role: nextRole,
|
||
message: isAdmin ? '已授予管理员权限' : '已取消管理员权限',
|
||
});
|
||
} catch (error) {
|
||
console.error('更新管理员权限失败:', error);
|
||
res.status(500).json({ error: '更新管理员权限失败' });
|
||
}
|
||
});
|
||
|
||
// 删除用户
|
||
router.delete('/users/:id', async (req, res) => {
|
||
try {
|
||
const targetUser = await getManagedUser(req.params.id);
|
||
if (!targetUser) {
|
||
return res.status(404).json({ error: '用户不存在' });
|
||
}
|
||
|
||
if (String(req.user.sub) === String(targetUser.id)) {
|
||
return res.status(400).json({ error: '不能删除当前登录账号' });
|
||
}
|
||
|
||
const roleCheck = await ensureAdminRoleChangeAllowed(req, targetUser, 'user');
|
||
if (!roleCheck.allowed) {
|
||
return res.status(roleCheck.status).json({ error: roleCheck.error });
|
||
}
|
||
|
||
await database.local.query('DELETE FROM user_page_permissions WHERE user_id = ?', [req.params.id]);
|
||
await database.local.query('DELETE FROM users WHERE id = ?', [req.params.id]);
|
||
res.status(204).send();
|
||
} catch (error) {
|
||
console.error('删除用户失败:', error);
|
||
res.status(500).json({ error: '删除用户失败' });
|
||
}
|
||
});
|
||
|
||
// 重置密码
|
||
router.post('/users/:id/reset-password', async (req, res) => {
|
||
try {
|
||
const { newPassword } = req.body;
|
||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||
await database.local.update('users', req.params.id, { password_hash: hashedPassword });
|
||
res.json({ message: '密码已重置' });
|
||
} catch (error) {
|
||
console.error('重置密码失败:', error);
|
||
res.status(500).json({ error: '重置密码失败' });
|
||
}
|
||
});
|
||
|
||
// ====================
|
||
// Backfill Jobs
|
||
// ====================
|
||
|
||
router.post('/backfill/start', async (req, res) => {
|
||
try {
|
||
const { startDate, endDate } = req.body || {};
|
||
if (!startDate || !endDate) {
|
||
return res.status(400).json({ success: false, error: 'Missing startDate or endDate' });
|
||
}
|
||
const start = dayjs(startDate);
|
||
const end = dayjs(endDate);
|
||
if (!start.isValid() || !end.isValid() || end.isBefore(start)) {
|
||
return res.status(400).json({ success: false, error: 'Invalid date range' });
|
||
}
|
||
|
||
const hasRunning = Array.from(backfillJobs.values()).some(job => job.status === 'running');
|
||
if (hasRunning) {
|
||
return res.status(400).json({ success: false, error: 'Another backfill job is running' });
|
||
}
|
||
|
||
const jobId = uuidv4();
|
||
const totalDays = end.diff(start, 'day') + 1;
|
||
const job = {
|
||
id: jobId,
|
||
startDate: start.format('YYYY-MM-DD'),
|
||
endDate: end.format('YYYY-MM-DD'),
|
||
totalDays,
|
||
completedDays: 0,
|
||
currentDate: null,
|
||
currentTask: 'Waiting',
|
||
currentStep: 0,
|
||
totalStepsPerDay: 21,
|
||
totalTasks: totalDays * 21,
|
||
completedTasks: 0,
|
||
remainingTasks: totalDays * 21,
|
||
remainingDays: totalDays,
|
||
status: 'running',
|
||
error: null,
|
||
startedAt: new Date().toISOString(),
|
||
finishedAt: null,
|
||
};
|
||
backfillJobs.set(jobId, job);
|
||
|
||
setImmediate(() => runBackfill(job));
|
||
|
||
return res.json({ success: true, jobId, job });
|
||
} catch (error) {
|
||
return res.status(500).json({ success: false, error: error.message });
|
||
}
|
||
});
|
||
|
||
router.get('/backfill/status', async (req, res) => {
|
||
try {
|
||
const { jobId } = req.query || {};
|
||
if (!jobId) {
|
||
return res.status(400).json({ success: false, error: 'Missing jobId' });
|
||
}
|
||
const job = backfillJobs.get(jobId);
|
||
if (!job) {
|
||
return res.status(404).json({ success: false, error: 'Job not found or expired' });
|
||
}
|
||
const percent = job.totalDays > 0 ? Number(((job.completedDays / job.totalDays) * 100).toFixed(1)) : 0;
|
||
return res.json({
|
||
success: true,
|
||
job: {
|
||
...job,
|
||
percent,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
return res.status(500).json({ success: false, error: error.message });
|
||
}
|
||
});
|
||
|
||
router.get('/departments', async (req, res) => {
|
||
try {
|
||
const departments = await database.local.query(
|
||
'SELECT id, name, code, parent_id as parentId, sort, status, created_at, source, external_id as externalId FROM departments ORDER BY sort ASC, id ASC'
|
||
);
|
||
res.json(departments);
|
||
} catch (error) {
|
||
console.error('获取部门列表失败:', error);
|
||
res.status(500).json({ error: '获取部门列表失败' });
|
||
}
|
||
});
|
||
|
||
router.get('/departments/:id/page-permissions', async (req, res) => {
|
||
try {
|
||
const department = await database.local.query('SELECT id, name FROM departments WHERE id = ? LIMIT 1', [req.params.id]);
|
||
if (department.length === 0) {
|
||
return res.status(404).json({ error: '部门不存在' });
|
||
}
|
||
|
||
const permissions = await getDepartmentPagePermissions(req.params.id);
|
||
res.json({
|
||
department: department[0],
|
||
permissions,
|
||
});
|
||
} catch (error) {
|
||
console.error('获取部门页面权限失败:', error);
|
||
res.status(500).json({ error: '获取部门页面权限失败' });
|
||
}
|
||
});
|
||
|
||
router.put('/departments/:id/page-permissions', async (req, res) => {
|
||
try {
|
||
const department = await database.local.query('SELECT id, name FROM departments WHERE id = ? LIMIT 1', [req.params.id]);
|
||
if (department.length === 0) {
|
||
return res.status(404).json({ error: '部门不存在' });
|
||
}
|
||
|
||
const permissions = Array.isArray(req.body?.permissions) ? req.body.permissions : [];
|
||
const result = await saveDepartmentPagePermissions(req.params.id, permissions);
|
||
res.json({
|
||
message: '部门功能页面权限已更新',
|
||
department: department[0],
|
||
permissions: result,
|
||
});
|
||
} catch (error) {
|
||
console.error('更新部门页面权限失败:', error);
|
||
res.status(500).json({ error: '更新部门页面权限失败' });
|
||
}
|
||
});
|
||
|
||
// 创建部门
|
||
router.post('/departments', async (req, res) => {
|
||
try {
|
||
const { name, code, parentId, sort } = req.body;
|
||
const result = await database.local.insert('departments', {
|
||
name,
|
||
code,
|
||
parent_id: parentId || null,
|
||
sort: sort || 0,
|
||
status: 'active',
|
||
source: 'local',
|
||
external_id: null,
|
||
});
|
||
res.status(201).json({ id: result.insertId, name, code, parentId, sort, status: 'active' });
|
||
} catch (error) {
|
||
console.error('创建部门失败:', error);
|
||
res.status(500).json({ error: '创建部门失败' });
|
||
}
|
||
});
|
||
|
||
// 更新部门
|
||
router.put('/departments/:id', async (req, res) => {
|
||
try {
|
||
const { name, code, parentId, sort, status } = req.body;
|
||
await database.local.update('departments', req.params.id, {
|
||
name,
|
||
code,
|
||
parent_id: parentId || null,
|
||
sort,
|
||
status,
|
||
});
|
||
res.json({ id: req.params.id, name, code, parentId, sort, status });
|
||
} catch (error) {
|
||
console.error('更新部门失败:', error);
|
||
res.status(500).json({ error: '更新部门失败' });
|
||
}
|
||
});
|
||
|
||
// 删除部门
|
||
router.delete('/departments/:id', async (req, res) => {
|
||
try {
|
||
// 检查是否有子部门
|
||
const children = await database.local.query(
|
||
'SELECT id FROM departments WHERE parent_id = ?',
|
||
[req.params.id]
|
||
);
|
||
if (children.length > 0) {
|
||
return res.status(400).json({ error: '请先删除子部门' });
|
||
}
|
||
|
||
await database.local.query('DELETE FROM departments WHERE id = ?', [req.params.id]);
|
||
res.status(204).send();
|
||
} catch (error) {
|
||
console.error('删除部门失败:', error);
|
||
res.status(500).json({ error: '删除部门失败' });
|
||
}
|
||
});
|
||
|
||
// ==================== 权限管理 ====================
|
||
|
||
router.get('/auth-config', async (_req, res) => {
|
||
try {
|
||
const config = await getAuthConfig();
|
||
res.json(config);
|
||
} catch (error) {
|
||
console.error('获取认证配置失败:', error);
|
||
res.status(500).json({ error: '获取认证配置失败' });
|
||
}
|
||
});
|
||
|
||
router.put('/auth-config', async (req, res) => {
|
||
try {
|
||
const config = await saveAuthConfig(req.body || {});
|
||
res.json(config);
|
||
} catch (error) {
|
||
console.error('保存认证配置失败:', error);
|
||
res.status(500).json({ error: '保存认证配置失败' });
|
||
}
|
||
});
|
||
|
||
router.post('/auth-config/sync-dingtalk', async (_req, res) => {
|
||
try {
|
||
const result = await syncDingTalkUsers();
|
||
res.json({
|
||
message: '钉钉用户同步完成',
|
||
...result,
|
||
});
|
||
} catch (error) {
|
||
console.error('同步钉钉用户失败:', error);
|
||
res.status(500).json({ error: error.message || '同步钉钉用户失败' });
|
||
}
|
||
});
|
||
|
||
router.get('/sidebar-pages', async (_req, res) => {
|
||
res.json(SIDEBAR_PAGES);
|
||
});
|
||
|
||
// 获取权限列表
|
||
router.get('/permissions', async (req, res) => {
|
||
try {
|
||
const permissions = await getSidebarRolePermissions();
|
||
res.json(permissions);
|
||
} catch (error) {
|
||
console.error('获取权限列表失败:', error);
|
||
res.status(500).json({ error: '获取权限列表失败' });
|
||
}
|
||
});
|
||
|
||
// 更新权限
|
||
router.put('/permissions/:page/:role', async (req, res) => {
|
||
try {
|
||
const { page, role } = req.params;
|
||
if (!SIDEBAR_PAGES.some((item) => item.pageKey === page) || !['admin', 'analyst', 'user', 'guest'].includes(role)) {
|
||
return res.status(400).json({ error: '无效的页面或角色' });
|
||
}
|
||
const { allowed } = req.body;
|
||
|
||
// 检查是否存在
|
||
const existing = await database.local.query(
|
||
'SELECT id FROM permissions WHERE page_key = ? AND role = ?',
|
||
[page, role]
|
||
);
|
||
|
||
if (existing.length > 0) {
|
||
await database.local.query(
|
||
'UPDATE permissions SET allowed = ? WHERE page_key = ? AND role = ?',
|
||
[allowed ? 1 : 0, page, role]
|
||
);
|
||
} else {
|
||
await database.local.insert('permissions', {
|
||
page_key: page,
|
||
page_name: page,
|
||
role,
|
||
allowed: allowed ? 1 : 0,
|
||
});
|
||
}
|
||
|
||
res.json({ message: '权限已更新' });
|
||
} catch (error) {
|
||
console.error('更新权限失败:', error);
|
||
res.status(500).json({ error: '更新权限失败' });
|
||
}
|
||
});
|
||
|
||
// ==================== 系统配置 ====================
|
||
|
||
// 获取系统配置
|
||
router.get('/system-config', async (req, res) => {
|
||
try {
|
||
const configs = await database.local.query('SELECT config_key, config_value, config_type FROM system_config');
|
||
const result = {};
|
||
filterPublicSystemConfigs(configs).forEach(c => {
|
||
if (c.config_type === 'boolean') {
|
||
result[c.config_key] = c.config_value === 'true' || c.config_value === '1';
|
||
} else if (c.config_type === 'number') {
|
||
result[c.config_key] = Number(c.config_value);
|
||
} else {
|
||
result[c.config_key] = c.config_key === 'sidebar_navigation' ? pruneRetiredSidebarConfig(c.config_value) : c.config_value;
|
||
}
|
||
});
|
||
res.json(result);
|
||
} catch (error) {
|
||
console.error('获取系统配置失败:', error);
|
||
res.status(500).json({ error: '获取系统配置失败' });
|
||
}
|
||
});
|
||
|
||
// 更新系统配置
|
||
router.put('/system-config', async (req, res) => {
|
||
try {
|
||
const updates = { ...req.body };
|
||
if (Object.keys(updates).some(isRetiredSystemConfigKey)) {
|
||
return res.status(400).json({ error: '该功能已移除,不能保存旧设置' });
|
||
}
|
||
if (updates.sidebar_navigation !== undefined) {
|
||
const clean = pruneRetiredSidebarConfig(updates.sidebar_navigation);
|
||
updates.sidebar_navigation = typeof clean === 'string' ? clean : JSON.stringify(clean);
|
||
}
|
||
const protectedKeys = Object.keys(updates || {}).filter(isProtectedSystemConfigKey);
|
||
if (protectedKeys.length > 0) {
|
||
return res.status(400).json({ error: '专用服务密钥必须通过对应的安全配置接口更新' });
|
||
}
|
||
|
||
for (const [key, value] of Object.entries(updates)) {
|
||
const configType = typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'string';
|
||
const configValue = String(value);
|
||
|
||
// 检查是否存在
|
||
const existing = await database.local.query('SELECT id FROM system_config WHERE config_key = ?', [key]);
|
||
|
||
if (existing.length > 0) {
|
||
await database.local.query(
|
||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||
[configValue, configType, key]
|
||
);
|
||
} else {
|
||
await database.local.insert('system_config', {
|
||
config_key: key,
|
||
config_value: configValue,
|
||
config_type: configType,
|
||
});
|
||
}
|
||
}
|
||
|
||
clearPublicSystemConfigCache();
|
||
res.json(updates);
|
||
} catch (error) {
|
||
console.error('更新系统配置失败:', error);
|
||
res.status(500).json({ error: '更新系统配置失败' });
|
||
}
|
||
});
|
||
|
||
// ==================== 第三方 API Key ====================
|
||
|
||
router.get('/external-automation-apps', async (_req, res) => {
|
||
try {
|
||
const data = await listExternalAutomationApps();
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
res.json({ success: true, data });
|
||
} catch (error) {
|
||
handleApiKeyError(res, error, '获取自动上架 App 链接失败');
|
||
}
|
||
});
|
||
|
||
router.post('/external-automation-apps', async (req, res) => {
|
||
try {
|
||
const data = await createExternalAutomationApp(req.body || {}, req.user || {});
|
||
res.status(201).json({ success: true, message: 'App 链接已创建', data });
|
||
} catch (error) {
|
||
handleApiKeyError(res, error, '创建自动上架 App 链接失败');
|
||
}
|
||
});
|
||
|
||
router.put('/external-automation-apps/:id', async (req, res) => {
|
||
try {
|
||
const data = await updateExternalAutomationApp(req.params.id, req.body || {}, req.user || {});
|
||
res.json({ success: true, message: 'App 链接已更新', data });
|
||
} catch (error) {
|
||
handleApiKeyError(res, error, '更新自动上架 App 链接失败');
|
||
}
|
||
});
|
||
|
||
router.delete('/external-automation-apps/:id', async (req, res) => {
|
||
try {
|
||
const data = await deleteExternalAutomationApp(req.params.id);
|
||
res.json({ success: true, message: 'App 链接已删除', data });
|
||
} catch (error) {
|
||
handleApiKeyError(res, error, '删除自动上架 App 链接失败');
|
||
}
|
||
});
|
||
|
||
function handleApiKeyError(res, error, fallbackMessage) {
|
||
console.error(`[AdminAPIKey] ${fallbackMessage}:`, error);
|
||
res.status(error.statusCode || error.status || 500).json({
|
||
success: false,
|
||
error: error.message || fallbackMessage,
|
||
});
|
||
}
|
||
|
||
router.get('/api-keys', async (_req, res) => {
|
||
try {
|
||
const rows = await listSelectionFissionApiKeys();
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
res.json({ success: true, data: rows });
|
||
} catch (error) {
|
||
handleApiKeyError(res, error, '获取 API Key 列表失败');
|
||
}
|
||
});
|
||
|
||
router.post('/api-keys', async (req, res) => {
|
||
try {
|
||
const data = await createSelectionFissionApiKey(req.body || {}, req.user || {});
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
res.status(201).json({
|
||
success: true,
|
||
message: 'API Key 已创建,请立即保存完整 Key',
|
||
data,
|
||
});
|
||
} catch (error) {
|
||
handleApiKeyError(res, error, '创建 API Key 失败');
|
||
}
|
||
});
|
||
|
||
router.put('/api-keys/:id', async (req, res) => {
|
||
try {
|
||
const data = await updateSelectionFissionApiKey(req.params.id, req.body || {}, req.user || {});
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
res.json({
|
||
success: true,
|
||
message: data.plainApiKey ? 'API Key 已更新并完成轮换' : 'API Key 已更新',
|
||
data,
|
||
});
|
||
} catch (error) {
|
||
handleApiKeyError(res, error, '更新 API Key 失败');
|
||
}
|
||
});
|
||
|
||
router.delete('/api-keys/:id', async (req, res) => {
|
||
try {
|
||
const data = await deleteSelectionFissionApiKey(req.params.id);
|
||
res.json({
|
||
success: true,
|
||
message: 'API Key 已删除',
|
||
data,
|
||
});
|
||
} catch (error) {
|
||
handleApiKeyError(res, error, '删除 API Key 失败');
|
||
}
|
||
});
|
||
|
||
// ==================== 选品 AI 分析模型 ====================
|
||
|
||
router.get('/selection-fission-ai-settings', async (_req, res) => {
|
||
try {
|
||
res.json(await getSelectionFissionAiAdminSettings());
|
||
} catch (error) {
|
||
console.error('获取选品AI分析模型配置失败:', error);
|
||
res.status(error.statusCode || error.status || 500).json({
|
||
error: error.message || '获取选品AI分析模型配置失败',
|
||
});
|
||
}
|
||
});
|
||
|
||
router.put('/selection-fission-ai-settings', async (req, res) => {
|
||
try {
|
||
const config = await saveSelectionFissionAiConfig(req.body || {});
|
||
res.json({ success: true, data: config, message: '选品AI分析模型配置已保存' });
|
||
} catch (error) {
|
||
console.error('保存选品AI分析模型配置失败:', error);
|
||
res.status(error.statusCode || error.status || 500).json({
|
||
error: error.message || '保存选品AI分析模型配置失败',
|
||
});
|
||
}
|
||
});
|
||
|
||
router.post('/selection-fission-ai-settings/test', async (_req, res) => {
|
||
try {
|
||
const result = await testSelectionFissionAiConfig();
|
||
res.json({ success: true, data: result });
|
||
} catch (error) {
|
||
console.error('测试选品AI分析模型失败:', error);
|
||
res.status(error.statusCode || error.status || 500).json({
|
||
success: false,
|
||
error: error.message || '测试选品AI分析模型失败',
|
||
code: error.code || undefined,
|
||
});
|
||
}
|
||
});
|
||
|
||
router.post('/selection-fission-ai-settings/image-generation/test', async (_req, res) => {
|
||
try {
|
||
const result = await testSelectionImageGenerationConfig();
|
||
res.json({ success: true, data: result });
|
||
} catch (error) {
|
||
console.error('测试选品生图服务失败:', error);
|
||
res.status(error.statusCode || error.status || 500).json({
|
||
success: false,
|
||
error: error.message || '测试选品生图服务失败',
|
||
code: error.code || undefined,
|
||
});
|
||
}
|
||
});
|
||
|
||
// ==================== 以图搜图服务 ====================
|
||
|
||
|
||
|
||
|
||
// ==================== 商品标签配置(规模标签 / 利润标签 / SABC 映射) ====================
|
||
|
||
router.get('/product-tag-config', async (_req, res) => {
|
||
try {
|
||
res.json({ success: true, data: await getProductTagConfig() });
|
||
} catch (error) {
|
||
console.error('获取商品标签配置失败:', error);
|
||
res.status(error.statusCode || error.status || 500).json({
|
||
success: false,
|
||
error: error.message || '获取商品标签配置失败',
|
||
});
|
||
}
|
||
});
|
||
|
||
router.put('/product-tag-config', async (req, res) => {
|
||
try {
|
||
const result = await saveProductTagConfig(req.body || {});
|
||
const config = await getProductTagConfig();
|
||
res.json({
|
||
success: true,
|
||
message: '商品标签配置已保存到系统数据库',
|
||
data: { ...config, saved: result },
|
||
});
|
||
} catch (error) {
|
||
console.error('保存商品标签配置失败:', error);
|
||
// 校验类错误回 400,方便前端直接展示原因
|
||
const status = /行|重复|不能为空|取值非法|必须小于/.test(error.message || '') ? 400 : (error.statusCode || error.status || 500);
|
||
res.status(status).json({
|
||
success: false,
|
||
error: error.message || '保存商品标签配置失败',
|
||
});
|
||
}
|
||
});
|
||
|
||
// ==================== 1688 专用以图搜图 ====================
|
||
|
||
|
||
|
||
|
||
// 上传 Logo
|
||
router.post('/system-config/logo', async (req, res) => {
|
||
try {
|
||
const { logo } = req.body;
|
||
let nextLogo = logo;
|
||
|
||
const s3Config = await getDefaultS3Config(true);
|
||
const hasObjectStorage =
|
||
s3Config &&
|
||
s3Config.endpoint &&
|
||
s3Config.bucket &&
|
||
s3Config.accessKeyId &&
|
||
s3Config.secretAccessKey;
|
||
|
||
if (typeof logo === 'string' && logo.startsWith('data:image/') && hasObjectStorage) {
|
||
const uploadResult = await uploadBase64ImageToDefaultS3(logo, 'logos');
|
||
nextLogo = uploadResult.url;
|
||
}
|
||
|
||
const existing = await database.local.query('SELECT id FROM system_config WHERE config_key = ?', ['logo']);
|
||
|
||
if (existing.length > 0) {
|
||
await database.local.query('UPDATE system_config SET config_value = ? WHERE config_key = ?', [nextLogo, 'logo']);
|
||
} else {
|
||
await database.local.insert('system_config', { config_key: 'logo', config_value: nextLogo, config_type: 'string' });
|
||
}
|
||
|
||
clearPublicSystemConfigCache();
|
||
res.json({ logo: nextLogo });
|
||
} catch (error) {
|
||
console.error('上传 Logo 失败:', error);
|
||
res.status(500).json({ error: '上传 Logo 失败' });
|
||
}
|
||
});
|
||
|
||
// ==================== 数据库配置 ====================
|
||
|
||
router.get('/system-database', async (_req, res) => {
|
||
try {
|
||
res.json(getSystemDbConfig());
|
||
} catch (error) {
|
||
console.error('获取系统数据库配置失败:', error);
|
||
res.status(500).json({ error: '获取系统数据库配置失败' });
|
||
}
|
||
});
|
||
|
||
router.put('/system-database', async (req, res) => {
|
||
try {
|
||
const { driver = 'mysql', host, port, database: dbName, username, password } = req.body || {};
|
||
const config = await saveSystemDbConfig({
|
||
driver,
|
||
host,
|
||
port: Number(port || 3306),
|
||
database: dbName,
|
||
username,
|
||
password,
|
||
});
|
||
|
||
res.json({
|
||
message: '系统数据库配置已保存,请重启服务使配置生效',
|
||
needRestart: true,
|
||
...config,
|
||
});
|
||
} catch (error) {
|
||
console.error('保存系统数据库配置失败:', error);
|
||
res.status(500).json({ error: '保存系统数据库配置失败' });
|
||
}
|
||
});
|
||
|
||
router.post('/system-database/test', async (req, res) => {
|
||
try {
|
||
const { host, port, database: dbName, username, password } = req.body || {};
|
||
const startTime = Date.now();
|
||
await testSystemDbConnection({
|
||
host,
|
||
port: Number(port || 3306),
|
||
database: dbName,
|
||
username,
|
||
password,
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
message: '系统数据库连接成功',
|
||
responseTime: Date.now() - startTime,
|
||
});
|
||
} catch (error) {
|
||
console.error('测试系统数据库连接失败:', error);
|
||
res.status(400).json({
|
||
success: false,
|
||
message: `连接失败: ${error.message}`,
|
||
});
|
||
}
|
||
});
|
||
|
||
router.get('/redis', async (_req, res) => {
|
||
try {
|
||
const [storedConfig, effectiveConfig, storedProfiles] = await Promise.all([
|
||
getRedisConfig(),
|
||
getEffectiveRedisConfig(),
|
||
getStoredRedisConfigs(),
|
||
]);
|
||
|
||
res.json({
|
||
...storedConfig,
|
||
storedProfiles,
|
||
runtimeProfile: getRedisRuntimeProfile(),
|
||
runtimeProfiles: getRedisRuntimeProfiles(),
|
||
effective: effectiveConfig,
|
||
});
|
||
} catch (error) {
|
||
console.error('获取 Redis 配置失败:', error);
|
||
res.status(500).json({ error: '获取 Redis 配置失败' });
|
||
}
|
||
});
|
||
|
||
router.post('/redis/runtime-profile', async (req, res) => {
|
||
try {
|
||
const { profile } = req.body || {};
|
||
const config = await setRedisRuntimeProfile(profile || 'stored');
|
||
await redisRuntime.reload();
|
||
|
||
res.json({
|
||
message: config.enabled
|
||
? `Redis 已切换到${config.profileName || config.source || '自动选择 Redis'}`
|
||
: 'Redis 运行态已关闭',
|
||
needRestart: false,
|
||
runtimeProfile: getRedisRuntimeProfile(),
|
||
effective: config,
|
||
});
|
||
} catch (error) {
|
||
console.error('切换 Redis 运行态失败:', error);
|
||
res.status(400).json({ error: error.message || '切换 Redis 运行态失败' });
|
||
}
|
||
});
|
||
|
||
router.put('/redis', async (req, res) => {
|
||
try {
|
||
const { host, port, password, db, profileKey, profile } = req.body || {};
|
||
const config = await saveRedisConfig({
|
||
profileKey: profileKey || profile,
|
||
host: typeof host === 'string' ? host.trim() : '',
|
||
port: Number(port || 6379),
|
||
password,
|
||
db: Number(db || 0),
|
||
});
|
||
await redisRuntime.reload();
|
||
|
||
res.json({
|
||
message: config.enabled
|
||
? `${config.profileName || 'Redis'}配置已保存到系统库并立即生效`
|
||
: `${config.profileName || 'Redis'}已在系统库中关闭并立即生效`,
|
||
needRestart: false,
|
||
...config,
|
||
});
|
||
} catch (error) {
|
||
console.error('保存 Redis 配置失败:', error);
|
||
res.status(500).json({ error: '保存 Redis 配置失败' });
|
||
}
|
||
});
|
||
|
||
router.post('/redis/test', async (req, res) => {
|
||
try {
|
||
const { host, port, password, db, profileKey, profile } = req.body || {};
|
||
const startTime = Date.now();
|
||
await testRedisConnection({
|
||
profileKey: profileKey || profile,
|
||
host: typeof host === 'string' ? host.trim() : '',
|
||
port: Number(port || 6379),
|
||
password,
|
||
db: Number(db || 0),
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
message: 'Redis 连接成功',
|
||
responseTime: Date.now() - startTime,
|
||
});
|
||
} catch (error) {
|
||
console.error('测试 Redis 连接失败:', error);
|
||
res.status(400).json({
|
||
success: false,
|
||
message: `连接失败: ${error.message}`,
|
||
});
|
||
}
|
||
});
|
||
|
||
// ==================== 工单通知系统配置 ====================
|
||
|
||
router.get('/work-order-config', async (_req, res) => {
|
||
try {
|
||
const config = await getWorkOrderConfig();
|
||
res.json(config);
|
||
} catch (error) {
|
||
console.error('获取工单系统配置失败:', error);
|
||
res.status(500).json({ error: '获取工单系统配置失败' });
|
||
}
|
||
});
|
||
|
||
router.put('/work-order-config', async (req, res) => {
|
||
try {
|
||
const config = await saveWorkOrderConfig(req.body || {});
|
||
res.json({
|
||
message: '工单系统配置已保存',
|
||
needRestart: false,
|
||
...config,
|
||
});
|
||
} catch (error) {
|
||
console.error('保存工单系统配置失败:', error);
|
||
res.status(400).json({ error: error.message || '保存工单系统配置失败' });
|
||
}
|
||
});
|
||
|
||
router.post('/work-order-config/test', async (req, res) => {
|
||
try {
|
||
const startTime = Date.now();
|
||
const result = await testWorkOrderConnection(req.body || {});
|
||
res.json({
|
||
...result,
|
||
responseTime: Date.now() - startTime,
|
||
});
|
||
} catch (error) {
|
||
console.error('测试工单系统连接失败:', error);
|
||
res.status(400).json({
|
||
success: false,
|
||
message: `连接失败: ${error.message}`,
|
||
});
|
||
}
|
||
});
|
||
|
||
const DEFAULT_DATABASE_CONFIG_NAME = 'cloud_database';
|
||
const CLOUD_DATABASE_PUBLIC_CONFIG_NAME = 'cloud_database_public';
|
||
const PLANNING_DATABASE_CONFIG_NAME = 'planning_database';
|
||
const AI_BUYER_SHOW_DATABASE_CONFIG_NAME = 'ai_buyer_show_database';
|
||
const PROTECTED_DATABASE_CONFIG_NAMES = new Set([
|
||
DEFAULT_DATABASE_CONFIG_NAME,
|
||
CLOUD_DATABASE_PUBLIC_CONFIG_NAME,
|
||
PLANNING_DATABASE_CONFIG_NAME,
|
||
AI_BUYER_SHOW_DATABASE_CONFIG_NAME,
|
||
]);
|
||
|
||
function normalizeDatabaseConfigName(rawName) {
|
||
const configName = String(rawName || '').trim();
|
||
if (!configName) {
|
||
return DEFAULT_DATABASE_CONFIG_NAME;
|
||
}
|
||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{1,99}$/.test(configName)) {
|
||
const error = new Error('数据库配置标识只能使用 2-100 位英文、数字、下划线或短横线');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
return configName;
|
||
}
|
||
|
||
function getDefaultPortByDriver(driver) {
|
||
if (driver === 'mssql') return 1433;
|
||
if (driver === 'postgres') return 5432;
|
||
return 3306;
|
||
}
|
||
|
||
async function getStoredDatabaseConfig(configName) {
|
||
const normalizedName = normalizeDatabaseConfigName(configName);
|
||
let configs = await database.local.query(
|
||
'SELECT id, name, driver, host, port, database_name as `database`, username, `ssl`, use_env_config, is_default, status FROM database_configs WHERE name = ? LIMIT 1',
|
||
[normalizedName]
|
||
);
|
||
|
||
// 兼容旧数据:默认业务数据库历史上只靠 is_default 标记,没有固定 name。
|
||
if (configs.length === 0 && normalizedName === DEFAULT_DATABASE_CONFIG_NAME) {
|
||
configs = await database.local.query(
|
||
'SELECT id, name, driver, host, port, database_name as `database`, username, `ssl`, use_env_config, is_default, status FROM database_configs WHERE is_default = 1 LIMIT 1'
|
||
);
|
||
}
|
||
|
||
return configs[0] || null;
|
||
}
|
||
|
||
async function getStoredDatabaseCredentials(configName) {
|
||
const normalizedName = normalizeDatabaseConfigName(configName);
|
||
const configs = await database.local.query(
|
||
'SELECT driver, host, port, database_name as `database`, username, password, `ssl` FROM database_configs WHERE name = ? LIMIT 1',
|
||
[normalizedName]
|
||
);
|
||
return configs[0] || null;
|
||
}
|
||
|
||
function getBusinessEnvDatabasePayload(configName) {
|
||
const normalizedName = normalizeDatabaseConfigName(configName);
|
||
if (![DEFAULT_DATABASE_CONFIG_NAME, CLOUD_DATABASE_PUBLIC_CONFIG_NAME].includes(normalizedName)) {
|
||
return {};
|
||
}
|
||
|
||
const summary = cloudDb.getEnvBusinessDatabaseSummary(
|
||
process.env,
|
||
normalizedName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME ? 'public' : 'default'
|
||
);
|
||
return {
|
||
envConfigAvailable: summary.available,
|
||
envConfig: summary,
|
||
};
|
||
}
|
||
|
||
// 获取全部业务及扩展数据库配置(不返回密码)
|
||
router.get('/databases', async (_req, res) => {
|
||
try {
|
||
const configs = await database.local.query(
|
||
'SELECT id, name, driver, host, port, database_name as `database`, username, `ssl`, use_env_config, is_default, status, updated_at FROM database_configs ORDER BY is_default DESC, id ASC'
|
||
);
|
||
res.json(configs.map((config) => ({
|
||
...config,
|
||
ssl: config.ssl === 1 || config.ssl === true,
|
||
useEnvConfig: config.use_env_config === 1 || config.use_env_config === true,
|
||
protected: PROTECTED_DATABASE_CONFIG_NAMES.has(config.name) || Boolean(config.is_default),
|
||
use_env_config: undefined,
|
||
})));
|
||
} catch (error) {
|
||
console.error('获取数据库配置列表失败:', error);
|
||
res.status(500).json({ error: '获取数据库配置列表失败' });
|
||
}
|
||
});
|
||
|
||
router.delete('/databases/:id', async (req, res) => {
|
||
try {
|
||
const id = Number(req.params.id);
|
||
if (!Number.isInteger(id) || id <= 0) {
|
||
return res.status(400).json({ error: '数据库配置 ID 无效' });
|
||
}
|
||
const configs = await database.local.query(
|
||
'SELECT id, name, is_default FROM database_configs WHERE id = ? LIMIT 1',
|
||
[id]
|
||
);
|
||
const config = configs[0];
|
||
if (!config) {
|
||
return res.status(404).json({ error: '数据库配置不存在' });
|
||
}
|
||
if (config.is_default === 1 || PROTECTED_DATABASE_CONFIG_NAMES.has(config.name)) {
|
||
return res.status(400).json({ error: '核心数据库配置不能删除' });
|
||
}
|
||
await database.local.query('DELETE FROM database_configs WHERE id = ?', [id]);
|
||
res.json({ message: '数据库配置已删除' });
|
||
} catch (error) {
|
||
console.error('删除数据库配置失败:', error);
|
||
res.status(500).json({ error: '删除数据库配置失败' });
|
||
}
|
||
});
|
||
|
||
// 获取单个数据库配置
|
||
router.get('/database', async (req, res) => {
|
||
try {
|
||
const configName = normalizeDatabaseConfigName(req.query.name);
|
||
const config = await getStoredDatabaseConfig(configName);
|
||
|
||
if (config) {
|
||
// 转换 ssl 为布尔值
|
||
config.ssl = config.ssl === 1;
|
||
config.useEnvConfig = config.use_env_config === 1 || config.use_env_config === true;
|
||
delete config.use_env_config;
|
||
config.name = config.name || configName;
|
||
res.json({
|
||
...config,
|
||
...getBusinessEnvDatabasePayload(configName),
|
||
});
|
||
} else {
|
||
res.json({
|
||
name: configName,
|
||
driver: 'mysql',
|
||
port: 3306,
|
||
ssl: false,
|
||
useEnvConfig: false,
|
||
...getBusinessEnvDatabasePayload(configName),
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('获取数据库配置失败:', error);
|
||
res.status(error.statusCode || 500).json({ error: error.statusCode ? error.message : '获取数据库配置失败' });
|
||
}
|
||
});
|
||
|
||
// 更新数据库配置
|
||
router.put('/database', async (req, res) => {
|
||
try {
|
||
const { name, driver, host, port, database: dbName, username, password, ssl, useEnvConfig, status } = req.body;
|
||
const configName = normalizeDatabaseConfigName(name);
|
||
const isDefaultConfig = configName === DEFAULT_DATABASE_CONFIG_NAME;
|
||
const isBusinessConfig = configName === DEFAULT_DATABASE_CONFIG_NAME || configName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME;
|
||
const existingConfig = await getStoredDatabaseConfig(configName);
|
||
const envProfile = configName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME ? 'public' : 'default';
|
||
const envSummary = cloudDb.getEnvBusinessDatabaseSummary(process.env, envProfile);
|
||
|
||
if (isBusinessConfig && useEnvConfig && !envSummary.available) {
|
||
return res.status(400).json({
|
||
error: configName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME
|
||
? '当前 .env 公网业务数据库配置不完整,请先配置 CLOUD_DB_PUBLIC_HOST / CLOUD_DB_PUBLIC_NAME / CLOUD_DB_PUBLIC_USER'
|
||
: '当前 .env 业务数据库配置不完整,请先配置 CLOUD_DB_HOST / CLOUD_DB_NAME / CLOUD_DB_USER',
|
||
});
|
||
}
|
||
|
||
// 根据数据库类型设置默认端口
|
||
const resolvedDriver = driver || existingConfig?.driver || (useEnvConfig ? envSummary.driver : 'mysql');
|
||
const defaultPort = getDefaultPortByDriver(resolvedDriver);
|
||
|
||
const configData = {
|
||
name: configName,
|
||
driver: resolvedDriver,
|
||
host: host || existingConfig?.host || (useEnvConfig ? envSummary.host : ''),
|
||
port: port || existingConfig?.port || (useEnvConfig ? envSummary.port : defaultPort),
|
||
database_name: dbName || existingConfig?.database || (useEnvConfig ? envSummary.database : ''),
|
||
username: username || existingConfig?.username || (useEnvConfig ? envSummary.username : ''),
|
||
ssl: ssl ? 1 : 0,
|
||
use_env_config: isBusinessConfig && useEnvConfig ? 1 : 0,
|
||
is_default: isDefaultConfig ? 1 : 0,
|
||
status: status === 'inactive' && !isDefaultConfig ? 'inactive' : 'active',
|
||
};
|
||
|
||
// 只有密码不为空时才更新密码
|
||
if (password) {
|
||
configData.password = password;
|
||
}
|
||
|
||
if (existingConfig) {
|
||
await database.local.update('database_configs', existingConfig.id, configData);
|
||
} else {
|
||
configData.password = password || '';
|
||
await database.local.insert('database_configs', configData);
|
||
}
|
||
|
||
if (isDefaultConfig) {
|
||
await database.local.query(
|
||
'UPDATE database_configs SET is_default = 0 WHERE name != ? AND is_default = 1',
|
||
[configName]
|
||
);
|
||
}
|
||
if (isBusinessConfig) {
|
||
await cloudDb.close();
|
||
}
|
||
|
||
res.json({
|
||
message: '配置已保存,业务数据库连接已重新加载',
|
||
needRestart: false,
|
||
name: configName,
|
||
...configData
|
||
});
|
||
} catch (error) {
|
||
console.error('更新数据库配置失败:', error);
|
||
res.status(error.statusCode || 500).json({ error: error.statusCode ? error.message : '更新数据库配置失败' });
|
||
}
|
||
});
|
||
|
||
// 测试数据库连接
|
||
router.post('/database/test', async (req, res) => {
|
||
let { name, driver, host, port, database: dbName, username, password, ssl, useEnvConfig } = req.body;
|
||
|
||
try {
|
||
const startTime = Date.now();
|
||
const configName = normalizeDatabaseConfigName(name);
|
||
const envProfile = configName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME ? 'public' : 'default';
|
||
if (useEnvConfig) {
|
||
const envConfig = cloudDb.getEnvBusinessDatabaseConfig(process.env, envProfile);
|
||
if (!cloudDb.hasCompleteEnvConfig(process.env, envProfile)) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: configName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME
|
||
? '当前 .env 公网业务数据库配置不完整,请先配置 CLOUD_DB_PUBLIC_HOST / CLOUD_DB_PUBLIC_NAME / CLOUD_DB_PUBLIC_USER'
|
||
: '当前 .env 业务数据库配置不完整,请先配置 CLOUD_DB_HOST / CLOUD_DB_NAME / CLOUD_DB_USER',
|
||
message: configName === CLOUD_DATABASE_PUBLIC_CONFIG_NAME
|
||
? '当前 .env 公网业务数据库配置不完整,请先配置 CLOUD_DB_PUBLIC_HOST / CLOUD_DB_PUBLIC_NAME / CLOUD_DB_PUBLIC_USER'
|
||
: '当前 .env 业务数据库配置不完整,请先配置 CLOUD_DB_HOST / CLOUD_DB_NAME / CLOUD_DB_USER',
|
||
});
|
||
}
|
||
|
||
driver = envConfig.driver || 'mssql';
|
||
host = envConfig.host;
|
||
port = envConfig.port;
|
||
dbName = envConfig.database;
|
||
username = envConfig.user;
|
||
password = envConfig.password;
|
||
ssl = envConfig.ssl;
|
||
} else {
|
||
const storedConfig = await getStoredDatabaseCredentials(configName);
|
||
driver = driver || storedConfig?.driver;
|
||
host = host || storedConfig?.host;
|
||
port = port || storedConfig?.port;
|
||
dbName = dbName || storedConfig?.database;
|
||
username = username || storedConfig?.username;
|
||
password = password || storedConfig?.password;
|
||
ssl = typeof ssl === 'boolean' ? ssl : Boolean(storedConfig?.ssl);
|
||
}
|
||
|
||
if (!host || !dbName || !username || !password) {
|
||
return res.status(400).json({ success: false, error: '请完整填写数据库连接信息' });
|
||
}
|
||
|
||
if (driver === 'mssql') {
|
||
// SQL Server 连接测试
|
||
const sql = require('mssql');
|
||
const config = {
|
||
server: host,
|
||
port: port || 1433,
|
||
database: dbName,
|
||
user: username,
|
||
password,
|
||
options: {
|
||
encrypt: ssl || false,
|
||
trustServerCertificate: true,
|
||
},
|
||
connectionTimeout: 10000,
|
||
};
|
||
|
||
const pool = await sql.connect(config);
|
||
await pool.request().query('SELECT 1 as test');
|
||
await pool.close();
|
||
} else {
|
||
// MySQL 连接测试
|
||
const mysql = require('mysql2/promise');
|
||
const connection = await mysql.createConnection({
|
||
host,
|
||
port: port || 3306,
|
||
user: username,
|
||
password,
|
||
database: dbName,
|
||
connectTimeout: 10000,
|
||
ssl: ssl ? { rejectUnauthorized: false } : false,
|
||
});
|
||
|
||
await connection.ping();
|
||
await connection.end();
|
||
}
|
||
|
||
const responseTime = Date.now() - startTime;
|
||
|
||
res.json({
|
||
success: true,
|
||
message: '数据库连接成功',
|
||
responseTime,
|
||
});
|
||
} catch (error) {
|
||
console.error('数据库连接测试失败:', error);
|
||
res.status(400).json({
|
||
success: false,
|
||
error: `连接失败: ${error.message}`,
|
||
message: `连接失败: ${error.message}`,
|
||
});
|
||
}
|
||
});
|
||
|
||
// ==================== S3 配置 ====================
|
||
|
||
// 获取 S3 配置
|
||
router.get('/s3', async (req, res) => {
|
||
try {
|
||
const config = await getDefaultS3Config(true);
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
if (config) {
|
||
const { secretAccessKey, ...publicConfig } = config;
|
||
res.json({
|
||
...publicConfig,
|
||
hasSecret: Boolean(secretAccessKey),
|
||
});
|
||
} else {
|
||
res.json({
|
||
provider: 'minio',
|
||
region: 'auto',
|
||
protocol: 'https',
|
||
storageClass: 'STANDARD',
|
||
hasSecret: false,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('获取 S3 配置失败:', error);
|
||
res.status(500).json({ error: '获取 S3 配置失败' });
|
||
}
|
||
});
|
||
|
||
// 更新 S3 配置
|
||
router.put('/s3', async (req, res) => {
|
||
try {
|
||
await ensureObjectStorageSchema();
|
||
const {
|
||
provider,
|
||
endpoint,
|
||
region,
|
||
zone,
|
||
protocol,
|
||
storageClass,
|
||
bucket,
|
||
accessKeyId,
|
||
secretAccessKey,
|
||
customDomain,
|
||
} = req.body || {};
|
||
const normalizedProvider = String(provider || '').trim().toLowerCase();
|
||
const normalizedProtocol = normalizeProtocol(protocol, endpoint);
|
||
const normalizedEndpoint = normalizeEndpoint(endpoint, normalizedProtocol);
|
||
const normalizedBucket = String(bucket || '').trim();
|
||
const normalizedAccessKeyId = String(accessKeyId || '').trim();
|
||
const existing = await getDefaultS3Config(true);
|
||
const nextSecret = String(secretAccessKey || '').trim() || existing?.secretAccessKey || '';
|
||
|
||
if (!normalizedProvider || !normalizedEndpoint || !normalizedBucket || !normalizedAccessKeyId || !nextSecret) {
|
||
return res.status(400).json({
|
||
error: '请完整填写提供商、Endpoint、Bucket、Access Key 和 Secret Key',
|
||
});
|
||
}
|
||
|
||
const configData = {
|
||
provider: normalizedProvider,
|
||
endpoint: normalizedEndpoint,
|
||
region: String(region || 'auto').trim() || 'auto',
|
||
zone: String(zone || '').trim() || null,
|
||
protocol: normalizedProtocol,
|
||
storage_class: normalizeStorageClass(storageClass),
|
||
bucket: normalizedBucket,
|
||
access_key_id: normalizedAccessKeyId,
|
||
secret_access_key: nextSecret,
|
||
custom_domain: normalizePublicDomain(customDomain, normalizedProtocol) || null,
|
||
is_default: 1,
|
||
};
|
||
|
||
if (existing) {
|
||
await database.local.update('s3_configs', existing.id, configData);
|
||
} else {
|
||
await database.local.insert('s3_configs', configData);
|
||
}
|
||
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
res.json({
|
||
message: '对象存储配置已更新',
|
||
data: {
|
||
provider: configData.provider,
|
||
endpoint: configData.endpoint,
|
||
region: configData.region,
|
||
zone: configData.zone,
|
||
protocol: configData.protocol,
|
||
storageClass: configData.storage_class,
|
||
bucket: configData.bucket,
|
||
accessKeyId: configData.access_key_id,
|
||
customDomain: configData.custom_domain,
|
||
hasSecret: true,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('更新 S3 配置失败:', error);
|
||
res.status(500).json({ error: '更新 S3 配置失败' });
|
||
}
|
||
});
|
||
|
||
// 测试 S3 连接
|
||
router.post('/s3/test', async (req, res) => {
|
||
try {
|
||
const input = req.body || {};
|
||
const existing = await getDefaultS3Config(true);
|
||
const config = {
|
||
provider: input.provider || existing?.provider,
|
||
endpoint: input.endpoint || existing?.endpoint,
|
||
region: input.region || existing?.region || 'auto',
|
||
zone: input.zone || existing?.zone || '',
|
||
protocol: input.protocol || existing?.protocol,
|
||
storageClass: input.storageClass || existing?.storageClass || 'STANDARD',
|
||
bucket: input.bucket || existing?.bucket,
|
||
accessKeyId: input.accessKeyId || existing?.accessKeyId,
|
||
secretAccessKey: input.secretAccessKey || existing?.secretAccessKey,
|
||
};
|
||
const result = await testObjectStorageConnection(config);
|
||
|
||
res.json({
|
||
success: true,
|
||
message: '对象存储连接成功',
|
||
data: result,
|
||
});
|
||
} catch (error) {
|
||
console.error('S3 连接测试失败:', error);
|
||
res.status(400).json({
|
||
success: false,
|
||
message: `连接失败: ${error.message}`,
|
||
});
|
||
}
|
||
});
|
||
|
||
// ==================== KPI目标值配置 ====================
|
||
|
||
// 获取所有页面列表(用于下拉选择)
|
||
router.get('/kpi-targets/pages', async (req, res) => {
|
||
try {
|
||
const pages = await database.local.query(
|
||
'SELECT DISTINCT page_code as pageCode FROM sys_kpi_targets ORDER BY page_code'
|
||
);
|
||
|
||
// 页面名称映射
|
||
const pageNames = {
|
||
customerService: '客服分析',
|
||
warehouse: '仓库分析',
|
||
logistics: '快递分析',
|
||
supplier: '供应商分析',
|
||
procurement: '采购分析',
|
||
productDesign: '开发分析',
|
||
region: '地区分析',
|
||
summary: '监控大厅',
|
||
};
|
||
|
||
const result = pages.map(p => ({
|
||
code: p.pageCode,
|
||
name: pageNames[p.pageCode] || p.pageCode,
|
||
}));
|
||
|
||
res.json({ success: true, data: result });
|
||
} catch (error) {
|
||
console.error('获取页面列表失败:', error);
|
||
res.status(500).json({ success: false, error: '获取页面列表失败' });
|
||
}
|
||
});
|
||
|
||
// 获取指定页面的KPI目标值
|
||
router.get('/kpi-targets/:pageCode', async (req, res) => {
|
||
try {
|
||
const { pageCode } = req.params;
|
||
const targets = await database.local.query(
|
||
`SELECT id, page_code as pageCode, kpi_code as kpiCode, kpi_name as kpiName,
|
||
unit, direction, target_good as targetGood, target_pass as targetPass,
|
||
target_warn as targetWarn, is_enabled as isEnabled, sort_order as sortOrder, remark
|
||
FROM sys_kpi_targets
|
||
WHERE page_code = ? AND is_enabled = 1
|
||
ORDER BY sort_order`,
|
||
[pageCode]
|
||
);
|
||
|
||
res.json({ success: true, data: targets });
|
||
} catch (error) {
|
||
console.error('获取KPI目标值失败:', error);
|
||
res.status(500).json({ success: false, error: '获取KPI目标值失败' });
|
||
}
|
||
});
|
||
|
||
// 获取所有KPI目标值(管理界面用)
|
||
router.get('/kpi-targets', async (req, res) => {
|
||
try {
|
||
const targets = await database.local.query(
|
||
`SELECT id, page_code as pageCode, kpi_code as kpiCode, kpi_name as kpiName,
|
||
unit, direction, target_good as targetGood, target_pass as targetPass,
|
||
target_warn as targetWarn, is_enabled as isEnabled, sort_order as sortOrder, remark
|
||
FROM sys_kpi_targets
|
||
ORDER BY page_code, sort_order`
|
||
);
|
||
|
||
res.json({ success: true, data: targets });
|
||
} catch (error) {
|
||
console.error('获取所有KPI目标值失败:', error);
|
||
res.status(500).json({ success: false, error: '获取所有KPI目标值失败' });
|
||
}
|
||
});
|
||
|
||
// 更新KPI目标值
|
||
router.put('/kpi-targets/:id', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const { kpiName, unit, direction, targetGood, targetPass, targetWarn, isEnabled, remark } = req.body;
|
||
|
||
await database.local.query(
|
||
`UPDATE sys_kpi_targets SET
|
||
kpi_name = ?, unit = ?, direction = ?,
|
||
target_good = ?, target_pass = ?, target_warn = ?,
|
||
is_enabled = ?, remark = ?, updated_at = NOW()
|
||
WHERE id = ?`,
|
||
[kpiName, unit, direction, targetGood, targetPass, targetWarn, isEnabled ? 1 : 0, remark || '', id]
|
||
);
|
||
|
||
res.json({ success: true, message: '更新成功' });
|
||
} catch (error) {
|
||
console.error('更新KPI目标值失败:', error);
|
||
res.status(500).json({ success: false, error: '更新KPI目标值失败' });
|
||
}
|
||
});
|
||
|
||
// 新增KPI指标
|
||
router.post('/kpi-targets', async (req, res) => {
|
||
try {
|
||
const { pageCode, kpiCode, kpiName, unit, direction, targetGood, targetPass, targetWarn, sortOrder } = req.body;
|
||
|
||
const result = await database.local.insert('sys_kpi_targets', {
|
||
page_code: pageCode,
|
||
kpi_code: kpiCode,
|
||
kpi_name: kpiName,
|
||
unit: unit || '',
|
||
direction: direction || 'lower',
|
||
target_good: targetGood,
|
||
target_pass: targetPass,
|
||
target_warn: targetWarn,
|
||
sort_order: sortOrder || 0,
|
||
is_enabled: 1,
|
||
});
|
||
|
||
res.status(201).json({ success: true, id: result.insertId, message: '新增成功' });
|
||
} catch (error) {
|
||
console.error('新增KPI指标失败:', error);
|
||
res.status(500).json({ success: false, error: '新增KPI指标失败' });
|
||
}
|
||
});
|
||
|
||
// 删除KPI指标
|
||
router.delete('/kpi-targets/:id', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
await database.local.query('DELETE FROM sys_kpi_targets WHERE id = ?', [id]);
|
||
res.json({ success: true, message: '删除成功' });
|
||
} catch (error) {
|
||
console.error('删除KPI指标失败:', error);
|
||
res.status(500).json({ success: false, error: '删除KPI指标失败' });
|
||
}
|
||
});
|
||
|
||
router.post('/ai-image-features/upsert', async (req, res) => {
|
||
try {
|
||
const records = Array.isArray(req.body?.records)
|
||
? req.body.records
|
||
: (req.body ? [req.body] : []);
|
||
|
||
if (records.length === 0) {
|
||
return res.status(400).json({ error: 'records 不能为空' });
|
||
}
|
||
|
||
const results = await batchUpsertAiImageFeatures(records);
|
||
res.json({
|
||
success: true,
|
||
message: `已写入系统库,共 ${results.length} 条`,
|
||
count: results.length,
|
||
data: results,
|
||
});
|
||
} catch (error) {
|
||
console.error('写入 AI 图片标签与向量失败:', error);
|
||
res.status(500).json({ error: error.message || '写入 AI 图片标签与向量失败' });
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|