Commit db3476b4 authored by xuchentao's avatar xuchentao

feat: add multi-tenant MVP

parent 331123b5
PORT=3100 PORT=3100
HOST=127.0.0.1 HOST=127.0.0.1
FRONTEND_ORIGIN=http://localhost FRONTEND_ORIGIN=http://localhost
# 单用户登录账号固定为 user;生产环境请务必修改默认密码。 # development/test 默认初始化下面的测试账号;production 必须保持 false,否则服务拒绝启动。
WEBAGENT_USER_PASSWORD=user WEBAGENT_INIT_TEST_DATA=true
WEBAGENT_DATABASE_PATH=.runtime/webagent.sqlite
WEBAGENT_SESSION_TTL_DAYS=30
WEBAGENT_TEST_ADMIN_USERNAME=admin
WEBAGENT_TEST_ADMIN_PASSWORD=admin123
WEBAGENT_TEST_TENANT_A_ID=tenant_default_a
WEBAGENT_TEST_TENANT_A_NAME=默认租户 A
WEBAGENT_TEST_TENANT_A_USERNAME=tenant_a
WEBAGENT_TEST_TENANT_A_PASSWORD=tenant123
WEBAGENT_TEST_TENANT_B_ID=tenant_default_b
WEBAGENT_TEST_TENANT_B_NAME=默认租户 B
WEBAGENT_TEST_TENANT_B_USERNAME=tenant_b
WEBAGENT_TEST_TENANT_B_PASSWORD=tenant123
# 相对路径以 WebAgent 项目根目录为基准,也支持 /data/WebAgent-sites 等绝对路径。 # 相对路径以 WebAgent 项目根目录为基准,也支持 /data/WebAgent-sites 等绝对路径。
WEBAGENT_SITES_DIR=../WebAgent-sites WEBAGENT_SITES_DIR=../WebAgent-sites
# 测试预览静态产物独立保存,便于直接查看,也不会随运行缓存清理。 # 测试预览静态产物独立保存,便于直接查看,也不会随运行缓存清理。
......
...@@ -14,8 +14,17 @@ pnpm dev ...@@ -14,8 +14,17 @@ pnpm dev
- API:<http://localhost:3100> - API:<http://localhost:3100>
- 站点预览端口:`4300-4399` - 站点预览端口:`4300-4399`
- Nginx 统一入口:<http://localhost> - Nginx 统一入口:<http://localhost>
- 测试预览链接:`http://你的主机/previews/site_xxx/` - 测试预览链接:`http://你的主机/previews/tenant_xxx/site_xxx/`
- 生产站点链接:`http://你的主机/sites/site_xxx/`(用户手动发布后可用) - 生产站点链接:`http://你的主机/sites/tenant_xxx/site_xxx/`(用户手动发布后可用)
开发环境首次启动会创建三个测试账号:系统管理员 `admin / admin123`,以及租户账号 `tenant_a / tenant123``tenant_b / tenant123`。账号值均可通过 `.env` 中的 `WEBAGENT_TEST_*` 配置覆盖;已有账号和密码不会在重启时被覆盖。生产环境默认不初始化测试数据,且在 `NODE_ENV=production` 时显式开启 `WEBAGENT_INIT_TEST_DATA=true` 会拒绝启动。
生产环境可通过 CLI 创建或重置系统管理员(重置密码会注销该管理员已有 session):
```bash
pnpm --filter @webagent/backend auth:admin create <username> <password>
pnpm --filter @webagent/backend auth:admin reset <username> <password>
```
生成的网站默认保存在 WebAgent 项目同级的 `WebAgent-sites/`。配置支持相对路径和绝对路径;相对路径以 WebAgent 项目根目录为基准: 生成的网站默认保存在 WebAgent 项目同级的 `WebAgent-sites/`。配置支持相对路径和绝对路径;相对路径以 WebAgent 项目根目录为基准:
...@@ -36,8 +45,8 @@ WEBAGENT_PRODUCTION_DIR=../WebAgent-production ...@@ -36,8 +45,8 @@ WEBAGENT_PRODUCTION_DIR=../WebAgent-production
- `/`:React 控制台静态文件 - `/`:React 控制台静态文件
- `/api/`:反向代理到 `127.0.0.1:3100` - `/api/`:反向代理到 `127.0.0.1:3100`
- `/previews/site_xxx/`:当前代码的测试预览,每次成功构建后更新 - `/previews/tenant_xxx/site_xxx/`:当前代码的测试预览,每次成功构建后更新
- `/sites/site_xxx/`:生产站点,只有用户执行“发布上线”后更新 - `/sites/tenant_xxx/site_xxx/`:生产站点,只有用户执行“发布上线”后更新
当前 Intel macOS Homebrew 配置位于 [deploy/nginx/webagent.conf](deploy/nginx/webagent.conf)。服务器路径变化时,需要同步调整配置中的 `root``alias` 绝对路径。 当前 Intel macOS Homebrew 配置位于 [deploy/nginx/webagent.conf](deploy/nginx/webagent.conf)。服务器路径变化时,需要同步调整配置中的 `root``alias` 绝对路径。
......
...@@ -7,7 +7,9 @@ ...@@ -7,7 +7,9 @@
"dev": "tsx watch src/server.ts", "dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"start": "node dist/server.js", "start": "node dist/server.js",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit",
"test": "NODE_ENV=test WEBAGENT_INIT_TEST_DATA=true tsx --test src/**/*.test.ts",
"auth:admin": "tsx src/auth/admin-cli.ts"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.0.1", "@fastify/cors": "^11.0.1",
......
import { AuthError, AuthRepository } from "./auth-repository.js";
const [command, username, password] = process.argv.slice(2);
if (!command || !username || !password || !["create", "reset"].includes(command)) {
console.error("用法: pnpm --filter @webagent/backend auth:admin <create|reset> <username> <password>");
process.exitCode = 1;
} else {
const auth = new AuthRepository();
try {
if (command === "create") auth.createAdmin(username, password);
else auth.resetAdminPassword(username, password);
console.log(command === "create" ? `管理员 ${username} 已创建` : `管理员 ${username} 的密码已重置,旧 session 已注销`);
} catch (error) {
console.error(error instanceof AuthError ? error.message : error);
process.exitCode = 1;
} finally {
auth.close();
}
}
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { config } from "../config.js";
import { AuthRepository } from "./auth-repository.js";
test("initializes all default accounts once and persists sessions", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "webagent-auth-"));
const databasePath = path.join(directory, "auth.sqlite");
try {
const first = new AuthRepository(databasePath);
first.initializeTestData();
const admin = first.login(config.auth.testData.adminUsername, config.auth.testData.adminPassword);
const tenantA = first.login(config.auth.testData.tenantAUsername, config.auth.testData.tenantAPassword);
const tenantB = first.login(config.auth.testData.tenantBUsername, config.auth.testData.tenantBPassword);
assert.equal(admin.session.accountType, "system_admin");
assert.equal(admin.session.tenantId, null);
assert.equal(tenantA.session.tenantId, config.auth.testData.tenantAId);
assert.equal(tenantB.session.tenantId, config.auth.testData.tenantBId);
first.close();
const restarted = new AuthRepository(databasePath);
restarted.initializeTestData();
assert.deepEqual(restarted.getSession(tenantA.token), tenantA.session);
assert.equal(restarted.listTenants().length, 2);
restarted.close();
} finally { await rm(directory, { recursive: true, force: true }); }
});
test("does not overwrite passwords and invalidates a disabled tenant session", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "webagent-auth-"));
const databasePath = path.join(directory, "auth.sqlite");
try {
const auth = new AuthRepository(databasePath);
auth.initializeTestData();
auth.resetAdminPassword(config.auth.testData.adminUsername, "changed-password");
auth.initializeTestData();
assert.equal(auth.login(config.auth.testData.adminUsername, "changed-password").session.username, config.auth.testData.adminUsername);
assert.throws(() => auth.login(config.auth.testData.adminUsername, config.auth.testData.adminPassword));
const tenantSession = auth.login(config.auth.testData.tenantAUsername, config.auth.testData.tenantAPassword);
auth.setTenantEnabled(config.auth.testData.tenantAId, false);
assert.equal(auth.getSession(tenantSession.token), undefined);
assert.throws(() => auth.login(config.auth.testData.tenantAUsername, config.auth.testData.tenantAPassword));
auth.setTenantEnabled(config.auth.testData.tenantAId, true);
assert.equal(auth.login(config.auth.testData.tenantAUsername, config.auth.testData.tenantAPassword).session.tenantId, config.auth.testData.tenantAId);
auth.close();
} finally { await rm(directory, { recursive: true, force: true }); }
});
import crypto from "node:crypto";
import path from "node:path";
import { mkdirSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
import type { AccountType, CreateTenantInput, SessionInfo, TenantAdminInfo } from "@webagent/shared";
import { config } from "../config.js";
import { hashPassword, verifyPassword } from "./password.js";
type UserRow = {
user_id: string; username: string; password_hash: string; account_type: AccountType;
tenant_id: string | null; enabled: number; tenant_enabled: number | null;
};
type SessionRow = UserRow & { expires_at: string };
type TenantRow = {
tenant_id: string; name: string; enabled: number; is_test: number; created_at: string; updated_at: string;
};
type TenantAccountRow = {
user_id: string; tenant_id: string; username: string; enabled: number; created_at: string;
};
export class AuthError extends Error {
constructor(message: string, public readonly statusCode = 400) { super(message); }
}
export class AuthRepository {
private readonly database: DatabaseSync;
constructor(databasePath = config.auth.databasePath) {
mkdirSync(path.dirname(databasePath), { recursive: true });
this.database = new DatabaseSync(databasePath);
this.database.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
this.migrate();
}
initializeTestData(): void {
if (!config.auth.testData.enabled) return;
const test = config.auth.testData;
this.transaction(() => {
this.insertTenantIfMissing(test.tenantAId, test.tenantAName, true);
this.insertTenantIfMissing(test.tenantBId, test.tenantBName, true);
this.insertUserIfMissing(test.adminUsername, test.adminPassword, "system_admin", null);
this.insertUserIfMissing(test.tenantAUsername, test.tenantAPassword, "tenant_user", test.tenantAId);
this.insertUserIfMissing(test.tenantBUsername, test.tenantBPassword, "tenant_user", test.tenantBId);
});
}
login(username: string, password: string): { token: string; session: SessionInfo } {
const row = this.database.prepare(`
SELECT u.user_id, u.username, u.password_hash, u.account_type, u.tenant_id, u.enabled,
t.enabled AS tenant_enabled
FROM users u LEFT JOIN tenants t ON t.tenant_id = u.tenant_id
WHERE u.username = ?
`).get(username) as UserRow | undefined;
const usable = row && row.enabled === 1 && (row.account_type === "system_admin" || row.tenant_enabled === 1);
if (!usable || !verifyPassword(password, row.password_hash)) throw new AuthError("账号或密码错误", 401);
const token = crypto.randomBytes(32).toString("hex");
const now = new Date();
const expiresAt = new Date(now.getTime() + config.auth.sessionTtlDays * 86400000).toISOString();
this.database.prepare("INSERT INTO sessions (session_id, token_hash, user_id, expires_at, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?)")
.run("session_" + crypto.randomBytes(16).toString("hex"), tokenDigest(token), row.user_id, expiresAt, now.toISOString(), now.toISOString());
return { token, session: toSession(row) };
}
getSession(token: string): SessionInfo | undefined {
const digest = tokenDigest(token);
const row = this.database.prepare(`
SELECT u.user_id, u.username, u.password_hash, u.account_type, u.tenant_id, u.enabled,
t.enabled AS tenant_enabled, s.expires_at
FROM sessions s JOIN users u ON u.user_id = s.user_id
LEFT JOIN tenants t ON t.tenant_id = u.tenant_id
WHERE s.token_hash = ?
`).get(digest) as SessionRow | undefined;
const invalid = !row || row.expires_at <= new Date().toISOString() || row.enabled !== 1
|| (row.account_type === "tenant_user" && row.tenant_enabled !== 1);
if (invalid) {
this.database.prepare("DELETE FROM sessions WHERE token_hash = ?").run(digest);
return undefined;
}
this.database.prepare("UPDATE sessions SET last_seen_at = ? WHERE token_hash = ?").run(new Date().toISOString(), digest);
return toSession(row);
}
logout(token: string): void {
this.database.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenDigest(token));
}
createAdmin(username: string, password: string): SessionInfo {
if (this.findUserByUsername(username)) throw new AuthError("账号名已存在", 409);
const userId = this.insertUser(username, password, "system_admin", null);
return { userId, username, accountType: "system_admin", tenantId: null };
}
resetAdminPassword(username: string, password: string): void {
const user = this.findUserByUsername(username);
if (!user || user.account_type !== "system_admin") throw new AuthError("管理员账号不存在", 404);
this.database.prepare("UPDATE users SET password_hash = ?, updated_at = ? WHERE user_id = ?")
.run(hashPassword(password), new Date().toISOString(), user.user_id);
this.revokeUserSessions(user.user_id);
}
listTenants(): TenantAdminInfo[] {
const tenants = this.database.prepare("SELECT tenant_id, name, enabled, is_test, created_at, updated_at FROM tenants ORDER BY created_at DESC").all() as TenantRow[];
const accounts = this.database.prepare("SELECT user_id, tenant_id, username, enabled, created_at FROM users WHERE account_type = 'tenant_user' ORDER BY created_at").all() as TenantAccountRow[];
return tenants.map((tenant) => ({
tenantId: tenant.tenant_id, name: tenant.name, enabled: tenant.enabled === 1, isTest: tenant.is_test === 1,
createdAt: tenant.created_at, updatedAt: tenant.updated_at, siteCount: 0,
accounts: accounts.filter((account) => account.tenant_id === tenant.tenant_id).map((account) => ({
userId: account.user_id, username: account.username, enabled: account.enabled === 1, createdAt: account.created_at,
})),
}));
}
getTenant(tenantId: string): TenantAdminInfo {
const tenant = this.listTenants().find((item) => item.tenantId === tenantId);
if (!tenant) throw new AuthError("租户不存在", 404);
return tenant;
}
createTenant(input: CreateTenantInput): TenantAdminInfo {
if (this.findUserByUsername(input.username)) throw new AuthError("账号名已存在", 409);
const tenantId = "tenant_" + crypto.randomBytes(12).toString("hex");
this.transaction(() => {
const now = new Date().toISOString();
this.database.prepare("INSERT INTO tenants (tenant_id, name, enabled, is_test, created_at, updated_at) VALUES (?, ?, 1, 0, ?, ?)")
.run(tenantId, input.name, now, now);
this.insertUser(input.username, input.password, "tenant_user", tenantId);
});
return this.getTenant(tenantId);
}
setTenantEnabled(tenantId: string, enabled: boolean): TenantAdminInfo {
const result = this.database.prepare("UPDATE tenants SET enabled = ?, updated_at = ? WHERE tenant_id = ?")
.run(enabled ? 1 : 0, new Date().toISOString(), tenantId);
if (result.changes === 0) throw new AuthError("租户不存在", 404);
if (!enabled) this.database.prepare("DELETE FROM sessions WHERE user_id IN (SELECT user_id FROM users WHERE tenant_id = ?)").run(tenantId);
return this.getTenant(tenantId);
}
resetTenantAccountPassword(tenantId: string, userId: string, password: string): void {
const result = this.database.prepare("UPDATE users SET password_hash = ?, updated_at = ? WHERE user_id = ? AND tenant_id = ? AND account_type = 'tenant_user'")
.run(hashPassword(password), new Date().toISOString(), userId, tenantId);
if (result.changes === 0) throw new AuthError("租户账号不存在", 404);
this.revokeUserSessions(userId);
}
revokeTenantAccountSessions(tenantId: string, userId: string): void {
const user = this.database.prepare("SELECT user_id FROM users WHERE user_id = ? AND tenant_id = ? AND account_type = 'tenant_user'").get(userId, tenantId);
if (!user) throw new AuthError("租户账号不存在", 404);
this.revokeUserSessions(userId);
}
close(): void { this.database.close(); }
private migrate(): void {
this.database.exec(`
CREATE TABLE IF NOT EXISTS tenants (
tenant_id TEXT PRIMARY KEY, name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)),
is_test INTEGER NOT NULL DEFAULT 0 CHECK(is_test IN (0,1)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL,
account_type TEXT NOT NULL CHECK(account_type IN ('system_admin','tenant_user')), tenant_id TEXT REFERENCES tenants(tenant_id),
enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
CHECK((account_type = 'system_admin' AND tenant_id IS NULL) OR (account_type = 'tenant_user' AND tenant_id IS NOT NULL))
);
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
expires_at TEXT NOT NULL, created_at TEXT NOT NULL, last_seen_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_user_id_idx ON sessions(user_id);
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at);
`);
this.database.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(new Date().toISOString());
}
private insertTenantIfMissing(tenantId: string, name: string, isTest: boolean): void {
const now = new Date().toISOString();
this.database.prepare("INSERT OR IGNORE INTO tenants (tenant_id, name, enabled, is_test, created_at, updated_at) VALUES (?, ?, 1, ?, ?, ?)")
.run(tenantId, name, isTest ? 1 : 0, now, now);
}
private insertUserIfMissing(username: string, password: string, accountType: AccountType, tenantId: string | null): void {
if (!this.findUserByUsername(username)) this.insertUser(username, password, accountType, tenantId);
}
private insertUser(username: string, password: string, accountType: AccountType, tenantId: string | null): string {
const userId = "user_" + crypto.randomBytes(12).toString("hex");
const now = new Date().toISOString();
this.database.prepare("INSERT INTO users (user_id, username, password_hash, account_type, tenant_id, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, ?, ?)")
.run(userId, username, hashPassword(password), accountType, tenantId, now, now);
return userId;
}
private findUserByUsername(username: string): UserRow | undefined {
return this.database.prepare("SELECT user_id, username, password_hash, account_type, tenant_id, enabled, NULL AS tenant_enabled FROM users WHERE username = ?")
.get(username) as UserRow | undefined;
}
private revokeUserSessions(userId: string): void {
this.database.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
}
private transaction<T>(operation: () => T): T {
this.database.exec("BEGIN IMMEDIATE");
try {
const result = operation();
this.database.exec("COMMIT");
return result;
} catch (error) {
this.database.exec("ROLLBACK");
throw error;
}
}
}
function tokenDigest(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
function toSession(row: Pick<UserRow, "user_id" | "username" | "account_type" | "tenant_id">): SessionInfo {
return { userId: row.user_id, username: row.username, accountType: row.account_type, tenantId: row.tenant_id };
}
import crypto from "node:crypto";
const KEY_LENGTH = 64;
const SCRYPT_COST = 16384;
export function hashPassword(password: string): string {
const salt = crypto.randomBytes(16);
const digest = crypto.scryptSync(password, salt, KEY_LENGTH, { N: SCRYPT_COST, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
return ["scrypt", SCRYPT_COST, 8, 1, salt.toString("base64"), digest.toString("base64")].join("$");
}
export function verifyPassword(password: string, encoded: string): boolean {
const [algorithm, costText, blockSizeText, parallelText, saltText, digestText] = encoded.split("$");
if (algorithm !== "scrypt" || !costText || !blockSizeText || !parallelText || !saltText || !digestText) return false;
try {
const expected = Buffer.from(digestText, "base64");
const actual = crypto.scryptSync(password, Buffer.from(saltText, "base64"), expected.length, {
N: Number(costText), r: Number(blockSizeText), p: Number(parallelText), maxmem: 64 * 1024 * 1024,
});
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
} catch {
return false;
}
}
...@@ -34,20 +34,20 @@ export class BuildManager { ...@@ -34,20 +34,20 @@ export class BuildManager {
} }
} }
async publishPreview(siteId: string, distPath: string): Promise<string> { async publishPreview(tenantId: string, siteId: string, distPath: string): Promise<string> {
return this.publishAtomically(path.join(config.previewsDir, siteId), distPath); return this.publishAtomically(path.join(config.previewsDir, tenantId, siteId), distPath);
} }
async publishProduction(siteId: string, distPath: string): Promise<string> { async publishProduction(tenantId: string, siteId: string, distPath: string): Promise<string> {
return this.publishAtomically(path.join(config.productionDir, siteId), distPath); return this.publishAtomically(path.join(config.productionDir, tenantId, siteId), distPath);
} }
async publishCustomDomain(siteId: string, distPath: string): Promise<string> { async publishCustomDomain(tenantId: string, siteId: string, distPath: string): Promise<string> {
return this.publishAtomically(path.join(config.customDomainsDir, siteId), distPath); return this.publishAtomically(path.join(config.customDomainsDir, tenantId, siteId), distPath);
} }
async ensureProductionPlaceholder(siteId: string, siteName: string): Promise<string> { async ensureProductionPlaceholder(tenantId: string, siteId: string, siteName: string): Promise<string> {
const target = path.join(config.productionDir, siteId); const target = path.join(config.productionDir, tenantId, siteId);
const indexPath = path.join(target, "index.html"); const indexPath = path.join(target, "index.html");
if (await stat(indexPath).then((value) => value.isFile()).catch(() => false)) return target; if (await stat(indexPath).then((value) => value.isFile()).catch(() => false)) return target;
const safeName = siteName.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;"); const safeName = siteName.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
......
...@@ -20,10 +20,23 @@ const configuredCustomDomainsDir = process.env.WEBAGENT_CUSTOM_DOMAINS_DIR?.trim ...@@ -20,10 +20,23 @@ const configuredCustomDomainsDir = process.env.WEBAGENT_CUSTOM_DOMAINS_DIR?.trim
const customDomainsDir = path.isAbsolute(configuredCustomDomainsDir) const customDomainsDir = path.isAbsolute(configuredCustomDomainsDir)
? configuredCustomDomainsDir ? configuredCustomDomainsDir
: path.resolve(rootDir, configuredCustomDomainsDir); : path.resolve(rootDir, configuredCustomDomainsDir);
const configuredDatabasePath = process.env.WEBAGENT_DATABASE_PATH?.trim() || ".runtime/webagent.sqlite";
const databasePath = path.isAbsolute(configuredDatabasePath)
? configuredDatabasePath
: path.resolve(rootDir, configuredDatabasePath);
const configuredDomainCheckInterval = Number(process.env.WEBAGENT_DOMAIN_CHECK_INTERVAL_MS || 60000); const configuredDomainCheckInterval = Number(process.env.WEBAGENT_DOMAIN_CHECK_INTERVAL_MS || 60000);
const domainCheckIntervalMs = Number.isFinite(configuredDomainCheckInterval) const domainCheckIntervalMs = Number.isFinite(configuredDomainCheckInterval)
? Math.max(15000, configuredDomainCheckInterval) ? Math.max(15000, configuredDomainCheckInterval)
: 60000; : 60000;
const environment = process.env.NODE_ENV?.trim() || "development";
const testDataFlag = process.env.WEBAGENT_INIT_TEST_DATA?.trim();
const testDataEnabled = testDataFlag == null || testDataFlag === ""
? environment === "development" || environment === "test"
: testDataFlag === "true";
if (environment === "production" && testDataEnabled) {
throw new Error("生产环境禁止启用 WEBAGENT_INIT_TEST_DATA,请关闭测试账号初始化后再启动");
}
export const config = { export const config = {
rootDir, rootDir,
...@@ -33,12 +46,26 @@ export const config = { ...@@ -33,12 +46,26 @@ export const config = {
previewsDir, previewsDir,
productionDir, productionDir,
customDomainsDir, customDomainsDir,
environment,
host: process.env.HOST || "127.0.0.1", host: process.env.HOST || "127.0.0.1",
port: Number(process.env.PORT || 3100), port: Number(process.env.PORT || 3100),
frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173", frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173",
auth: { auth: {
username: "user", databasePath,
password: process.env.WEBAGENT_USER_PASSWORD || "user", sessionTtlDays: Math.max(1, Number(process.env.WEBAGENT_SESSION_TTL_DAYS || 30)),
testData: {
enabled: testDataEnabled,
adminUsername: process.env.WEBAGENT_TEST_ADMIN_USERNAME?.trim() || "admin",
adminPassword: process.env.WEBAGENT_TEST_ADMIN_PASSWORD || "admin123",
tenantAId: process.env.WEBAGENT_TEST_TENANT_A_ID?.trim() || "tenant_default_a",
tenantAName: process.env.WEBAGENT_TEST_TENANT_A_NAME?.trim() || "默认租户 A",
tenantAUsername: process.env.WEBAGENT_TEST_TENANT_A_USERNAME?.trim() || "tenant_a",
tenantAPassword: process.env.WEBAGENT_TEST_TENANT_A_PASSWORD || "tenant123",
tenantBId: process.env.WEBAGENT_TEST_TENANT_B_ID?.trim() || "tenant_default_b",
tenantBName: process.env.WEBAGENT_TEST_TENANT_B_NAME?.trim() || "默认租户 B",
tenantBUsername: process.env.WEBAGENT_TEST_TENANT_B_USERNAME?.trim() || "tenant_b",
tenantBPassword: process.env.WEBAGENT_TEST_TENANT_B_PASSWORD || "tenant123",
},
}, },
openai: { openai: {
apiKey: process.env.OPENAI_API_KEY || "", apiKey: process.env.OPENAI_API_KEY || "",
...@@ -70,10 +97,10 @@ export const runtimePaths = { ...@@ -70,10 +97,10 @@ export const runtimePaths = {
domainMaps: path.join(config.runtimeDir, "nginx"), domainMaps: path.join(config.runtimeDir, "nginx"),
}; };
export function getPublicPreviewUrl(siteId: string): string { export function getPublicPreviewUrl(tenantId: string, siteId: string): string {
return `/previews/${siteId}/`; return `/previews/${tenantId}/${siteId}/`;
} }
export function getPublicProductionUrl(siteId: string): string { export function getPublicProductionUrl(tenantId: string, siteId: string): string {
return `/sites/${siteId}/`; return `/sites/${tenantId}/${siteId}/`;
} }
...@@ -19,36 +19,37 @@ export class DomainDeploymentService { ...@@ -19,36 +19,37 @@ export class DomainDeploymentService {
private readonly routing: DomainRoutingConfig, private readonly routing: DomainRoutingConfig,
) {} ) {}
schedule(siteId: string, commit: string): Promise<void> { schedule(tenantId: string, siteId: string, commit: string): Promise<void> {
const current = this.inFlight.get(siteId); const key = tenantId + "/" + siteId;
if (current) return current.then(() => this.schedule(siteId, commit)); const current = this.inFlight.get(key);
const operation = this.deploy(siteId, commit).finally(() => this.inFlight.delete(siteId)); if (current) return current.then(() => this.schedule(tenantId, siteId, commit));
this.inFlight.set(siteId, operation); const operation = this.deploy(tenantId, siteId, commit).finally(() => this.inFlight.delete(key));
this.inFlight.set(key, operation);
return operation; return operation;
} }
private async deploy(siteId: string, commit: string): Promise<void> { private async deploy(tenantId: string, siteId: string, commit: string): Promise<void> {
const bindings = await this.domains.list(siteId); const bindings = await this.domains.list(tenantId, siteId);
if (!bindings.some((domain) => domain.ownershipStatus === "verified")) return; if (!bindings.some((domain) => domain.ownershipStatus === "verified")) return;
const project = this.sites.getProjectPath(siteId); const project = this.sites.getProjectPath(tenantId, siteId);
await this.git.assertCommit(project, commit); await this.git.assertCommit(project, commit);
const taskId = "domain_" + crypto.randomBytes(5).toString("hex"); const taskId = "domain_" + crypto.randomBytes(5).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace"); const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.domains.updateSite(siteId, (domain) => domain.ownershipStatus === "verified" await this.domains.updateSite(tenantId, siteId, (domain) => domain.ownershipStatus === "verified"
? { deploymentStatus: "deploying", lastDeploymentError: undefined } ? { deploymentStatus: "deploying", lastDeploymentError: undefined }
: {}); : {});
try { try {
await this.git.createWorktree(project, workspace, commit); await this.git.createWorktree(project, workspace, commit);
await ensureEnvironmentConfig(workspace); await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, "/"); const dist = await this.builds.build(workspace, taskId, "/");
await this.builds.publishCustomDomain(siteId, dist); await this.builds.publishCustomDomain(tenantId, siteId, dist);
await this.domains.updateSite(siteId, (domain) => domain.ownershipStatus === "verified" await this.domains.updateSite(tenantId, siteId, (domain) => domain.ownershipStatus === "verified"
? { deploymentStatus: "active", deployedCommit: commit, lastDeploymentError: undefined } ? { deploymentStatus: "active", deployedCommit: commit, lastDeploymentError: undefined }
: {}); : {});
await this.routing.sync(); await this.routing.sync();
} catch (error) { } catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error); const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.domains.updateSite(siteId, (domain) => domain.ownershipStatus === "verified" await this.domains.updateSite(tenantId, siteId, (domain) => domain.ownershipStatus === "verified"
? { deploymentStatus: "failed", lastDeploymentError: details } ? { deploymentStatus: "failed", lastDeploymentError: details }
: {}); : {});
throw error; throw error;
......
...@@ -15,21 +15,26 @@ export class DomainError extends Error { ...@@ -15,21 +15,26 @@ export class DomainError extends Error {
export class DomainRepository { export class DomainRepository {
private mutationQueue: Promise<unknown> = Promise.resolve(); private mutationQueue: Promise<unknown> = Promise.resolve();
async list(siteId: string): Promise<StoredDomainBinding[]> { async list(tenantId: string, siteId: string): Promise<StoredDomainBinding[]> {
this.assertTenantId(tenantId);
this.assertSiteId(siteId); this.assertSiteId(siteId);
return this.readSite(siteId); return this.readSite(tenantId, siteId);
} }
async listAll(): Promise<StoredDomainBinding[]> { async listAll(): Promise<StoredDomainBinding[]> {
const entries = await readdir(runtimePaths.sites, { withFileTypes: true }).catch(() => []); const tenants = await readdir(runtimePaths.sites, { withFileTypes: true }).catch(() => []);
const groups = await Promise.all(entries const tenantGroups = await Promise.all(tenants
.filter((entry) => entry.isDirectory() && /^site_[a-z0-9]+$/.test(entry.name)) .filter((entry) => entry.isDirectory() && /^tenant_[a-z0-9_]+$/.test(entry.name))
.map((entry) => this.readSite(entry.name))); .map(async (tenant) => {
return groups.flat(); const sites = await readdir(path.join(runtimePaths.sites, tenant.name), { withFileTypes: true }).catch(() => []);
return Promise.all(sites.filter((entry) => entry.isDirectory() && /^site_[a-z0-9]+$/.test(entry.name))
.map((entry) => this.readSite(tenant.name, entry.name)));
}));
return tenantGroups.flat(2);
} }
async get(siteId: string, domainId: string): Promise<StoredDomainBinding> { async get(tenantId: string, siteId: string, domainId: string): Promise<StoredDomainBinding> {
const domain = (await this.list(siteId)).find((item) => item.domainId === domainId); const domain = (await this.list(tenantId, siteId)).find((item) => item.domainId === domainId);
if (!domain) throw new DomainError("域名绑定不存在", 404); if (!domain) throw new DomainError("域名绑定不存在", 404);
return domain; return domain;
} }
...@@ -38,13 +43,14 @@ export class DomainRepository { ...@@ -38,13 +43,14 @@ export class DomainRepository {
return (await this.listAll()).find((item) => item.hostname === hostname); return (await this.listAll()).find((item) => item.hostname === hostname);
} }
async create(siteId: string, hostname: string, verificationToken: string, tlsStatus: StoredDomainBinding["tlsStatus"]): Promise<StoredDomainBinding> { async create(tenantId: string, siteId: string, hostname: string, verificationToken: string, tlsStatus: StoredDomainBinding["tlsStatus"]): Promise<StoredDomainBinding> {
return this.mutate(async () => { return this.mutate(async () => {
if ((await this.listAll()).some((item) => item.hostname === hostname)) throw new DomainError("该域名已经绑定到其他官网", 409); if ((await this.listAll()).some((item) => item.hostname === hostname)) throw new DomainError("该域名已经绑定到其他官网", 409);
const current = await this.readSite(siteId); const current = await this.readSite(tenantId, siteId);
const now = new Date().toISOString(); const now = new Date().toISOString();
const domain: StoredDomainBinding = { const domain: StoredDomainBinding = {
domainId: "domain_" + crypto.randomBytes(6).toString("hex"), domainId: "domain_" + crypto.randomBytes(6).toString("hex"),
tenantId,
siteId, siteId,
hostname, hostname,
isPrimary: current.length === 0, isPrimary: current.length === 0,
...@@ -56,67 +62,69 @@ export class DomainRepository { ...@@ -56,67 +62,69 @@ export class DomainRepository {
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
await this.writeSite(siteId, [...current, domain]); await this.writeSite(tenantId, siteId, [...current, domain]);
return domain; return domain;
}); });
} }
async update(siteId: string, domainId: string, values: Partial<StoredDomainBinding>): Promise<StoredDomainBinding> { async update(tenantId: string, siteId: string, domainId: string, values: Partial<StoredDomainBinding>): Promise<StoredDomainBinding> {
return this.mutate(async () => { return this.mutate(async () => {
const current = await this.readSite(siteId); const current = await this.readSite(tenantId, siteId);
const index = current.findIndex((item) => item.domainId === domainId); const index = current.findIndex((item) => item.domainId === domainId);
if (index < 0) throw new DomainError("域名绑定不存在", 404); if (index < 0) throw new DomainError("域名绑定不存在", 404);
const next = { ...current[index], ...values, domainId, siteId, updatedAt: new Date().toISOString() }; const next = { ...current[index], ...values, domainId, tenantId, siteId, updatedAt: new Date().toISOString() };
current[index] = next; current[index] = next;
await this.writeSite(siteId, current); await this.writeSite(tenantId, siteId, current);
return next; return next;
}); });
} }
async updateSite(siteId: string, values: (domain: StoredDomainBinding) => Partial<StoredDomainBinding>): Promise<StoredDomainBinding[]> { async updateSite(tenantId: string, siteId: string, values: (domain: StoredDomainBinding) => Partial<StoredDomainBinding>): Promise<StoredDomainBinding[]> {
return this.mutate(async () => { return this.mutate(async () => {
const current = await this.readSite(siteId); const current = await this.readSite(tenantId, siteId);
const updatedAt = new Date().toISOString(); const updatedAt = new Date().toISOString();
const next = current.map((domain) => ({ ...domain, ...values(domain), domainId: domain.domainId, siteId, updatedAt })); const next = current.map((domain) => ({ ...domain, ...values(domain), domainId: domain.domainId, tenantId, siteId, updatedAt }));
await this.writeSite(siteId, next); await this.writeSite(tenantId, siteId, next);
return next; return next;
}); });
} }
async setPrimary(siteId: string, domainId: string): Promise<StoredDomainBinding> { async setPrimary(tenantId: string, siteId: string, domainId: string): Promise<StoredDomainBinding> {
return this.mutate(async () => { return this.mutate(async () => {
const current = await this.readSite(siteId); const current = await this.readSite(tenantId, siteId);
if (!current.some((item) => item.domainId === domainId)) throw new DomainError("域名绑定不存在", 404); if (!current.some((item) => item.domainId === domainId)) throw new DomainError("域名绑定不存在", 404);
const updatedAt = new Date().toISOString(); const updatedAt = new Date().toISOString();
const next = current.map((item) => ({ ...item, isPrimary: item.domainId === domainId, updatedAt })); const next = current.map((item) => ({ ...item, isPrimary: item.domainId === domainId, updatedAt }));
await this.writeSite(siteId, next); await this.writeSite(tenantId, siteId, next);
return next.find((item) => item.domainId === domainId)!; return next.find((item) => item.domainId === domainId)!;
}); });
} }
async remove(siteId: string, domainId: string): Promise<void> { async remove(tenantId: string, siteId: string, domainId: string): Promise<void> {
await this.mutate(async () => { await this.mutate(async () => {
const current = await this.readSite(siteId); const current = await this.readSite(tenantId, siteId);
const removed = current.find((item) => item.domainId === domainId); const removed = current.find((item) => item.domainId === domainId);
if (!removed) throw new DomainError("域名绑定不存在", 404); if (!removed) throw new DomainError("域名绑定不存在", 404);
const next = current.filter((item) => item.domainId !== domainId); const next = current.filter((item) => item.domainId !== domainId);
if (removed.isPrimary && next[0]) next[0] = { ...next[0], isPrimary: true, updatedAt: new Date().toISOString() }; if (removed.isPrimary && next[0]) next[0] = { ...next[0], isPrimary: true, updatedAt: new Date().toISOString() };
await this.writeSite(siteId, next); await this.writeSite(tenantId, siteId, next);
}); });
} }
private getPath(siteId: string): string { private getPath(tenantId: string, siteId: string): string {
this.assertTenantId(tenantId);
this.assertSiteId(siteId); this.assertSiteId(siteId);
return path.join(runtimePaths.sites, siteId, "metadata", "domains.json"); return path.join(runtimePaths.sites, tenantId, siteId, "metadata", "domains.json");
} }
private async readSite(siteId: string): Promise<StoredDomainBinding[]> { private async readSite(tenantId: string, siteId: string): Promise<StoredDomainBinding[]> {
try { try {
const domains = JSON.parse(await readFile(this.getPath(siteId), "utf8")) as StoredDomainBinding[]; const domains = JSON.parse(await readFile(this.getPath(tenantId, siteId), "utf8")) as StoredDomainBinding[];
return domains.map((domain) => { return domains.map((domain) => {
const { lastError, ...stored } = domain; const { lastError, ...stored } = domain;
return { return {
...stored, ...stored,
tenantId,
tlsStatus: domain.tlsStatus || (config.domains.httpsMode === "cloudflare" ? "pending" : "managed"), tlsStatus: domain.tlsStatus || (config.domains.httpsMode === "cloudflare" ? "pending" : "managed"),
lastDeploymentError: domain.lastDeploymentError || lastError, lastDeploymentError: domain.lastDeploymentError || lastError,
}; };
...@@ -127,8 +135,8 @@ export class DomainRepository { ...@@ -127,8 +135,8 @@ export class DomainRepository {
} }
} }
private async writeSite(siteId: string, domains: StoredDomainBinding[]): Promise<void> { private async writeSite(tenantId: string, siteId: string, domains: StoredDomainBinding[]): Promise<void> {
const target = this.getPath(siteId); const target = this.getPath(tenantId, siteId);
await mkdir(path.dirname(target), { recursive: true }); await mkdir(path.dirname(target), { recursive: true });
const temporary = target + ".tmp"; const temporary = target + ".tmp";
await writeFile(temporary, JSON.stringify(domains, null, 2) + "\n", "utf8"); await writeFile(temporary, JSON.stringify(domains, null, 2) + "\n", "utf8");
...@@ -144,4 +152,8 @@ export class DomainRepository { ...@@ -144,4 +152,8 @@ export class DomainRepository {
private assertSiteId(siteId: string): void { private assertSiteId(siteId: string): void {
if (!/^site_[a-z0-9]+$/.test(siteId)) throw new DomainError("无效的 siteId", 400); if (!/^site_[a-z0-9]+$/.test(siteId)) throw new DomainError("无效的 siteId", 400);
} }
private assertTenantId(tenantId: string): void {
if (!/^tenant_[a-z0-9_]+$/.test(tenantId)) throw new DomainError("无效的 tenantId", 400);
}
} }
...@@ -16,7 +16,7 @@ export class DomainRoutingConfig { ...@@ -16,7 +16,7 @@ export class DomainRoutingConfig {
if (domain.isPrimary || !primaryBySite.has(domain.siteId)) primaryBySite.set(domain.siteId, domain.hostname); if (domain.isPrimary || !primaryBySite.has(domain.siteId)) primaryBySite.set(domain.siteId, domain.hostname);
} }
const routes = active.map((domain) => `${domain.hostname} ${domain.siteId};`).join("\n"); const routes = active.map((domain) => `${domain.hostname} ${domain.tenantId}/${domain.siteId};`).join("\n");
const redirects = active const redirects = active
.filter((domain) => primaryBySite.get(domain.siteId) !== domain.hostname) .filter((domain) => primaryBySite.get(domain.siteId) !== domain.hostname)
.map((domain) => `${domain.hostname} ${primaryBySite.get(domain.siteId)};`) .map((domain) => `${domain.hostname} ${primaryBySite.get(domain.siteId)};`)
......
...@@ -16,10 +16,10 @@ export class DomainService { ...@@ -16,10 +16,10 @@ export class DomainService {
private readonly provider: DomainProvider, private readonly provider: DomainProvider,
) {} ) {}
async list(siteId: string): Promise<DomainListResult> { async list(tenantId: string, siteId: string): Promise<DomainListResult> {
await this.sites.get(siteId); await this.sites.get(tenantId, siteId);
return { return {
domains: (await this.domains.list(siteId)).map((domain) => this.present(domain)), domains: (await this.domains.list(tenantId, siteId)).map((domain) => this.present(domain)),
cnameTarget: config.domains.cnameTarget, cnameTarget: config.domains.cnameTarget,
configured: !config.domains.cnameTarget.endsWith(".local") && this.provider.isConfigured(), configured: !config.domains.cnameTarget.endsWith(".local") && this.provider.isConfigured(),
ipv4: config.domains.ipv4 || undefined, ipv4: config.domains.ipv4 || undefined,
...@@ -28,17 +28,17 @@ export class DomainService { ...@@ -28,17 +28,17 @@ export class DomainService {
}; };
} }
async add(siteId: string, input: string): Promise<DomainBinding> { async add(tenantId: string, siteId: string, input: string): Promise<DomainBinding> {
await this.sites.get(siteId); await this.sites.get(tenantId, siteId);
if (config.domains.cnameTarget.endsWith(".local")) throw new DomainError("域名接入尚未配置,请先设置 WEBAGENT_DOMAIN_CNAME_TARGET"); if (config.domains.cnameTarget.endsWith(".local")) throw new DomainError("域名接入尚未配置,请先设置 WEBAGENT_DOMAIN_CNAME_TARGET");
if (!this.provider.isConfigured()) throw new DomainError("域名 HTTPS Provider 尚未配置完整"); if (!this.provider.isConfigured()) throw new DomainError("域名 HTTPS Provider 尚未配置完整");
const hostname = this.normalizeHostname(input); const hostname = this.normalizeHostname(input);
const verificationToken = "webagent-verification=" + crypto.randomBytes(18).toString("base64url"); const verificationToken = "webagent-verification=" + crypto.randomBytes(18).toString("base64url");
return this.present(await this.domains.create(siteId, hostname, verificationToken, this.provider.initialTlsStatus())); return this.present(await this.domains.create(tenantId, siteId, hostname, verificationToken, this.provider.initialTlsStatus()));
} }
async verify(siteId: string, domainId: string): Promise<DomainBinding> { async verify(tenantId: string, siteId: string, domainId: string): Promise<DomainBinding> {
const domain = await this.domains.get(siteId, domainId); const domain = await this.domains.get(tenantId, siteId, domainId);
const [ownership, routing] = await Promise.all([ const [ownership, routing] = await Promise.all([
domain.ownershipStatus === "verified" domain.ownershipStatus === "verified"
? Promise.resolve<{ valid: boolean; error?: string }>({ valid: true }) ? Promise.resolve<{ valid: boolean; error?: string }>({ valid: true })
...@@ -46,7 +46,7 @@ export class DomainService { ...@@ -46,7 +46,7 @@ export class DomainService {
this.checkRouting(domain.hostname), this.checkRouting(domain.hostname),
]); ]);
const errors = [ownership.error, routing.error].filter(Boolean); const errors = [ownership.error, routing.error].filter(Boolean);
let next = await this.domains.update(siteId, domainId, { let next = await this.domains.update(tenantId, siteId, domainId, {
ownershipStatus: ownership.valid ? "verified" : "failed", ownershipStatus: ownership.valid ? "verified" : "failed",
dnsStatus: routing.valid ? "valid" : "invalid", dnsStatus: routing.valid ? "valid" : "invalid",
lastCheckedAt: new Date().toISOString(), lastCheckedAt: new Date().toISOString(),
...@@ -55,7 +55,7 @@ export class DomainService { ...@@ -55,7 +55,7 @@ export class DomainService {
if (next.ownershipStatus === "verified") { if (next.ownershipStatus === "verified") {
try { try {
const provider = await this.provider.sync(next); const provider = await this.provider.sync(next);
next = await this.domains.update(siteId, domainId, { next = await this.domains.update(tenantId, siteId, domainId, {
providerHostnameId: provider.providerHostnameId, providerHostnameId: provider.providerHostnameId,
providerStatus: provider.providerStatus, providerStatus: provider.providerStatus,
tlsStatus: provider.tlsStatus, tlsStatus: provider.tlsStatus,
...@@ -64,7 +64,7 @@ export class DomainService { ...@@ -64,7 +64,7 @@ export class DomainService {
lastTlsError: provider.error, lastTlsError: provider.error,
}); });
} catch (error) { } catch (error) {
next = await this.domains.update(siteId, domainId, { next = await this.domains.update(tenantId, siteId, domainId, {
tlsStatus: "failed", tlsStatus: "failed",
lastTlsError: error instanceof Error ? error.message : String(error), lastTlsError: error instanceof Error ? error.message : String(error),
}); });
...@@ -73,16 +73,16 @@ export class DomainService { ...@@ -73,16 +73,16 @@ export class DomainService {
return this.present(next); return this.present(next);
} }
async setPrimary(siteId: string, domainId: string): Promise<DomainBinding> { async setPrimary(tenantId: string, siteId: string, domainId: string): Promise<DomainBinding> {
const domain = this.present(await this.domains.setPrimary(siteId, domainId)); const domain = this.present(await this.domains.setPrimary(tenantId, siteId, domainId));
await this.routing.sync(); await this.routing.sync();
return domain; return domain;
} }
async remove(siteId: string, domainId: string): Promise<void> { async remove(tenantId: string, siteId: string, domainId: string): Promise<void> {
const domain = await this.domains.get(siteId, domainId); const domain = await this.domains.get(tenantId, siteId, domainId);
await this.provider.remove(domain); await this.provider.remove(domain);
await this.domains.remove(siteId, domainId); await this.domains.remove(tenantId, siteId, domainId);
await this.routing.sync(); await this.routing.sync();
} }
......
...@@ -13,8 +13,9 @@ const mimeTypes: Record<string, string> = { ...@@ -13,8 +13,9 @@ const mimeTypes: Record<string, string> = {
export class PreviewProcessManager { export class PreviewProcessManager {
private servers = new Map<string, Server>(); private servers = new Map<string, Server>();
async start(siteId: string, distPath: string, port: number): Promise<string> { async start(tenantId: string, siteId: string, distPath: string, port: number): Promise<string> {
await this.stop(siteId); const key = tenantId + "/" + siteId;
await this.stop(tenantId, siteId);
const root = path.resolve(distPath); const root = path.resolve(distPath);
const server = http.createServer(async (request, response) => { const server = http.createServer(async (request, response) => {
try { try {
...@@ -36,18 +37,19 @@ export class PreviewProcessManager { ...@@ -36,18 +37,19 @@ export class PreviewProcessManager {
server.once("error", reject); server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve()); server.listen(port, "127.0.0.1", () => resolve());
}); });
this.servers.set(siteId, server); this.servers.set(key, server);
return this.getPreviewUrl(port); return this.getPreviewUrl(port);
} }
async stop(siteId: string): Promise<void> { async stop(tenantId: string, siteId: string): Promise<void> {
const server = this.servers.get(siteId); const key = tenantId + "/" + siteId;
const server = this.servers.get(key);
if (!server) return; if (!server) return;
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
server.close(() => resolve()); server.close(() => resolve());
server.closeAllConnections(); server.closeAllConnections();
}); });
this.servers.delete(siteId); this.servers.delete(key);
} }
getPreviewUrl(port: number): string { return "http://localhost:" + port; } getPreviewUrl(port: number): string { return "http://localhost:" + port; }
......
...@@ -31,6 +31,15 @@ export const updateDomainSchema = z.object({ ...@@ -31,6 +31,15 @@ export const updateDomainSchema = z.object({
isPrimary: z.literal(true), isPrimary: z.literal(true),
}); });
export const createTenantSchema = z.object({
name: z.string().trim().min(2, "租户名称至少 2 个字符").max(80),
username: z.string().trim().min(3, "账号至少 3 个字符").max(50).regex(/^[a-zA-Z0-9_.-]+$/, "账号只能包含字母、数字、点、下划线和短横线"),
password: z.string().min(8, "密码至少 8 个字符").max(200),
});
export const tenantStatusSchema = z.object({ enabled: z.boolean() });
export const resetPasswordSchema = z.object({ password: z.string().min(8, "密码至少 8 个字符").max(200) });
export const sitePatchSchema = z.object({ export const sitePatchSchema = z.object({
summary: z.string().trim().min(2).max(200), summary: z.string().trim().min(2).max(200),
operations: z.array(z.discriminatedUnion("type", [ operations: z.array(z.discriminatedUnion("type", [
......
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto";
import { access, mkdir, rm } from "node:fs/promises"; import { access, mkdir, rm } from "node:fs/promises";
import Fastify from "fastify"; import Fastify from "fastify";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import { ZodError } from "zod"; import { ZodError } from "zod";
import type { SessionInfo, TenantAdminInfo } from "@webagent/shared";
import { AgentLoop } from "./agent/agent-loop.js"; import { AgentLoop } from "./agent/agent-loop.js";
import { AuthRepository } from "./auth/auth-repository.js";
import { BuildManager } from "./build/build-manager.js"; import { BuildManager } from "./build/build-manager.js";
import { config, runtimePaths } from "./config.js"; import { config, runtimePaths } from "./config.js";
import { GitManager } from "./git/git-manager.js"; import { GitManager } from "./git/git-manager.js";
import { PreviewProcessManager } from "./preview/preview-process-manager.js"; import { PreviewProcessManager } from "./preview/preview-process-manager.js";
import { addDomainSchema, chatSchema, createSiteSchema, loginSchema, updateDomainSchema, versionCommitSchema } from "./schemas.js"; import {
addDomainSchema, chatSchema, createSiteSchema, createTenantSchema, loginSchema, resetPasswordSchema,
tenantStatusSchema, updateDomainSchema, versionCommitSchema,
} from "./schemas.js";
import { CreateSiteService } from "./sites/create-site.js"; 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";
...@@ -20,9 +24,15 @@ import { DomainRoutingConfig } from "./domains/domain-routing-config.js"; ...@@ -20,9 +24,15 @@ import { DomainRoutingConfig } from "./domains/domain-routing-config.js";
import { DomainService } from "./domains/domain-service.js"; import { DomainService } from "./domains/domain-service.js";
import { createDomainProvider } from "./domains/domain-provider.js"; import { createDomainProvider } from "./domains/domain-provider.js";
declare module "fastify" {
interface FastifyRequest { auth?: SessionInfo }
}
const app = Fastify({ logger: { level: process.env.LOG_LEVEL || "info" }, bodyLimit: 1024 * 1024 }); const app = Fastify({ logger: { level: process.env.LOG_LEVEL || "info" }, bodyLimit: 1024 * 1024 });
await app.register(cors, { origin: [config.frontendOrigin, "http://127.0.0.1:5173"] }); await app.register(cors, { origin: [config.frontendOrigin, "http://127.0.0.1:5173"] });
const auth = new AuthRepository();
auth.initializeTestData();
const sites = new SiteRepository(); const sites = new SiteRepository();
const git = new GitManager(); const git = new GitManager();
const builds = new BuildManager(); const builds = new BuildManager();
...@@ -34,72 +44,112 @@ const domainRepository = new DomainRepository(); ...@@ -34,72 +44,112 @@ const domainRepository = new DomainRepository();
const domainRouting = new DomainRoutingConfig(domainRepository); const domainRouting = new DomainRoutingConfig(domainRepository);
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, createDomainProvider());
const sessions = new Set<string>();
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;
const token = request.headers.authorization?.replace(/^Bearer\s+/i, ""); const token = bearerToken(request.headers.authorization);
if (!token || !sessions.has(token)) return reply.code(401).send({ error: "登录已失效,请重新登录" }); const session = token ? auth.getSession(token) : undefined;
if (!session) return reply.code(401).send({ error: "登录已失效,请重新登录" });
request.auth = session;
}); });
app.get("/api/health", async () => ({ status: "ok", service: "WebAgent", agentMode: config.openai.apiKey ? "model" : "local", timestamp: new Date().toISOString() })); app.get("/api/health", async () => ({ status: "ok", service: "WebAgent", agentMode: config.openai.apiKey ? "model" : "local", timestamp: new Date().toISOString() }));
app.post("/api/login", async (request, reply) => { app.post("/api/login", async (request) => {
const body = loginSchema.parse(request.body); const body = loginSchema.parse(request.body);
const digest = (value: string) => crypto.createHash("sha256").update(value).digest(); const result = auth.login(body.username, body.password);
const validUsername = crypto.timingSafeEqual(digest(body.username), digest(config.auth.username)); return { token: result.token, ...result.session };
const validPassword = crypto.timingSafeEqual(digest(body.password), digest(config.auth.password));
if (!validUsername || !validPassword) return reply.code(401).send({ error: "账号或密码错误" });
const token = crypto.randomBytes(32).toString("hex");
sessions.add(token);
return { token, username: config.auth.username };
}); });
app.post("/api/logout", async (request) => { app.post("/api/logout", async (request) => {
const token = request.headers.authorization?.replace(/^Bearer\s+/i, ""); const token = bearerToken(request.headers.authorization);
if (token) sessions.delete(token); if (token) auth.logout(token);
return { success: true }; return { success: true };
}); });
app.get("/api/session", async () => ({ username: config.auth.username })); app.get("/api/session", async (request) => request.auth!);
app.get("/api/sites", async () => sites.list());
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId", async (request) => sites.get(request.params.siteId)); app.get("/api/sites", async (request) => sites.list(requireTenant(request.auth)));
app.post("/api/sites", async (request, reply) => reply.code(201).send(await createSite.execute(createSiteSchema.parse(request.body)))); 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<{ 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); return siteAgent.execute(request.params.siteId, body.message); const body = chatSchema.parse(request.body);
return siteAgent.execute(requireTenant(request.auth), request.params.siteId, body.message);
}); });
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/build", async (request) => versions.rebuild(request.params.siteId)); app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/build", async (request) => versions.rebuild(requireTenant(request.auth), request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-version", async (request) => { app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-version", async (request) => {
const body = versionCommitSchema.parse(request.body); return versions.previewVersion(request.params.siteId, body.commit); const body = versionCommitSchema.parse(request.body);
return versions.previewVersion(requireTenant(request.auth), request.params.siteId, body.commit);
}); });
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-draft", async (request) => versions.previewDraft(request.params.siteId)); app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-draft", async (request) => versions.previewDraft(requireTenant(request.auth), request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (request) => { app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (request) => {
const result = await versions.publish(request.params.siteId); const tenantId = requireTenant(request.auth);
void domainDeployments.schedule(request.params.siteId, result.commit).catch((error) => app.log.error(error)); const result = await versions.publish(tenantId, request.params.siteId);
void domainDeployments.schedule(tenantId, request.params.siteId, result.commit).catch((error) => app.log.error(error));
return result; return result;
}); });
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/history", async (request) => git.history(sites.getProjectPath(request.params.siteId))); app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/history", async (request) => {
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/undo", async (request) => versions.undo(request.params.siteId)); const tenantId = requireTenant(request.auth);
await sites.get(tenantId, request.params.siteId);
return git.history(sites.getProjectPath(tenantId, request.params.siteId));
});
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/undo", async (request) => versions.undo(requireTenant(request.auth), request.params.siteId));
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/domains", async (request) => domainService.list(request.params.siteId)); app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/domains", async (request) => domainService.list(requireTenant(request.auth), request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/domains", async (request, reply) => { app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/domains", async (request, reply) => {
const body = addDomainSchema.parse(request.body); const body = addDomainSchema.parse(request.body);
return reply.code(201).send(await domainService.add(request.params.siteId, body.hostname)); return reply.code(201).send(await domainService.add(requireTenant(request.auth), request.params.siteId, body.hostname));
}); });
app.post<{ Params: { siteId: string; domainId: string } }>("/api/sites/:siteId/domains/:domainId/verify", async (request) => { app.post<{ Params: { siteId: string; domainId: string } }>("/api/sites/:siteId/domains/:domainId/verify", async (request) => {
const domain = await domainService.verify(request.params.siteId, request.params.domainId); const tenantId = requireTenant(request.auth);
const site = await sites.get(request.params.siteId); const domain = await domainService.verify(tenantId, request.params.siteId, request.params.domainId);
const site = await sites.get(tenantId, request.params.siteId);
if (domain.ownershipStatus === "verified" && site.publishedCommit) { if (domain.ownershipStatus === "verified" && site.publishedCommit) {
void domainDeployments.schedule(site.siteId, site.publishedCommit).catch((error) => app.log.error(error)); void domainDeployments.schedule(tenantId, site.siteId, site.publishedCommit).catch((error) => app.log.error(error));
} }
return domain; return domain;
}); });
app.patch<{ Params: { siteId: string; domainId: string } }>("/api/sites/:siteId/domains/:domainId", async (request) => { app.patch<{ Params: { siteId: string; domainId: string } }>("/api/sites/:siteId/domains/:domainId", async (request) => {
updateDomainSchema.parse(request.body); updateDomainSchema.parse(request.body);
return domainService.setPrimary(request.params.siteId, request.params.domainId); return domainService.setPrimary(requireTenant(request.auth), request.params.siteId, request.params.domainId);
}); });
app.delete<{ Params: { siteId: string; domainId: string } }>("/api/sites/:siteId/domains/:domainId", async (request, reply) => { app.delete<{ Params: { siteId: string; domainId: string } }>("/api/sites/:siteId/domains/:domainId", async (request, reply) => {
await domainService.remove(request.params.siteId, request.params.domainId); await domainService.remove(requireTenant(request.auth), request.params.siteId, request.params.domainId);
return reply.code(204).send(); return reply.code(204).send();
}); });
app.get("/api/admin/tenants", async (request) => enrichTenants(requireAdmin(request.auth), auth.listTenants()));
app.post("/api/admin/tenants", async (request, reply) => {
requireAdmin(request.auth);
return reply.code(201).send(await enrichTenant(auth.createTenant(createTenantSchema.parse(request.body))));
});
app.get<{ Params: { tenantId: string } }>("/api/admin/tenants/:tenantId", async (request) => {
requireAdmin(request.auth);
return enrichTenant(auth.getTenant(request.params.tenantId));
});
app.get<{ Params: { tenantId: string } }>("/api/admin/tenants/:tenantId/sites", async (request) => {
requireAdmin(request.auth);
auth.getTenant(request.params.tenantId);
return (await sites.list(request.params.tenantId)).map((site) => ({
tenantId: site.tenantId, siteId: site.siteId, name: site.name, industry: site.industry,
status: site.status, publishStatus: site.publishStatus, previewUrl: site.previewUrl,
productionUrl: site.productionUrl, publishedAt: site.publishedAt, createdAt: site.createdAt,
updatedAt: site.updatedAt, lastError: site.lastError, lastPublishError: site.lastPublishError,
}));
});
app.patch<{ Params: { tenantId: string } }>("/api/admin/tenants/:tenantId", async (request) => {
requireAdmin(request.auth);
const body = tenantStatusSchema.parse(request.body);
return enrichTenant(auth.setTenantEnabled(request.params.tenantId, body.enabled));
});
app.post<{ Params: { tenantId: string; userId: string } }>("/api/admin/tenants/:tenantId/accounts/:userId/reset-password", async (request) => {
requireAdmin(request.auth);
auth.resetTenantAccountPassword(request.params.tenantId, request.params.userId, resetPasswordSchema.parse(request.body).password);
return { success: true };
});
app.post<{ Params: { tenantId: string; userId: string } }>("/api/admin/tenants/:tenantId/accounts/:userId/revoke-sessions", async (request) => {
requireAdmin(request.auth);
auth.revokeTenantAccountSessions(request.params.tenantId, request.params.userId);
return { success: true };
});
app.get<{ Querystring: { domain?: string; token?: string } }>("/internal/domains/allow", async (request, reply) => { app.get<{ Querystring: { domain?: string; token?: string } }>("/internal/domains/allow", async (request, reply) => {
if (config.domains.askToken && request.query.token !== config.domains.askToken) return reply.code(403).send(); if (config.domains.askToken && request.query.token !== config.domains.askToken) return reply.code(403).send();
if (!request.query.domain || !await domainService.isCertificateAllowed(request.query.domain)) return reply.code(404).send(); if (!request.query.domain || !await domainService.isCertificateAllowed(request.query.domain)) return reply.code(404).send();
...@@ -113,35 +163,33 @@ app.setErrorHandler((error, _request, reply) => { ...@@ -113,35 +163,33 @@ app.setErrorHandler((error, _request, reply) => {
const message = error instanceof ZodError const message = error instanceof ZodError
? error.issues.map((item) => item.message).join(";") ? error.issues.map((item) => item.message).join(";")
: error instanceof Error ? error.message : "请求处理失败"; : error instanceof Error ? error.message : "请求处理失败";
app.log.error(error); if (status >= 500) app.log.error(error); else app.log.warn({ error: message, status });
reply.code(status).send({ error: message || "请求处理失败" }); reply.code(status).send({ error: status === 404 ? "资源不存在" : message || "请求处理失败" });
}); });
await sites.ensureRuntime(); await sites.ensureRuntime();
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 });
for (let site of await sites.list()) { for (let site of await sites.listAll()) {
await git.pruneWorktrees(sites.getProjectPath(site.siteId)); await git.pruneWorktrees(sites.getProjectPath(site.tenantId, site.siteId));
await builds.ensureProductionPlaceholder(site.siteId, site.name); await builds.ensureProductionPlaceholder(site.tenantId, site.siteId, site.name);
if (site.environmentVersion !== 3) { if (site.environmentVersion !== 3) {
await versions.rebuild(site.siteId).catch((error) => app.log.warn(error)); await versions.rebuild(site.tenantId, site.siteId).catch((error) => app.log.warn(error));
site = await sites.get(site.siteId); site = await sites.get(site.tenantId, site.siteId);
} }
const previewPath = path.join(config.previewsDir, site.siteId); const previewPath = path.join(config.previewsDir, site.tenantId, site.siteId);
if (await access(previewPath).then(() => true).catch(() => false)) { if (await access(previewPath).then(() => true).catch(() => false)) {
await previews.start(site.siteId, previewPath, site.previewPort) await previews.start(site.tenantId, site.siteId, previewPath, site.previewPort)
.then(async () => sites.update(site.siteId, { .then(async () => sites.update(site.tenantId, site.siteId, {
status: "ready", status: "ready", currentCommit: await git.currentCommit(sites.getProjectPath(site.tenantId, site.siteId)), lastError: undefined,
currentCommit: await git.currentCommit(sites.getProjectPath(site.siteId)),
lastError: undefined,
})) }))
.catch((error) => app.log.warn(error)); .catch((error) => app.log.warn(error));
} }
if (site.publishedCommit) { if (site.publishedCommit) {
const domains = await domainRepository.list(site.siteId); const domains = await domainRepository.list(site.tenantId, site.siteId);
if (domains.some((domain) => domain.ownershipStatus === "verified" && (domain.deploymentStatus !== "active" || domain.deployedCommit !== site.publishedCommit))) { if (domains.some((domain) => domain.ownershipStatus === "verified" && (domain.deploymentStatus !== "active" || domain.deployedCommit !== site.publishedCommit))) {
void domainDeployments.schedule(site.siteId, site.publishedCommit).catch((error) => app.log.error(error)); void domainDeployments.schedule(site.tenantId, site.siteId, site.publishedCommit).catch((error) => app.log.error(error));
} }
} }
} }
...@@ -155,24 +203,42 @@ const checkPendingDomains = async () => { ...@@ -155,24 +203,42 @@ const checkPendingDomains = async () => {
try { try {
const domains = await domainRepository.listAll(); const domains = await domainRepository.listAll();
for (const stored of domains) { for (const stored of domains) {
const fullyConnected = stored.ownershipStatus === "verified" const fullyConnected = stored.ownershipStatus === "verified" && stored.dnsStatus === "valid"
&& stored.dnsStatus === "valid" && (stored.tlsStatus === "active" || stored.tlsStatus === "managed") && stored.deploymentStatus === "active";
&& (stored.tlsStatus === "active" || stored.tlsStatus === "managed")
&& stored.deploymentStatus === "active";
if (fullyConnected) continue; if (fullyConnected) continue;
const refreshed = await domainService.verify(stored.siteId, stored.domainId).catch((error) => { const refreshed = await domainService.verify(stored.tenantId, stored.siteId, stored.domainId).catch((error) => {
app.log.warn(error); app.log.warn(error); return undefined;
return undefined;
}); });
if (!refreshed || refreshed.ownershipStatus !== "verified") continue; if (!refreshed || refreshed.ownershipStatus !== "verified") continue;
const site = await sites.get(stored.siteId); const site = await sites.get(stored.tenantId, stored.siteId);
if (site.publishedCommit && refreshed.deploymentStatus !== "deploying" if (site.publishedCommit && refreshed.deploymentStatus !== "deploying"
&& (refreshed.deploymentStatus !== "active" || refreshed.deployedCommit !== site.publishedCommit)) { && (refreshed.deploymentStatus !== "active" || refreshed.deployedCommit !== site.publishedCommit)) {
void domainDeployments.schedule(site.siteId, site.publishedCommit).catch((error) => app.log.error(error)); void domainDeployments.schedule(stored.tenantId, site.siteId, site.publishedCommit).catch((error) => app.log.error(error));
}
} }
} finally {
checkingDomains = false;
} }
} finally { checkingDomains = false; }
}; };
setInterval(() => void checkPendingDomains(), config.domains.checkIntervalMs).unref(); setInterval(() => void checkPendingDomains(), config.domains.checkIntervalMs).unref();
function bearerToken(header: string | undefined): string | undefined {
const match = header?.match(/^Bearer\s+(.+)$/i);
return match?.[1];
}
function requireTenant(session: SessionInfo | undefined): string {
if (!session || session.accountType !== "tenant_user" || !session.tenantId) throw Object.assign(new Error("系统管理员不能调用租户站点接口"), { statusCode: 403 });
return session.tenantId;
}
function requireAdmin(session: SessionInfo | undefined): SessionInfo {
if (!session || session.accountType !== "system_admin") throw Object.assign(new Error("仅系统管理员可以执行此操作"), { statusCode: 403 });
return session;
}
async function enrichTenant(tenant: TenantAdminInfo): Promise<TenantAdminInfo> {
return { ...tenant, siteCount: await sites.count(tenant.tenantId) };
}
async function enrichTenants(_admin: SessionInfo, tenants: TenantAdminInfo[]): Promise<TenantAdminInfo[]> {
return Promise.all(tenants.map(enrichTenant));
}
...@@ -16,15 +16,15 @@ export class CreateSiteService { ...@@ -16,15 +16,15 @@ export class CreateSiteService {
private readonly previews: PreviewProcessManager, private readonly previews: PreviewProcessManager,
) {} ) {}
async execute(input: CreateSiteInput): Promise<SiteInfo> { async execute(tenantId: string, input: CreateSiteInput): Promise<SiteInfo> {
await this.sites.ensureRuntime(); await this.sites.ensureRuntime();
const siteId = "site_" + crypto.randomBytes(4).toString("hex"); const siteId = "site_" + crypto.randomBytes(16).toString("hex");
const projectPath = this.sites.getProjectPath(siteId); const projectPath = this.sites.getProjectPath(tenantId, siteId);
const previewPort = await this.sites.allocatePreviewPort(); const previewPort = await this.sites.allocatePreviewPort();
const now = new Date().toISOString(); const now = new Date().toISOString();
const site: SiteInfo = { const site: SiteInfo = {
siteId, name: input.name, industry: input.industry, status: "creating", templateVersion: "0.1.0", environmentVersion: 3, tenantId, siteId, name: input.name, industry: input.industry, status: "creating", templateVersion: "0.1.0", environmentVersion: 3,
previewPort, previewUrl: getPublicPreviewUrl(siteId), currentCommit: "", publishStatus: "unpublished", previewPort, previewUrl: getPublicPreviewUrl(tenantId, siteId), currentCommit: "", publishStatus: "unpublished",
createdAt: now, updatedAt: now, createdAt: now, updatedAt: now,
}; };
await mkdir(projectPath, { recursive: true }); await mkdir(projectPath, { recursive: true });
...@@ -43,16 +43,16 @@ export class CreateSiteService { ...@@ -43,16 +43,16 @@ export class CreateSiteService {
const themePath = path.join(projectPath, "src/styles/theme.css"); const themePath = path.join(projectPath, "src/styles/theme.css");
const theme = (await readFile(themePath, "utf8")).replaceAll("#7028ff", input.brandColor.toLowerCase()); const theme = (await readFile(themePath, "utf8")).replaceAll("#7028ff", input.brandColor.toLowerCase());
await writeFile(themePath, theme, "utf8"); await writeFile(themePath, theme, "utf8");
const dist = await this.builds.build(projectPath, "create_" + siteId, getPublicPreviewUrl(siteId)); const dist = await this.builds.build(projectPath, "create_" + siteId, getPublicPreviewUrl(tenantId, siteId));
const commit = await this.git.init(projectPath); const commit = await this.git.init(projectPath);
await this.sites.update(siteId, { currentCommit: commit }); await this.sites.update(tenantId, siteId, { currentCommit: commit });
const published = await this.builds.publishPreview(siteId, dist); const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(siteId, published, previewPort); await this.previews.start(tenantId, siteId, published, previewPort);
await this.builds.ensureProductionPlaceholder(siteId, input.name); await this.builds.ensureProductionPlaceholder(tenantId, siteId, input.name);
return await this.sites.update(siteId, { status: "ready", currentCommit: commit, previewCommit: commit, lastError: undefined }); return await this.sites.update(tenantId, siteId, { status: "ready", currentCommit: commit, previewCommit: commit, lastError: undefined });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { status: "failed", lastError: message }); await this.sites.update(tenantId, siteId, { status: "failed", lastError: message });
throw error; throw error;
} }
} }
......
...@@ -11,13 +11,13 @@ export class SiteAgentService { ...@@ -11,13 +11,13 @@ export class SiteAgentService {
private readonly agent: AgentLoop, private readonly agent: AgentLoop,
) {} ) {}
async execute(siteId: string, message: string): Promise<ChatResult> { async execute(tenantId: string, siteId: string, message: string): Promise<ChatResult> {
const site = await this.sites.get(siteId); const site = await this.sites.get(tenantId, siteId);
const projectPath = this.sites.getProjectPath(siteId); const projectPath = this.sites.getProjectPath(tenantId, siteId);
const workspace = this.sites.getDraftPath(siteId); const workspace = this.sites.getDraftPath(tenantId, siteId);
const baseCommit = site.draftBaseCommit || site.currentCommit; const baseCommit = site.draftBaseCommit || site.currentCommit;
const creatingDraft = !site.draftBaseCommit; const creatingDraft = !site.draftBaseCommit;
await this.sites.update(siteId, { status: "building", lastError: undefined }); await this.sites.update(tenantId, siteId, { status: "building", lastError: undefined });
let generated: { patch: SitePatch; mode: "model" | "local" } | undefined; let generated: { patch: SitePatch; mode: "model" | "local" } | undefined;
try { try {
if (creatingDraft) await this.git.createPersistentWorktree(projectPath, workspace, baseCommit); if (creatingDraft) await this.git.createPersistentWorktree(projectPath, workspace, baseCommit);
...@@ -26,7 +26,7 @@ export class SiteAgentService { ...@@ -26,7 +26,7 @@ export class SiteAgentService {
await applyPatch(workspace, generated.patch); await applyPatch(workspace, generated.patch);
const changedFiles = generated.patch.operations.map((item) => item.path); const changedFiles = generated.patch.operations.map((item) => item.path);
const updatedAt = new Date().toISOString(); const updatedAt = new Date().toISOString();
await this.sites.update(siteId, { await this.sites.update(tenantId, siteId, {
status: "ready", draftBaseCommit: baseCommit, draftUpdatedAt: updatedAt, status: "ready", draftBaseCommit: baseCommit, draftUpdatedAt: updatedAt,
draftSummary: generated.patch.summary, lastError: undefined, draftSummary: generated.patch.summary, lastError: undefined,
}); });
...@@ -35,7 +35,7 @@ export class SiteAgentService { ...@@ -35,7 +35,7 @@ export class SiteAgentService {
const details = error instanceof Error ? error.message : String(error); const details = error instanceof Error ? error.message : String(error);
const keepDraft = !creatingDraft || await this.git.hasChanges(workspace).catch(() => false); const keepDraft = !creatingDraft || await this.git.hasChanges(workspace).catch(() => false);
if (!keepDraft) await this.git.removePersistentWorktree(projectPath, workspace); if (!keepDraft) await this.git.removePersistentWorktree(projectPath, workspace);
await this.sites.update(siteId, { await this.sites.update(tenantId, siteId, {
status: site.previewCommit ? "ready" : "failed", lastError: details, status: site.previewCommit ? "ready" : "failed", lastError: details,
draftBaseCommit: keepDraft ? baseCommit : undefined, draftBaseCommit: keepDraft ? baseCommit : undefined,
draftUpdatedAt: keepDraft ? new Date().toISOString() : undefined, draftUpdatedAt: keepDraft ? new Date().toISOString() : undefined,
......
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import type { SiteInfo } from "@webagent/shared";
test("repository paths and queries are tenant-scoped", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "webagent-sites-"));
process.env.WEBAGENT_SITES_DIR = path.join(directory, "sites");
process.env.WEBAGENT_PREVIEWS_DIR = path.join(directory, "previews");
process.env.WEBAGENT_PRODUCTION_DIR = path.join(directory, "production");
process.env.WEBAGENT_CUSTOM_DOMAINS_DIR = path.join(directory, "domains");
try {
const { SiteRepository } = await import("./site-repository.js");
const sites = new SiteRepository();
await sites.ensureRuntime();
const now = new Date().toISOString();
const createStoredSite = (tenantId: string, siteId: string, name: string): SiteInfo => ({
tenantId, siteId, name, industry: "测试", status: "ready", templateVersion: "test", previewPort: 4300,
previewUrl: "", currentCommit: "commit", publishStatus: "unpublished", createdAt: now, updatedAt: now,
});
const tenantA = "tenant_test_a";
const tenantB = "tenant_test_b";
const siteA = "site_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const siteB = "site_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
await sites.save(createStoredSite(tenantA, siteA, "A"));
await sites.save(createStoredSite(tenantB, siteB, "B"));
assert.deepEqual((await sites.list(tenantA)).map((site) => site.siteId), [siteA]);
assert.deepEqual((await sites.list(tenantB)).map((site) => site.siteId), [siteB]);
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.notEqual(sites.getSiteRoot(tenantA, siteA), sites.getSiteRoot(tenantB, siteA));
} finally { await rm(directory, { recursive: true, force: true }); }
});
...@@ -11,61 +11,77 @@ export class SiteRepository { ...@@ -11,61 +11,77 @@ export class SiteRepository {
await this.runtimeReady; await this.runtimeReady;
} }
getSiteRoot(siteId: string): string { getTenantRoot(tenantId: string): string {
this.assertTenantId(tenantId);
return path.join(runtimePaths.sites, tenantId);
}
getSiteRoot(tenantId: string, siteId: string): string {
this.assertTenantId(tenantId);
this.assertSiteId(siteId); this.assertSiteId(siteId);
return path.join(runtimePaths.sites, siteId); return path.join(runtimePaths.sites, tenantId, siteId);
} }
getProjectPath(siteId: string): string { getProjectPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(siteId), "project"); return path.join(this.getSiteRoot(tenantId, siteId), "project");
} }
getDraftPath(siteId: string): string { getDraftPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(siteId), "draft"); return path.join(this.getSiteRoot(tenantId, siteId), "draft");
} }
getMetadataPath(siteId: string): string { getMetadataPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(siteId), "metadata", "site.json"); return path.join(this.getSiteRoot(tenantId, siteId), "metadata", "site.json");
} }
async get(siteId: string): Promise<SiteInfo> { async get(tenantId: string, siteId: string): Promise<SiteInfo> {
const raw = await readFile(this.getMetadataPath(siteId), "utf8"); const raw = await readFile(this.getMetadataPath(tenantId, siteId), "utf8");
const stored = JSON.parse(raw) as SiteInfo; const stored = JSON.parse(raw) as SiteInfo;
if (stored.tenantId !== tenantId || stored.siteId !== siteId) throw Object.assign(new Error("站点不存在"), { statusCode: 404 });
return { return {
...stored, ...stored,
previewUrl: getPublicPreviewUrl(siteId), previewUrl: getPublicPreviewUrl(tenantId, siteId),
previewCommit: stored.previewCommit || stored.currentCommit, previewCommit: stored.previewCommit || stored.currentCommit,
productionUrl: getPublicProductionUrl(siteId), productionUrl: getPublicProductionUrl(tenantId, siteId),
publishStatus: stored.publishStatus || (stored.publishedCommit ? "published" : "unpublished"), publishStatus: stored.publishStatus || (stored.publishedCommit ? "published" : "unpublished"),
}; };
} }
async list(): Promise<SiteInfo[]> { async list(tenantId: string): Promise<SiteInfo[]> {
await this.ensureRuntime(); await this.ensureRuntime();
const entries = await readdir(runtimePaths.sites, { withFileTypes: true }); const entries = await readdir(this.getTenantRoot(tenantId), { withFileTypes: true }).catch(() => []);
const results = await Promise.all(entries.filter((entry) => entry.isDirectory() && /^site_[a-z0-9]+$/.test(entry.name)).map(async (entry) => { const results = await Promise.all(entries.filter((entry) => entry.isDirectory() && /^site_[a-z0-9]+$/.test(entry.name)).map(async (entry) => {
try { return await this.get(entry.name); } catch { return null; } try { return await this.get(tenantId, entry.name); } catch { return null; }
})); }));
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 listAll(): Promise<SiteInfo[]> {
await this.ensureRuntime();
const tenants = await readdir(runtimePaths.sites, { withFileTypes: true }).catch(() => []);
const groups = await Promise.all(tenants.filter((entry) => entry.isDirectory() && /^tenant_[a-z0-9_]+$/.test(entry.name)).map((entry) => this.list(entry.name)));
return groups.flat().sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
async count(tenantId: string): Promise<number> { return (await this.list(tenantId)).length; }
async save(site: SiteInfo): Promise<void> { async save(site: SiteInfo): Promise<void> {
const metadataPath = this.getMetadataPath(site.siteId); const metadataPath = this.getMetadataPath(site.tenantId, site.siteId);
await mkdir(path.dirname(metadataPath), { recursive: true }); await mkdir(path.dirname(metadataPath), { recursive: true });
const temporaryPath = metadataPath + ".tmp"; const temporaryPath = metadataPath + ".tmp";
await writeFile(temporaryPath, JSON.stringify(site, null, 2) + "\n", "utf8"); await writeFile(temporaryPath, JSON.stringify(site, null, 2) + "\n", "utf8");
await rename(temporaryPath, metadataPath); await rename(temporaryPath, metadataPath);
} }
async update(siteId: string, values: Partial<SiteInfo>): Promise<SiteInfo> { async update(tenantId: string, siteId: string, values: Partial<SiteInfo>): Promise<SiteInfo> {
const current = await this.get(siteId); const current = await this.get(tenantId, siteId);
const next = { ...current, ...values, siteId, updatedAt: new Date().toISOString() }; const next = { ...current, ...values, tenantId, siteId, updatedAt: new Date().toISOString() };
await this.save(next); await this.save(next);
return next; return next;
} }
async allocatePreviewPort(): Promise<number> { async allocatePreviewPort(): Promise<number> {
const used = new Set((await this.list()).map((site) => site.previewPort)); const used = new Set((await this.listAll()).map((site) => site.previewPort));
for (let port = 4300; port <= 4399; port += 1) if (!used.has(port)) return port; for (let port = 4300; port <= 4399; port += 1) if (!used.has(port)) return port;
throw new Error("本地预览端口 4300-4399 已全部占用"); throw new Error("本地预览端口 4300-4399 已全部占用");
} }
...@@ -74,6 +90,10 @@ export class SiteRepository { ...@@ -74,6 +90,10 @@ export class SiteRepository {
if (!/^site_[a-z0-9]+$/.test(siteId)) throw new Error("无效的 siteId"); if (!/^site_[a-z0-9]+$/.test(siteId)) throw new Error("无效的 siteId");
} }
private assertTenantId(tenantId: string): void {
if (!/^tenant_[a-z0-9_]+$/.test(tenantId)) throw new Error("无效的 tenantId");
}
private async initializeRuntime(): Promise<void> { private async initializeRuntime(): Promise<void> {
await Promise.all([ await Promise.all([
...Object.values(runtimePaths).map((directory) => mkdir(directory, { recursive: true })), ...Object.values(runtimePaths).map((directory) => mkdir(directory, { recursive: true })),
...@@ -82,7 +102,13 @@ export class SiteRepository { ...@@ -82,7 +102,13 @@ export class SiteRepository {
mkdir(config.customDomainsDir, { recursive: true }), mkdir(config.customDomainsDir, { recursive: true }),
]); ]);
await this.migrateLegacySites(); await this.migrateLegacySites();
await this.migrateLegacyPreviews(); await this.migrateFlatSites();
await Promise.all([
this.migrateLegacyPreviews(),
this.migrateFlatArtifacts(config.previewsDir),
this.migrateFlatArtifacts(config.productionDir),
this.migrateFlatArtifacts(config.customDomainsDir),
]);
} }
private async migrateLegacySites(): Promise<void> { private async migrateLegacySites(): Promise<void> {
...@@ -95,16 +121,12 @@ export class SiteRepository { ...@@ -95,16 +121,12 @@ export class SiteRepository {
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory() || !/^site_[a-z0-9]+$/.test(entry.name)) continue; if (!entry.isDirectory() || !/^site_[a-z0-9]+$/.test(entry.name)) continue;
const source = path.join(legacySitesDir, entry.name); const source = path.join(legacySitesDir, entry.name);
const destination = path.join(runtimePaths.sites, entry.name); const legacyTenant = config.auth.testData.enabled ? config.auth.testData.tenantAId : "tenant_legacy";
const destination = path.join(runtimePaths.sites, legacyTenant, entry.name);
const destinationExists = await stat(destination).then(() => true).catch(() => false); const destinationExists = await stat(destination).then(() => true).catch(() => false);
if (destinationExists) continue; if (destinationExists) continue;
try { await this.moveDirectory(source, destination);
await rename(source, destination); await this.adoptSiteMetadata(destination, legacyTenant, entry.name);
} catch (error) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "EXDEV") throw error;
await cp(source, destination, { recursive: true, errorOnExist: true });
await rm(source, { recursive: true, force: true });
}
} }
} }
...@@ -118,9 +140,41 @@ export class SiteRepository { ...@@ -118,9 +140,41 @@ export class SiteRepository {
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory() || !/^site_[a-z0-9]+$/.test(entry.name)) continue; if (!entry.isDirectory() || !/^site_[a-z0-9]+$/.test(entry.name)) continue;
const source = path.join(legacyPreviewsDir, entry.name); const source = path.join(legacyPreviewsDir, entry.name);
const destination = path.join(config.previewsDir, entry.name); const tenantId = config.auth.testData.enabled ? config.auth.testData.tenantAId : "tenant_legacy";
const destination = path.join(config.previewsDir, tenantId, entry.name);
const destinationExists = await stat(destination).then(() => true).catch(() => false); const destinationExists = await stat(destination).then(() => true).catch(() => false);
if (destinationExists) continue; if (destinationExists) continue;
await this.moveDirectory(source, destination);
}
}
private async migrateFlatSites(): Promise<void> {
const tenantId = config.auth.testData.enabled ? config.auth.testData.tenantAId : "tenant_legacy";
const entries = await readdir(runtimePaths.sites, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (!entry.isDirectory() || !/^site_[a-z0-9]+$/.test(entry.name)) continue;
const source = path.join(runtimePaths.sites, entry.name);
const destination = path.join(runtimePaths.sites, tenantId, entry.name);
if (await stat(destination).then(() => true).catch(() => false)) continue;
await this.moveDirectory(source, destination);
await this.adoptSiteMetadata(destination, tenantId, entry.name);
}
}
private async migrateFlatArtifacts(root: string): Promise<void> {
const tenantId = config.auth.testData.enabled ? config.auth.testData.tenantAId : "tenant_legacy";
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (!entry.isDirectory() || !/^site_[a-z0-9]+$/.test(entry.name)) continue;
const source = path.join(root, entry.name);
const destination = path.join(root, tenantId, entry.name);
if (await stat(destination).then(() => true).catch(() => false)) continue;
await this.moveDirectory(source, destination);
}
}
private async moveDirectory(source: string, destination: string): Promise<void> {
await mkdir(path.dirname(destination), { recursive: true });
try { try {
await rename(source, destination); await rename(source, destination);
} catch (error) { } catch (error) {
...@@ -129,5 +183,13 @@ export class SiteRepository { ...@@ -129,5 +183,13 @@ export class SiteRepository {
await rm(source, { recursive: true, force: true }); await rm(source, { recursive: true, force: true });
} }
} }
private async adoptSiteMetadata(siteRoot: string, tenantId: string, siteId: string): Promise<void> {
const metadataPath = path.join(siteRoot, "metadata", "site.json");
const stored = JSON.parse(await readFile(metadataPath, "utf8")) as SiteInfo;
await writeFile(metadataPath, JSON.stringify({ ...stored, tenantId, siteId }, null, 2) + "\n", "utf8");
const domainsPath = path.join(siteRoot, "metadata", "domains.json");
const domains = await readFile(domainsPath, "utf8").then((raw) => JSON.parse(raw) as Array<Record<string, unknown>>).catch(() => undefined);
if (domains) await writeFile(domainsPath, JSON.stringify(domains.map((domain) => ({ ...domain, tenantId, siteId })), null, 2) + "\n", "utf8");
} }
} }
...@@ -14,9 +14,9 @@ export class SiteVersionService { ...@@ -14,9 +14,9 @@ export class SiteVersionService {
private readonly builds: BuildManager, private readonly previews: PreviewProcessManager, private readonly builds: BuildManager, private readonly previews: PreviewProcessManager,
) {} ) {}
async rebuild(siteId: string): Promise<{ previewUrl: string }> { async rebuild(tenantId: string, siteId: string): Promise<{ previewUrl: string }> {
const project = this.sites.getProjectPath(siteId); const project = this.sites.getProjectPath(tenantId, siteId);
const site = await this.sites.get(siteId); const site = await this.sites.get(tenantId, siteId);
let currentCommit = await this.git.currentCommit(project); let currentCommit = await this.git.currentCommit(project);
let previewCommit = site.previewCommit || site.currentCommit || currentCommit; let previewCommit = site.previewCommit || site.currentCommit || currentCommit;
if (await ensureEnvironmentConfig(project)) { if (await ensureEnvironmentConfig(project)) {
...@@ -27,31 +27,31 @@ export class SiteVersionService { ...@@ -27,31 +27,31 @@ export class SiteVersionService {
return { previewUrl: site.previewUrl }; return { previewUrl: site.previewUrl };
} }
async previewVersion(siteId: string, targetCommit: string): Promise<PreviewVersionResult> { async previewVersion(tenantId: string, siteId: string, targetCommit: string): Promise<PreviewVersionResult> {
const site = await this.sites.get(siteId); const site = await this.sites.get(tenantId, siteId);
const project = this.sites.getProjectPath(siteId); const project = this.sites.getProjectPath(tenantId, siteId);
await this.git.assertCommit(project, targetCommit); await this.git.assertCommit(project, targetCommit);
await this.buildPreview(site, targetCommit, "preview_version"); await this.buildPreview(site, targetCommit, "preview_version");
return { commit: targetCommit, previewUrl: site.previewUrl }; return { commit: targetCommit, previewUrl: site.previewUrl };
} }
async previewDraft(siteId: string): Promise<PreviewVersionResult> { async previewDraft(tenantId: string, siteId: string): Promise<PreviewVersionResult> {
const site = await this.sites.get(siteId); const site = await this.sites.get(tenantId, siteId);
if (!site.draftBaseCommit) throw new Error("当前没有可预览的工作草稿"); if (!site.draftBaseCommit) throw new Error("当前没有可预览的工作草稿");
const project = this.sites.getProjectPath(siteId); const project = this.sites.getProjectPath(tenantId, siteId);
const workspace = this.sites.getDraftPath(siteId); const workspace = this.sites.getDraftPath(tenantId, siteId);
const taskId = "draft_" + crypto.randomBytes(4).toString("hex"); const taskId = "draft_" + crypto.randomBytes(4).toString("hex");
const draftHead = await this.git.currentCommit(workspace); const draftHead = await this.git.currentCommit(workspace);
if (draftHead === site.draftBaseCommit && !await this.git.hasChanges(workspace)) throw new Error("工作草稿没有实际修改"); if (draftHead === site.draftBaseCommit && !await this.git.hasChanges(workspace)) throw new Error("工作草稿没有实际修改");
await this.sites.update(siteId, { status: "building", lastError: undefined }); await this.sites.update(tenantId, siteId, { status: "building", lastError: undefined });
try { try {
await ensureEnvironmentConfig(workspace); await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(siteId)); const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(tenantId, siteId));
const draftCommit = await this.git.commit(workspace, "Agent: " + (site.draftSummary || "保存工作草稿")); const draftCommit = await this.git.commit(workspace, "Agent: " + (site.draftSummary || "保存工作草稿"));
const commit = await this.git.fastForward(project, draftCommit, site.draftBaseCommit); const commit = await this.git.fastForward(project, draftCommit, site.draftBaseCommit);
const published = await this.builds.publishPreview(siteId, dist); const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(siteId, published, site.previewPort); await this.previews.start(tenantId, siteId, published, site.previewPort);
await this.sites.update(siteId, { await this.sites.update(tenantId, siteId, {
status: "ready", currentCommit: commit, previewCommit: commit, status: "ready", currentCommit: commit, previewCommit: commit,
previousCommit: site.currentCommit, draftBaseCommit: undefined, previousCommit: site.currentCommit, draftBaseCommit: undefined,
draftUpdatedAt: undefined, draftSummary: undefined, draftUpdatedAt: undefined, draftSummary: undefined,
...@@ -61,15 +61,15 @@ export class SiteVersionService { ...@@ -61,15 +61,15 @@ export class SiteVersionService {
return { commit, previewUrl: site.previewUrl }; return { commit, previewUrl: site.previewUrl };
} catch (error) { } catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error); const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { status: site.previewCommit ? "ready" : "failed", lastError: details }); await this.sites.update(tenantId, siteId, { status: site.previewCommit ? "ready" : "failed", lastError: details });
throw error; throw error;
} }
} }
async undo(siteId: string): Promise<PreviewVersionResult> { async undo(tenantId: string, siteId: string): Promise<PreviewVersionResult> {
const site = await this.sites.get(siteId); const site = await this.sites.get(tenantId, siteId);
if (site.draftBaseCommit) throw new Error("工作草稿尚未预览,请先预览草稿或继续编辑"); if (site.draftBaseCommit) throw new Error("工作草稿尚未预览,请先预览草稿或继续编辑");
const project = this.sites.getProjectPath(siteId); const project = this.sites.getProjectPath(tenantId, siteId);
if (site.previewCommit && site.previewCommit !== site.currentCommit) { if (site.previewCommit && site.previewCommit !== site.currentCommit) {
throw new Error("当前正在查看历史版本,请先切换回最近保存版本再撤销修改"); throw new Error("当前正在查看历史版本,请先切换回最近保存版本再撤销修改");
} }
...@@ -79,49 +79,49 @@ export class SiteVersionService { ...@@ -79,49 +79,49 @@ export class SiteVersionService {
await this.git.assertCommit(project, targetCommit); await this.git.assertCommit(project, targetCommit);
const taskId = "undo_" + crypto.randomBytes(4).toString("hex"); const taskId = "undo_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace"); const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(siteId, { status: "building", lastError: undefined }); await this.sites.update(tenantId, siteId, { status: "building", lastError: undefined });
try { try {
await this.git.createWorktree(project, workspace, targetCommit); await this.git.createWorktree(project, workspace, targetCommit);
await ensureEnvironmentConfig(workspace); await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(siteId)); const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(tenantId, siteId));
const commit = await this.git.restoreAsCommit(project, targetCommit); const commit = await this.git.restoreAsCommit(project, targetCommit);
const published = await this.builds.publishPreview(siteId, dist); const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(siteId, published, site.previewPort); await this.previews.start(tenantId, siteId, published, site.previewPort);
await this.sites.update(siteId, { await this.sites.update(tenantId, siteId, {
status: "ready", currentCommit: commit, previewCommit: commit, status: "ready", currentCommit: commit, previewCommit: commit,
previousCommit: undefined, environmentVersion: 3, lastError: undefined, previousCommit: undefined, environmentVersion: 3, lastError: undefined,
}); });
return { commit, previewUrl: site.previewUrl }; return { commit, previewUrl: site.previewUrl };
} catch (error) { } catch (error) {
await this.sites.update(siteId, { status: site.previewCommit ? "ready" : "failed", lastError: error instanceof Error ? error.message : String(error) }); await this.sites.update(tenantId, siteId, { status: site.previewCommit ? "ready" : "failed", lastError: error instanceof Error ? error.message : String(error) });
throw error; throw error;
} finally { await this.git.removeWorktree(project, workspace); } } finally { await this.git.removeWorktree(project, workspace); }
} }
async publish(siteId: string): Promise<PublishResult> { async publish(tenantId: string, siteId: string): Promise<PublishResult> {
const project = this.sites.getProjectPath(siteId); const project = this.sites.getProjectPath(tenantId, siteId);
const site = await this.sites.get(siteId); const site = await this.sites.get(tenantId, siteId);
if (site.status !== "ready") throw new Error("测试环境尚未构建成功,不能发布到生产环境"); if (site.status !== "ready") throw new Error("测试环境尚未构建成功,不能发布到生产环境");
const taskId = "publish_" + crypto.randomBytes(4).toString("hex"); const taskId = "publish_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace"); const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(siteId, { publishStatus: "publishing", lastPublishError: undefined }); await this.sites.update(tenantId, siteId, { publishStatus: "publishing", lastPublishError: undefined });
try { try {
const commit = site.previewCommit || site.currentCommit; const commit = site.previewCommit || site.currentCommit;
await this.git.assertCommit(project, commit); await this.git.assertCommit(project, commit);
await this.git.createWorktree(project, workspace, commit); await this.git.createWorktree(project, workspace, commit);
await ensureEnvironmentConfig(workspace); await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicProductionUrl(siteId)); const dist = await this.builds.build(workspace, taskId, getPublicProductionUrl(tenantId, siteId));
await this.builds.publishProduction(siteId, dist); await this.builds.publishProduction(tenantId, siteId, dist);
const publishedAt = new Date().toISOString(); const publishedAt = new Date().toISOString();
const productionUrl = getPublicProductionUrl(siteId); const productionUrl = getPublicProductionUrl(tenantId, siteId);
await this.sites.update(siteId, { await this.sites.update(tenantId, siteId, {
publishStatus: "published", publishedCommit: commit, publishedAt, publishStatus: "published", publishedCommit: commit, publishedAt,
productionUrl, lastPublishError: undefined, productionUrl, lastPublishError: undefined,
}); });
return { productionUrl, commit, publishedAt }; return { productionUrl, commit, publishedAt };
} catch (error) { } catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error); const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { publishStatus: "failed", lastPublishError: details }); await this.sites.update(tenantId, siteId, { publishStatus: "failed", lastPublishError: details });
throw error; throw error;
} finally { } finally {
await this.git.removeWorktree(project, workspace); await this.git.removeWorktree(project, workspace);
...@@ -134,24 +134,24 @@ export class SiteVersionService { ...@@ -134,24 +134,24 @@ export class SiteVersionService {
taskPrefix: string, taskPrefix: string,
metadata: Partial<SiteInfo> = {}, metadata: Partial<SiteInfo> = {},
): Promise<void> { ): Promise<void> {
const project = this.sites.getProjectPath(site.siteId); const project = this.sites.getProjectPath(site.tenantId, site.siteId);
await this.git.assertCommit(project, commit); await this.git.assertCommit(project, commit);
const taskId = taskPrefix + "_" + crypto.randomBytes(4).toString("hex"); const taskId = taskPrefix + "_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace"); const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(site.siteId, { status: "building", lastError: undefined }); await this.sites.update(site.tenantId, site.siteId, { status: "building", lastError: undefined });
try { try {
await this.git.createWorktree(project, workspace, commit); await this.git.createWorktree(project, workspace, commit);
await ensureEnvironmentConfig(workspace); await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(site.siteId)); const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(site.tenantId, site.siteId));
const published = await this.builds.publishPreview(site.siteId, dist); const published = await this.builds.publishPreview(site.tenantId, site.siteId, dist);
await this.previews.start(site.siteId, published, site.previewPort); await this.previews.start(site.tenantId, site.siteId, published, site.previewPort);
await this.sites.update(site.siteId, { await this.sites.update(site.tenantId, site.siteId, {
...metadata, ...metadata,
status: "ready", previewCommit: commit, environmentVersion: 3, lastError: undefined, status: "ready", previewCommit: commit, environmentVersion: 3, lastError: undefined,
}); });
} catch (error) { } catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error); const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(site.siteId, { status: site.previewCommit ? "ready" : "failed", lastError: details }); await this.sites.update(site.tenantId, site.siteId, { status: site.previewCommit ? "ready" : "failed", lastError: details });
throw error; throw error;
} finally { } finally {
await this.git.removeWorktree(project, workspace); await this.git.removeWorktree(project, workspace);
......
...@@ -15,8 +15,8 @@ pnpm --filter @webagent/backend start ...@@ -15,8 +15,8 @@ pnpm --filter @webagent/backend start
- 控制台:`http://主机地址/` - 控制台:`http://主机地址/`
- API:`http://主机地址/api/` - API:`http://主机地址/api/`
- 测试预览:`http://主机地址/previews/site_xxx/` - 测试预览:`http://主机地址/previews/tenant_xxx/site_xxx/`
- 生产站点:`http://主机地址/sites/site_xxx/` - 生产站点:`http://主机地址/sites/tenant_xxx/site_xxx/`
## 自定义域名源站 ## 自定义域名源站
...@@ -41,7 +41,7 @@ pnpm --filter @webagent/backend start ...@@ -41,7 +41,7 @@ pnpm --filter @webagent/backend start
nginx -t && nginx -s reload nginx -t && nginx -s reload
``` ```
自定义域名内容位于 `WEBAGENT_CUSTOM_DOMAINS_DIR/site_xxx/`,使用 `SITE_BASE_PATH=/` 单独构建。该流程失败不会影响 `/sites/site_xxx/` 自定义域名内容位于 `WEBAGENT_CUSTOM_DOMAINS_DIR/tenant_xxx/site_xxx/`,使用 `SITE_BASE_PATH=/` 单独构建。该流程失败不会影响 `/sites/tenant_xxx/site_xxx/`
### Cloudflare for SaaS ### Cloudflare for SaaS
......
...@@ -4,20 +4,25 @@ ...@@ -4,20 +4,25 @@
```text ```text
WebAgent-sites/ WebAgent-sites/
└── site_xxxxxx/ └── tenant_xxxxxx/
└── site_xxxxxx/
├── project/ # 独立 Astro 项目,也是独立 Git 仓库 ├── project/ # 独立 Astro 项目,也是独立 Git 仓库
├── draft/ # 持久化工作草稿 Git Worktree,不进入正式版本历史 ├── draft/ # 持久化工作草稿 Git Worktree,不进入正式版本历史
└── metadata/ └── metadata/
└── site.json # WebAgent 管理信息,不交给 Agent 修改 ├── site.json # WebAgent 管理信息,不交给 Agent 修改
└── domains.json # 当前租户站点的域名绑定
WebAgent-previews/ WebAgent-previews/
└── site_xxxxxx/ # 当前代码最近一次构建成功的测试产物 └── tenant_xxxxxx/
└── site_xxxxxx/ # 当前代码最近一次构建成功的测试产物
WebAgent-production/ WebAgent-production/
└── site_xxxxxx/ # 用户确认发布的生产静态产物 └── tenant_xxxxxx/
└── site_xxxxxx/ # 用户确认发布的生产静态产物
WebAgent-custom-domains/ WebAgent-custom-domains/
└── site_xxxxxx/ # 自定义域名根路径产物,使用 SITE_BASE_PATH=/ 构建 └── tenant_xxxxxx/
└── site_xxxxxx/ # 自定义域名根路径产物,使用 SITE_BASE_PATH=/ 构建
WebAgent/.runtime/ WebAgent/.runtime/
├── builds/ ├── builds/
...@@ -39,6 +44,6 @@ WebAgent/.runtime/ ...@@ -39,6 +44,6 @@ WebAgent/.runtime/
4. 测试预览更新不能覆盖生产产物;生产环境只能通过显式发布操作更新。 4. 测试预览更新不能覆盖生产产物;生产环境只能通过显式发布操作更新。
5. `metadata/site.json` 由 WebAgent 独占写入,站点代码不能反向引用它。 5. `metadata/site.json` 由 WebAgent 独占写入,站点代码不能反向引用它。
6. 删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。 6. 删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。
7. 每个站点拥有稳定的 `siteId` 和预览端口;站点名称不参与路径计算。 7. 每个站点拥有强随机且稳定的 `siteId` 和预览端口;站点名称不参与路径计算。
8. 自定义域名必须通过 TXT 所有权验证后才允许生成路由和申请证书。 8. 自定义域名必须通过 TXT 所有权验证后才允许生成路由和申请证书。
9. 自定义域名构建或 DNS 故障不能覆盖、阻塞或改变 `/sites/site_xxx/` 生产环境。 9. 自定义域名构建或 DNS 故障不能覆盖、阻塞或改变 `/sites/tenant_xxx/site_xxx/` 生产环境。
...@@ -6,20 +6,26 @@ import { ...@@ -6,20 +6,26 @@ import {
Plus, RefreshCw, Rocket, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Trash2, Undo2, Plus, RefreshCw, Rocket, Send, Settings2, ShieldCheck, Smartphone, Sparkles, Trash2, Undo2,
WandSparkles, X, WandSparkles, X,
} from "lucide-react"; } from "lucide-react";
import type { CreateSiteInput, DomainBinding, SiteInfo } from "@webagent/shared"; import type { CreateSiteInput, CreateTenantInput, DomainBinding, SessionInfo, SiteInfo, TenantAdminInfo } from "@webagent/shared";
import { api } from "./api"; import { api } from "./api";
type ChatMessage = { role: "user" | "agent"; text: string; meta?: string }; type ChatMessage = { role: "user" | "agent"; text: string; meta?: string };
export default function App() { export default function App() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [authenticated, setAuthenticated] = useState(() => Boolean(sessionStorage.getItem("webagent-session"))); const [authenticated, setAuthenticated] = useState(() => Boolean(localStorage.getItem("webagent-session")));
const sitesQuery = useQuery({ queryKey: ["sites"], queryFn: api.sites, enabled: authenticated });
const sessionQuery = useQuery({ queryKey: ["session"], queryFn: api.session, enabled: authenticated, retry: false }); const sessionQuery = useQuery({ queryKey: ["session"], queryFn: api.session, enabled: authenticated, retry: false });
const sitesQuery = useQuery({ queryKey: ["sites"], queryFn: api.sites, enabled: authenticated && sessionQuery.data?.accountType === "tenant_user" });
const healthQuery = useQuery({ queryKey: ["health"], queryFn: api.health }); const healthQuery = useQuery({ queryKey: ["health"], queryFn: api.health });
const [currentSiteId, setCurrentSiteId] = useState(() => localStorage.getItem("webagent-current-site") || ""); const [currentSiteId, setCurrentSiteId] = useState(() => localStorage.getItem("webagent-current-site") || "");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
useEffect(() => {
const invalidate = () => { setAuthenticated(false); setCreating(false); queryClient.clear(); };
window.addEventListener("webagent-session-invalid", invalidate);
return () => window.removeEventListener("webagent-session-invalid", invalidate);
}, [queryClient]);
useEffect(() => { useEffect(() => {
if (sitesQuery.data?.[0] && !sitesQuery.data.some((site) => site.siteId === currentSiteId)) { if (sitesQuery.data?.[0] && !sitesQuery.data.some((site) => site.siteId === currentSiteId)) {
setCurrentSiteId(sitesQuery.data[0].siteId); setCurrentSiteId(sitesQuery.data[0].siteId);
...@@ -30,7 +36,7 @@ export default function App() { ...@@ -30,7 +36,7 @@ export default function App() {
}, [currentSiteId]); }, [currentSiteId]);
useEffect(() => { useEffect(() => {
if (!sessionQuery.isError) return; if (!sessionQuery.isError) return;
sessionStorage.removeItem("webagent-session"); localStorage.removeItem("webagent-session");
setAuthenticated(false); setAuthenticated(false);
}, [sessionQuery.isError]); }, [sessionQuery.isError]);
...@@ -41,12 +47,14 @@ export default function App() { ...@@ -41,12 +47,14 @@ export default function App() {
const logout = async () => { const logout = async () => {
await api.logout().catch(() => undefined); await api.logout().catch(() => undefined);
sessionStorage.removeItem("webagent-session"); localStorage.removeItem("webagent-session");
setAuthenticated(false); setCreating(false); setAuthenticated(false); setCreating(false);
queryClient.clear(); queryClient.clear();
}; };
if (!authenticated) return <LoginPage onAuthenticated={() => setAuthenticated(true)} />; if (!authenticated) return <LoginPage onAuthenticated={() => setAuthenticated(true)} />;
if (sessionQuery.isLoading) return <Splash />;
if (sessionQuery.data?.accountType === "system_admin") return <AdminWorkspace session={sessionQuery.data} onLogout={logout} />;
if (sitesQuery.isLoading) return <Splash />; if (sitesQuery.isLoading) return <Splash />;
if (creating || !sitesQuery.data?.length) { if (creating || !sitesQuery.data?.length) {
return <CreateSiteWorkspace sites={sitesQuery.data || []} currentSiteId={currentSiteId} onCreated={openSite} onCancel={sitesQuery.data?.length ? () => setCreating(false) : undefined} onSelectSite={(id) => { setCurrentSiteId(id); setCreating(false); }} onLogout={logout} />; return <CreateSiteWorkspace sites={sitesQuery.data || []} currentSiteId={currentSiteId} onCreated={openSite} onCancel={sitesQuery.data?.length ? () => setCreating(false) : undefined} onSelectSite={(id) => { setCurrentSiteId(id); setCreating(false); }} onLogout={logout} />;
...@@ -70,15 +78,15 @@ function Logo({ compact = false }: { compact?: boolean }) { ...@@ -70,15 +78,15 @@ function Logo({ compact = false }: { compact?: boolean }) {
} }
function LoginPage({ onAuthenticated }: { onAuthenticated: () => void }) { function LoginPage({ onAuthenticated }: { onAuthenticated: () => void }) {
const [username, setUsername] = useState("user"); const [username, setUsername] = useState("admin");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => api.login(username, password), mutationFn: () => api.login(username, password),
onSuccess: (result) => { sessionStorage.setItem("webagent-session", result.token); onAuthenticated(); }, onSuccess: (result) => { localStorage.setItem("webagent-session", result.token); onAuthenticated(); },
}); });
return <main className="create-page login-page"> return <main className="create-page login-page">
<div className="create-orb orb-a" /><div className="create-orb orb-b" /> <div className="create-orb orb-a" /><div className="create-orb orb-b" />
<header className="landing-header"><Logo /><div className="landing-status"><span /><span>单用户工作台</span></div></header> <header className="landing-header"><Logo /><div className="landing-status"><span /><span>多租户工作台</span></div></header>
<div className="create-layout login-layout"> <div className="create-layout login-layout">
<section className="create-intro"> <section className="create-intro">
<div className="intro-tag"><Sparkles size={14} /> AI WEBSITE WORKSPACE</div> <div className="intro-tag"><Sparkles size={14} /> AI WEBSITE WORKSPACE</div>
...@@ -94,20 +102,98 @@ function LoginPage({ onAuthenticated }: { onAuthenticated: () => void }) { ...@@ -94,20 +102,98 @@ function LoginPage({ onAuthenticated }: { onAuthenticated: () => void }) {
<form className="create-card login-card" onSubmit={(event) => { event.preventDefault(); mutation.mutate(); }}> <form className="create-card login-card" onSubmit={(event) => { event.preventDefault(); mutation.mutate(); }}>
<div className="login-card-icon"><ShieldCheck size={20} /></div> <div className="login-card-icon"><ShieldCheck size={20} /></div>
<div className="card-heading"><div><span>WELCOME BACK</span><h2>登录工作台</h2></div></div> <div className="card-heading"><div><span>WELCOME BACK</span><h2>登录工作台</h2></div></div>
<p className="login-copy">使用管理员账号继续管理你的官网</p> <p className="login-copy">使用系统管理员或租户账号进入对应工作台</p>
<div className="login-fields"> <div className="login-fields">
<Field label="账号"><input required autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} /></Field> <Field label="账号"><input required autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} /></Field>
<Field label="密码"><input required type="password" autoComplete="current-password" placeholder="请输入密码" value={password} onChange={(event) => setPassword(event.target.value)} /></Field> <Field label="密码"><input required type="password" autoComplete="current-password" placeholder="请输入密码" value={password} onChange={(event) => setPassword(event.target.value)} /></Field>
</div> </div>
{mutation.error && <div className="form-error"><X size={15} />{mutation.error.message}</div>} {mutation.error && <div className="form-error"><X size={15} />{mutation.error.message}</div>}
<button className="create-submit login-submit" disabled={mutation.isPending}>{mutation.isPending ? <><LoaderCircle className="spin" size={18} /> 正在登录…</> : <>进入工作台 <ArrowRight size={18} /></>}</button> <button className="create-submit login-submit" disabled={mutation.isPending}>{mutation.isPending ? <><LoaderCircle className="spin" size={18} /> 正在登录…</> : <>进入工作台 <ArrowRight size={18} /></>}</button>
<p className="local-note"><ShieldCheck size={13} /> 本地单用户安全登录</p> <p className="local-note"><ShieldCheck size={13} /> 账号、租户和会话安全隔离</p>
</form> </form>
</section> </section>
</div> </div>
</main>; </main>;
} }
function AdminWorkspace({ session, onLogout }: { session: SessionInfo; onLogout: () => void }) {
const queryClient = useQueryClient();
const tenantsQuery = useQuery({ queryKey: ["admin-tenants"], queryFn: api.tenants });
const [selectedTenantId, setSelectedTenantId] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState<CreateTenantInput>({ name: "", username: "", password: "" });
const refresh = () => queryClient.invalidateQueries({ queryKey: ["admin-tenants"] });
const createMutation = useMutation({
mutationFn: api.createTenant,
onSuccess: async (tenant) => { setForm({ name: "", username: "", password: "" }); setShowCreate(false); setSelectedTenantId(tenant.tenantId); await refresh(); },
});
const statusMutation = useMutation({ mutationFn: ({ tenantId, enabled }: { tenantId: string; enabled: boolean }) => api.setTenantEnabled(tenantId, enabled), onSuccess: refresh });
const passwordMutation = useMutation({
mutationFn: ({ tenantId, userId, password }: { tenantId: string; userId: string; password: string }) => api.resetTenantPassword(tenantId, userId, password),
onSuccess: () => window.alert("密码已重置,该账号的全部 session 已注销"),
});
const revokeMutation = useMutation({
mutationFn: ({ tenantId, userId }: { tenantId: string; userId: string }) => api.revokeTenantSessions(tenantId, userId),
onSuccess: () => window.alert("该账号的全部 session 已注销"),
});
const tenants = tenantsQuery.data || [];
const selected = tenants.find((tenant) => tenant.tenantId === selectedTenantId) || tenants[0];
const tenantSitesQuery = useQuery({
queryKey: ["admin-tenant-sites", selected?.tenantId],
queryFn: () => api.tenantSites(selected!.tenantId),
enabled: Boolean(selected && !showCreate),
});
const resetPassword = (tenant: TenantAdminInfo, userId: string) => {
const password = window.prompt("输入新密码(至少 8 个字符)");
if (password) passwordMutation.mutate({ tenantId: tenant.tenantId, userId, password });
};
return <main className="admin-page">
<header className="admin-topbar"><Logo /><div><span className="settings-avatar">{session.username.slice(0, 1).toUpperCase()}</span><span><strong>{session.username}</strong><small>系统管理员</small></span><button type="button" onClick={onLogout}><ArrowLeft size={14} />退出</button></div></header>
<div className="admin-layout">
<aside className="admin-sidebar">
<div><span>租户管理</span><strong>{tenants.length}</strong></div>
<button className="admin-create-button" type="button" onClick={() => setShowCreate(true)}><Plus size={15} />创建租户及账号</button>
<nav>{tenants.map((tenant) => <button type="button" className={tenant.tenantId === selected?.tenantId ? "active" : ""} key={tenant.tenantId} onClick={() => { setSelectedTenantId(tenant.tenantId); setShowCreate(false); }}>
<span>{tenant.name.slice(0, 1)}</span><span><strong>{tenant.name}</strong><small>{tenant.accounts[0]?.username || "暂无账号"}</small></span><i className={tenant.enabled ? "enabled" : "disabled"} />
</button>)}</nav>
</aside>
<section className="admin-content">
{showCreate ? <form className="admin-card admin-create-form" onSubmit={(event) => { event.preventDefault(); createMutation.mutate(form); }}>
<div className="admin-heading"><div><span>NEW TENANT</span><h1>创建租户及首个账号</h1><p>创建后即可使用租户账号登录并测试完整站点流程。</p></div><button type="button" onClick={() => setShowCreate(false)}><X size={16} /></button></div>
<div className="admin-form-grid">
<Field label="租户名称"><input required minLength={2} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="例如:星河科技" /></Field>
<Field label="登录账号"><input required minLength={3} value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} placeholder="例如:xinghe" /></Field>
<Field label="初始密码" wide><input required minLength={8} type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} placeholder="至少 8 个字符" /></Field>
</div>
{createMutation.error && <div className="form-error"><X size={15} />{createMutation.error.message}</div>}
<button className="create-submit" disabled={createMutation.isPending}>{createMutation.isPending ? <LoaderCircle className="spin" size={16} /> : <Plus size={16} />}创建租户</button>
</form> : selected ? <>
<div className="admin-heading admin-tenant-heading"><div><span>{selected.isTest ? "TEST TENANT" : "TENANT"}</span><h1>{selected.name}{selected.isTest && <em>测试租户</em>}</h1><p><code>{selected.tenantId}</code></p></div><button className={selected.enabled ? "disable" : "enable"} disabled={statusMutation.isPending} type="button" onClick={() => statusMutation.mutate({ tenantId: selected.tenantId, enabled: !selected.enabled })}>{selected.enabled ? "禁用租户" : "重新启用"}</button></div>
<div className="admin-stats"><article><strong>{selected.accounts.length}</strong><span>租户账号</span></article><article><strong>{selected.siteCount}</strong><span>站点数量</span></article><article><strong className={selected.enabled ? "ok" : "off"}>{selected.enabled ? "已启用" : "已禁用"}</strong><span>租户状态</span></article></div>
<section className="admin-card">
<div className="admin-section-title"><div><h2>租户账号</h2><p>重置密码会同时注销该账号的所有现有 session。</p></div></div>
<div className="admin-account-list">{selected.accounts.map((account) => <article key={account.userId}><span className="settings-avatar">{account.username.slice(0, 1).toUpperCase()}</span><div><strong>{account.username}</strong><small>{account.userId} · {account.enabled ? "已启用" : "已禁用"}</small></div><button type="button" disabled={passwordMutation.isPending} onClick={() => resetPassword(selected, account.userId)}>重置密码</button><button type="button" disabled={revokeMutation.isPending} onClick={() => revokeMutation.mutate({ tenantId: selected.tenantId, userId: account.userId })}>注销全部 session</button></article>)}</div>
</section>
<section className="admin-card admin-sites-card">
<div className="admin-section-title"><div><h2>租户站点</h2><p>管理员仅可查看站点状态并打开公开链接,不能编辑或发布。</p></div><span>{tenantSitesQuery.data?.length ?? selected.siteCount}</span></div>
{tenantSitesQuery.isLoading ? <div className="admin-sites-loading"><LoaderCircle className="spin" size={18} />正在读取站点…</div>
: tenantSitesQuery.error ? <div className="form-error"><X size={15} />{tenantSitesQuery.error.message}</div>
: tenantSitesQuery.data?.length ? <div className="admin-site-list">{tenantSitesQuery.data.map((site) => <article key={site.siteId}>
<span className="admin-site-avatar">{site.name.slice(0, 1)}</span>
<div className="admin-site-copy"><strong>{site.name}</strong><small>{site.industry} · <code>{site.siteId}</code></small><small>更新于 {formatDate(site.updatedAt)}{site.publishedAt ? ` · 发布于 ${formatDate(site.publishedAt)}` : ""}</small></div>
<div className="admin-site-state"><span className={site.status === "ready" ? "ready" : site.status}>{site.lastError ? "测试异常" : site.status === "ready" ? "测试就绪" : site.status === "building" ? "构建中" : site.status === "creating" ? "创建中" : "测试失败"}</span><span className={site.publishStatus}>{site.lastPublishError ? "发布异常" : site.publishStatus === "published" ? "已上线" : site.publishStatus === "publishing" ? "发布中" : site.publishStatus === "failed" ? "发布失败" : "未上线"}</span></div>
<div className="admin-site-links"><a href={site.previewUrl} target="_blank" rel="noreferrer"><Monitor size={13} />测试链接</a>{site.publishStatus === "published" && site.productionUrl ? <a className="production" href={site.productionUrl} target="_blank" rel="noreferrer"><Globe2 size={13} />生产链接</a> : <span><Globe2 size={13} />尚未上线</span>}</div>
</article>)}</div> : <div className="admin-sites-empty"><Monitor size={20} /><span>该租户还没有创建站点</span></div>}
</section>
<section className="admin-card admin-info-card"><div className="admin-section-title"><div><h2>租户信息</h2><p>测试租户不可物理删除,可以按需禁用。</p></div></div><dl><div><dt>创建时间</dt><dd>{formatDate(selected.createdAt)}</dd></div><div><dt>更新时间</dt><dd>{formatDate(selected.updatedAt)}</dd></div><div><dt>数据标记</dt><dd>{selected.isTest ? "默认测试数据" : "普通租户"}</dd></div></dl></section>
</> : tenantsQuery.isLoading ? <Splash /> : <div className="admin-empty"><ShieldCheck size={30} /><h2>暂无租户</h2><button type="button" onClick={() => setShowCreate(true)}>创建第一个租户</button></div>}
</section>
</div>
</main>;
}
function CreateSiteForm({ onCreated, onCancel }: { onCreated: (site: SiteInfo) => void; onCancel?: () => void }) { function CreateSiteForm({ onCreated, onCancel }: { onCreated: (site: SiteInfo) => void; onCancel?: () => void }) {
const [form, setForm] = useState<CreateSiteInput>({ const [form, setForm] = useState<CreateSiteInput>({
name: "云启科技", industry: "企业数字化服务", description: "专注于为成长型企业提供智能化、可持续的数字解决方案,让技术真正服务于业务增长。", name: "云启科技", industry: "企业数字化服务", description: "专注于为成长型企业提供智能化、可持续的数字解决方案,让技术真正服务于业务增长。",
...@@ -324,7 +410,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onLogout ...@@ -324,7 +410,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onLogout
const currentVersion = historyQuery.data?.find((item) => item.hash === site.currentCommit); const currentVersion = historyQuery.data?.find((item) => item.hash === site.currentCommit);
const productionVersion = historyQuery.data?.find((item) => item.hash === site.publishedCommit); const productionVersion = historyQuery.data?.find((item) => item.hash === site.publishedCommit);
const productionMatchesPreview = Boolean(site.publishedCommit && site.publishedCommit === site.previewCommit); const productionMatchesPreview = Boolean(site.publishedCommit && site.publishedCommit === site.previewCommit);
const canPublish = site.status === "ready" && Boolean(site.previewCommit) && !productionMatchesPreview; const canPublish = site.status === "ready" && Boolean(site.previewCommit);
const canUndo = !historicalPreview && Boolean(site.previousCommit || currentVersion?.message.startsWith("Agent:")); const canUndo = !historicalPreview && Boolean(site.previousCommit || currentVersion?.message.startsWith("Agent:"));
const showingProduction = environment === "production"; const showingProduction = environment === "production";
const activeUrl = showingProduction ? site.productionUrl : site.previewUrl; const activeUrl = showingProduction ? site.productionUrl : site.previewUrl;
...@@ -392,8 +478,8 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onLogout ...@@ -392,8 +478,8 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onLogout
<a className="environment-link preview-link" href={site.previewUrl} target="_blank" rel="noreferrer" title="打开测试环境"><Monitor size={13} /><span>测试链接</span></a> <a className="environment-link preview-link" href={site.previewUrl} target="_blank" rel="noreferrer" title="打开测试环境"><Monitor size={13} /><span>测试链接</span></a>
<a className="environment-link production-link" href={site.productionUrl} target="_blank" rel="noreferrer" title="打开生产环境"><Globe2 size={13} /><span>生产链接</span></a> <a className="environment-link production-link" href={site.productionUrl} target="_blank" rel="noreferrer" title="打开生产环境"><Globe2 size={13} /><span>生产链接</span></a>
{!showingProduction && <button className="undo-button" disabled={busy || !canUndo} onClick={() => { if (window.confirm("确定撤销最近一次修改吗?测试环境将恢复到上一份已保存版本,线上版本不会改变。")) undoMutation.mutate(); }} title={historicalPreview ? "请先切换回最近保存版本" : canUndo ? "恢复上一份已保存版本" : "当前没有可撤销的修改"}><Undo2 size={14} /><span>撤销最近修改</span></button>} {!showingProduction && <button className="undo-button" disabled={busy || !canUndo} onClick={() => { if (window.confirm("确定撤销最近一次修改吗?测试环境将恢复到上一份已保存版本,线上版本不会改变。")) undoMutation.mutate(); }} title={historicalPreview ? "请先切换回最近保存版本" : canUndo ? "恢复上一份已保存版本" : "当前没有可撤销的修改"}><Undo2 size={14} /><span>撤销最近修改</span></button>}
{!showingProduction && <button className="publish-button" disabled={busy || !canPublish} onClick={() => { if (window.confirm("确定将当前测试预览版本发布到生产环境吗?")) publishMutation.mutate(); }} title={productionMatchesPreview ? "当前测试版本已经上线" : site.status !== "ready" ? "测试环境构建成功后才能发布" : "发布当前测试预览版本到生产环境"}> {!showingProduction && <button className="publish-button" disabled={busy || !canPublish} onClick={() => { if (window.confirm(productionMatchesPreview ? "确定重新发布当前版本吗?这会重新构建并更新生产产物。" : "确定将当前测试预览版本发布到生产环境吗?")) publishMutation.mutate(); }} title={site.status !== "ready" ? "测试环境构建成功后才能发布" : productionMatchesPreview ? "重新构建并发布当前版本" : "发布当前测试预览版本到生产环境"}>
{publishMutation.isPending ? <LoaderCircle className="spin" size={14} /> : <Rocket size={14} />}<span>{productionMatchesPreview ? "上线" : "上线当前版本"}</span> {publishMutation.isPending ? <LoaderCircle className="spin" size={14} /> : <Rocket size={14} />}<span>{productionMatchesPreview ? "重新上线" : "上线当前版本"}</span>
</button>} </button>}
</div> </div>
</header> </header>
......
import type { ChatResult, CreateSiteInput, DomainBinding, DomainListResult, GitHistoryItem, PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared"; import type { ChatResult, CreateSiteInput, CreateTenantInput, DomainBinding, DomainListResult, GitHistoryItem, PreviewVersionResult, PublishResult, SessionInfo, SiteInfo, TenantAdminInfo, TenantAdminSiteInfo } from "@webagent/shared";
async function request<T>(url: string, options?: RequestInit): Promise<T> { async function request<T>(url: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers); const headers = new Headers(options?.headers);
const token = sessionStorage.getItem("webagent-session"); const token = localStorage.getItem("webagent-session");
if (token) headers.set("authorization", "Bearer " + token); if (token) headers.set("authorization", "Bearer " + token);
if (options?.body != null && !headers.has("content-type")) headers.set("content-type", "application/json"); if (options?.body != null && !headers.has("content-type")) headers.set("content-type", "application/json");
const response = await fetch(url, { const response = await fetch(url, {
...@@ -10,14 +10,18 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> { ...@@ -10,14 +10,18 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
headers, headers,
}); });
const data = await response.json().catch(() => ({})) as { error?: string }; const data = await response.json().catch(() => ({})) as { error?: string };
if (response.status === 401) {
localStorage.removeItem("webagent-session");
window.dispatchEvent(new Event("webagent-session-invalid"));
}
if (!response.ok) throw new Error(data.error || "请求失败,请稍后重试"); if (!response.ok) throw new Error(data.error || "请求失败,请稍后重试");
return data as T; return data as T;
} }
export const api = { export const api = {
login: (username: string, password: string) => request<{ token: string; username: string }>("/api/login", { method: "POST", body: JSON.stringify({ username, password }) }), login: (username: string, password: string) => request<{ token: string } & SessionInfo>("/api/login", { method: "POST", body: JSON.stringify({ username, password }) }),
logout: () => request<{ success: true }>("/api/logout", { method: "POST" }), logout: () => request<{ success: true }>("/api/logout", { method: "POST" }),
session: () => request<{ username: string }>("/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"),
site: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId), site: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId),
...@@ -34,4 +38,11 @@ export const api = { ...@@ -34,4 +38,11 @@ export const api = {
verifyDomain: (siteId: string, domainId: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains/" + domainId + "/verify", { method: "POST" }), verifyDomain: (siteId: string, domainId: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains/" + domainId + "/verify", { method: "POST" }),
setPrimaryDomain: (siteId: string, domainId: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains/" + domainId, { method: "PATCH", body: JSON.stringify({ isPrimary: true }) }), setPrimaryDomain: (siteId: string, domainId: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains/" + domainId, { method: "PATCH", body: JSON.stringify({ isPrimary: true }) }),
removeDomain: (siteId: string, domainId: string) => request<void>("/api/sites/" + siteId + "/domains/" + domainId, { method: "DELETE" }), removeDomain: (siteId: string, domainId: string) => request<void>("/api/sites/" + siteId + "/domains/" + domainId, { method: "DELETE" }),
tenants: () => request<TenantAdminInfo[]>("/api/admin/tenants"),
tenant: (tenantId: string) => request<TenantAdminInfo>("/api/admin/tenants/" + tenantId),
tenantSites: (tenantId: string) => request<TenantAdminSiteInfo[]>(`/api/admin/tenants/${tenantId}/sites`),
createTenant: (input: CreateTenantInput) => request<TenantAdminInfo>("/api/admin/tenants", { method: "POST", body: JSON.stringify(input) }),
setTenantEnabled: (tenantId: string, enabled: boolean) => request<TenantAdminInfo>("/api/admin/tenants/" + tenantId, { method: "PATCH", body: JSON.stringify({ enabled }) }),
resetTenantPassword: (tenantId: string, userId: string, password: string) => request<{ success: true }>(`/api/admin/tenants/${tenantId}/accounts/${userId}/reset-password`, { method: "POST", body: JSON.stringify({ password }) }),
revokeTenantSessions: (tenantId: string, userId: string) => request<{ success: true }>(`/api/admin/tenants/${tenantId}/accounts/${userId}/revoke-sessions`, { method: "POST" }),
}; };
...@@ -60,3 +60,7 @@ ...@@ -60,3 +60,7 @@
@media(max-width:1100px){.prerequisite-grid{grid-template-columns:1fr 1fr}.instruction-list{grid-template-columns:1fr}.domain-overview-main{grid-template-columns:auto minmax(0,1fr) auto auto}.domain-overview-main>.domain-state{display:none}} @media(max-width:1100px){.prerequisite-grid{grid-template-columns:1fr 1fr}.instruction-list{grid-template-columns:1fr}.domain-overview-main{grid-template-columns:auto minmax(0,1fr) auto auto}.domain-overview-main>.domain-state{display:none}}
@media(max-width:800px){.prerequisite-grid{grid-template-columns:1fr}.wizard-heading{align-items:flex-start}.wizard-steps{padding:0 8px}.wizard-steps>div{flex-direction:column;gap:3px;text-align:center}.wizard-steps>div>i{top:25px}.wizard-domain-entry form{grid-template-columns:1fr}.wizard-domain-entry form>button{width:100%}.instruction-list,.system-readiness{grid-template-columns:1fr}.wizard-action{align-items:stretch;flex-direction:column}.wizard-action>button{width:100%}.wizard-success>div{flex-direction:column}.wizard-success a,.wizard-success button{width:100%;justify-content:center}.domain-overview-row{grid-template-columns:1fr}.domain-overview-actions{padding:0 10px 9px;justify-content:flex-end}.domain-overview-main{grid-template-columns:auto minmax(0,1fr) auto}.domain-overview-main>em,.domain-overview-main>.domain-state{display:none}} @media(max-width:800px){.prerequisite-grid{grid-template-columns:1fr}.wizard-heading{align-items:flex-start}.wizard-steps{padding:0 8px}.wizard-steps>div{flex-direction:column;gap:3px;text-align:center}.wizard-steps>div>i{top:25px}.wizard-domain-entry form{grid-template-columns:1fr}.wizard-domain-entry form>button{width:100%}.instruction-list,.system-readiness{grid-template-columns:1fr}.wizard-action{align-items:stretch;flex-direction:column}.wizard-action>button{width:100%}.wizard-success>div{flex-direction:column}.wizard-success a,.wizard-success button{width:100%;justify-content:center}.domain-overview-row{grid-template-columns:1fr}.domain-overview-actions{padding:0 10px 9px;justify-content:flex-end}.domain-overview-main{grid-template-columns:auto minmax(0,1fr) auto}.domain-overview-main>em,.domain-overview-main>.domain-state{display:none}}
.wizard-domain-entry form>button:disabled{color:#9a9ca5;background:#e9e9ef;opacity:1;box-shadow:none} .wizard-domain-entry form>button:disabled{color:#9a9ca5;background:#e9e9ef;opacity:1;box-shadow:none}
/* Multi-tenant administrator workspace */
.admin-page{min-height:100vh;background:#f6f6fa;color:#252333}.admin-topbar{height:72px;padding:0 34px;display:flex;align-items:center;justify-content:space-between;background:#fff;border-bottom:1px solid #ebe9f1}.admin-topbar>div{display:flex;align-items:center;gap:10px}.admin-topbar>div>span:nth-child(2){display:flex;flex-direction:column}.admin-topbar small{color:#8a8795;font-size:11px}.admin-topbar button{margin-left:14px;padding:8px 12px;display:flex;align-items:center;gap:6px;border:1px solid #e3e0ea;border-radius:9px;background:#fff;color:#666273}.admin-layout{min-height:calc(100vh - 73px);display:grid;grid-template-columns:286px 1fr}.admin-sidebar{padding:28px 20px;background:#fff;border-right:1px solid #ebe9f1}.admin-sidebar>div{padding:0 8px 18px;display:flex;justify-content:space-between;color:#706c7b;font-size:13px}.admin-sidebar>div strong{padding:2px 7px;border-radius:20px;background:#f0edf7;color:#6d43cf}.admin-create-button{width:100%;padding:11px 14px;display:flex;align-items:center;justify-content:center;gap:7px;border:0;border-radius:10px;background:#6d3fe0;color:#fff;font-weight:700}.admin-sidebar nav{margin-top:18px;display:grid;gap:5px}.admin-sidebar nav button{width:100%;padding:10px;display:grid;grid-template-columns:36px 1fr 8px;align-items:center;gap:9px;text-align:left;border:1px solid transparent;border-radius:11px;background:transparent}.admin-sidebar nav button.active{background:#f5f1ff;border-color:#e7dcff}.admin-sidebar nav button>span:first-child{width:34px;height:34px;display:grid;place-items:center;border-radius:9px;background:#ece8f6;color:#6337c8;font-weight:800}.admin-sidebar nav button>span:nth-child(2){min-width:0;display:flex;flex-direction:column}.admin-sidebar nav strong,.admin-sidebar nav small{overflow:hidden;text-overflow:ellipsis}.admin-sidebar nav small{margin-top:3px;color:#9894a1}.admin-sidebar nav i{width:7px;height:7px;border-radius:50%}.admin-sidebar nav i.enabled{background:#35ae75}.admin-sidebar nav i.disabled{background:#bbb7c1}.admin-content{max-width:1040px;width:100%;padding:48px 56px;margin:0 auto}.admin-heading{display:flex;align-items:flex-start;justify-content:space-between}.admin-heading>div>span{color:#7548dd;font-size:11px;font-weight:800;letter-spacing:.14em}.admin-heading h1{margin:6px 0 8px;font-size:30px}.admin-heading p{margin:0;color:#85818f}.admin-heading code{font-size:12px}.admin-heading>button{border:0;background:transparent}.admin-tenant-heading h1{display:flex;align-items:center;gap:10px}.admin-tenant-heading h1 em{padding:4px 8px;border-radius:6px;background:#eee7ff;color:#6940c5;font-size:11px;font-style:normal}.admin-tenant-heading>button{padding:9px 14px;border-radius:9px;font-weight:700}.admin-tenant-heading>button.disable{background:#fff0f0;color:#bf4545}.admin-tenant-heading>button.enable{background:#e9f8f0;color:#27835a}.admin-stats{margin:28px 0 22px;display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.admin-stats article{padding:22px;border:1px solid #e8e5ed;border-radius:14px;background:#fff;display:flex;flex-direction:column;gap:5px}.admin-stats strong{font-size:24px}.admin-stats strong.ok{font-size:17px;color:#27835a}.admin-stats strong.off{font-size:17px;color:#b84747}.admin-stats span{color:#8c8896;font-size:12px}.admin-card{padding:26px;border:1px solid #e8e5ed;border-radius:16px;background:#fff;box-shadow:0 12px 40px rgba(47,36,76,.04)}.admin-card+.admin-card{margin-top:18px}.admin-section-title h2{margin:0 0 5px;font-size:17px}.admin-section-title p{margin:0;color:#8c8896;font-size:12px}.admin-account-list{margin-top:18px;display:grid;gap:10px}.admin-account-list article{padding:13px 0;display:grid;grid-template-columns:38px 1fr auto auto;align-items:center;gap:10px;border-top:1px solid #f0edf3}.admin-account-list article>div{display:flex;flex-direction:column;gap:3px}.admin-account-list small{color:#9995a2;font-size:10px}.admin-account-list button{padding:7px 10px;border:1px solid #dfdbe7;border-radius:8px;background:#fff;color:#615d6c}.admin-info-card dl{margin:18px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.admin-info-card dl div{display:flex;flex-direction:column;gap:5px}.admin-info-card dt{color:#9894a1;font-size:11px}.admin-info-card dd{margin:0;font-size:13px}.admin-create-form{max-width:700px;margin:20px auto}.admin-form-grid{margin:28px 0;display:grid;grid-template-columns:1fr 1fr;gap:16px}.admin-empty{min-height:400px;display:grid;place-content:center;justify-items:center;gap:10px;color:#85818f}.admin-empty h2{margin:5px}.admin-empty button{padding:10px 15px;border:0;border-radius:9px;background:#6d3fe0;color:#fff}.settings-avatar{width:34px;height:34px;display:grid;place-items:center;border-radius:9px;background:#ebe5fa;color:#6138c1;font-weight:800}
.admin-sites-card>.admin-section-title{display:flex;align-items:center;justify-content:space-between}.admin-sites-card>.admin-section-title>span{padding:3px 8px;border-radius:20px;background:#f0edf7;color:#6d43cf;font-size:12px;font-weight:700}.admin-site-list{margin-top:18px;display:grid;gap:9px}.admin-site-list article{padding:14px;display:grid;grid-template-columns:40px minmax(0,1fr) auto auto;align-items:center;gap:12px;border:1px solid #ece9f1;border-radius:12px;background:#fcfbfd}.admin-site-avatar{width:40px;height:40px;display:grid;place-items:center;border-radius:10px;background:#eee8fc;color:#6740c4;font-weight:800}.admin-site-copy{min-width:0;display:flex;flex-direction:column;gap:4px}.admin-site-copy strong,.admin-site-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.admin-site-copy small{color:#8f8b98;font-size:10px}.admin-site-copy code{font-size:9px}.admin-site-state{display:flex;align-items:center;gap:5px}.admin-site-state span{padding:4px 7px;border-radius:6px;background:#f0eff3;color:#777380;font-size:9px;white-space:nowrap}.admin-site-state span.ready,.admin-site-state span.published{color:#187558;background:#e7f8f1}.admin-site-state span.building,.admin-site-state span.creating,.admin-site-state span.publishing{color:#946300;background:#fff6df}.admin-site-state span.failed{color:#b74343;background:#fff0f0}.admin-site-links{display:flex;align-items:center;gap:6px}.admin-site-links a,.admin-site-links>span{height:31px;padding:0 9px;display:flex;align-items:center;gap:5px;border:1px solid #ded9e7;border-radius:8px;color:#6540bc;background:#fff;font-size:9px;font-weight:700;text-decoration:none;white-space:nowrap}.admin-site-links a.production{color:#187558}.admin-site-links>span{color:#aaa6b1;background:#f5f4f6}.admin-sites-loading,.admin-sites-empty{min-height:90px;display:flex;align-items:center;justify-content:center;gap:8px;color:#8c8896;font-size:12px}
@media(max-width:800px){.admin-layout{grid-template-columns:1fr}.admin-sidebar{border-right:0;border-bottom:1px solid #ebe9f1}.admin-sidebar nav{grid-template-columns:repeat(2,minmax(0,1fr))}.admin-content{padding:28px 20px}.admin-stats{grid-template-columns:1fr}.admin-account-list article{grid-template-columns:38px 1fr}.admin-account-list button{grid-column:auto}.admin-info-card dl,.admin-form-grid{grid-template-columns:1fr}.admin-site-list article{grid-template-columns:40px minmax(0,1fr)}.admin-site-state,.admin-site-links{grid-column:2;justify-content:flex-start}.admin-topbar{padding:0 18px}}
...@@ -12,6 +12,7 @@ export interface CreateSiteInput { ...@@ -12,6 +12,7 @@ export interface CreateSiteInput {
} }
export interface SiteInfo { export interface SiteInfo {
tenantId: string;
siteId: string; siteId: string;
name: string; name: string;
industry: string; industry: string;
...@@ -36,6 +37,55 @@ export interface SiteInfo { ...@@ -36,6 +37,55 @@ export interface SiteInfo {
lastPublishError?: string; lastPublishError?: string;
} }
export type AccountType = "system_admin" | "tenant_user";
export interface SessionInfo {
userId: string;
username: string;
accountType: AccountType;
tenantId: string | null;
}
export interface TenantAccountInfo {
userId: string;
username: string;
enabled: boolean;
createdAt: string;
}
export interface TenantAdminInfo {
tenantId: string;
name: string;
enabled: boolean;
isTest: boolean;
createdAt: string;
updatedAt: string;
siteCount: number;
accounts: TenantAccountInfo[];
}
export interface TenantAdminSiteInfo {
tenantId: string;
siteId: string;
name: string;
industry: string;
status: SiteStatus;
publishStatus: PublishStatus;
previewUrl: string;
productionUrl?: string;
publishedAt?: string;
createdAt: string;
updatedAt: string;
lastError?: string;
lastPublishError?: string;
}
export interface CreateTenantInput {
name: string;
username: string;
password: string;
}
export interface SitePatch { export interface SitePatch {
summary: string; summary: string;
operations: Array< operations: Array<
...@@ -87,6 +137,7 @@ export interface DomainDnsRecord { ...@@ -87,6 +137,7 @@ export interface DomainDnsRecord {
} }
export interface DomainBinding { export interface DomainBinding {
tenantId: string;
domainId: string; domainId: string;
siteId: string; siteId: string;
hostname: string; hostname: string;
......
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