feat: persist offline player name in LinkEntry for proper display in links command

Add offlinePlayerName field to LinkEntry record, stored only for
offline-mode UUIDs (version 3) since Mojang API cannot resolve them.
Online-mode players continue to use real-time Mojang API resolution.
The field is omitted from JSON when null for clean serialization.

MojangUtils.resolvePlayerName() now accepts an optional fallback name
for offline UUIDs, returning "N/A" when no name is available.

Updated README.md storage constraints and query display rules to
reflect the new offline player name persistence.

Co-authored-by: Xujiayao <58985541+Xujiayao@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-11 04:30:32 +00:00 committed by Jason Xu
parent b2f841e1fa
commit 7afea647a5
4 changed files with 52 additions and 10 deletions

View file

@ -62,8 +62,9 @@ DMCC 所有运行模式都基于一个统一的通信模型,该模型包含两
- **一个 Discord 账户可关联多个 Minecraft 账户**(方便玩家管理大号与小号)。
- **一个 Minecraft 账户只能关联一个 Discord 账户**(确保游戏内身份的绝对唯一性)。
- **数据持久化**: 绑定关系作为永久数据存储在 `Server` 端的 `account_linking/links.json` 中,以 Discord ID 为主键。
- **存储约束**: `links.json` 中仅存储绑定关系最小必要字段Discord ID、Minecraft UUID、添加时间**不得额外存储**
Discord 用户名或 Minecraft 玩家名;显示名称在查询时实时解析。
- **存储约束**: `links.json` 中存储绑定关系最小必要字段Discord ID、Minecraft UUID、添加时间。对于离线模式玩家
额外存储 `offlinePlayerName`(绑定时的玩家名),因为离线 UUID 无法通过 Mojang API 反查玩家名。
在线模式玩家不存储玩家名,显示名称在查询时通过 Mojang API 实时解析。Discord 用户名始终不存储,查询时实时解析。
### 4.2 安全绑定工作流 (严格的 MC 优先原则)
@ -82,7 +83,8 @@ DMCC 所有运行模式都基于一个统一的通信模型,该模型包含两
- **Discord `/unlink`**: 直接取消该 Discord 用户名下的所有 Minecraft 绑定(无需二次确认)。
- **Minecraft `/dmcc unlink`**: 直接取消“当前执行玩家”对应的绑定关系(无需二次确认)。
- **查询展示规则**: `links` 查询时实时解析显示名称,若无法解析则回退显示 UUID / Discord ID。
- **查询展示规则**: `links` 查询时实时解析显示名称;在线模式玩家通过 Mojang API 解析,离线模式玩家使用绑定时记录的玩家名。
若无法解析则显示 "N/A"Minecraft 玩家名)或 Discord ID。
### 4.4 同步与跨平台交互

View file

@ -71,7 +71,7 @@ public class LinksCommand implements Command {
for (LinkedAccountManager.LinkEntry link : links) {
String time = DATE_FORMATTER.format(Instant.ofEpochMilli(link.linkedAt()));
String mcName = MojangUtils.resolvePlayerName(link.minecraftUuid());
String mcName = MojangUtils.resolvePlayerName(link.minecraftUuid(), link.offlinePlayerName());
builder.append("\n - MC: ").append(mcName).append(" (").append(link.minecraftUuid()).append(")");
builder.append(" (").append(time).append(")");
}

View file

@ -1,5 +1,6 @@
package com.xujiayao.discord_mc_chat.server.linking;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.type.TypeReference;
import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager;
@ -11,6 +12,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
@ -151,7 +153,7 @@ public class LinkedAccountManager {
}
LINKED_ACCOUNTS.computeIfAbsent(discordId, k -> new ArrayList<>())
.add(new LinkEntry(minecraftUuid, System.currentTimeMillis()));
.add(new LinkEntry(minecraftUuid, System.currentTimeMillis(), isOfflineUuid(minecraftUuid) ? minecraftName : null));
UUID_TO_DISCORD.put(minecraftUuid, discordId);
LOGGER.info(I18nManager.getDmccTranslation("linking.manager.linked", discordName, discordId, minecraftName, minecraftUuid));
@ -260,12 +262,32 @@ public class LinkedAccountManager {
return Collections.unmodifiableMap(LINKED_ACCOUNTS);
}
/**
* Checks if a Minecraft UUID is an offline-mode UUID (version 3).
*
* @param uuidString The UUID string.
* @return true if the UUID is offline-mode (version 3), false otherwise.
*/
private static boolean isOfflineUuid(String uuidString) {
try {
return UUID.fromString(uuidString).version() == 3;
} catch (Exception e) {
return false;
}
}
/**
* A linked Minecraft account entry.
*
* @param minecraftUuid The UUID of the linked Minecraft account.
* @param linkedAt The timestamp (epoch millis) when the link was created.
* @param minecraftUuid The UUID of the linked Minecraft account.
* @param linkedAt The timestamp (epoch millis) when the link was created.
* @param offlinePlayerName The player name stored at link time for offline-mode UUIDs only.
* {@code null} for online-mode players (resolvable via Mojang API).
*/
public record LinkEntry(String minecraftUuid, long linkedAt) {
public record LinkEntry(
String minecraftUuid,
long linkedAt,
@JsonInclude(JsonInclude.Include.NON_NULL) String offlinePlayerName
) {
}
}

View file

@ -33,6 +33,22 @@ public class MojangUtils {
* @return The resolved player name, or the original UUID string if resolution fails.
*/
public static String resolvePlayerName(String uuidString) {
return resolvePlayerName(uuidString, null);
}
/**
* Resolves a Minecraft player name from a UUID string, with an optional fallback name
* for offline-mode UUIDs.
* <p>
* If the UUID is an offline-mode UUID (version 3), {@code offlineFallbackName} is returned
* if non-null; otherwise "N/A" is returned. For online-mode UUIDs (version 4), the Mojang
* session server is queried. Network failures fall back to the raw UUID.
*
* @param uuidString The UUID string (standard dashed format).
* @param offlineFallbackName The player name to use for offline UUIDs, or null.
* @return The resolved player name, or the fallback/"N/A"/UUID string if resolution fails.
*/
public static String resolvePlayerName(String uuidString, String offlineFallbackName) {
String cached = NAME_CACHE.get(uuidString);
if (cached != null) {
return cached;
@ -44,8 +60,10 @@ public class MojangUtils {
// Check if this is an offline-mode UUID (version 3)
if (uuid.version() == 3) {
// Offline UUIDs are generated from "OfflinePlayer:" + name
// We cannot reverse this, so return the raw UUID
return uuidString;
// We cannot reverse this, so use the fallback name or "N/A"
String name = (offlineFallbackName != null) ? offlineFallbackName : "N/A";
NAME_CACHE.put(uuidString, name);
return name;
}
// Online UUID (version 4) - query Mojang