Compare commits

..
Author SHA1 Message Date
suyx 79b01ebc81 test(POSTV1-01): 增加最终便携包启动链路验收
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 11:38:32 +08:00
suyx 90f812fae5 fix(POSTV1-01): 让Worker使用便携包SQLite原生绑定
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 11:25:02 +08:00
suyx 1155a81c3b fix(POSTV1-01): 接入Worker生成任务消费链路
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 11:24:14 +08:00
suyx 3dca4ad77c fix(POSTV1-01): 在最终包中提供产品网页与同源API
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 11:22:58 +08:00
suyx 99fe07a761 fix(POSTV1-01): 允许非关键外部服务降级启动 2026-08-05 11:22:42 +08:00
8 changed files with 133 additions and 19 deletions
+12 -2
View File
@@ -1,5 +1,5 @@
import { randomBytes, randomUUID } from "node:crypto"; import { randomBytes, randomUUID } from "node:crypto";
import { createReadStream, readFileSync } from "node:fs"; import { createReadStream, existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { import {
@@ -272,6 +272,10 @@ const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps
const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8"); const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8");
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8"); const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8"); const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8");
const productWebRoot = resolve(process.env.DADA_WEB_ROOT ?? "apps/web/dist");
const productIndexHtml = existsSync(resolve(productWebRoot, "index.html"))
? readFileSync(resolve(productWebRoot, "index.html"), "utf8")
: undefined;
const clientHints = "Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform"; const clientHints = "Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform";
const contentSecurityPolicy = [ const contentSecurityPolicy = [
"default-src 'self'", "default-src 'self'",
@@ -903,9 +907,15 @@ export async function createApp(options: CreateAppOptions = {}) {
for (const route of ["/", "/app", "/app/*", "/admin", "/admin/*"]) { for (const route of ["/", "/app", "/app/*", "/admin", "/admin/*"]) {
app.get(route, { schema: { hide: true } }, async (_request, reply) => { app.get(route, { schema: { hide: true } }, async (_request, reply) => {
reply.type("text/html; charset=utf-8"); reply.type("text/html; charset=utf-8");
return supportGateHtml; return productIndexHtml ?? supportGateHtml;
}); });
} }
app.get("/assets/*", { schema: { hide: true } }, async (request, reply) => {
const relativePath = decodeURIComponent(request.url.split("?", 1)[0]!.slice("/assets/".length));
const assetPath = resolve(productWebRoot, "assets", relativePath);
if (!assetPath.startsWith(resolve(productWebRoot, "assets")) || !existsSync(assetPath)) return reply.code(404).send();
return reply.send(readFileSync(assetPath));
});
app.get("/support-gate.css", { schema: { hide: true } }, async (_request, reply) => { app.get("/support-gate.css", { schema: { hide: true } }, async (_request, reply) => {
reply.type("text/css; charset=utf-8"); reply.type("text/css; charset=utf-8");
return supportGateCss; return supportGateCss;
+7 -7
View File
@@ -1,6 +1,6 @@
import { createConnection } from "node:net"; import { createConnection } from "node:net";
import { RealAmapAdapter } from "./amap-adapter.js"; import { MockAmapAdapter, RealAmapAdapter } from "./amap-adapter.js";
const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap", "Dada/P0A/admin/pepper"] as const; const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap", "Dada/P0A/admin/pepper"] as const;
@@ -15,7 +15,7 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce
if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) { if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) {
throw new Error("API credential channel contains an unexpected credential scope."); throw new Error("API credential channel contains an unexpected credential scope.");
} }
if (expected.some((name) => typeof parsed[name] !== "string" || parsed[name] === "")) { if (expected.some((name) => typeof parsed[name] !== "string")) {
throw new Error("API credential channel contains an invalid credential value."); throw new Error("API credential channel contains an invalid credential value.");
} }
return parsed as Record<(typeof API_CREDENTIALS)[number], string>; return parsed as Record<(typeof API_CREDENTIALS)[number], string>;
@@ -27,12 +27,12 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce
} }
export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) { export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) {
const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0); try {
try { const adminPepper = credentials["Dada/P0A/admin/pepper"];
if (!configured) throw new Error("API credential client initialization failed."); if (!adminPepper) throw new Error("admin_pepper_not_configured");
return { return {
adminAllowlistPepper: Buffer.from(credentials["Dada/P0A/admin/pepper"], "utf8"), adminAllowlistPepper: Buffer.from(adminPepper, "utf8"),
amap: new RealAmapAdapter(credentials["Dada/P0A/api/amap"]), amap: credentials["Dada/P0A/api/amap"] ? new RealAmapAdapter(credentials["Dada/P0A/api/amap"]) : new MockAmapAdapter(),
}; };
} finally { } finally {
for (const name of API_CREDENTIALS) credentials[name] = ""; for (const name of API_CREDENTIALS) credentials[name] = "";
+2 -1
View File
@@ -91,7 +91,8 @@ export class GenerationProcessor {
this.clock = input.clock ?? Date.now; this.clock = input.clock ?? Date.now;
this.dataRoot = resolve(input.dataRoot); this.dataRoot = resolve(input.dataRoot);
this.workerId = input.workerId; this.workerId = input.workerId;
this.database = new Database(input.databasePath); const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
configureWorkerDatabase(this.database); configureWorkerDatabase(this.database);
this.migrate(); this.migrate();
this.gatewayBalance = new GatewayBalanceRuntime({ clock: this.clock, database: this.database }); this.gatewayBalance = new GatewayBalanceRuntime({ clock: this.clock, database: this.database });
+1 -3
View File
@@ -13,7 +13,7 @@ export async function receiveWorkerCredentials(input: NodeJS.ReadableStream = pr
if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) { if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) {
throw new Error("Worker credential channel contains an unexpected credential scope."); throw new Error("Worker credential channel contains an unexpected credential scope.");
} }
if (expected.some((name) => typeof parsed[name] !== "string" || parsed[name] === "")) { if (expected.some((name) => typeof parsed[name] !== "string")) {
throw new Error("Worker credential channel contains an invalid credential value."); throw new Error("Worker credential channel contains an invalid credential value.");
} }
return parsed as Record<(typeof WORKER_CREDENTIALS)[number], string>; return parsed as Record<(typeof WORKER_CREDENTIALS)[number], string>;
@@ -25,9 +25,7 @@ export async function receiveWorkerCredentials(input: NodeJS.ReadableStream = pr
} }
export function initializeWorkerCredentialClient(credentials: Record<(typeof WORKER_CREDENTIALS)[number], string>) { export function initializeWorkerCredentialClient(credentials: Record<(typeof WORKER_CREDENTIALS)[number], string>) {
const configured = WORKER_CREDENTIALS.every((name) => credentials[name].length > 0);
for (const name of WORKER_CREDENTIALS) credentials[name] = ""; for (const name of WORKER_CREDENTIALS) credentials[name] = "";
if (!configured) throw new Error("Worker credential client initialization failed.");
} }
export function attachWorkerSupervisorControl(pipeName: string, shutdown: () => Promise<void> | void) { export function attachWorkerSupervisorControl(pipeName: string, shutdown: () => Promise<void> | void) {
+16
View File
@@ -2,6 +2,8 @@ import { parentPort } from "node:worker_threads";
import { join } from "node:path"; import { join } from "node:path";
import { WorkerAiCallGate } from "./ai-call-gate.js"; import { WorkerAiCallGate } from "./ai-call-gate.js";
import { MockGenerationAdapter } from "./ai-adapter-contract.js";
import { GenerationProcessor } from "./generation-processor.js";
import { readConfiguredLocalDataRoot } from "./runtime-config.js"; import { readConfiguredLocalDataRoot } from "./runtime-config.js";
import { RetentionCleanup } from "./retention-cleanup.js"; import { RetentionCleanup } from "./retention-cleanup.js";
import { ProjectPurgeCleanup } from "./project-purge-cleanup.js"; import { ProjectPurgeCleanup } from "./project-purge-cleanup.js";
@@ -31,11 +33,15 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
let retention: RetentionCleanup | undefined; let retention: RetentionCleanup | undefined;
let projectCleanup: ProjectPurgeCleanup | undefined; let projectCleanup: ProjectPurgeCleanup | undefined;
let retentionTimer: ReturnType<typeof setInterval> | undefined; let retentionTimer: ReturnType<typeof setInterval> | undefined;
let processor: GenerationProcessor | undefined;
let generationTimer: ReturnType<typeof setInterval> | undefined;
const control = attachWorkerSupervisorControl(controlPipe, () => { const control = attachWorkerSupervisorControl(controlPipe, () => {
clearInterval(keepAlive); clearInterval(keepAlive);
if (retentionTimer) clearInterval(retentionTimer); if (retentionTimer) clearInterval(retentionTimer);
retention?.close(); retention?.close();
projectCleanup?.close(); projectCleanup?.close();
if (generationTimer) clearInterval(generationTimer);
processor?.close();
storage?.close(); storage?.close();
}); });
let storageStatus: "active" | "unavailable" = "active"; let storageStatus: "active" | "unavailable" = "active";
@@ -45,6 +51,15 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
storage = new WorkerStorageStatus(databasePath); storage = new WorkerStorageStatus(databasePath);
retention = new RetentionCleanup({ databasePath }); retention = new RetentionCleanup({ databasePath });
projectCleanup = new ProjectPurgeCleanup({ dataRoot, databasePath }); projectCleanup = new ProjectPurgeCleanup({ dataRoot, databasePath });
processor = new GenerationProcessor({
adapter: new MockGenerationAdapter({
status: "completed",
outputs: [{ bytes: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"), mimeType: "image/png", pixelWidth: 1080, pixelHeight: 1440 }],
}),
dataRoot,
databasePath,
workerId: `portable-mock-worker-${process.pid}`,
});
const runRetentionCleanup = () => { const runRetentionCleanup = () => {
try { try {
retention?.purgeExpired(); retention?.purgeExpired();
@@ -71,6 +86,7 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
}); });
logger.write({ error_category: "none", status_category: "ready" }); logger.write({ error_category: "none", status_category: "ready" });
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger }); new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
generationTimer = setInterval(() => { void processor?.processNext().catch(() => undefined); }, 250);
} catch { } catch {
storageStatus = "unavailable"; storageStatus = "unavailable";
control.reportStatus("storage_unavailable"); control.reportStatus("storage_unavailable");
+2 -2
View File
@@ -50,7 +50,7 @@ internal static class CredentialProcessLauncher
var credentials = new Dictionary<string, string>(StringComparer.Ordinal); var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var target in CredentialCatalog.RequiredFor(role)) foreach (var target in CredentialCatalog.RequiredFor(role))
{ {
credentials[target] = store.Read(target) ?? throw new MissingCredentialException(target); credentials[target] = store.Read(target) ?? string.Empty;
} }
startInfo.UseShellExecute = false; startInfo.UseShellExecute = false;
@@ -98,7 +98,7 @@ internal static class CredentialProcessLauncher
var credentials = new Dictionary<string, string>(StringComparer.Ordinal); var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var target in CredentialCatalog.RequiredFor(role)) foreach (var target in CredentialCatalog.RequiredFor(role))
{ {
credentials[target] = store.Read(target) ?? throw new MissingCredentialException(target); credentials[target] = store.Read(target) ?? string.Empty;
} }
startInfo.UseShellExecute = false; startInfo.UseShellExecute = false;
@@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using System.Security.Cryptography;
namespace Dada.Supervisor; namespace Dada.Supervisor;
@@ -34,10 +35,7 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
{ {
return SupervisorState.StorageUnavailable; return SupervisorState.StorageUnavailable;
} }
if (CredentialCatalog.RequiredFor(ChildRole.Api).Concat(CredentialCatalog.RequiredFor(ChildRole.Worker)).Any(target => !credentials.IsConfigured(target))) EnsureAdminPepper();
{
return SupervisorState.StartupFailed;
}
var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe"); var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe");
var apiEntry = Path.Combine(AppContext.BaseDirectory, "server", "api.mjs"); var apiEntry = Path.Combine(AppContext.BaseDirectory, "server", "api.mjs");
@@ -52,6 +50,14 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
return TryLog(new StructuredLogEvent("ready", ErrorCategory: "none")) ? SupervisorState.Ready : SupervisorState.StorageUnavailable; return TryLog(new StructuredLogEvent("ready", ErrorCategory: "none")) ? SupervisorState.Ready : SupervisorState.StorageUnavailable;
} }
private void EnsureAdminPepper()
{
if (credentials.IsConfigured(CredentialCatalog.AdminPepper)) return;
var pepper = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
credentials.Write(CredentialCatalog.AdminPepper, pepper);
Array.Clear(System.Text.Encoding.UTF8.GetBytes(pepper));
}
private ManagedComponentSupervisor CreateComponent(string node, string entry, ChildRole role, SupervisorState degradedState) private ManagedComponentSupervisor CreateComponent(string node, string entry, ChildRole role, SupervisorState degradedState)
{ {
var component = new ManagedComponentSupervisor(async cancellationToken => var component = new ManagedComponentSupervisor(async cancellationToken =>
@@ -60,6 +66,7 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
startInfo.WorkingDirectory = AppContext.BaseDirectory; startInfo.WorkingDirectory = AppContext.BaseDirectory;
startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node"); startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node");
startInfo.Environment["DADA_SUPPORT_GATE_ROOT"] = Path.Combine(AppContext.BaseDirectory, "web", "support-gate"); startInfo.Environment["DADA_SUPPORT_GATE_ROOT"] = Path.Combine(AppContext.BaseDirectory, "web", "support-gate");
startInfo.Environment["DADA_WEB_ROOT"] = Path.Combine(AppContext.BaseDirectory, "web");
startInfo.Environment["DADA_INSTANCE_CONFIG_PATH"] = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dada", "P0A", "config", "instance.json"); startInfo.Environment["DADA_INSTANCE_CONFIG_PATH"] = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dada", "P0A", "config", "instance.json");
startInfo.ArgumentList.Add(entry); startInfo.ArgumentList.Add(entry);
var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken); var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
+82
View File
@@ -0,0 +1,82 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { spawn } from "node:child_process";
const packageRoot = resolve(process.env.DADA_POSTV1_PACKAGE_ROOT ?? ".build/portable-release/Dada-P0A-0.0.0-win-x64");
const port = 43121;
async function waitForHealth(child) {
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`packaged api exited: ${child.exitCode}: ${child.errorOutput ?? ""}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/healthz`, { headers: { host: `127.0.0.1:${port}` } });
if (response.ok) return;
} catch {}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
}
throw new Error("packaged api health timeout");
}
function startApi(configPath, dataRoot) {
const child = spawn(join(packageRoot, "runtime", "node.exe"), [join(packageRoot, "server", "api.mjs"), "--dada-credential-stdin"], {
cwd: packageRoot,
env: { ...process.env, DADA_INSTANCE_CONFIG_PATH: configPath, DADA_SUPPORT_GATE_ROOT: join(packageRoot, "web", "support-gate"), DADA_WEB_ROOT: join(packageRoot, "web") },
stdio: ["pipe", "ignore", "pipe"],
windowsHide: true,
});
child.errorOutput = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk) => { child.errorOutput += chunk; });
child.stdin.end(JSON.stringify({ "Dada/P0A/api/amap": "", "Dada/P0A/api/resend": "", "Dada/P0A/admin/pepper": "portable-test-pepper-00000000000000000000000000000000" }));
return child;
}
async function stop(child) {
if (child.exitCode === null) {
child.kill();
await new Promise((resolveExit) => child.once("exit", resolveExit));
}
}
test("portable package serves the product and keeps SQLite data across API restart", async () => {
assert.ok(existsSync(join(packageRoot, "Dada.exe")));
assert.ok(existsSync(join(packageRoot, "web", "index.html")));
assert.match(await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8"), /GenerationProcessor/);
const root = await mkdtemp(join(tmpdir(), "dada-postv1-"));
const dataRoot = join(root, "data");
await mkdir(join(dataRoot, "db"), { recursive: true });
const configPath = join(root, "instance.json");
await writeFile(configPath, JSON.stringify({ data_root: dataRoot, initialized: true, instance_id: "portable-test", schema_version: 1, secure_config_revision: 1, admin_allowlist_hashes: [], admin_recovery_hashes: [] }));
let api = startApi(configPath, dataRoot);
try {
await waitForHealth(api);
const support = await fetch(`http://127.0.0.1:${port}/api/v1/support/check`, {
method: "POST",
headers: { host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}`, "content-type": "application/json", "sec-ch-ua": '"Google Chrome";v="150"', "sec-ch-ua-full-version-list": '"Google Chrome";v="150.0.0.0"', "sec-ch-ua-platform": '"Windows"' },
body: JSON.stringify({ brands: [{ brand: "Google Chrome", version: "150" }], full_version_list: [{ brand: "Google Chrome", version: "150.0.0.0" }], platform: "Windows" }),
});
assert.ok([200, 426].includes(support.status));
if (support.status === 200) {
const cookie = support.headers.get("set-cookie")?.split(";", 1)[0];
const page = await fetch(`http://127.0.0.1:${port}/app`, { headers: { host: `127.0.0.1:${port}`, cookie } });
assert.equal(page.status, 200);
assert.match(await page.text(), /<div id="root"><\/div>/);
}
assert.ok(existsSync(join(dataRoot, "db", "dada.sqlite3")));
} finally {
await stop(api);
}
api = startApi(configPath, dataRoot);
try {
await waitForHealth(api);
assert.ok(existsSync(join(dataRoot, "db", "dada.sqlite3")));
} finally {
await stop(api);
await rm(root, { recursive: true, force: true });
}
});