package xiaozhi.modules.parent.catalog; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; /** * 家长场景目录。优先读 data/catalog/parent-scenes.json(改文件即生效),否则用 classpath。 */ @Slf4j public final class ParentSceneCatalog { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final TypeReference> MAP = new TypeReference<>() { }; private static final Object LOCK = new Object(); private static final long CLASSPATH_RECHECK_MS = 2000L; private static List> cached = List.of(); private static String cachedSource = ""; private static long cachedMtime = Long.MIN_VALUE; private static long cachedAtMs = 0L; private ParentSceneCatalog() { } public static List> scenes() { List> out = new ArrayList<>(); for (Map row : snapshot()) { if (bool(row.get("enabled"), true)) { out.add(publicScene(row)); } } return out; } public static Map find(String sceneId) { String id = text(sceneId).toLowerCase(Locale.ROOT); if (id.isEmpty()) { return null; } for (Map row : snapshot()) { if (id.equalsIgnoreCase(text(row.get("id")))) { return row; } } return null; } public static Map require(String sceneId) { Map row = find(sceneId); if (row == null || !bool(row.get("enabled"), true)) { throw new IllegalArgumentException("不支持的场景"); } return row; } public static Map sceneToAppTask(String sceneId) { Map row = find(sceneId); if (row == null || !bool(row.get("enabled"), true)) { return null; } String appId = text(row.get("appId")); String taskId = text(row.get("taskId")); if (appId.isEmpty() || taskId.isEmpty()) { return null; } Map mapped = new LinkedHashMap<>(); mapped.put("appId", appId); mapped.put("taskId", taskId); mapped.put("name", text(row.get("name"))); return mapped; } public static String sceneName(String sceneId) { Map row = find(sceneId); if (row == null) { return text(sceneId); } String name = text(row.get("name")); return name.isEmpty() ? text(sceneId) : name; } public static String sceneIdFromAppTask(String appId, String taskId) { String app = text(appId); String task = text(taskId); for (Map row : snapshot()) { if (app.equals(text(row.get("appId"))) && (task.isEmpty() || task.equals(text(row.get("taskId"))))) { return text(row.get("id")); } } return ""; } public static String kindFromAppTask(String appId, String taskId) { Map row = find(sceneIdFromAppTask(appId, taskId)); if (row == null) { return "chat"; } String kind = text(row.get("kind")); return kind.isEmpty() ? text(row.get("id")) : kind; } public static boolean flag(String sceneId, String key) { Map row = find(sceneId); return row != null && bool(row.get(key), false); } public static boolean flag(Map scene, String key) { return scene != null && bool(scene.get(key), false); } static void resetForTests() { synchronized (LOCK) { cached = List.of(); cachedSource = ""; cachedMtime = Long.MIN_VALUE; cachedAtMs = 0L; } } private static List> snapshot() { synchronized (LOCK) { reloadIfNeeded(); return cached; } } @SuppressWarnings("unchecked") private static void reloadIfNeeded() { Path file = firstExisting(catalogCandidates()); long now = System.currentTimeMillis(); if (file != null) { long mtime = file.toFile().lastModified(); if (!cached.isEmpty() && file.toAbsolutePath().toString().equals(cachedSource) && mtime == cachedMtime) { return; } try { Map data = MAPPER.readValue(Files.readAllBytes(file), MAP); cached = normalizeScenes(data); cachedSource = file.toAbsolutePath().toString(); cachedMtime = mtime; cachedAtMs = now; log.info("loaded parent scenes {} count={}", cachedSource, cached.size()); return; } catch (Exception ex) { log.warn("failed to read {}, keep previous catalog: {}", file, ex.getMessage()); if (!cached.isEmpty()) { return; } } } if (!cached.isEmpty() && "classpath".equals(cachedSource) && now - cachedAtMs < CLASSPATH_RECHECK_MS) { return; } try (InputStream in = ParentSceneCatalog.class.getResourceAsStream("/catalog/parent-scenes.json")) { if (in != null) { Map data = MAPPER.readValue(in, MAP); cached = normalizeScenes(data); cachedSource = "classpath"; cachedMtime = Long.MIN_VALUE; cachedAtMs = now; return; } } catch (Exception ex) { log.warn("failed to read classpath parent-scenes.json: {}", ex.getMessage()); } if (cached.isEmpty()) { cached = builtinScenes(); cachedSource = "builtin"; cachedMtime = Long.MIN_VALUE; cachedAtMs = now; } } private static Path firstExisting(List candidates) { for (Path path : candidates) { if (path != null && Files.isRegularFile(path)) { return path; } } return null; } private static List catalogCandidates() { List out = new ArrayList<>(); String override = System.getProperty("xiaozhi.catalog.dir", System.getenv("XIAOZHI_CATALOG_DIR")); if (override != null && !override.isBlank()) { out.add(Paths.get(override.trim(), "parent-scenes.json")); } String userDir = System.getProperty("user.dir", "."); out.add(Paths.get(userDir, "data", "catalog", "parent-scenes.json")); out.add(Paths.get(userDir, "src", "main", "resources", "catalog", "parent-scenes.json")); return out; } @SuppressWarnings("unchecked") private static List> normalizeScenes(Map data) { Object raw = data == null ? null : data.get("scenes"); if (!(raw instanceof List list)) { return builtinScenes(); } List> out = new ArrayList<>(); for (Object item : list) { if (!(item instanceof Map map)) { continue; } Map row = sanitize((Map) map); if (row != null) { out.add(row); } } out.sort(Comparator.comparingInt(a -> intVal(a.get("sort"), 100))); return out.isEmpty() ? builtinScenes() : out; } private static Map sanitize(Map raw) { String id = text(raw.get("id")).toLowerCase(Locale.ROOT); String appId = text(raw.get("appId")); String taskId = text(raw.get("taskId")); if (!id.matches("[a-z0-9_]{1,32}") || !safeId(appId) || !safeId(taskId)) { return null; } Map row = new LinkedHashMap<>(); row.put("id", id); row.put("name", text(raw.get("name")).isEmpty() ? id : text(raw.get("name"))); row.put("hint", text(raw.get("hint"))); row.put("enabled", bool(raw.get("enabled"), true)); row.put("sort", intVal(raw.get("sort"), 100)); row.put("appId", appId); row.put("taskId", taskId); String kind = text(raw.get("kind")); row.put("kind", kind.isEmpty() ? id : kind); row.put("audioPackage", bool(raw.get("audioPackage"), false)); row.put("needsLesson", bool(raw.get("needsLesson"), false)); row.put("needsEnPasses", bool(raw.get("needsEnPasses"), false)); row.put("needsShadowingSettings", bool(raw.get("needsShadowingSettings"), false)); row.put("needsRepeat", bool(raw.get("needsRepeat"), false)); row.put("needsLanguagePair", bool(raw.get("needsLanguagePair"), false)); row.put("needsDuration", bool(raw.get("needsDuration"), false)); row.put("needsOnComplete", bool(raw.get("needsOnComplete"), true)); return row; } private static Map publicScene(Map row) { Map out = new LinkedHashMap<>(); out.put("id", row.get("id")); out.put("name", row.get("name")); out.put("hint", row.get("hint")); out.put("appId", row.get("appId")); out.put("taskId", row.get("taskId")); out.put("kind", row.get("kind")); out.put("audioPackage", row.get("audioPackage")); out.put("needsLesson", row.get("needsLesson")); out.put("needsEnPasses", row.get("needsEnPasses")); out.put("needsShadowingSettings", row.get("needsShadowingSettings")); out.put("needsRepeat", row.get("needsRepeat")); out.put("needsLanguagePair", row.get("needsLanguagePair")); out.put("needsDuration", row.get("needsDuration")); out.put("needsOnComplete", row.get("needsOnComplete")); return out; } private static List> builtinScenes() { List> out = new ArrayList<>(); out.add(sanitize(scene("extensive", "泛听", "english_learning", "repeater", "extensive", 10, true, true, false, false, false, true, true))); out.add(sanitize(scene("shadowing", "跟读", "english_learning", "shadowing", "shadowing", 20, true, false, true, false, false, true, true))); out.add(sanitize(scene("chat", "默认聊天", "chat", "conversation", "chat", 30, false, false, false, false, true, true, false))); out.add(sanitize(scene("translation", "翻译", "english_translation", "live_translate", "translation", 40, false, false, false, true, true, true, false))); out.add(sanitize(scene("bilingual", "中英对话", "english_conversation", "bilingual_chat", "bilingual", 50, false, false, false, true, true, true, false))); return out; } private static Map scene(String id, String name, String appId, String taskId, String kind, int sort, boolean lesson, boolean enPasses, boolean shadowing, boolean pair, boolean duration, boolean onComplete, boolean audio) { Map row = new LinkedHashMap<>(); row.put("id", id); row.put("name", name); row.put("appId", appId); row.put("taskId", taskId); row.put("kind", kind); row.put("sort", sort); row.put("needsLesson", lesson); row.put("needsEnPasses", enPasses); row.put("needsShadowingSettings", shadowing); row.put("needsLanguagePair", pair); row.put("needsDuration", duration); row.put("needsOnComplete", onComplete); row.put("audioPackage", audio); row.put("enabled", true); return row; } private static boolean safeId(String value) { return value != null && value.matches("[A-Za-z0-9_-]{1,31}"); } private static boolean bool(Object value, boolean fallback) { if (value instanceof Boolean bool) { return bool; } if (value == null) { return fallback; } String text = String.valueOf(value).trim(); if ("true".equalsIgnoreCase(text) || "1".equals(text)) { return true; } if ("false".equalsIgnoreCase(text) || "0".equals(text)) { return false; } return fallback; } private static int intVal(Object value, int fallback) { if (value instanceof Number number) { return number.intValue(); } try { return Integer.parseInt(String.valueOf(value).trim()); } catch (Exception ex) { return fallback; } } private static String text(Object value) { return value == null ? "" : String.valueOf(value).trim(); } }