forked from lunanting/cangku
255 lines
9.1 KiB
JavaScript
255 lines
9.1 KiB
JavaScript
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,
|
||
};
|