From ae820f0f943a7673013f8fc26c0b565cbc9e5f18 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 28 Apr 2026 23:41:56 +0800 Subject: [PATCH] fix(workspace): make default-workspace owner bootstrap first-run-only Replace the per-startup admin reconciliation in WorkspaceSchemaMigration.ensureDefaultWorkspaceMembership() with a one-shot bootstrap. Once the default workspace has any owner, the method returns immediately, so an operator's deliberate removal of an admin from the default workspace persists across restarts. If no owner exists yet, pick the lowest-id active admin and add them as owner; if no admin exists at all, log a warning and skip rather than failing startup. Refs https://github.com/matevip/mateclaw/issues/29 --- .../core/config/WorkspaceSchemaMigration.java | 60 +++++++++++++------ 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSchemaMigration.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSchemaMigration.java index cb57ef16..74eebb49 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSchemaMigration.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSchemaMigration.java @@ -9,11 +9,19 @@ import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; +import java.util.List; + /** - * 工作区 Schema 迁移 + * Workspace schema bootstrap. *

- * 确保默认工作区(id=1, slug='default')存在。 - * 在 DatabaseBootstrapRunner (@Order(1)) 之后执行。 + * Ensures the default workspace (id=1, slug='default') exists, and performs a + * first-run-only bootstrap of an admin owner. This is not a + * reconciliation loop: once the default workspace has any owner, subsequent + * startups make no membership changes — operators may legitimately remove an + * admin from the default workspace and that decision must persist across + * restarts (see issue #29). + *

+ * Runs after {@code DatabaseBootstrapRunner} ({@code @Order(1)}). * * @author MateClaw Team */ @@ -60,27 +68,41 @@ public class WorkspaceSchemaMigration implements ApplicationRunner { } /** - * Ensure all admin users have an owner-role membership in the default workspace. - * Non-admin users must be added explicitly via WorkspaceController.addMember. + * First-run-only bootstrap: if the default workspace has no owner yet, pick + * the lowest-id active admin and add them as owner. If an owner already + * exists (or was deliberately removed and re-installed by an operator), do + * nothing. If no admin exists at all, log a warning and skip — failing + * startup here would be worse than the recoverable "no owner" state. */ private void ensureDefaultWorkspaceMembership() { try { - int inserted = jdbcTemplate.update(""" - INSERT INTO mate_workspace_member (id, workspace_id, user_id, role, create_time, update_time, deleted) - SELECT u.id, 1, u.id, 'owner', NOW(), NOW(), 0 - FROM mate_user u - WHERE u.deleted = 0 - AND u.role = 'admin' - AND NOT EXISTS ( - SELECT 1 FROM mate_workspace_member wm - WHERE wm.workspace_id = 1 AND wm.user_id = u.id AND wm.deleted = 0 - ) - """); - if (inserted > 0) { - log.info("Added {} admin user(s) as owner of default workspace", inserted); + Integer ownerCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM mate_workspace_member " + + "WHERE workspace_id = 1 AND role = 'owner' AND deleted = 0", + Integer.class); + if (ownerCount != null && ownerCount > 0) { + return; } + List adminIds = jdbcTemplate.queryForList( + "SELECT id FROM mate_user WHERE deleted = 0 AND role = 'admin' " + + "ORDER BY id ASC LIMIT 1", + Long.class); + if (adminIds.isEmpty()) { + log.warn("Default workspace has no owner and no admin user exists; " + + "skipping bootstrap. Operator must assign an owner manually."); + return; + } + Long adminId = adminIds.get(0); + jdbcTemplate.update( + "INSERT INTO mate_workspace_member " + + "(id, workspace_id, user_id, role, create_time, update_time, deleted) " + + "VALUES (?, 1, ?, 'owner', NOW(), NOW(), 0)", + adminId, adminId); + log.info("Bootstrapped admin user {} as owner of default workspace", adminId); } catch (DataAccessException e) { - log.debug("Skipping default workspace admin membership init: {}", e.getMessage()); + // Defensive guard: tables may not exist yet during very early startup, + // or the insert may collide with a soft-deleted row's primary key. + log.debug("Skipping default workspace owner bootstrap: {}", e.getMessage()); } } }