cangku/backend/src/api/admin.js

2224 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;