Commit 9b34b595 authored by xuchentao's avatar xuchentao

feat: add site management and recycle bin

parent 57609ddd
...@@ -90,6 +90,7 @@ export const config = { ...@@ -90,6 +90,7 @@ export const config = {
export const runtimePaths = { export const runtimePaths = {
sites: config.sitesDir, sites: config.sitesDir,
archivedSites: path.join(config.sitesDir, ".trash"),
builds: path.join(config.runtimeDir, "builds"), builds: path.join(config.runtimeDir, "builds"),
uploads: path.join(config.runtimeDir, "uploads"), uploads: path.join(config.runtimeDir, "uploads"),
pnpmStore: path.join(config.runtimeDir, "pnpm-store"), pnpmStore: path.join(config.runtimeDir, "pnpm-store"),
......
...@@ -18,6 +18,7 @@ import { CreateSiteService } from "./sites/create-site.js"; ...@@ -18,6 +18,7 @@ import { CreateSiteService } from "./sites/create-site.js";
import { SiteAgentService } from "./sites/site-agent-service.js"; import { SiteAgentService } from "./sites/site-agent-service.js";
import { SiteRepository } from "./sites/site-repository.js"; import { SiteRepository } from "./sites/site-repository.js";
import { SiteVersionService } from "./sites/site-version-service.js"; import { SiteVersionService } from "./sites/site-version-service.js";
import { SiteLifecycleService } from "./sites/site-lifecycle-service.js";
import { DomainDeploymentService } from "./domains/domain-deployment-service.js"; import { DomainDeploymentService } from "./domains/domain-deployment-service.js";
import { DomainRepository } from "./domains/domain-repository.js"; import { DomainRepository } from "./domains/domain-repository.js";
import { DomainRoutingConfig } from "./domains/domain-routing-config.js"; import { DomainRoutingConfig } from "./domains/domain-routing-config.js";
...@@ -42,8 +43,10 @@ const siteAgent = new SiteAgentService(sites, git, new AgentLoop()); ...@@ -42,8 +43,10 @@ const siteAgent = new SiteAgentService(sites, git, new AgentLoop());
const versions = new SiteVersionService(sites, git, builds, previews); const versions = new SiteVersionService(sites, git, builds, previews);
const domainRepository = new DomainRepository(); const domainRepository = new DomainRepository();
const domainRouting = new DomainRoutingConfig(domainRepository); const domainRouting = new DomainRoutingConfig(domainRepository);
const domainProvider = createDomainProvider();
const domainDeployments = new DomainDeploymentService(sites, domainRepository, git, builds, domainRouting); const domainDeployments = new DomainDeploymentService(sites, domainRepository, git, builds, domainRouting);
const domainService = new DomainService(sites, domainRepository, domainRouting, createDomainProvider()); const domainService = new DomainService(sites, domainRepository, domainRouting, domainProvider);
const siteLifecycle = new SiteLifecycleService(sites, previews, domainRepository, domainRouting, domainProvider);
app.addHook("onRequest", async (request, reply) => { app.addHook("onRequest", async (request, reply) => {
if (!request.url.startsWith("/api/") || request.url === "/api/health" || request.url === "/api/login") return; if (!request.url.startsWith("/api/") || request.url === "/api/health" || request.url === "/api/login") return;
...@@ -67,8 +70,15 @@ app.post("/api/logout", async (request) => { ...@@ -67,8 +70,15 @@ app.post("/api/logout", async (request) => {
app.get("/api/session", async (request) => request.auth!); app.get("/api/session", async (request) => request.auth!);
app.get("/api/sites", async (request) => sites.list(requireTenant(request.auth))); app.get("/api/sites", async (request) => sites.list(requireTenant(request.auth)));
app.get("/api/archived-sites", async (request) => sites.listArchived(requireTenant(request.auth)));
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId", async (request) => sites.get(requireTenant(request.auth), request.params.siteId)); app.get<{ Params: { siteId: string } }>("/api/sites/:siteId", async (request) => sites.get(requireTenant(request.auth), request.params.siteId));
app.post("/api/sites", async (request, reply) => reply.code(201).send(await createSite.execute(requireTenant(request.auth), createSiteSchema.parse(request.body)))); app.post("/api/sites", async (request, reply) => reply.code(201).send(await createSite.execute(requireTenant(request.auth), createSiteSchema.parse(request.body))));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/archive", async (request) => siteLifecycle.archive(requireTenant(request.auth), request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/archived-sites/:siteId/restore", async (request) => siteLifecycle.restore(requireTenant(request.auth), request.params.siteId));
app.delete<{ Params: { siteId: string } }>("/api/archived-sites/:siteId", async (request, reply) => {
await siteLifecycle.permanentlyDelete(requireTenant(request.auth), request.params.siteId);
return reply.code(204).send();
});
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/chat", async (request) => { app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/chat", async (request) => {
const body = chatSchema.parse(request.body); const body = chatSchema.parse(request.body);
return siteAgent.execute(requireTenant(request.auth), request.params.siteId, body.message); return siteAgent.execute(requireTenant(request.auth), request.params.siteId, body.message);
...@@ -168,6 +178,7 @@ app.setErrorHandler((error, _request, reply) => { ...@@ -168,6 +178,7 @@ app.setErrorHandler((error, _request, reply) => {
}); });
await sites.ensureRuntime(); await sites.ensureRuntime();
await siteLifecycle.purgeExpired().catch((error) => app.log.warn(error));
await domainRouting.sync(); await domainRouting.sync();
await rm(runtimePaths.builds, { recursive: true, force: true }); await rm(runtimePaths.builds, { recursive: true, force: true });
await mkdir(runtimePaths.builds, { recursive: true }); await mkdir(runtimePaths.builds, { recursive: true });
...@@ -219,6 +230,7 @@ const checkPendingDomains = async () => { ...@@ -219,6 +230,7 @@ const checkPendingDomains = async () => {
} finally { checkingDomains = false; } } finally { checkingDomains = false; }
}; };
setInterval(() => void checkPendingDomains(), config.domains.checkIntervalMs).unref(); setInterval(() => void checkPendingDomains(), config.domains.checkIntervalMs).unref();
setInterval(() => void siteLifecycle.purgeExpired().catch((error) => app.log.warn(error)), 24 * 60 * 60 * 1000).unref();
function bearerToken(header: string | undefined): string | undefined { function bearerToken(header: string | undefined): string | undefined {
const match = header?.match(/^Bearer\s+(.+)$/i); const match = header?.match(/^Bearer\s+(.+)$/i);
......
import path from "node:path";
import { mkdir, rename, rm, stat } from "node:fs/promises";
import type { SiteInfo } from "@webagent/shared";
import { config, runtimePaths } from "../config.js";
import type { DomainProvider } from "../domains/domain-provider.js";
import { DomainRepository } from "../domains/domain-repository.js";
import { DomainRoutingConfig } from "../domains/domain-routing-config.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { SiteRepository } from "./site-repository.js";
export class SiteLifecycleService {
constructor(
private readonly sites: SiteRepository,
private readonly previews: PreviewProcessManager,
private readonly domains: DomainRepository,
private readonly routing: DomainRoutingConfig,
private readonly provider: DomainProvider,
) {}
async archive(tenantId: string, siteId: string): Promise<SiteInfo> {
const site = await this.sites.get(tenantId, siteId);
const archivedAt = new Date();
const bindings = await this.domains.list(tenantId, siteId);
for (const domain of bindings) await this.provider.remove(domain);
if (bindings.length) {
await this.domains.updateSite(tenantId, siteId, () => ({
providerHostnameId: undefined,
providerStatus: undefined,
deploymentStatus: "pending",
deployedCommit: undefined,
tlsStatus: this.provider.initialTlsStatus(),
}));
}
await this.sites.update(tenantId, siteId, {
lifecycleStatus: "archived",
archivedAt: archivedAt.toISOString(),
deleteAfter: new Date(archivedAt.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(),
});
await this.previews.stop(tenantId, siteId);
await this.move(this.sites.getSiteRoot(tenantId, siteId), this.sites.getArchivedSiteRoot(tenantId, siteId), true);
await Promise.all(this.artifactRoots().map(({ active, archived }) => this.move(path.join(active, tenantId, siteId), path.join(archived, tenantId, siteId), false)));
await this.routing.sync();
return this.sites.getArchived(tenantId, siteId);
}
async restore(tenantId: string, siteId: string): Promise<SiteInfo> {
await this.sites.getArchived(tenantId, siteId);
if (await this.exists(this.sites.getSiteRoot(tenantId, siteId))) throw Object.assign(new Error("已有同标识的网站,无法恢复"), { statusCode: 409 });
await this.move(this.sites.getArchivedSiteRoot(tenantId, siteId), this.sites.getSiteRoot(tenantId, siteId), true);
await Promise.all(this.artifactRoots().map(({ active, archived }) => this.move(path.join(archived, tenantId, siteId), path.join(active, tenantId, siteId), false)));
const restored = await this.sites.update(tenantId, siteId, { lifecycleStatus: "active", archivedAt: undefined, deleteAfter: undefined });
const previewPath = path.join(config.previewsDir, tenantId, siteId);
if (await this.exists(previewPath)) {
await this.previews.start(tenantId, siteId, previewPath, restored.previewPort).catch(async (error) => {
await this.sites.update(tenantId, siteId, { status: "failed", lastError: error instanceof Error ? error.message : String(error) });
});
}
await this.routing.sync();
return this.sites.get(tenantId, siteId);
}
async permanentlyDelete(tenantId: string, siteId: string): Promise<void> {
await this.sites.getArchived(tenantId, siteId);
await Promise.all([
rm(this.sites.getArchivedSiteRoot(tenantId, siteId), { recursive: true, force: true }),
...this.artifactRoots().flatMap(({ active, archived }) => [
rm(path.join(active, tenantId, siteId), { recursive: true, force: true }),
rm(path.join(archived, tenantId, siteId), { recursive: true, force: true }),
]),
]);
}
async purgeExpired(now = new Date()): Promise<number> {
const expired = (await this.sites.listAllArchived()).filter((site) => site.deleteAfter && new Date(site.deleteAfter).getTime() <= now.getTime());
for (const site of expired) await this.permanentlyDelete(site.tenantId, site.siteId);
return expired.length;
}
private artifactRoots(): Array<{ active: string; archived: string }> {
return [config.previewsDir, config.productionDir, config.customDomainsDir].map((active) => ({ active, archived: path.join(active, ".trash") }));
}
private async move(source: string, destination: string, required: boolean): Promise<void> {
if (!await this.exists(source)) {
if (required) throw Object.assign(new Error("站点不存在"), { statusCode: 404 });
return;
}
await mkdir(path.dirname(destination), { recursive: true });
await rename(source, destination);
}
private async exists(target: string): Promise<boolean> {
return stat(target).then(() => true).catch(() => false);
}
}
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdir, mkdtemp, rename, rm } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import test from "node:test"; import test from "node:test";
...@@ -32,5 +32,11 @@ test("repository paths and queries are tenant-scoped", async () => { ...@@ -32,5 +32,11 @@ test("repository paths and queries are tenant-scoped", async () => {
await assert.rejects(() => sites.get(tenantA, siteB), (error: NodeJS.ErrnoException) => error.code === "ENOENT"); await assert.rejects(() => sites.get(tenantA, siteB), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
assert.equal(sites.getProjectPath(tenantA, siteA), path.join(directory, "sites", tenantA, siteA, "project")); assert.equal(sites.getProjectPath(tenantA, siteA), path.join(directory, "sites", tenantA, siteA, "project"));
assert.notEqual(sites.getSiteRoot(tenantA, siteA), sites.getSiteRoot(tenantB, siteA)); assert.notEqual(sites.getSiteRoot(tenantA, siteA), sites.getSiteRoot(tenantB, siteA));
await mkdir(path.dirname(sites.getArchivedSiteRoot(tenantA, siteA)), { recursive: true });
await rename(sites.getSiteRoot(tenantA, siteA), sites.getArchivedSiteRoot(tenantA, siteA));
assert.deepEqual(await sites.list(tenantA), []);
assert.deepEqual((await sites.listArchived(tenantA)).map((site) => site.siteId), [siteA]);
assert.equal((await sites.getArchived(tenantA, siteA)).lifecycleStatus, "archived");
} finally { await rm(directory, { recursive: true, force: true }); } } finally { await rm(directory, { recursive: true, force: true }); }
}); });
...@@ -22,6 +22,12 @@ export class SiteRepository { ...@@ -22,6 +22,12 @@ export class SiteRepository {
return path.join(runtimePaths.sites, tenantId, siteId); return path.join(runtimePaths.sites, tenantId, siteId);
} }
getArchivedSiteRoot(tenantId: string, siteId: string): string {
this.assertTenantId(tenantId);
this.assertSiteId(siteId);
return path.join(runtimePaths.archivedSites, tenantId, siteId);
}
getProjectPath(tenantId: string, siteId: string): string { getProjectPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(tenantId, siteId), "project"); return path.join(this.getSiteRoot(tenantId, siteId), "project");
} }
...@@ -56,6 +62,30 @@ export class SiteRepository { ...@@ -56,6 +62,30 @@ export class SiteRepository {
return results.filter((site): site is SiteInfo => site !== null).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return results.filter((site): site is SiteInfo => site !== null).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
} }
async getArchived(tenantId: string, siteId: string): Promise<SiteInfo> {
const raw = await readFile(path.join(this.getArchivedSiteRoot(tenantId, siteId), "metadata", "site.json"), "utf8");
const stored = JSON.parse(raw) as SiteInfo;
if (stored.tenantId !== tenantId || stored.siteId !== siteId) throw Object.assign(new Error("站点不存在"), { statusCode: 404 });
return { ...stored, lifecycleStatus: "archived", previewUrl: "", productionUrl: undefined };
}
async listArchived(tenantId: string): Promise<SiteInfo[]> {
await this.ensureRuntime();
const root = path.join(runtimePaths.archivedSites, tenantId);
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
const results = await Promise.all(entries.filter((entry) => entry.isDirectory() && /^site_[a-z0-9]+$/.test(entry.name)).map(async (entry) => {
try { return await this.getArchived(tenantId, entry.name); } catch { return null; }
}));
return results.filter((site): site is SiteInfo => site !== null).sort((a, b) => (b.archivedAt || b.updatedAt).localeCompare(a.archivedAt || a.updatedAt));
}
async listAllArchived(): Promise<SiteInfo[]> {
await this.ensureRuntime();
const tenants = await readdir(runtimePaths.archivedSites, { withFileTypes: true }).catch(() => []);
const groups = await Promise.all(tenants.filter((entry) => entry.isDirectory() && /^tenant_[a-z0-9_]+$/.test(entry.name)).map((entry) => this.listArchived(entry.name)));
return groups.flat().sort((a, b) => (b.archivedAt || b.updatedAt).localeCompare(a.archivedAt || a.updatedAt));
}
async listAll(): Promise<SiteInfo[]> { async listAll(): Promise<SiteInfo[]> {
await this.ensureRuntime(); await this.ensureRuntime();
const tenants = await readdir(runtimePaths.sites, { withFileTypes: true }).catch(() => []); const tenants = await readdir(runtimePaths.sites, { withFileTypes: true }).catch(() => []);
......
This diff is collapsed.
...@@ -24,8 +24,12 @@ export const api = { ...@@ -24,8 +24,12 @@ export const api = {
session: () => request<SessionInfo>("/api/session"), session: () => request<SessionInfo>("/api/session"),
health: () => request<{ status: string; agentMode: "model" | "local" }>("/api/health"), health: () => request<{ status: string; agentMode: "model" | "local" }>("/api/health"),
sites: () => request<SiteInfo[]>("/api/sites"), sites: () => request<SiteInfo[]>("/api/sites"),
archivedSites: () => request<SiteInfo[]>("/api/archived-sites"),
site: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId), site: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId),
createSite: (input: CreateSiteInput) => request<SiteInfo>("/api/sites", { method: "POST", body: JSON.stringify(input) }), createSite: (input: CreateSiteInput) => request<SiteInfo>("/api/sites", { method: "POST", body: JSON.stringify(input) }),
archiveSite: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId + "/archive", { method: "POST" }),
restoreSite: (siteId: string) => request<SiteInfo>("/api/archived-sites/" + siteId + "/restore", { method: "POST" }),
deleteSite: (siteId: string) => request<void>("/api/archived-sites/" + siteId, { method: "DELETE" }),
chat: (siteId: string, message: string) => request<ChatResult>("/api/sites/" + siteId + "/chat", { method: "POST", body: JSON.stringify({ message }) }), chat: (siteId: string, message: string) => request<ChatResult>("/api/sites/" + siteId + "/chat", { method: "POST", body: JSON.stringify({ message }) }),
history: (siteId: string) => request<GitHistoryItem[]>("/api/sites/" + siteId + "/history"), history: (siteId: string) => request<GitHistoryItem[]>("/api/sites/" + siteId + "/history"),
previewVersion: (siteId: string, commit: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/preview-version", { method: "POST", body: JSON.stringify({ commit }) }), previewVersion: (siteId: string, commit: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/preview-version", { method: "POST", body: JSON.stringify({ commit }) }),
......
This diff is collapsed.
export type SiteStatus = "creating" | "ready" | "building" | "failed"; export type SiteStatus = "creating" | "ready" | "building" | "failed";
export type PublishStatus = "unpublished" | "publishing" | "published" | "failed"; export type PublishStatus = "unpublished" | "publishing" | "published" | "failed";
export type SiteLifecycleStatus = "active" | "archived";
export interface CreateSiteInput { export interface CreateSiteInput {
name: string; name: string;
...@@ -35,6 +36,9 @@ export interface SiteInfo { ...@@ -35,6 +36,9 @@ export interface SiteInfo {
updatedAt: string; updatedAt: string;
lastError?: string; lastError?: string;
lastPublishError?: string; lastPublishError?: string;
lifecycleStatus?: SiteLifecycleStatus;
archivedAt?: string;
deleteAfter?: string;
} }
export type AccountType = "system_admin" | "tenant_user"; export type AccountType = "system_admin" | "tenant_user";
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment