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
This commit is contained in:
matevip 2026-04-28 23:41:56 +08:00
parent a73b640c87
commit ae820f0f94

View File

@ -9,11 +9,19 @@ import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List;
/** /**
* 工作区 Schema 迁移 * Workspace schema bootstrap.
* <p> * <p>
* 确保默认工作区id=1, slug='default'存在 * Ensures the default workspace (id=1, slug='default') exists, and performs a
* DatabaseBootstrapRunner (@Order(1)) 之后执行 * <em>first-run-only</em> 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).
* <p>
* Runs after {@code DatabaseBootstrapRunner} ({@code @Order(1)}).
* *
* @author MateClaw Team * @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. * First-run-only bootstrap: if the default workspace has no owner yet, pick
* Non-admin users must be added explicitly via WorkspaceController.addMember. * 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() { private void ensureDefaultWorkspaceMembership() {
try { try {
int inserted = jdbcTemplate.update(""" Integer ownerCount = jdbcTemplate.queryForObject(
INSERT INTO mate_workspace_member (id, workspace_id, user_id, role, create_time, update_time, deleted) "SELECT COUNT(1) FROM mate_workspace_member "
SELECT u.id, 1, u.id, 'owner', NOW(), NOW(), 0 + "WHERE workspace_id = 1 AND role = 'owner' AND deleted = 0",
FROM mate_user u Integer.class);
WHERE u.deleted = 0 if (ownerCount != null && ownerCount > 0) {
AND u.role = 'admin' return;
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);
} }
List<Long> 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) { } 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());
} }
} }
} }