cangku/backend/src/services/desktopReleaseService.js

255 lines
9.1 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 database = require('../config/database');
const DESKTOP_RELEASE_CONFIG_KEY = 'desktop_client_releases';
const MAX_RELEASE_HISTORY = 50;
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
const SUPPORTED_PLATFORMS = new Set(['win32', 'darwin']);
const SUPPORTED_ARCHITECTURES = new Set(['x64', 'arm64', 'universal']);
function normalizeVersion(value) {
return String(value || '').trim().replace(/^v/i, '');
}
function versionParts(value) {
const normalized = normalizeVersion(value);
if (!VERSION_PATTERN.test(normalized)) return null;
return normalized.split('.').map((part) => Number(part));
}
function compareVersions(left, right) {
const leftParts = versionParts(left);
const rightParts = versionParts(right);
if (!leftParts || !rightParts) return 0;
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] !== rightParts[index]) {
return leftParts[index] > rightParts[index] ? 1 : -1;
}
}
return 0;
}
function validateVersion(value) {
const version = normalizeVersion(value);
if (!VERSION_PATTERN.test(version)) {
throw new Error('版本号格式必须为 x.y.z,例如 0.0.9');
}
return version;
}
function normalizePlatform(value, fileName = '') {
const normalized = String(value || '').trim().toLowerCase();
if (['darwin', 'mac', 'macos', 'osx'].includes(normalized)) return 'darwin';
if (['win32', 'win', 'windows'].includes(normalized)) return 'win32';
if (String(fileName || '').toLowerCase().endsWith('.dmg')) return 'darwin';
return 'win32';
}
function normalizeArchitecture(value, fileName = '', platform = '') {
const normalized = String(value || '').trim().toLowerCase();
if (SUPPORTED_ARCHITECTURES.has(normalized)) return normalized;
const fileMatch = String(fileName || '').toLowerCase().match(/[-_.](arm64|x64|universal)(?=[-_.]|$)/);
if (fileMatch) return fileMatch[1];
return normalizePlatform(platform, fileName) === 'darwin' ? 'arm64' : 'x64';
}
function normalizeStoredRelease(value) {
if (!value || typeof value !== 'object') return null;
const version = normalizeVersion(value.version);
if (!VERSION_PATTERN.test(version) || !value.objectKey) return null;
const platform = normalizePlatform(value.platform, value.fileName || value.objectKey);
const architecture = normalizeArchitecture(value.architecture, value.fileName || value.objectKey, platform);
const defaultFileName = platform === 'darwin'
? `pdd-auto-sale-${version}-${architecture}.dmg`
: `pdd-auto-sale-${version}-setup.exe`;
return {
id: Number(value.id) || Date.now(),
version,
releaseNotes: String(value.releaseNotes || ''),
objectKey: String(value.objectKey),
platform,
architecture,
fileName: String(value.fileName || defaultFileName),
fileSize: Number(value.fileSize || 0),
sha256: String(value.sha256 || ''),
publishedByUserId: value.publishedByUserId == null ? null : Number(value.publishedByUserId),
publishedByName: String(value.publishedByName || ''),
publishedAt: value.publishedAt || value.createdAt || null,
createdAt: value.createdAt || value.publishedAt || null,
updatedAt: value.updatedAt || value.publishedAt || null,
};
}
function parseReleaseStorage(value) {
try {
const parsed = typeof value === 'string' ? JSON.parse(value || '{}') : value;
const releases = Array.isArray(parsed?.releases)
? parsed.releases.map(normalizeStoredRelease).filter(Boolean)
: [];
const storedLatestVersions = parsed?.latestVersions && typeof parsed.latestVersions === 'object'
? parsed.latestVersions
: {};
const latestVersions = {};
for (const platform of SUPPORTED_PLATFORMS) {
const version = normalizeVersion(storedLatestVersions[platform]);
if (VERSION_PATTERN.test(version)) latestVersions[platform] = version;
}
const legacyLatestVersion = normalizeVersion(parsed?.latestVersion);
if (!latestVersions.win32 && VERSION_PATTERN.test(legacyLatestVersion)) {
latestVersions.win32 = legacyLatestVersion;
}
for (const release of releases) {
if (!latestVersions[release.platform]) latestVersions[release.platform] = release.version;
}
return {
latestVersion: latestVersions.win32 || legacyLatestVersion,
latestVersions,
releases,
};
} catch {
return { latestVersion: '', latestVersions: {}, releases: [] };
}
}
async function loadReleaseStorage() {
const rows = await database.local.query(
'SELECT config_value FROM system_config WHERE config_key = ? LIMIT 1',
[DESKTOP_RELEASE_CONFIG_KEY]
);
return parseReleaseStorage(rows[0]?.config_value);
}
async function saveReleaseStorage(storage) {
const value = JSON.stringify(storage);
const existing = await database.local.query(
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
[DESKTOP_RELEASE_CONFIG_KEY]
);
if (existing.length) {
await database.local.query(
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
[value, 'json', DESKTOP_RELEASE_CONFIG_KEY]
);
return;
}
await database.local.insert('system_config', {
config_key: DESKTOP_RELEASE_CONFIG_KEY,
config_value: value,
config_type: 'json',
});
}
async function ensureDesktopReleaseSchema() {
// Release metadata uses system_config because the runtime account cannot create tables.
}
function mapRelease(release, latestVersion = '') {
const normalized = normalizeStoredRelease(release);
if (!normalized) return null;
return {
...normalized,
isLatest: normalized.version === normalizeVersion(latestVersion),
};
}
async function getLatestDesktopRelease(platform = 'win32', architecture = '') {
const storage = await loadReleaseStorage();
const targetPlatform = normalizePlatform(platform);
const targetArchitecture = architecture
? normalizeArchitecture(architecture, '', targetPlatform)
: '';
const platformReleases = storage.releases
.filter((release) => release.platform === targetPlatform)
.sort((left, right) => (
compareVersions(right.version, left.version)
|| String(right.publishedAt || '').localeCompare(String(left.publishedAt || ''))
));
const latestVersion = storage.latestVersions[targetPlatform] || platformReleases[0]?.version || '';
const versionReleases = platformReleases.filter((release) => release.version === latestVersion);
const latest = (targetArchitecture
? versionReleases.find((release) => release.architecture === targetArchitecture)
|| versionReleases.find((release) => release.architecture === 'universal')
: null)
|| versionReleases[0]
|| platformReleases[0]
|| null;
return latest ? mapRelease(latest, latestVersion || latest.version) : null;
}
async function getDesktopReleaseById(id) {
const storage = await loadReleaseStorage();
const release = storage.releases.find((item) => String(item.id) === String(id));
return release ? mapRelease(release, storage.latestVersions[release.platform]) : null;
}
async function listDesktopReleases(limit = 50) {
const storage = await loadReleaseStorage();
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 100);
return storage.releases
.map((release) => mapRelease(release, storage.latestVersions[release.platform]))
.filter(Boolean)
.sort((left, right) => {
if (left.isLatest !== right.isLatest) return left.isLatest ? -1 : 1;
return String(right.publishedAt || '').localeCompare(String(left.publishedAt || ''));
})
.slice(0, safeLimit);
}
async function publishDesktopRelease(input) {
const version = validateVersion(input.version);
const storage = await loadReleaseStorage();
const platform = normalizePlatform(input.platform, input.fileName || input.objectKey);
const architecture = normalizeArchitecture(input.architecture, input.fileName || input.objectKey, platform);
const existing = storage.releases.find((release) => (
release.version === version
&& release.platform === platform
&& release.architecture === architecture
));
const now = new Date().toISOString();
const release = {
id: existing?.id || Date.now(),
version,
platform,
architecture,
releaseNotes: input.releaseNotes || '',
objectKey: input.objectKey,
fileName: input.fileName,
fileSize: Number(input.fileSize || 0),
sha256: input.sha256,
publishedByUserId: input.publishedByUserId || null,
publishedByName: input.publishedByName || '',
publishedAt: now,
createdAt: existing?.createdAt || now,
updatedAt: now,
};
const releases = [
release,
...storage.releases.filter((item) => !(
item.version === version
&& item.platform === platform
&& item.architecture === architecture
)),
].slice(0, MAX_RELEASE_HISTORY);
const latestVersions = { ...storage.latestVersions, [platform]: version };
await saveReleaseStorage({
latestVersion: latestVersions.win32 || version,
latestVersions,
releases,
});
return mapRelease(release, version);
}
module.exports = {
compareVersions,
ensureDesktopReleaseSchema,
getDesktopReleaseById,
getLatestDesktopRelease,
listDesktopReleases,
mapRelease,
normalizeVersion,
normalizeArchitecture,
normalizePlatform,
parseReleaseStorage,
publishDesktopRelease,
validateVersion,
};