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.stereotype.Component;
import java.util.List;
/**
* 工作区 Schema 迁移
* Workspace schema bootstrap.
* <p>
* 确保默认工作区id=1, slug='default'存在
* DatabaseBootstrapRunner (@Order(1)) 之后执行
* Ensures the default workspace (id=1, slug='default') exists, and performs a
* <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
*/
@ -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<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) {
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());
}
}
}