XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEServerManager

【Java】我的世界XFE服务器管理器

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEServerManager

支持处罚理由与玩家可见消息分离

为所有处罚类型新增“玩家可见消息”字段,后端数据结构与数据库表同步更新,前端表单与表格支持 message 输入与展示。命令行支持可选 message 参数,API 文档与接口兼容。优化处罚提示内容,增强中英文显示。完善单元测试,确保兼容旧数据。修复命令参数树与客户端兼容性问题,提升健壮性与用户体验。

1a199eb
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

24 个文件 +659 -63
Modified common/core/src/main/java/com/xfestudio/xfeservermanager/core/governance/moderation/ModerationCase.java +1 -0
@@ -62,6 +62,7 @@ public record ModerationCase(
62 62 SanctionEntry original = next.get(index);
63 63 if (original.type() != replacement.type()
64 64 || !original.reason().equals(replacement.reason())
65 || !original.message().equals(replacement.message())
65 66 || !original.issuedBy().equals(replacement.issuedBy())
66 67 || !original.issuedAt().equals(replacement.issuedAt())
67 68 || !Objects.equals(original.expiresAt(), replacement.expiresAt())) {
Modified common/core/src/main/java/com/xfestudio/xfeservermanager/core/governance/moderation/ModerationLedger.java +33 -1
@@ -10,6 +10,7 @@ import java.util.List;
10 10 import java.util.Map;
11 11 import java.util.Objects;
12 12 import java.util.Optional;
13 import java.util.Set;
13 14 import java.util.UUID;
14 15 import java.util.concurrent.ConcurrentHashMap;
15 16
@@ -45,6 +46,11 @@ public final class ModerationLedger {
45 46
46 47 public synchronized ModerationCase issue(UUID caseId, SanctionType type, String reason,
47 48 String actor, Duration duration) {
49 return issue(caseId, type, reason, reason, actor, duration);
50 }
51
52 public synchronized ModerationCase issue(UUID caseId, SanctionType type, String reason,
53 String message, String actor, Duration duration) {
48 54 Objects.requireNonNull(type, "type");
49 55 Instant now = clock.instant();
50 56 Instant expiresAt = duration == null ? null : now.plus(requirePositive(duration));
@@ -57,7 +63,8 @@ public final class ModerationLedger {
57 63 if ((type == SanctionType.WARN || type == SanctionType.KICK) && expiresAt != null) {
58 64 throw new IllegalArgumentException(type + " cannot have a duration");
59 65 }
60 var action = new SanctionEntry(UUID.randomUUID(), type, reason, actor, now, expiresAt, null, null);
66 var action = new SanctionEntry(UUID.randomUUID(), type, reason, message, actor,
67 now, expiresAt, null, null);
61 68 ModerationCase next = requireCase(cases.get(caseId), caseId).append(action);
62 69 repository.save(next);
63 70 cases.put(caseId, next);
@@ -86,6 +93,31 @@ public final class ModerationLedger {
86 93 return next;
87 94 }
88 95
96 /** Revokes every active action of the requested types without erasing immutable history. */
97 public synchronized List<ModerationCase> revokeActive(UUID subjectId, Set<SanctionType> types,
98 String actor) {
99 Objects.requireNonNull(subjectId, "subjectId");
100 Set<SanctionType> accepted = Set.copyOf(types);
101 if (accepted.isEmpty()) throw new IllegalArgumentException("at least one sanction type is required");
102 Instant now = clock.instant();
103 List<ModerationCase> changed = new ArrayList<>();
104 for (ModerationCase found : cases()) {
105 if (!found.subjectId().equals(subjectId)) continue;
106 ModerationCase next = found;
107 for (SanctionEntry entry : found.history()) {
108 if (accepted.contains(entry.type()) && entry.activeAt(now)) {
109 next = next.replaceAction(entry.revoke(actor, now));
110 }
111 }
112 if (next != found) {
113 repository.save(next);
114 cases.put(next.caseId(), next);
115 changed.add(next);
116 }
117 }
118 return List.copyOf(changed);
119 }
120
89 121 public Optional<ModerationCase> find(UUID caseId) {
90 122 return Optional.ofNullable(cases.get(caseId));
91 123 }
Modified common/core/src/main/java/com/xfestudio/xfeservermanager/core/governance/moderation/SanctionEntry.java +18 -6
@@ -10,6 +10,7 @@ public record SanctionEntry(
10 10 UUID actionId,
11 11 SanctionType type,
12 12 String reason,
13 String message,
13 14 String issuedBy,
14 15 Instant issuedAt,
15 16 Instant expiresAt,
@@ -19,8 +20,9 @@ public record SanctionEntry(
19 20 public SanctionEntry {
20 21 Objects.requireNonNull(actionId, "actionId");
21 22 Objects.requireNonNull(type, "type");
22 reason = requireText(reason, "reason");
23 issuedBy = requireText(issuedBy, "issuedBy");
23 reason = requireText(reason, "reason", 500);
24 message = requireText(message, "message", 500);
25 issuedBy = requireText(issuedBy, "issuedBy", 128);
24 26 Objects.requireNonNull(issuedAt, "issuedAt");
25 27 if (expiresAt != null && !expiresAt.isAfter(issuedAt)) {
26 28 throw new IllegalArgumentException("expiresAt must be after issuedAt");
@@ -42,6 +44,12 @@ public record SanctionEntry(
42 44 }
43 45 }
44 46
47 /** Source-compatible constructor for sanctions created before player-facing messages existed. */
48 public SanctionEntry(UUID actionId, SanctionType type, String reason, String issuedBy,
49 Instant issuedAt, Instant expiresAt, String revokedBy, Instant revokedAt) {
50 this(actionId, type, reason, reason, issuedBy, issuedAt, expiresAt, revokedBy, revokedAt);
51 }
52
45 53 public boolean activeAt(Instant instant) {
46 54 Objects.requireNonNull(instant, "instant");
47 55 if (type == SanctionType.WARN || type == SanctionType.KICK) return false;
@@ -56,14 +64,18 @@ public record SanctionEntry(
56 64 if (revokedAt != null) {
57 65 throw new IllegalStateException("sanction is already revoked");
58 66 }
59 return new SanctionEntry(actionId, type, reason, issuedBy, issuedAt, expiresAt,
60 requireText(actor, "actor"), Objects.requireNonNull(at, "at"));
67 return new SanctionEntry(actionId, type, reason, message, issuedBy, issuedAt, expiresAt,
68 requireText(actor, "actor", 128), Objects.requireNonNull(at, "at"));
61 69 }
62 70
63 private static String requireText(String value, String name) {
71 private static String requireText(String value, String name, int maximum) {
64 72 if (value == null || value.isBlank()) {
65 73 throw new IllegalArgumentException(name + " must not be blank");
66 74 }
67 return value;
75 String normalized = value.strip();
76 if (normalized.length() > maximum || normalized.indexOf('\0') >= 0) {
77 throw new IllegalArgumentException(name + " must contain 1-" + maximum + " safe characters");
78 }
79 return normalized;
68 80 }
69 81 }
Modified common/infra/src/main/java/com/xfestudio/xfeservermanager/infra/persistence/SqliteGovernanceStateRepository.java +11 -8
@@ -288,8 +288,8 @@ public final class SqliteGovernanceStateRepository
288 288 + "ON CONFLICT(case_id) DO UPDATE SET reason_template=excluded.reason_template, "
289 289 + "evidence_json=excluded.evidence_json, staff_notes_json=excluded.staff_notes_json, "
290 290 + "updated_at=excluded.updated_at";
291 String actionSql = "INSERT INTO sanctions(action_id, case_id, sanction_type, reason, issued_by, "
292 + "issued_at, expires_at, revoked_by, revoked_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) "
291 String actionSql = "INSERT INTO sanctions(action_id, case_id, sanction_type, reason, message, issued_by, "
292 + "issued_at, expires_at, revoked_by, revoked_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
293 293 + "ON CONFLICT(action_id) DO UPDATE SET revoked_by=excluded.revoked_by, revoked_at=excluded.revoked_at";
294 294 try (Connection connection = database.openConnection()) {
295 295 connection.setAutoCommit(false);
@@ -309,11 +309,12 @@ public final class SqliteGovernanceStateRepository
309 309 actionStatement.setString(2, moderationCase.caseId().toString());
310 310 actionStatement.setString(3, action.type().name());
311 311 actionStatement.setString(4, action.reason());
312 actionStatement.setString(5, action.issuedBy());
313 actionStatement.setLong(6, action.issuedAt().toEpochMilli());
314 setInstant(actionStatement, 7, action.expiresAt());
315 actionStatement.setString(8, action.revokedBy());
316 setInstant(actionStatement, 9, action.revokedAt());
312 actionStatement.setString(5, action.message());
313 actionStatement.setString(6, action.issuedBy());
314 actionStatement.setLong(7, action.issuedAt().toEpochMilli());
315 setInstant(actionStatement, 8, action.expiresAt());
316 actionStatement.setString(9, action.revokedBy());
317 setInstant(actionStatement, 10, action.revokedAt());
317 318 actionStatement.addBatch();
318 319 }
319 320 actionStatement.executeBatch();
@@ -333,7 +334,7 @@ public final class SqliteGovernanceStateRepository
333 334 public List<ModerationCase> loadAll() {
334 335 String casesSql = "SELECT case_id, subject_id, opened_by, opened_at, reason_template, "
335 336 + "evidence_json, staff_notes_json FROM moderation_cases ORDER BY opened_at, case_id";
336 String actionsSql = "SELECT action_id, sanction_type, reason, issued_by, issued_at, expires_at, "
337 String actionsSql = "SELECT action_id, sanction_type, reason, message, issued_by, issued_at, expires_at, "
337 338 + "revoked_by, revoked_at FROM sanctions WHERE case_id=? ORDER BY issued_at, action_id";
338 339 List<ModerationCase> result = new ArrayList<>();
339 340 try (Connection connection = database.openConnection();
@@ -349,6 +350,8 @@ public final class SqliteGovernanceStateRepository
349 350 UUID.fromString(rows.getString("action_id")),
350 351 SanctionType.valueOf(rows.getString("sanction_type")),
351 352 rows.getString("reason"),
353 rows.getString("message").isBlank()
354 ? rows.getString("reason") : rows.getString("message"),
352 355 rows.getString("issued_by"),
353 356 Instant.ofEpochMilli(rows.getLong("issued_at")),
354 357 nullableInstant(rows, "expires_at"),
Added common/infra/src/main/resources/db/migration/V010__moderation_messages.sql +1 -0
@@ -0,0 +1 @@
1 ALTER TABLE sanctions ADD COLUMN message TEXT NOT NULL DEFAULT '';
Modified common/infra/src/main/resources/db/migration/index.txt +1 -0
@@ -7,3 +7,4 @@
7 7 7|trigger groups and definitions|db/migration/V007__triggers.sql
8 8 8|interactive menu definitions and images|db/migration/V008__menus.sql
9 9 9|multi-currency economy accounts and ledger|db/migration/V009__economy.sql
10 10|player-facing moderation messages|db/migration/V010__moderation_messages.sql
Modified common/infra/src/test/java/com/xfestudio/xfeservermanager/infra/persistence/SqliteDatabaseTest.java +1 -1
@@ -24,7 +24,7 @@ class SqliteDatabaseTest {
24 24 var query = connection.createStatement().executeQuery(
25 25 "SELECT COUNT(*) FROM schema_migrations")) {
26 26 assertThat(query.next()).isTrue();
27 assertThat(query.getInt(1)).isEqualTo(9);
27 assertThat(query.getInt(1)).isEqualTo(10);
28 28 }
29 29 }
30 30
Modified common/infra/src/test/java/com/xfestudio/xfeservermanager/infra/persistence/SqliteGovernanceStateRepositoryTest.java +20 -0
@@ -10,6 +10,8 @@ import com.xfestudio.xfeservermanager.core.governance.maintenance.MaintenanceSer
10 10 import com.xfestudio.xfeservermanager.core.governance.maintenance.ScheduledJob;
11 11 import com.xfestudio.xfeservermanager.core.governance.maintenance.ScheduledJobStatus;
12 12 import com.xfestudio.xfeservermanager.core.governance.maintenance.StructuredAction;
13 import com.xfestudio.xfeservermanager.core.governance.moderation.ModerationLedger;
14 import com.xfestudio.xfeservermanager.core.governance.moderation.SanctionType;
13 15 import java.nio.file.Path;
14 16 import java.time.Clock;
15 17 import java.time.Duration;
@@ -101,4 +103,22 @@ class SqliteGovernanceStateRepositoryTest {
101 103 assertThat(nextDay.firstJoin()).isFalse();
102 104 assertThat(nextDay.dailyAnnouncementDue()).isTrue();
103 105 }
106
107 @Test
108 void restoresDistinctPlayerFacingModerationMessages() {
109 Clock clock = Clock.systemUTC();
110 var database = new SqliteDatabase(directory.resolve("moderation.db"), clock);
111 database.migrate();
112 var repository = new SqliteGovernanceStateRepository(database, clock);
113 var ledger = new ModerationLedger(clock, repository, repository.loadAll());
114 var moderationCase = ledger.openCase(UUID.randomUUID(), "owner", "private reason",
115 java.util.List.of(), java.util.List.of());
116 ledger.issue(moderationCase.caseId(), SanctionType.TEMPBAN, "private reason",
117 "Visible reconnect message", "owner", Duration.ofHours(3));
118
119 var restored = repository.loadAll();
120 assertThat(restored).hasSize(1);
121 assertThat(restored.get(0).history().get(0).reason()).isEqualTo("private reason");
122 assertThat(restored.get(0).history().get(0).message()).isEqualTo("Visible reconnect message");
123 }
104 124 }
Modified common/testkit/src/test/java/com/xfestudio/xfeservermanager/core/governance/moderation/ModerationLedgerTest.java +21 -0
@@ -12,6 +12,7 @@ import java.time.Instant;
12 12 import java.time.ZoneId;
13 13 import java.time.ZoneOffset;
14 14 import java.util.List;
15 import java.util.Set;
15 16 import java.util.UUID;
16 17 import org.junit.jupiter.api.Test;
17 18
@@ -76,6 +77,26 @@ class ModerationLedgerTest {
76 77 assertEquals(SanctionType.WARN, afterLookback.history().get(3).type());
77 78 }
78 79
80 @Test
81 void keepsPlayerFacingMessageSeparateAndRevokesEveryActiveBanForAPlayer() {
82 var clock = new MutableClock(Instant.parse("2026-09-04T00:00:00Z"));
83 var ledger = new ModerationLedger(clock);
84 UUID player = UUID.randomUUID();
85 var permanent = ledger.openCase(player, "owner", "internal evidence", List.of(), List.of());
86 permanent = ledger.issue(permanent.caseId(), SanctionType.BAN, "internal evidence",
87 "You may appeal at example.invalid", "owner", null);
88 var temporary = ledger.openCase(player, "owner", "continued abuse", List.of(), List.of());
89 ledger.issue(temporary.caseId(), SanctionType.TEMPBAN, "continued abuse",
90 "Return after the cooldown", "owner", Duration.ofHours(2));
91
92 assertEquals("You may appeal at example.invalid", permanent.history().get(0).message());
93 assertEquals(2, ledger.activeSanctions(player).size());
94 assertEquals(2, ledger.revokeActive(player, Set.of(SanctionType.BAN, SanctionType.TEMPBAN),
95 "administrator").size());
96 assertTrue(ledger.activeSanctions(player).isEmpty());
97 assertEquals(2, ledger.history(player).size(), "unban must retain immutable moderation history");
98 }
99
79 100 private static final class MutableClock extends Clock {
80 101 private Instant instant;
81 102
Modified docs/openapi.yaml +4 -0
@@ -2525,6 +2525,8 @@ components:
2525 2525 playerId: {type: string, format: uuid}
2526 2526 action: {type: string, enum: [warn, mute, kick, tempban, ban]}
2527 2527 reason: {type: string, minLength: 1, maxLength: 500}
2528 message: {type: string, minLength: 1, maxLength: 500, description: Player-facing message; defaults to reason when omitted.}
2529 duration: {type: string, description: Required for tempban. Examples include 30m, 2h, 7d, or an ISO-8601 duration.}
2528 2530 templateId: {type: [string, "null"]}
2529 2531 expiresAt: {type: [string, "null"], format: date-time}
2530 2532 evidenceUrls: {type: array, items: {type: string, format: uri}}
@@ -2537,9 +2539,11 @@ components:
2537 2539 playerId: {type: string, format: uuid}
2538 2540 action: {type: string}
2539 2541 reason: {type: string}
2542 message: {type: string}
2540 2543 createdAt: {type: string, format: date-time}
2541 2544 expiresAt: {type: [string, "null"], format: date-time}
2542 2545 active: {type: boolean}
2546 remainingSeconds: {type: integer, description: Seconds remaining, or -1 for a permanent/no-expiry action.}
2543 2547 evidenceUrls: {type: array, items: {type: string, format: uri}}
2544 2548 ModerationCasePage:
2545 2549 type: object
Modified platform/shared/src/forge-common/java/com/xfestudio/xfeservermanager/platform/forge/ForgeCommands.java +152 -3
@@ -7,17 +7,22 @@ import com.xfestudio.xfeservermanager.api.status.MemoryMetrics;
7 7 import com.xfestudio.xfeservermanager.api.status.ServerStatusSnapshot;
8 8 import com.xfestudio.xfeservermanager.api.status.TickMetrics;
9 9 import com.xfestudio.xfeservermanager.api.command.CommandSourceKind;
10 import com.xfestudio.xfeservermanager.core.governance.moderation.SanctionType;
10 11 import com.xfestudio.xfeservermanager.platform.bridge.BuildMetadata;
11 12 import com.xfestudio.xfeservermanager.platform.bridge.CommandPolicyBridge;
12 13 import com.xfestudio.xfeservermanager.platform.bridge.PlatformServices;
13 14 import net.minecraft.commands.CommandSourceStack;
14 15 import net.minecraft.commands.Commands;
15 16 import net.minecraft.commands.arguments.EntityArgument;
17 import net.minecraft.commands.arguments.GameProfileArgument;
16 18 import net.minecraft.network.chat.Component;
17 19 import net.minecraft.server.level.ServerPlayer;
18 20
19 21 import java.time.Duration;
22 import java.util.Collection;
23 import java.util.List;
20 24 import java.util.Locale;
25 import java.util.UUID;
21 26
22 27 /** Minimal in-game/console fallback for deployments where the web panel is unavailable. */
23 28 public final class ForgeCommands {
@@ -50,7 +55,7 @@ public final class ForgeCommands {
50 55 .then(Commands.literal("checkpoint")
51 56 .executes(context -> checkpoint(context.getSource()))))
52 57 .then(tick())
53 .then(governance("moderation"))
58 .then(moderation())
54 59 .then(governance("maintenance"))
55 60 .then(governance("announce"))
56 61 .then(governance("schedule"))
@@ -147,8 +152,8 @@ public final class ForgeCommands {
147 152 "Commands: status | web | player | policy | audit | system | tick",
148 153 "命令:status | web | player | policy | audit | system | tick"));
149 154 success(source, ForgeLocalization.select(source,
150 "v2 commands: moderation | maintenance | announce | schedule | claim | lookup | rollback | restore",
151 "v2 命令:moderation | maintenance | announce | schedule | claim | lookup | rollback | restore"));
155 "Moderation: /xfesm moderation warn|ban|tempban|unban ...",
156 "处罚命令:/xfesm moderation warn|ban|tempban|unban ..."));
152 157 return 1;
153 158 }
154 159
@@ -302,6 +307,150 @@ public final class ForgeCommands {
302 307 .executes(context -> governance(context.getSource(), name));
303 308 }
304 309
310 private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack> moderation() {
311 return Commands.literal("moderation")
312 .requires(source -> ForgePermissionCompat.hasPermission(source, 4))
313 .executes(context -> moderationHelp(context.getSource()))
314 .then(Commands.literal("warn")
315 .then(Commands.argument("player", EntityArgument.player())
316 .then(Commands.argument("reason", StringArgumentType.string())
317 .executes(context -> issueModeration(context.getSource(),
318 List.of(target(EntityArgument.getPlayer(context, "player"))),
319 SanctionType.WARN, null,
320 StringArgumentType.getString(context, "reason"), null))
321 .then(Commands.argument("message", StringArgumentType.greedyString())
322 .executes(context -> issueModeration(context.getSource(),
323 List.of(target(EntityArgument.getPlayer(context, "player"))),
324 SanctionType.WARN, null,
325 StringArgumentType.getString(context, "reason"),
326 StringArgumentType.getString(context, "message")))))))
327 .then(profileModeration("ban", SanctionType.BAN, false))
328 .then(profileModeration("tempban", SanctionType.TEMPBAN, true))
329 .then(Commands.literal("unban")
330 .then(Commands.argument("players", GameProfileArgument.gameProfile())
331 .executes(context -> unban(context.getSource(), profiles(
332 GameProfileArgument.getGameProfiles(context, "players")),
333 "Unbanned by " + context.getSource().getTextName()))
334 .then(Commands.argument("reason", StringArgumentType.greedyString())
335 .executes(context -> unban(context.getSource(), profiles(
336 GameProfileArgument.getGameProfiles(context, "players")),
337 StringArgumentType.getString(context, "reason"))))));
338 }
339
340 private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack> profileModeration(
341 String command, SanctionType type, boolean durationRequired) {
342 var target = Commands.argument("players", GameProfileArgument.gameProfile());
343 if (durationRequired) {
344 target.then(Commands.argument("duration", StringArgumentType.word())
345 .then(moderationReason(type, true)));
346 } else {
347 target.then(moderationReason(type, false));
348 }
349 return Commands.literal(command).then(target);
350 }
351
352 private static com.mojang.brigadier.builder.RequiredArgumentBuilder<CommandSourceStack, String> moderationReason(
353 SanctionType type, boolean withDuration) {
354 return Commands.argument("reason", StringArgumentType.string())
355 .executes(context -> issueModeration(context.getSource(), profiles(
356 GameProfileArgument.getGameProfiles(context, "players")), type,
357 withDuration ? ForgeGovernanceService.parseDuration(
358 StringArgumentType.getString(context, "duration"), true) : null,
359 StringArgumentType.getString(context, "reason"), null))
360 .then(Commands.argument("message", StringArgumentType.greedyString())
361 .executes(context -> issueModeration(context.getSource(), profiles(
362 GameProfileArgument.getGameProfiles(context, "players")), type,
363 withDuration ? ForgeGovernanceService.parseDuration(
364 StringArgumentType.getString(context, "duration"), true) : null,
365 StringArgumentType.getString(context, "reason"),
366 StringArgumentType.getString(context, "message"))));
367 }
368
369 private static int moderationHelp(CommandSourceStack source) {
370 success(source, ForgeLocalization.select(source,
371 "Usage: warn <player> <reason> [message]; ban <players> <reason> [message]; "
372 + "tempban <players> <30m|2h|7d> <reason> [message]; unban <players> [reason]",
373 "用法:warn <玩家> <理由> [消息];ban <玩家> <理由> [消息];"
374 + "tempban <玩家> <30m|2h|7d> <理由> [消息];unban <玩家> [理由]"));
375 success(source, ForgeLocalization.select(source,
376 "Quote a reason that contains spaces; the optional player-facing message may contain spaces.",
377 "理由含空格时请使用引号;可选的玩家可见消息可直接包含空格。"));
378 return 1;
379 }
380
381 private static int issueModeration(CommandSourceStack source,
382 List<ForgeManagementRuntime.ModerationTarget> targets,
383 SanctionType type, Duration duration, String reason, String message) {
384 String playerMessage = message == null || message.isBlank() ? reason : message;
385 try {
386 return moderationResult(source, ForgeManagementRuntime.issueModeration(targets, type, duration,
387 reason, playerMessage, source.getTextName(), ForgeCommandAdapter.sourceKind(source)),
388 type.name());
389 } catch (RuntimeException failure) {
390 ForgeLocalization.failure(source, "Moderation failed: " + rootMessage(failure),
391 "处罚执行失败:" + rootMessage(failure));
392 return 0;
393 }
394 }
395
396 private static int unban(CommandSourceStack source,
397 List<ForgeManagementRuntime.ModerationTarget> targets, String reason) {
398 try {
399 return moderationResult(source, ForgeManagementRuntime.unban(targets, reason,
400 source.getTextName(), ForgeCommandAdapter.sourceKind(source)), "UNBAN");
401 } catch (RuntimeException failure) {
402 ForgeLocalization.failure(source, "Unban failed: " + rootMessage(failure),
403 "解除封禁失败:" + rootMessage(failure));
404 return 0;
405 }
406 }
407
408 private static int moderationResult(CommandSourceStack source,
409 java.util.concurrent.CompletionStage<ForgeManagementRuntime.ModerationResult> stage, String action) {
410 stage.whenComplete((result, failure) -> source.getServer().execute(() -> {
411 if (failure != null) {
412 ForgeLocalization.failure(source, action + " failed: " + rootMessage(failure),
413 action + " 执行失败:" + rootMessage(failure));
414 return;
415 }
416 String text = ForgeLocalization.select(source,
417 "%s completed for %d target(s); %d case(s) changed",
418 "%s 已对 %d 个目标完成,变更 %d 个案件")
419 .formatted(action, result.matchedTargets(), result.changedCases());
420 success(source, text);
421 }));
422 return 1;
423 }
424
425 private static ForgeManagementRuntime.ModerationTarget target(ServerPlayer player) {
426 return new ForgeManagementRuntime.ModerationTarget(player.getUUID(), player.getScoreboardName());
427 }
428
429 private static List<ForgeManagementRuntime.ModerationTarget> profiles(Collection<?> values) {
430 return values.stream().map(ForgeCommands::profile).toList();
431 }
432
433 private static ForgeManagementRuntime.ModerationTarget profile(Object value) {
434 try {
435 Object id = accessor(value, "getId", "id");
436 Object name = accessor(value, "getName", "name");
437 return new ForgeManagementRuntime.ModerationTarget((UUID) id, String.valueOf(name));
438 } catch (ReflectiveOperationException | ClassCastException failure) {
439 throw new IllegalStateException("unsupported game profile value " + value.getClass().getName(), failure);
440 }
441 }
442
443 private static Object accessor(Object value, String... names) throws ReflectiveOperationException {
444 for (String name : names) {
445 try {
446 return value.getClass().getMethod(name).invoke(value);
447 } catch (NoSuchMethodException ignored) {
448 // Try the accessor used by the other supported Minecraft generation.
449 }
450 }
451 throw new NoSuchMethodException(value.getClass().getName());
452 }
453
305 454 private static int governance(CommandSourceStack source, String name) {
306 455 var status = ForgeManagementRuntime.governanceStatus(name);
307 456 boolean enabled = Boolean.TRUE.equals(status.get("enabled"));
Modified platform/shared/src/forge-common/java/com/xfestudio/xfeservermanager/platform/forge/ForgeGovernanceService.java +108 -13
@@ -150,21 +150,51 @@ final class ForgeGovernanceService {
150 150 };
151 151 }
152 152
153 ModerationCase createModerationCase(UUID subjectId, SanctionType type, String reason,
153 ModerationCase createModerationCase(UUID subjectId, SanctionType type, String reason, String message,
154 154 Duration duration, List<URI> evidence, String staffNote,
155 155 AuthenticatedPrincipal principal, String requestId) {
156 156 requireFeature("moderation");
157 String safeMessage = boundedMessage(message);
157 158 requireAudit(principal, "moderation.issue.admission", subjectId.toString(), reason, requestId,
158 159 Map.of("type", type.name()));
159 160 List<String> notes = staffNote == null || staffNote.isBlank() ? List.of() : List.of(staffNote.strip());
160 161 ModerationCase opened = moderation.openCase(subjectId, principal.username(), reason, evidence, notes);
161 ModerationCase result = moderation.issue(opened.caseId(), type, reason, principal.username(), duration);
162 ModerationCase result = moderation.issue(opened.caseId(), type, reason, safeMessage,
163 principal.username(), duration);
162 164 auditCompletion(principal, "moderation.issue", subjectId.toString(), reason, requestId,
163 165 Map.of("caseId", result.caseId().toString(), "type", type.name()));
164 166 runtime.publish("moderation", Map.of("changed", true));
165 167 return result;
166 168 }
167 169
170 ModerationCase createModerationCaseFromCommand(UUID subjectId, SanctionType type, String reason,
171 String message, Duration duration, String actor,
172 CommandSourceKind sourceKind, String requestId) {
173 requireFeature("moderation");
174 String safeMessage = boundedMessage(message);
175 requireActorAudit(actor, sourceKind, "moderation.issue.admission", subjectId.toString(), reason,
176 requestId, Map.of("type", type.name()));
177 ModerationCase opened = moderation.openCase(subjectId, actor, reason, List.of(), List.of());
178 ModerationCase result = moderation.issue(opened.caseId(), type, reason, safeMessage, actor, duration);
179 actorAuditCompletion(actor, sourceKind, "moderation.issue", subjectId.toString(), reason, requestId,
180 Map.of("caseId", result.caseId().toString(), "type", type.name()));
181 runtime.publish("moderation", Map.of("changed", true));
182 return result;
183 }
184
185 int revokeActiveBansFromCommand(UUID subjectId, String actor, CommandSourceKind sourceKind,
186 String reason, String requestId) {
187 requireFeature("moderation");
188 requireActorAudit(actor, sourceKind, "moderation.unban.admission", subjectId.toString(), reason,
189 requestId, Map.of());
190 List<ModerationCase> changed = moderation.revokeActive(subjectId,
191 Set.of(SanctionType.BAN, SanctionType.TEMPBAN), actor);
192 actorAuditCompletion(actor, sourceKind, "moderation.unban", subjectId.toString(), reason, requestId,
193 Map.of("revokedCases", Integer.toString(changed.size())));
194 if (!changed.isEmpty()) runtime.publish("moderation", Map.of("changed", true));
195 return changed.size();
196 }
197
168 198 ModerationCase revoke(UUID caseId, UUID actionId, AuthenticatedPrincipal principal,
169 199 String reason, String requestId) {
170 200 requireFeature("moderation");
@@ -195,21 +225,19 @@ final class ForgeGovernanceService {
195 225 return available("moderation") ? moderation.activeSanctions(playerId) : List.of();
196 226 }
197 227
198 void enforceSanctions(ServerPlayer player) {
199 if (!available("moderation")) return;
228 boolean enforceSanctions(ServerPlayer player) {
229 if (!available("moderation")) return true;
200 230 List<SanctionEntry> active = moderation.activeSanctions(player.getUUID());
201 231 Optional<SanctionEntry> ban = active.stream()
202 232 .filter(action -> action.type() == SanctionType.BAN || action.type() == SanctionType.TEMPBAN)
203 233 .max(Comparator.comparing(SanctionEntry::issuedAt));
204 234 if (ban.isPresent()) {
205 235 SanctionEntry action = ban.orElseThrow();
206 String until = action.expiresAt() == null
207 ? ForgeLocalization.select(player, "permanent", "永久")
208 : ForgeLocalization.select(player, "until ", "截止时间:") + action.expiresAt();
209 player.connection.disconnect(Component.literal(ForgeLocalization.select(player,
210 "Banned by XFEServerManager: ", "已被 XFEServerManager 封禁:")
211 + action.reason() + " (" + until + ')'));
236 player.connection.disconnect(Component.literal(banDisconnectMessage(action, clock.instant(),
237 ForgeLocalization.usesChinese(player))));
238 return false;
212 239 }
240 return true;
213 241 }
214 242
215 243 void applyIssuedSanction(ModerationCase moderationCase) {
@@ -220,11 +248,14 @@ final class ForgeGovernanceService {
220 248 String defaultMessageSender = runtime.configuration().defaultMessageSender();
221 249 switch (action.type()) {
222 250 case WARN -> player.sendSystemMessage(ForgeRichTextMessage.render(ForgeLocalization.select(player,
223 "Warning: ", "警告:") + action.reason(), player, defaultMessageSender));
224 case KICK -> player.connection.disconnect(Component.literal(action.reason()));
251 "Warning: ", "警告:") + action.message()
252 + reasonSuffix(action, player), player, defaultMessageSender));
253 case KICK -> player.connection.disconnect(Component.literal(action.message()
254 + reasonSuffix(action, player)));
225 255 case BAN, TEMPBAN -> enforceSanctions(player);
226 256 case MUTE -> player.sendSystemMessage(ForgeRichTextMessage.render(ForgeLocalization.select(player,
227 "You have been muted: ", "你已被禁言:") + action.reason(), player,
257 "You have been muted: ", "你已被禁言:") + action.message()
258 + reasonSuffix(action, player), player,
228 259 defaultMessageSender));
229 260 }
230 261 }
@@ -809,6 +840,21 @@ final class ForgeGovernanceService {
809 840 if (!auditAdmission.test(event)) throw new IllegalStateException("audit writer admission is unavailable");
810 841 }
811 842
843 private void requireActorAudit(String actor, CommandSourceKind sourceKind, String action, String target,
844 String reason, String requestId, Map<String, String> metadata) {
845 AuditEvent event = new AuditEvent(UUID.randomUUID(), clock.instant(), actor, actor, sourceKind,
846 action, target, boundedReason(reason), Map.of(), Map.of(), null, requestId,
847 AuditOutcome.SUCCEEDED, 0, metadata);
848 if (!auditAdmission.test(event)) throw new IllegalStateException("audit writer admission is unavailable");
849 }
850
851 private void actorAuditCompletion(String actor, CommandSourceKind sourceKind, String action, String target,
852 String reason, String requestId, Map<String, String> after) {
853 auditAdmission.test(new AuditEvent(UUID.randomUUID(), clock.instant(), actor, actor, sourceKind,
854 action, target, boundedReason(reason), Map.of(), after, null, requestId,
855 AuditOutcome.SUCCEEDED, 0, Map.of("phase", "completion")));
856 }
857
812 858 Map<String, Object> moderationDto(ModerationCase value) {
813 859 SanctionEntry action = value.history().isEmpty() ? null
814 860 : value.history().get(value.history().size() - 1);
@@ -824,9 +870,12 @@ final class ForgeGovernanceService {
824 870 dto.put("action", action == null ? "" : action.type().name().toLowerCase(Locale.ROOT));
825 871 dto.put("type", action == null ? "" : action.type().name());
826 872 dto.put("reason", action == null ? value.reasonTemplate() : action.reason());
873 dto.put("message", action == null ? value.reasonTemplate() : action.message());
827 874 dto.put("createdAt", value.openedAt().toString());
828 875 dto.put("expiresAt", action == null || action.expiresAt() == null ? "" : action.expiresAt().toString());
829 876 dto.put("active", action != null && action.activeAt(now));
877 dto.put("remainingSeconds", action == null || action.expiresAt() == null ? -1L
878 : Math.max(0L, Duration.between(now, action.expiresAt()).toSeconds()));
830 879 dto.put("state", state);
831 880 dto.put("actor", value.openedBy());
832 881 dto.put("evidenceUrls", value.evidenceUrls().stream().map(URI::toString).toList());
@@ -839,6 +888,7 @@ final class ForgeGovernanceService {
839 888 value.put("id", action.actionId().toString());
840 889 value.put("type", action.type().name());
841 890 value.put("reason", action.reason());
891 value.put("message", action.message());
842 892 value.put("issuedBy", action.issuedBy());
843 893 value.put("issuedAt", action.issuedAt().toString());
844 894 value.put("expiresAt", action.expiresAt() == null ? "" : action.expiresAt().toString());
@@ -949,6 +999,44 @@ final class ForgeGovernanceService {
949 999 };
950 1000 }
951 1001
1002 static String banDisconnectMessage(SanctionEntry action, Instant now, boolean chinese) {
1003 StringBuilder value = new StringBuilder(chinese
1004 ? "已被 XFEServerManager 封禁"
1005 : "Banned by XFEServerManager");
1006 if (!action.message().equals(action.reason())) {
1007 value.append("\n\n").append(action.message());
1008 }
1009 value.append("\n\n").append(chinese ? "理由:" : "Reason: ").append(action.reason());
1010 value.append('\n').append(chinese ? "剩余时间:" : "Remaining: ");
1011 if (action.expiresAt() == null) {
1012 value.append(chinese ? "永久" : "permanent");
1013 } else {
1014 value.append(formatRemaining(Duration.between(now, action.expiresAt()), chinese));
1015 value.append('\n').append(chinese ? "到期时间:" : "Expires: ")
1016 .append(action.expiresAt());
1017 }
1018 return value.toString();
1019 }
1020
1021 private static String reasonSuffix(SanctionEntry action, ServerPlayer player) {
1022 if (action.message().equals(action.reason())) return "";
1023 return ForgeLocalization.select(player, "\nReason: ", "\n理由:") + action.reason();
1024 }
1025
1026 private static String formatRemaining(Duration duration, boolean chinese) {
1027 long seconds = Math.max(1L, duration.toSeconds() + (duration.toNanosPart() == 0 ? 0 : 1));
1028 long days = seconds / 86_400;
1029 long hours = seconds % 86_400 / 3_600;
1030 long minutes = seconds % 3_600 / 60;
1031 long remainder = seconds % 60;
1032 List<String> parts = new ArrayList<>();
1033 if (days > 0) parts.add(days + (chinese ? "天" : days == 1 ? " day" : " days"));
1034 if (hours > 0) parts.add(hours + (chinese ? "小时" : hours == 1 ? " hour" : " hours"));
1035 if (minutes > 0) parts.add(minutes + (chinese ? "分钟" : minutes == 1 ? " minute" : " minutes"));
1036 if (remainder > 0 || parts.isEmpty()) parts.add(remainder + (chinese ? "秒" : remainder == 1 ? " second" : " seconds"));
1037 return String.join(chinese ? "" : " ", parts);
1038 }
1039
952 1040 private static String boundedReason(String reason) {
953 1041 if (reason == null || reason.isBlank() || reason.length() > 500) {
954 1042 throw new IllegalArgumentException("reason must contain 1-500 characters");
@@ -956,6 +1044,13 @@ final class ForgeGovernanceService {
956 1044 return reason.strip();
957 1045 }
958 1046
1047 private static String boundedMessage(String message) {
1048 if (message == null || message.isBlank() || message.length() > 500 || message.indexOf('\0') >= 0) {
1049 throw new IllegalArgumentException("message must contain 1-500 safe characters");
1050 }
1051 return message.strip();
1052 }
1053
959 1054 private static String scheduledKind(StructuredAction action) {
960 1055 if (action instanceof StructuredAction.Announce) return "announce";
961 1056 if (action instanceof StructuredAction.SetMaintenance value) {
Modified platform/shared/src/forge-common/java/com/xfestudio/xfeservermanager/platform/forge/ForgeManagementGateway.java +41 -2
Modified platform/shared/src/forge-common/java/com/xfestudio/xfeservermanager/platform/forge/ForgeManagementRuntime.java +39 -0
Modified platform/shared/src/forge-common/java/com/xfestudio/xfeservermanager/platform/forge/ForgeTriggerCommandRegistry.java +115 -10
Modified platform/shared/src/forge-common/java/com/xfestudio/xfeservermanager/platform/forge/mixin/MenuChatComponentMixin.java +4 -1
Modified platform/shared/src/forge-common/java/com/xfestudio/xfeservermanager/platform/forge/mixin/XFEServerManagerMixinPlugin.java +18 -0
Modified platform/shared/src/test/java/com/xfestudio/xfeservermanager/platform/forge/ForgeGovernanceServiceTest.java +21 -0
Modified platform/shared/src/test/java/com/xfestudio/xfeservermanager/platform/forge/ForgeTriggerCommandRegistryTest.java +16 -0
Modified web-ui/src/lib/i18n.tsx +4 -0
Modified web-ui/src/pages/moderation.tsx +9 -4
Modified web-ui/src/pages/triggers.test.tsx +17 -1
Modified web-ui/src/pages/triggers.tsx +1 -13
Modified web-ui/src/types.ts +3 -0