'use strict'; /** * 校区地图 · 学校数据(JSON 静态存储) * ---------------------------------------------------------------------------- * 两份文件合并成一份视图: * server/schools.seed.json 随版本发布的内置学校(只读,可被运行时覆盖 enabled/坐标) * data/schools.json 运行时新增/修改(gitignore;由 SCHOOLS_FILE 可覆盖,测试用) * * 合并规则:以 name 为唯一键,运行时同名字段覆盖种子;种子的 seed 标记始终保留。 */ const fs = require('fs'); const path = require('path'); const ROOT_DIR = __dirname; const SEED_FILE = process.env.SCHOOLS_SEED_FILE ? path.resolve(process.env.SCHOOLS_SEED_FILE) : path.join(ROOT_DIR, 'server', 'schools.seed.json'); const RUNTIME_FILE = process.env.SCHOOLS_FILE ? path.resolve(process.env.SCHOOLS_FILE) : path.join(ROOT_DIR, 'data', 'schools.json'); function readJson(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') console.warn(`[schools] 读取失败 ${file}: ${e.message}`); return fallback; } } function normalizeEntry(raw) { if (!raw || !raw.name) return null; const name = String(raw.name).trim(); if (!name) return null; const lng = Number(raw.lng); const lat = Number(raw.lat); const hasPoint = Number.isFinite(lng) && Number.isFinite(lat) && lng > 73 && lng < 136 && lat > 3 && lat < 54; return { id: raw.id || `sch_${Buffer.from(name).toString('base64url').slice(0, 10)}`, name, aliases: Array.isArray(raw.aliases) ? raw.aliases.map(String) : [], province: raw.province || '', city: raw.city || '', county: raw.county || '', lng: hasPoint ? lng : null, lat: hasPoint ? lat : null, source: raw.source || 'local', seed: raw.seed === true, enabled: raw.enabled !== false, created_at: raw.created_at || '', updated_at: raw.updated_at || '', }; } /** 读原始条目(不补默认值):合并时才能区分「显式给了空值」和「没给」 */ function readRawList(file) { const data = readJson(file, null); if (!data || !Array.isArray(data.schools)) return []; return data.schools.filter((item) => item && typeof item === 'object' && String(item.name || '').trim()); } /** 合并后的学校列表(种子 + 运行时),按省份、名称排序 */ function loadSchools() { const merged = new Map(); for (const raw of readRawList(SEED_FILE)) { const name = String(raw.name).trim(); merged.set(name, { ...raw, name, seed: true }); } for (const raw of readRawList(RUNTIME_FILE)) { const name = String(raw.name).trim(); const base = merged.get(name); merged.set(name, { // 只覆盖运行时显式给出的字段 ...(base || {}), ...raw, name, // 种子标记不可被运行时文件伪造/清除 seed: !!(base && base.seed), }); } return [...merged.values()].map(normalizeEntry).filter(Boolean).sort((a, b) => { const p = String(a.province).localeCompare(String(b.province), 'zh-Hans-CN'); return p !== 0 ? p : String(a.name).localeCompare(String(b.name), 'zh-Hans-CN'); }); } function runtimeFile() { return RUNTIME_FILE; } function seedFile() { return SEED_FILE; } module.exports = { loadSchools, normalizeEntry, runtimeFile, seedFile };