schools-store.js 3.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. 'use strict';
  2. /**
  3. * 校区地图 · 学校数据(JSON 静态存储)
  4. * ----------------------------------------------------------------------------
  5. * 两份文件合并成一份视图:
  6. * server/schools.seed.json 随版本发布的内置学校(只读,可被运行时覆盖 enabled/坐标)
  7. * data/schools.json 运行时新增/修改(gitignore;由 SCHOOLS_FILE 可覆盖,测试用)
  8. *
  9. * 合并规则:以 name 为唯一键,运行时同名字段覆盖种子;种子的 seed 标记始终保留。
  10. */
  11. const fs = require('fs');
  12. const path = require('path');
  13. const ROOT_DIR = __dirname;
  14. const SEED_FILE = process.env.SCHOOLS_SEED_FILE
  15. ? path.resolve(process.env.SCHOOLS_SEED_FILE)
  16. : path.join(ROOT_DIR, 'server', 'schools.seed.json');
  17. const RUNTIME_FILE = process.env.SCHOOLS_FILE
  18. ? path.resolve(process.env.SCHOOLS_FILE)
  19. : path.join(ROOT_DIR, 'data', 'schools.json');
  20. function readJson(file, fallback) {
  21. try {
  22. return JSON.parse(fs.readFileSync(file, 'utf8'));
  23. } catch (e) {
  24. if (e.code !== 'ENOENT') console.warn(`[schools] 读取失败 ${file}: ${e.message}`);
  25. return fallback;
  26. }
  27. }
  28. function normalizeEntry(raw) {
  29. if (!raw || !raw.name) return null;
  30. const name = String(raw.name).trim();
  31. if (!name) return null;
  32. const lng = Number(raw.lng);
  33. const lat = Number(raw.lat);
  34. const hasPoint = Number.isFinite(lng) && Number.isFinite(lat)
  35. && lng > 73 && lng < 136 && lat > 3 && lat < 54;
  36. return {
  37. id: raw.id || `sch_${Buffer.from(name).toString('base64url').slice(0, 10)}`,
  38. name,
  39. aliases: Array.isArray(raw.aliases) ? raw.aliases.map(String) : [],
  40. province: raw.province || '',
  41. city: raw.city || '',
  42. county: raw.county || '',
  43. lng: hasPoint ? lng : null,
  44. lat: hasPoint ? lat : null,
  45. source: raw.source || 'local',
  46. seed: raw.seed === true,
  47. enabled: raw.enabled !== false,
  48. created_at: raw.created_at || '',
  49. updated_at: raw.updated_at || '',
  50. };
  51. }
  52. /** 读原始条目(不补默认值):合并时才能区分「显式给了空值」和「没给」 */
  53. function readRawList(file) {
  54. const data = readJson(file, null);
  55. if (!data || !Array.isArray(data.schools)) return [];
  56. return data.schools.filter((item) => item && typeof item === 'object' && String(item.name || '').trim());
  57. }
  58. /** 合并后的学校列表(种子 + 运行时),按省份、名称排序 */
  59. function loadSchools() {
  60. const merged = new Map();
  61. for (const raw of readRawList(SEED_FILE)) {
  62. const name = String(raw.name).trim();
  63. merged.set(name, { ...raw, name, seed: true });
  64. }
  65. for (const raw of readRawList(RUNTIME_FILE)) {
  66. const name = String(raw.name).trim();
  67. const base = merged.get(name);
  68. merged.set(name, {
  69. // 只覆盖运行时显式给出的字段
  70. ...(base || {}),
  71. ...raw,
  72. name,
  73. // 种子标记不可被运行时文件伪造/清除
  74. seed: !!(base && base.seed),
  75. });
  76. }
  77. return [...merged.values()].map(normalizeEntry).filter(Boolean).sort((a, b) => {
  78. const p = String(a.province).localeCompare(String(b.province), 'zh-Hans-CN');
  79. return p !== 0 ? p : String(a.name).localeCompare(String(b.name), 'zh-Hans-CN');
  80. });
  81. }
  82. function runtimeFile() {
  83. return RUNTIME_FILE;
  84. }
  85. function seedFile() {
  86. return SEED_FILE;
  87. }
  88. module.exports = { loadSchools, normalizeEntry, runtimeFile, seedFile };