schools.js 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. 'use strict';
  2. // 校区地图:学校列表(JSON 种子 + 运行时),附带运营数据关联统计。
  3. // 本期只有只读接口;新建/编辑/启停在下一增量实现。
  4. const fs = require('fs');
  5. const path = require('path');
  6. const { sendJson } = require('../util.js');
  7. const { loadSchools } = require('../../schools-store.js');
  8. const { createOpsStore } = require('../../ops-store.js');
  9. // 导入数据里出现、但还没登记的校区:用本地高校坐标库自动定位(找不到就标未定位)
  10. let uniCache = null;
  11. function loadUniversities() {
  12. if (uniCache) return uniCache;
  13. try {
  14. uniCache = JSON.parse(fs.readFileSync(path.join(__dirname, '../../public/vendor/cn-universities.json'), 'utf8'));
  15. } catch {
  16. uniCache = {};
  17. }
  18. return uniCache;
  19. }
  20. function normalizeSchoolName(name) {
  21. return String(name || '')
  22. .replace(/\s+/g, '')
  23. .replace(/[((].*?[))]/g, '')
  24. .replace(/(校区|分校)$/, '');
  25. }
  26. function matchUniversity(name) {
  27. const uni = loadUniversities();
  28. const norm = normalizeSchoolName(name);
  29. if (!norm) return null;
  30. if (uni[norm]) return { lng: uni[norm].lng, lat: uni[norm].lat, province: uni[norm].province, approx: false };
  31. let bestKey = '';
  32. for (const key of Object.keys(uni)) {
  33. if (key.length < 4) continue;
  34. if (norm.startsWith(key) && key.length > bestKey.length) bestKey = key;
  35. }
  36. if (!bestKey) return null;
  37. return { lng: uni[bestKey].lng, lat: uni[bestKey].lat, province: uni[bestKey].province, approx: true, base: bestKey };
  38. }
  39. function namesOf(school) {
  40. return new Set([school.name, ...(school.aliases || [])].map((n) => String(n).trim()).filter(Boolean));
  41. }
  42. function register(app, ctx) {
  43. const opsStore = createOpsStore(ctx.config);
  44. app.get('/api/schools', async ({ res }) => {
  45. const schools = loadSchools();
  46. // 关联运营数据:按学校名(含别名)匹配 ops_shop.school 与 ops_row.school
  47. let shops = [];
  48. let rows = [];
  49. let platformRows = [];
  50. let opsAvailable = false;
  51. try {
  52. const summary = await opsStore.summary();
  53. shops = summary.shops || [];
  54. rows = await opsStore.queryRows({});
  55. platformRows = await opsStore.queryPlatformRows({});
  56. opsAvailable = true;
  57. } catch { /* ops 库不可用:只影响关联字段 */ }
  58. // 运营数据里出现、注册表里没有的学校:自动补进地图列表
  59. const knownNames = new Set();
  60. for (const school of schools) for (const n of namesOf(school)) knownNames.add(n);
  61. const discovered = new Set();
  62. for (const item of [...shops, ...platformRows]) {
  63. const name = String(item.school || '').trim();
  64. if (name && !knownNames.has(name)) discovered.add(name);
  65. }
  66. const now = new Date().toISOString();
  67. for (const name of discovered) {
  68. const hit = matchUniversity(name);
  69. schools.push({
  70. id: 'ops_' + Buffer.from(name).toString('hex').slice(0, 12),
  71. name,
  72. aliases: [],
  73. province: (hit && hit.province) || '',
  74. city: '',
  75. county: '',
  76. lng: hit ? hit.lng : null,
  77. lat: hit ? hit.lat : null,
  78. source: 'ops',
  79. seed: false,
  80. discovered: true,
  81. approx: !!(hit && hit.approx),
  82. enabled: !!hit,
  83. created_at: now,
  84. updated_at: now,
  85. });
  86. }
  87. const payload = schools.map((school) => {
  88. const names = namesOf(school);
  89. // 门店口径:人工表的 shop_key 与平台明细的 shopId 取并集
  90. const shopKeys = new Set();
  91. for (const shop of shops) {
  92. if (names.has(String(shop.school || ''))) shopKeys.add(String(shop.key || shop.shopId || ''));
  93. }
  94. for (const row of platformRows) {
  95. if (names.has(String(row.school || ''))) shopKeys.add(String(row.shopId || ''));
  96. }
  97. shopKeys.delete('');
  98. const shopCount = shopKeys.size;
  99. let lastDate = '';
  100. for (const row of rows) {
  101. if (!names.has(String(row.school || ''))) continue;
  102. if (!lastDate || row.date > lastDate) lastDate = row.date;
  103. }
  104. for (const row of platformRows) {
  105. if (!names.has(String(row.school || ''))) continue;
  106. if (!lastDate || row.date > lastDate) lastDate = row.date;
  107. }
  108. return {
  109. ...school,
  110. ops: opsAvailable ? { shopCount, lastDate: lastDate || null } : null,
  111. };
  112. });
  113. sendJson(res, 200, {
  114. schools: payload,
  115. opsAvailable,
  116. enabledCount: payload.filter((s) => s.enabled && s.lng != null).length,
  117. });
  118. });
  119. }
  120. module.exports = { register };