返回提交历史
Modified
common/core/src/main/java/com/xfestudio/xfeservermanager/core/governance/moderation/ModerationCase.java
+1
-0
Modified
common/core/src/main/java/com/xfestudio/xfeservermanager/core/governance/moderation/ModerationLedger.java
+33
-1
Modified
common/core/src/main/java/com/xfestudio/xfeservermanager/core/governance/moderation/SanctionEntry.java
+18
-6
Modified
common/infra/src/main/java/com/xfestudio/xfeservermanager/infra/persistence/SqliteGovernanceStateRepository.java
+11
-8
Added
common/infra/src/main/resources/db/migration/V010__moderation_messages.sql
+1
-0
Modified
common/infra/src/main/resources/db/migration/index.txt
+1
-0
Modified
common/infra/src/test/java/com/xfestudio/xfeservermanager/infra/persistence/SqliteDatabaseTest.java
+1
-1
Modified
common/infra/src/test/java/com/xfestudio/xfeservermanager/infra/persistence/SqliteGovernanceStateRepositoryTest.java
+20
-0
Modified
common/testkit/src/test/java/com/xfestudio/xfeservermanager/core/governance/moderation/ModerationLedgerTest.java
+21
-0
Modified
docs/openapi.yaml
+4
-0
Modified
platform/shared/src/forge-common/java/com/xfestudio/xfeservermanager/platform/forge/ForgeCommands.java
+152
-3
Modified
platform/shared/src/forge-common/java/com/xfestudio/xfeservermanager/platform/forge/ForgeGovernanceService.java
+108
-13
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
XFEstudio/XFEServerManager
支持处罚理由与玩家可见消息分离
为所有处罚类型新增“玩家可见消息”字段,后端数据结构与数据库表同步更新,前端表单与表格支持 message 输入与展示。命令行支持可选 message 参数,API 文档与接口兼容。优化处罚提示内容,增强中英文显示。完善单元测试,确保兼容旧数据。修复命令参数树与客户端兼容性问题,提升健壮性与用户体验。
1a199eb
代码差异
24 个文件
+659
-63
@@ -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())) {
@@ -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
}
@@ -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
}
@@ -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"),
@@ -0,0 +1 @@
1
ALTER TABLE sanctions ADD COLUMN message TEXT NOT NULL DEFAULT '';
@@ -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
@@ -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
@@ -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
}
@@ -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
@@ -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
@@ -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"));
@@ -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) {