Parcourir la source

docs: 校区地图改为 JSON 静态存储(种子+运行时),内置浙江工商桐庐校区

liangzhao il y a 2 semaines
Parent
commit
f0eab490ac
1 fichiers modifiés avec 79 ajouts et 23 suppressions
  1. 79 23
      docs/superpowers/specs/2026-10-20-campus-map-design.md

+ 79 - 23
docs/superpowers/specs/2026-10-20-campus-map-design.md

@@ -4,6 +4,7 @@
 - 分支:`dev`
 - 状态:待评审(本阶段只出设计,不写实现)
 - 变更:**取消**「选省/市 + 地图点选」流程,改为**输入学校名自动定位**
+- v3 变更:学校数据改为 **JSON 静态存储**(种子文件 + 运行时文件),不再用数据库;内置「浙江工商桐庐校区」
 
 ## 1. 背景与目标
 
@@ -24,6 +25,8 @@
 | 未收录的学校 | ① 在线地理编码兜底(Photon)→ ② 仍失败则创建为「未定位」,不点亮,列表可点「重新定位」重试 |
 | 坐标系 | 统一 **GCJ-02**(底图坐标系);数据源是 BD-09 需转换,在线兜底是 WGS84 需转换 |
 | 省份 | 由坐标预先算好(点落省份多边形),不需要用户选 |
+| 存储 | **JSON 文件**(种子 + 运行时),不依赖数据库;Node 18 也可用 |
+| 内置学校 | 预置「浙江工商大学桐庐校区」(别名:浙江工商桐庐校区等) |
 | 批量导入 | 不做 |
 | 删除保护 | 有同名运营数据时二次确认 |
 
@@ -166,28 +169,74 @@ geo(省级底图,roam)
 
 点击、悬停、选中联动;同屏像素距离 <14px 的两点做 ≤8px 确定性偏移。
 
-## 5. 数据模型
-
-沿用 ops 存储(SQLite 默认 / PG 可切):
-
-```sql
-CREATE TABLE IF NOT EXISTS ops_school (
-  id         text PRIMARY KEY,          -- sch_20261020_a1b2c3
-  name       text NOT NULL,
-  province   text NOT NULL DEFAULT '',
-  lng        real,                      -- GCJ-02;未定位时为 NULL
-  lat        real,
-  source     text NOT NULL DEFAULT 'local',  -- local | geocode | none
-  enabled    integer NOT NULL DEFAULT 1,
-  created_at text NOT NULL,
-  updated_at text NOT NULL
-);
-CREATE UNIQUE INDEX IF NOT EXISTS ops_school_name_idx ON ops_school (name);
+## 5. 数据模型(JSON 静态存储)
+
+### 5.1 两类文件
+
+| 文件 | 是否提交到仓库 | 作用 |
+|---|---|---|
+| `server/schools.seed.json` | **提交** | 内置学校(随版本发布);初始只有浙江工商桐庐校区 |
+| `data/schools.json` | 不提交(`data/` 已 gitignore) | 运行时新增/修改的学校,以及对内置项的覆盖 |
+
+读取时**合并**两者,对外的列表就是合并结果;写入只动 `data/schools.json`。
+
+### 5.2 条目结构
+
+```json
+{
+  "id": "sch_tonglu_001",
+  "name": "浙江工商大学桐庐校区",
+  "aliases": ["浙江工商桐庐校区", "浙江工商大学杭州商学院", "杭州商学院桐庐校区"],
+  "province": "浙江省",
+  "city": "杭州市",
+  "county": "桐庐县",
+  "lng": 119.744166,
+  "lat": 29.830417,
+  "source": "seed",
+  "seed": true,
+  "enabled": true,
+  "created_at": "2026-10-20T00:00:00.000Z",
+  "updated_at": "2026-10-20T00:00:00.000Z"
+}
 ```
 
-- `lng/lat` 允许 NULL = 未定位(不点亮)
-- `source` 记录定位来源,便于排查
-- 与运营数据的关联:按 `ops_shop.school = ops_school.name` 精确匹配,查询时聚合门店数与 `MAX(date)`
+字段说明:
+
+- `name` 为唯一键;`aliases` 只用于**搜索联想**,不参与唯一性
+- `lng/lat`(GCJ-02)为 `null` 表示「未定位」(灰点、不点亮)
+- `seed: true` 表示内置项
+- 内置项坐标:浙江工商大学杭州商学院桐庐校区(桐庐县),`[119.744166, 29.830417]`
+
+### 5.3 合并与写回规则
+
+| 操作 | 行为 |
+|---|---|
+| 读取 | 种子 + 运行时;同名(`name`)时运行时字段覆盖种子 |
+| 新建 | 只写 `data/schools.json`;若与种子/运行时同名 → 400 |
+| 修改(改名/重新定位/启停) | 写 `data/schools.json` 的同名记录(对种子项即"覆盖层") |
+| 删除 | 运行时项:从文件移除;**种子项:拒绝删除**,提示「内置学校只能停用」 |
+| 文件缺失/损坏 | 用种子继续工作,日志 warning,不崩溃 |
+| 写入 | 临时文件 + rename 原子替换;单进程串行 |
+
+### 5.4 与运营数据的关联
+
+学校存 JSON,运营数据在 ops 库(SQLite/PG),关联在读取时做:
+
+- 按 `ops_school_name`(即学校名及别名)去 `ops_shop.school` 精确匹配
+- 聚合出「门店数」与「最近数据日期」
+- **ops 库不可用时**:地图与学校管理照常,关联字段显示「未关联运营数据」
+
+### 5.5 内置学校
+
+| 字段 | 值 |
+|---|---|
+| 名称 | 浙江工商大学桐庐校区 |
+| 别名 | 浙江工商桐庐校区 / 浙江工商大学杭州商学院 / 杭州商学院桐庐校区 |
+| 省/市/县 | 浙江省 / 杭州市 / 桐庐县 |
+| 坐标(GCJ-02) | 119.744166, 29.830417 |
+| 默认状态 | 启用(点亮) |
+
+> 名称采用「浙江工商大学桐庐校区」是为了和上传表里的 `学校名称` 对得上(人工统计表用的就是全称),别名保证用户输入简称也能搜到。
 
 ## 6. 接口
 
@@ -203,6 +252,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS ops_school_name_idx ON ops_school (name);
 
 校验:名称 1~60 且唯一;坐标若提供必须在 GCJ-02 中国范围(lng 73~136,lat 3~54)。
 
+存储相关:写操作失败(磁盘只读等)返回 500 并保持文件原样;`GET /api/schools` 始终可用(JSON 不依赖数据库)。测试通过环境变量 `SCHOOLS_FILE` 指定临时文件,避免污染本机 `data/schools.json`。
+
 ## 7. 界面
 
 ```
@@ -249,7 +300,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS ops_school_name_idx ON ops_school (name);
 - 重名 400;停用/启用;删除
 - 未定位学校(lng/lat 为空)不报错,`enabled=1` 也不点亮
 - 关联字段:造 2 条 `ops_shop` 同名数据 → 返回门店数 2 与最近日期
-- 存储不可用 → GET 200 + `enabled:false`,写操作 503
+- 内置项不可删除(400),可停用;运行时项可删除
+- 种子 + 运行时合并:同名时运行时覆盖
+- 文件损坏:仍能返回种子数据,不 500
+- ops 库不可用:`GET /api/schools` 仍 200,关联字段为空
 
 ### 9.3 冒烟
 
@@ -263,8 +317,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS ops_school_name_idx ON ops_school (name);
 
 - [ ] `scripts/build-university-data.js`(数据生成脚本,含 BD-09 转换与省份归属;数据文件已就绪)
 - [ ] `public/vendor/cn-universities.json`(已生成,待登记静态白名单)
-- [ ] `ops_school` 表 + 存储方法(SQLite / PG)
-- [ ] `/api/schools` 端点(含 search / locate / 关联查询 / 删除保护所需计数)
+- [ ] `server/schools.seed.json`(内置浙江工商桐庐校区)
+- [ ] `schools-store.js`:种子 + 运行时文件合并、原子写、损坏兜底
+- [ ] `/api/schools` 端点(含 search / locate / 关联查询 / 删除保护)
 - [ ] `public/schools.js`:地图、联想、创建、编辑、启停、删除确认、列表联动
 - [ ] `index.html` 菜单与页面、`app.js` 注册、`style.css`
 - [ ] 测试脚本扩展 + smoke 只读断言
@@ -275,3 +330,4 @@ CREATE UNIQUE INDEX IF NOT EXISTS ops_school_name_idx ON ops_school (name);
 1. **未收录学校**:在线地理编码兜底 → 失败允许创建为「未定位」(灰点、不点亮),列表内可重试定位
 2. **关联跳转**:详情卡提供「查看运营数据」,跳转「店铺运营」并预选该学校
 3. 不做批量导入;创建后默认启用
+4. 学校数据用 JSON(种子 + 运行时),不进数据库;内置项只能停用不能删除