Files
makelore/electron/services/works-submission-binding.ts

226 lines
7.5 KiB
TypeScript

import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { OpencodeProjectStore } from '../opencode/project-store';
import { logger } from '../utils/logger';
import {
WORKS_SUBMISSION_BINDING_FILE_NAME,
WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
type WorksSubmissionBindingRecord,
} from '../../shared/works-submission-binding';
const LEGACY_PENDING_STATUSES = new Set([
'armed',
'waiting_for_package',
'waiting_for_login',
'uploading',
'failed',
]);
const LEGACY_RETIRED_MESSAGE = '旧版自动部署任务已停用,请在项目配置中点击“提交审核”重新提交。';
type ProjectStore = Pick<OpencodeProjectStore, 'listProjects'>;
type SubmittedBindingInput = {
appId: string;
versionId: string;
versionName: string;
reviewStatus: string;
zipSha256?: string;
};
type LegacyBindingRecord = {
schema_version: 1;
project_id: string;
status: string;
requested_at: string;
updated_at: string;
app_id?: string;
version_name?: string;
zip_sha256?: string;
version_id?: string;
review_status?: string;
};
function readString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function parseCurrentRecord(value: unknown): WorksSubmissionBindingRecord | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (record.schema_version !== WORKS_SUBMISSION_BINDING_SCHEMA_VERSION) return null;
const projectId = readString(record.project_id);
const requestedAt = readString(record.requested_at);
const updatedAt = readString(record.updated_at);
if (!projectId || !requestedAt || !updatedAt) return null;
if (record.status === 'submitted') {
const appId = readString(record.app_id);
const versionId = readString(record.version_id);
const versionName = readString(record.version_name);
const reviewStatus = record.review_status === undefined
? null
: readString(record.review_status);
const zipSha256 = record.zip_sha256 === undefined
? null
: readString(record.zip_sha256);
if (
!appId
|| !versionId
|| !versionName
|| (record.review_status !== undefined && !reviewStatus)
|| (record.zip_sha256 !== undefined && (!zipSha256 || !/^[a-f0-9]{64}$/i.test(zipSha256)))
) return null;
return {
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
project_id: projectId,
status: 'submitted',
requested_at: requestedAt,
updated_at: updatedAt,
app_id: appId,
version_id: versionId,
version_name: versionName,
...(reviewStatus ? { review_status: reviewStatus } : {}),
...(zipSha256 ? { zip_sha256: zipSha256.toLowerCase() } : {}),
};
}
const message = readString(record.message);
if (
record.status !== 'legacy_retired'
|| record.error_code !== 'LEGACY_AUTO_DEPLOY_RETIRED'
|| !message
) return null;
return {
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
project_id: projectId,
status: 'legacy_retired',
requested_at: requestedAt,
updated_at: updatedAt,
error_code: 'LEGACY_AUTO_DEPLOY_RETIRED',
message,
};
}
function parseLegacyRecord(value: unknown): LegacyBindingRecord | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const projectId = readString(record.project_id);
const status = readString(record.status);
const requestedAt = readString(record.requested_at);
const updatedAt = readString(record.updated_at);
if (record.schema_version !== 1 || !projectId || !status || !requestedAt || !updatedAt) {
return null;
}
return record as LegacyBindingRecord;
}
function migrateLegacyRecord(record: LegacyBindingRecord): WorksSubmissionBindingRecord {
const now = new Date().toISOString();
const appId = readString(record.app_id);
const versionId = readString(record.version_id);
const versionName = readString(record.version_name);
if (record.status === 'submitted' && appId && versionId && versionName) {
const reviewStatus = readString(record.review_status);
const zipSha256 = readString(record.zip_sha256);
return {
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
project_id: record.project_id,
status: 'submitted',
requested_at: record.requested_at,
updated_at: now,
app_id: appId,
version_id: versionId,
version_name: versionName,
...(reviewStatus ? { review_status: reviewStatus } : {}),
...(zipSha256 && /^[a-f0-9]{64}$/i.test(zipSha256)
? { zip_sha256: zipSha256.toLowerCase() }
: {}),
};
}
return {
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
project_id: record.project_id,
status: 'legacy_retired',
requested_at: record.requested_at,
updated_at: now,
error_code: 'LEGACY_AUTO_DEPLOY_RETIRED',
message: LEGACY_PENDING_STATUSES.has(record.status)
? LEGACY_RETIRED_MESSAGE
: '旧版部署记录已停用,请使用项目配置中的“提交审核”。',
};
}
async function persistRecord(
projectPath: string,
record: WorksSubmissionBindingRecord,
): Promise<WorksSubmissionBindingRecord> {
await writeFile(
join(projectPath, WORKS_SUBMISSION_BINDING_FILE_NAME),
`${JSON.stringify(record, null, 2)}\n`,
'utf8',
);
return record;
}
export async function readWorksSubmissionBinding(
projectPath: string,
expectedProjectId?: string,
): Promise<WorksSubmissionBindingRecord | null> {
let parsed: unknown;
try {
parsed = JSON.parse(await readFile(
join(projectPath, WORKS_SUBMISSION_BINDING_FILE_NAME),
'utf8',
)) as unknown;
} catch {
return null;
}
const current = parseCurrentRecord(parsed);
if (current) return !expectedProjectId || current.project_id === expectedProjectId ? current : null;
const legacy = parseLegacyRecord(parsed);
if (!legacy || (expectedProjectId && legacy.project_id !== expectedProjectId)) return null;
return persistRecord(projectPath, migrateLegacyRecord(legacy));
}
export function createWorksSubmissionBindingStore(projectStore: ProjectStore) {
async function recordSubmitted(
projectId: string,
input: SubmittedBindingInput,
): Promise<WorksSubmissionBindingRecord> {
const project = (await projectStore.listProjects()).find((item) => item.id === projectId);
if (!project) throw new Error('Project not found');
const now = new Date().toISOString();
return persistRecord(project.path, {
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
project_id: project.id,
status: 'submitted',
requested_at: now,
updated_at: now,
app_id: input.appId,
version_id: input.versionId,
version_name: input.versionName,
review_status: input.reviewStatus,
...(input.zipSha256 ? { zip_sha256: input.zipSha256 } : {}),
});
}
async function get(projectId: string): Promise<WorksSubmissionBindingRecord | null> {
const project = (await projectStore.listProjects()).find((item) => item.id === projectId);
return project ? readWorksSubmissionBinding(project.path, project.id) : null;
}
async function start(): Promise<void> {
for (const project of await projectStore.listProjects()) {
try {
await readWorksSubmissionBinding(project.path, project.id);
} catch (error) {
logger.warn(`[works-submission-binding] Failed to migrate ${project.id}`, error);
}
}
}
return { recordSubmitted, get, start };
}