'use strict'; const database = require('../config/database'); const APP_TABLE = 'external_automation_apps'; const PLATFORMS = new Set(['windows', 'macos', 'linux', 'all']); let schemaReady = false; let schemaPromise = null; function normalizeText(value, maxLength = 255) { return String(value ?? '').trim().slice(0, maxLength); } function normalizeId(value) { const id = Number.parseInt(value, 10); if (!Number.isFinite(id) || id <= 0) { const error = new Error('App 链接 ID 不正确'); error.status = 400; throw error; } return id; } function normalizeUrl(value, { required = false, label = '链接' } = {}) { const text = normalizeText(value, 2000); if (!text && !required) return ''; if (!text) { const error = new Error(`请输入${label}`); error.status = 400; throw error; } try { const url = new URL(text); if (!['http:', 'https:'].includes(url.protocol)) throw new Error('unsupported protocol'); return url.toString(); } catch { const error = new Error(`${label}必须是有效的 HTTP 或 HTTPS 地址`); error.status = 400; throw error; } } function getActor(user = {}) { return normalizeText(user.name || user.username || user.userName || user.email || user.sub, 120) || null; } function mapAppRow(row = {}) { return { id: Number(row.id), name: row.name || '', platform: row.platform || 'all', version: row.version || '', downloadUrl: row.download_url || '', releaseNotesUrl: row.release_notes_url || '', description: row.description || '', enabled: row.status === 'active', sortOrder: Number(row.sort_order || 0), createdBy: row.created_by_name || '', updatedBy: row.updated_by_name || '', createdAt: row.created_at || null, updatedAt: row.updated_at || null, }; } async function ensureExternalAutomationAppSchema() { if (schemaReady) return; if (schemaPromise) return schemaPromise; schemaPromise = database.local.query( `CREATE TABLE IF NOT EXISTS ${APP_TABLE} ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', name VARCHAR(120) NOT NULL COMMENT 'App名称', platform ENUM('windows', 'macos', 'linux', 'all') NOT NULL DEFAULT 'windows' COMMENT '适用平台', version VARCHAR(80) NULL COMMENT '版本号', download_url VARCHAR(2000) NOT NULL COMMENT '下载或启动链接', release_notes_url VARCHAR(2000) NULL COMMENT '更新说明链接', description VARCHAR(500) NULL COMMENT '备注', status ENUM('active', 'disabled') NOT NULL DEFAULT 'active' COMMENT '状态', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值', created_by_name VARCHAR(120) NULL COMMENT '创建人', updated_by_name VARCHAR(120) NULL COMMENT '更新人', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', KEY idx_external_automation_app_status_sort (status, sort_order, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自动上架第三方App链接'` ).then(() => { schemaReady = true; }); try { await schemaPromise; } finally { schemaPromise = null; } } async function getAppById(id) { await ensureExternalAutomationAppSchema(); const normalizedId = normalizeId(id); const rows = await database.local.query(`SELECT * FROM ${APP_TABLE} WHERE id = ? LIMIT 1`, [normalizedId]); if (!rows[0]) { const error = new Error('App 链接不存在'); error.status = 404; throw error; } return rows[0]; } async function listExternalAutomationApps() { await ensureExternalAutomationAppSchema(); const rows = await database.local.query( `SELECT * FROM ${APP_TABLE} ORDER BY status = 'active' DESC, sort_order ASC, updated_at DESC, id DESC` ); return rows.map(mapAppRow); } function normalizeAppInput(input = {}, { partial = false } = {}) { const output = {}; if (!partial || Object.prototype.hasOwnProperty.call(input, 'name')) { output.name = normalizeText(input.name, 120); if (!output.name) { const error = new Error('请输入 App 名称'); error.status = 400; throw error; } } if (!partial || Object.prototype.hasOwnProperty.call(input, 'platform')) { output.platform = normalizeText(input.platform, 20).toLowerCase() || 'windows'; if (!PLATFORMS.has(output.platform)) { const error = new Error('App 平台不正确'); error.status = 400; throw error; } } if (!partial || Object.prototype.hasOwnProperty.call(input, 'version')) { output.version = normalizeText(input.version, 80); } if (!partial || Object.prototype.hasOwnProperty.call(input, 'downloadUrl')) { output.downloadUrl = normalizeUrl(input.downloadUrl, { required: true, label: 'App 下载链接' }); } if (!partial || Object.prototype.hasOwnProperty.call(input, 'releaseNotesUrl')) { output.releaseNotesUrl = normalizeUrl(input.releaseNotesUrl, { label: '更新说明链接' }); } if (!partial || Object.prototype.hasOwnProperty.call(input, 'description')) { output.description = normalizeText(input.description, 500); } if (!partial || Object.prototype.hasOwnProperty.call(input, 'enabled')) { output.enabled = input.enabled !== false; } if (!partial || Object.prototype.hasOwnProperty.call(input, 'sortOrder')) { const sortOrder = Number.parseInt(input.sortOrder, 10); output.sortOrder = Number.isFinite(sortOrder) ? Math.max(-9999, Math.min(9999, sortOrder)) : 0; } return output; } async function createExternalAutomationApp(input = {}, user = {}) { await ensureExternalAutomationAppSchema(); const data = normalizeAppInput(input); const actor = getActor(user); const result = await database.local.insert( `INSERT INTO ${APP_TABLE} (name, platform, version, download_url, release_notes_url, description, status, sort_order, created_by_name, updated_by_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ data.name, data.platform, data.version || null, data.downloadUrl, data.releaseNotesUrl || null, data.description || null, data.enabled ? 'active' : 'disabled', data.sortOrder, actor, actor, ] ); return mapAppRow(await getAppById(result.insertId)); } async function updateExternalAutomationApp(id, input = {}, user = {}) { const existing = await getAppById(id); const data = normalizeAppInput(input, { partial: true }); const columnMap = { name: 'name', platform: 'platform', version: 'version', downloadUrl: 'download_url', releaseNotesUrl: 'release_notes_url', description: 'description', enabled: 'status', sortOrder: 'sort_order', }; const updates = []; const params = []; Object.entries(data).forEach(([key, value]) => { updates.push(`${columnMap[key]} = ?`); params.push(key === 'enabled' ? (value ? 'active' : 'disabled') : (['version', 'releaseNotesUrl', 'description'].includes(key) ? (value || null) : value)); }); updates.push('updated_by_name = ?'); params.push(getActor(user)); await database.local.query( `UPDATE ${APP_TABLE} SET ${updates.join(', ')} WHERE id = ?`, [...params, existing.id] ); return mapAppRow(await getAppById(existing.id)); } async function deleteExternalAutomationApp(id) { const existing = await getAppById(id); await database.local.query(`DELETE FROM ${APP_TABLE} WHERE id = ?`, [existing.id]); return mapAppRow(existing); } module.exports = { createExternalAutomationApp, deleteExternalAutomationApp, ensureExternalAutomationAppSchema, listExternalAutomationApps, normalizeAppInput, updateExternalAutomationApp, };