ソースを参照

知识库古诗文和日积月累更新

chengjie 1 週間 前
コミット
a189c7fd2d
共有23 個のファイルを変更した58442 個の追加45 個の削除を含む
  1. 5 0
      .vscode/秒过知识库更新/2026年秋季更新结果.md
  2. 52 2
      .vscode/秒过知识库更新/README.md
  3. 2369 0
      .vscode/秒过知识库更新/apply_ancient_daily_2026_fall.json
  4. 741 0
      .vscode/秒过知识库更新/audit_ancient_daily_2026_fall.json
  5. 6361 0
      .vscode/秒过知识库更新/backups/before_ancient_daily_2026_fall_2026-09-03T06-25-28-871Z.json
  6. 418 0
      .vscode/秒过知识库更新/dry_run_ancient_daily_2026_fall.json
  7. 5272 0
      .vscode/秒过知识库更新/research_db_snapshot_2026_fall.json
  8. 50 0
      .vscode/秒过知识库更新/research_db_snapshot_2026_fall.mjs
  9. 41449 0
      .vscode/秒过知识库更新/research_ocr_2026_fall.json
  10. 90 0
      .vscode/秒过知识库更新/research_ocr_2026_fall.swift
  11. 542 0
      .vscode/秒过知识库更新/source_ancient_daily_2026_fall.json
  12. 592 0
      .vscode/秒过知识库更新/update_ancient_daily_2026_fall.mjs
  13. 121 0
      .vscode/秒过知识库更新/古诗文与日积月累更新结果_2026年秋.md
  14. 201 0
      .vscode/秒过知识库更新/古诗文与日积月累调研清单_2026年秋.md
  15. 1 1
      README.md
  16. 1 1
      src/api/index/indexController.js
  17. 2 2
      src/api/virtualPay/virtualPayController.js
  18. 38 24
      src/api/yjbdc/readerController.js
  19. 1 1
      src/api/yjbdc/yjbdcController.js
  20. 38 11
      src/util/crypto/index.js
  21. 4 3
      src/util/stringClass.js
  22. 75 0
      test/errorRegression.test.js
  23. 19 0
      test/virtualPayFulfillment.test.js

+ 5 - 0
.vscode/秒过知识库更新/2026年秋季更新结果.md

@@ -74,3 +74,8 @@
74 74
 - MiaoguoLiteracy 补录报告:`miaoguo_literacy_apply_report_2026_fall.json`
75 75
 - MiaoguoLiteracy 补录后审计:`miaoguo_literacy_post_apply_audit_2026_fall.json`
76 76
 
77
+## 后续补充:六册古诗文与日积月累
78
+
79
+2026-09-03 已继续完成一年级上至六年级上的古诗文、现代背诵课文和日积月累更新:新增 17 条、迁移 7 条、脱册保留 9 条、删除 0 条。12 本目标书及 3 本供体书的 `UnitNum/WordNum` 已按最终实际行数校准,提交后独立审计与幂等 dry-run 均通过。
80
+
81
+详细逐项结果见 `古诗文与日积月累更新结果_2026年秋.md`;写入前备份为 `backups/before_ancient_daily_2026_fall_2026-09-03T06-25-28-871Z.json`。

+ 52 - 2
.vscode/秒过知识库更新/README.md

@@ -1,10 +1,10 @@
1 1
 # 秒过知识库更新维护手册
2 2
 
3
-本目录用于每年 2 月、9 月教材改版后的秒过知识库维护。目标是:只需放入新版教材图片、整理两份源数据、依次运行 dry-run、apply、audit,即可完成更新并留下可追溯记录。
3
+本目录用于每年 2 月、9 月教材改版后的秒过知识库维护。目标是:只需放入新版教材图片、整理结构化源数据、依次运行 dry-run、apply、audit,即可完成更新并留下可追溯记录。
4 4
 
5 5
 ## 一、不可违反的规则
6 6
 
7
-1. 语文 `HanziUnit` **禁止 DELETE**,只能 `UPDATE` 旧行或 `INSERT` 新行。旧 `ID` 可能已被历史试题或用户数据引用。
7
+1. 语文 `HanziUnit`、`AncientPoetry` **禁止 DELETE**,只能 `UPDATE` 旧行或 `INSERT` 新行。旧 `ID` 可能已被历史试题或用户数据引用。
8 8
 2. 英语 `Words` 允许按目标 `BookID` 删除后重建,但必须先备份,并且所有写入都必须在事务中完成。
9 9
 3. 每一本更新后,都要同步校准 `MiaoguoBook.UnitNum`、`MiaoguoBook.WordNum`。
10 10
 4. 识字表的蓝色多音字也要写入 `HanziUnit.Example`,并计入 `WordNum`。
@@ -21,6 +21,8 @@
21 21
 | 语文写字表 | 1~12 | HanziUnit | HanziBookID=BookIDOld | UnitType、Name、Example、OrderID |
22 22
 | 语文词语表 | 33~42 | HanziUnit | HanziBookID=BookIDOld | UnitType、Name、Example、OrderID |
23 23
 | 语文识字表 | 43~52 | HanziUnit | HanziBookID=BookIDOld | UnitType、Name、Example、OrderID |
24
+| 语文古诗文 | 13~30 | AncientPoetry | HanziBookID=BookIDOld | Sort、Category、Tag、Title、Author、Dynasty、PeomContent |
25
+| 语文日积月累 | 54~65 | AncientPoetry | HanziBookID=BookIDOld | Sort、Category、Tag、Title、Author、Dynasty、PeomContent |
24 26
 | 英语单词表 | 285~292、301~305 | Words | BookID=BookIDOld | Word、LessonID、LessonName、Soundmark、Translate、Sort |
25 27
 | 英语常用表达法 | 293~300 | Words | BookID=BookIDOld | Word、LessonID、LessonName、Translate、Sort |
26 28
 
@@ -36,6 +38,18 @@
36 38
 - 识字表要分别记录教材标注的普通识字数、蓝色多音字数;两者之和必须等于 `wordNum`。
37 39
 - 多次出现的 `语文园地` 保持同名,依靠数组位置最终生成 `OrderID=1..N`。
38 40
 
41
+#### 古诗文与日积月累
42
+
43
+- 每册图片通常依次为:目录、识字表、写字表、词语表、日积月累、古诗文/背诵课文。目录用于核对课文名单;凡图片中有“日积月累”字样的页面归入日积月累。
44
+- 古诗文范围不只包括古诗和小古文,还包括教材明确要求背诵的现代课文。只录指定背诵范围;部分自然段背诵时,标题统一加“(节选)”。
45
+- 现代文 `PeomContent` 按自然段保存为 JSON 数组,`Translation` 留空;作者在教材脚注中明确给出时写入 `Author`。
46
+- 日积月累中的诗词使用 `Category=0`;成语、名言、儿歌、常识等使用 `Category=1`,并沿用现有挖空 `Title` 和完整答案 `PeomContent` 的结构。
47
+- `PeomContent` 字段名是历史拼写,不能擅自改成 `PoemContent`。写入前必须验证其为合法 JSON,且不超过字段长度。
48
+- 同一首经典古诗文不重复新增。先按标题搜索整张 `AncientPoetry`:找到唯一旧记录就迁移原 `ID`,同时更新 `HanziBookID/Sort/Tag`;找不到才新增。
49
+- 新版中已取消、又没有明确新归属的旧记录不删除,改为 `HanziBookID=0` 脱册并保留正文。迁出、迁入和脱册都会影响关联书的实际行数。
50
+- `Sort` 在每本书内统一从 `0` 连续到 `N-1`。来源图片重复时按同一内容去重,不生成重复行。
51
+- `MiaoguoBook.WordNum` 按该 `BookIDOld` 最终实际 `AncientPoetry` 行数设置;经典内容从别册迁入时,供体书的 `WordNum` 也必须同步调整。
52
+
39 53
 ### 英语
40 54
 
41 55
 - 教材图 01~08 是单词表,09~14 是常用表达法;页码和星号不写入数据库。
@@ -80,6 +94,23 @@ node .vscode/秒过知识库更新/update_knowledge_base_2026_fall.mjs --audit
80 94
 
81 95
 7. 人工复核报告,并至少抽查每套数据的首行、末行、`语文园地`、英语每单元首末词。
82 96
 
97
+### 古诗文与日积月累标准命令
98
+
99
+后续批次复制源文件和脚本并修改年份、图片范围及书籍映射:
100
+
101
+```bash
102
+# 只读预检:校验最终名单、迁移、脱册、行数和元数据
103
+node .vscode/秒过知识库更新/update_ancient_daily_2026_fall.mjs --dry-run
104
+
105
+# 生成完整备份后,在单个事务中更新并做提交前审计
106
+node .vscode/秒过知识库更新/update_ancient_daily_2026_fall.mjs --apply
107
+
108
+# 事务提交后的独立审计
109
+node .vscode/秒过知识库更新/update_ancient_daily_2026_fall.mjs --audit
110
+```
111
+
112
+`--apply` 必须保证:没有 `DELETE`;所有来源条目逐字段匹配;每册 `Sort=0..N-1`;脱册记录的 `HanziBookID=0`;目标书和供体书的 `WordNum` 等于实际行数。执行后再次运行 `--dry-run`,应得到全部变更计数为 0,以证明脚本可幂等复跑。
113
+
83 114
 ## 五、程序的语文 ID 保留策略
84 115
 
85 116
 对于每本语文书,程序按以下优先级映射新版单元:
@@ -91,6 +122,14 @@ node .vscode/秒过知识库更新/update_knowledge_base_2026_fall.mjs --audit
91 122
 
92 123
 所有复用的旧行保留原 `ID` 和原 `IsLocked`。新增行默认 `IsLocked=1`。所有目标行都重新写入连续的 `OrderID=1..N`。
93 124
 
125
+### AncientPoetry 的 ID 保留策略
126
+
127
+1. 最终清单中的同一旧内容按 `AncientPoetry.ID` 更新,保留原主键。
128
+2. 经典内容已在别册存在时迁移该旧行,不能复制出同名第二行。
129
+3. 新增现代背诵课文或全库不存在的内容才使用 `INSERT`。
130
+4. 新版取消的旧内容改 `HanziBookID=0`,禁止 `DELETE`,其他正文信息继续保留。
131
+5. 每册所有最终行都重写连续的 `Sort=0..N-1`,不能依赖自增 ID 或旧数据中大量相同的 `Sort=0`。
132
+
94 133
 ## 六、检查项和判定标准
95 134
 
96 135
 - JSON 可以解析,且数据集、书籍 ID 没有重复。
@@ -102,6 +141,8 @@ node .vscode/秒过知识库更新/update_knowledge_base_2026_fall.mjs --audit
102 141
 - 英语更新后逐行 `Word/LessonID/LessonName/Soundmark/Translate/Sort` 与源文件完全一致,未提供字段仍为 `NULL`。
103 142
 - 所有 `MiaoguoBook.UnitNum/WordNum` 与最终数据一致。
104 143
 - 备份中的所有语文旧 `HanziUnit.ID` 在更新后仍然存在。
144
+- 古诗文与日积月累最终行数、标题、Category、Tag、Sort 与源数据一致,所有 `PeomContent` 均为合法 JSON。
145
+- 古诗文与日积月累的迁移行保留原 ID;脱册行仍存在且 `HanziBookID=0`;供体书 `WordNum` 与迁出后的实际行数一致。
105 146
 
106 147
 ## 七、备份与异常恢复
107 148
 
@@ -151,6 +192,10 @@ node .vscode/秒过知识库更新/update_miaoguo_literacy_2026_fall.mjs --audit
151 192
 - `语文园地` 同名是正确数据,不要为“唯一”而擅自编号。
152 193
 - 英语复合词的音标可能只标在其中一部分,必须忠实保存教材给出的内容,不补写推测音标。
153 194
 - 图片 OCR 只能辅助,最终必须逐页核对专名、音标、过去式和标点。
195
+- 目录只能帮助核对古诗文课目,不能代替“背诵课文”要求;现代文必须查看课后题,确认是全文还是指定自然段。
196
+- 日积月累不能只看目录,必须逐张查找图片中的“日积月累”标题。图片缺页或重复页要在清单中明确记录。
197
+- 跨册迁移经典内容时,不能只更新目标书:供体书的实际行数和 `MiaoguoBook.WordNum` 也会减少。
198
+- 古诗文 `Sort` 历史数据常全部为 0;必须按本批次最终清单重新生成连续值。
154 199
 - `MiaoguoLiteracy` 同一个词可能同时有 `zici` 和 `list` 等记录;低优先级 `list` 没有 `CHN` 不一定影响词语题,必须按线上查询排序判断真正选中的记录。
155 200
 - 任务目录可能包含真实数据库备份,提交或外发前先确认仓库的数据安全策略。
156 201
 
@@ -171,5 +216,10 @@ node .vscode/秒过知识库更新/update_miaoguo_literacy_2026_fall.mjs --audit
171 216
 - `miaoguo_literacy_apply_report_2026_fall.json`:补录 ID、完整 JSONString、事务前后审计和备份位置。
172 217
 - `miaoguo_literacy_post_apply_audit_2026_fall.json`:补录后的独立审计报告。
173 218
 - `MiaoguoLiteracy补录结果_2026年秋季.md`:20 条补录词语的人工复核表。
219
+- `source_ancient_daily_2026_fall.json`:六册古诗文、现代背诵课文和日积月累的最终结构化源数据。
220
+- `update_ancient_daily_2026_fall.mjs`:古诗文/日积月累的校验、迁移、脱册、备份、事务更新和审计程序。
221
+- `dry_run_ancient_daily_2026_fall.json`、`apply_ancient_daily_2026_fall.json`、`audit_ancient_daily_2026_fall.json`:对应的预检、执行和独立审计报告。
222
+- `research_ocr_2026_fall.json`、`research_db_snapshot_2026_fall.json`:本次只读调研的 OCR 与数据库快照。
223
+- `古诗文与日积月累调研清单_2026年秋.md`、`古诗文与日积月累更新结果_2026年秋.md`:人工确认清单和最终结果。
174 224
 - `2026年秋季更新结果.md`:便于人工复核的最终清单。
175 225
 - `backups/`:每次执行前的数据库完整备份。

File diff suppressed because it is too large
+ 2369 - 0
.vscode/秒过知识库更新/apply_ancient_daily_2026_fall.json


+ 741 - 0
.vscode/秒过知识库更新/audit_ancient_daily_2026_fall.json

@@ -0,0 +1,741 @@
1
+{
2
+  "generatedAt": "2026-09-03T06:28:11.125Z",
3
+  "sourceVersion": "2026-fall",
4
+  "ok": true,
5
+  "failures": [],
6
+  "books": [
7
+    {
8
+      "miaoguoBookId": 13,
9
+      "bookIdOld": 25,
10
+      "label": "一年级上",
11
+      "type": "ancient",
12
+      "rows": 8,
13
+      "checked": [
14
+        {
15
+          "id": 275,
16
+          "sort": 0,
17
+          "title": "金木水火土"
18
+        },
19
+        {
20
+          "id": 2,
21
+          "sort": 1,
22
+          "title": "江南"
23
+        },
24
+        {
25
+          "id": 276,
26
+          "sort": 2,
27
+          "title": "雪地里的小画家"
28
+        },
29
+        {
30
+          "id": 277,
31
+          "sort": 3,
32
+          "title": "对韵歌"
33
+        },
34
+        {
35
+          "id": 278,
36
+          "sort": 4,
37
+          "title": "升国旗"
38
+        },
39
+        {
40
+          "id": 279,
41
+          "sort": 5,
42
+          "title": "小小的船"
43
+        },
44
+        {
45
+          "id": 280,
46
+          "sort": 6,
47
+          "title": "两件宝"
48
+        },
49
+        {
50
+          "id": 281,
51
+          "sort": 7,
52
+          "title": "比尾巴"
53
+        }
54
+      ]
55
+    },
56
+    {
57
+      "miaoguoBookId": 15,
58
+      "bookIdOld": 27,
59
+      "label": "二年级上",
60
+      "type": "ancient",
61
+      "rows": 7,
62
+      "checked": [
63
+        {
64
+          "id": 16,
65
+          "sort": 0,
66
+          "title": "登鹳雀楼"
67
+        },
68
+        {
69
+          "id": 17,
70
+          "sort": 1,
71
+          "title": "望庐山瀑布"
72
+        },
73
+        {
74
+          "id": 18,
75
+          "sort": 2,
76
+          "title": "江雪"
77
+        },
78
+        {
79
+          "id": 20,
80
+          "sort": 3,
81
+          "title": "敕勒歌"
82
+        },
83
+        {
84
+          "id": 282,
85
+          "sort": 4,
86
+          "title": "田家四季歌"
87
+        },
88
+        {
89
+          "id": 283,
90
+          "sort": 5,
91
+          "title": "树之歌"
92
+        },
93
+        {
94
+          "id": 284,
95
+          "sort": 6,
96
+          "title": "日月潭(节选)"
97
+        }
98
+      ]
99
+    },
100
+    {
101
+      "miaoguoBookId": 17,
102
+      "bookIdOld": 29,
103
+      "label": "三年级上",
104
+      "type": "ancient",
105
+      "rows": 9,
106
+      "checked": [
107
+        {
108
+          "id": 34,
109
+          "sort": 0,
110
+          "title": "望洞庭"
111
+        },
112
+        {
113
+          "id": 29,
114
+          "sort": 1,
115
+          "title": "山行"
116
+        },
117
+        {
118
+          "id": 31,
119
+          "sort": 2,
120
+          "title": "夜书所见"
121
+        },
122
+        {
123
+          "id": 49,
124
+          "sort": 3,
125
+          "title": "鹿柴"
126
+        },
127
+        {
128
+          "id": 32,
129
+          "sort": 4,
130
+          "title": "望天门山"
131
+        },
132
+        {
133
+          "id": 33,
134
+          "sort": 5,
135
+          "title": "饮湖上初晴后雨"
136
+        },
137
+        {
138
+          "id": 37,
139
+          "sort": 6,
140
+          "title": "司马光"
141
+        },
142
+        {
143
+          "id": 285,
144
+          "sort": 7,
145
+          "title": "秋天的雨(节选)"
146
+        },
147
+        {
148
+          "id": 286,
149
+          "sort": 8,
150
+          "title": "大自然的声音(节选)"
151
+        }
152
+      ]
153
+    },
154
+    {
155
+      "miaoguoBookId": 19,
156
+      "bookIdOld": 31,
157
+      "label": "四年级上",
158
+      "type": "ancient",
159
+      "rows": 9,
160
+      "checked": [
161
+        {
162
+          "id": 50,
163
+          "sort": 0,
164
+          "title": "暮江吟"
165
+        },
166
+        {
167
+          "id": 51,
168
+          "sort": 1,
169
+          "title": "题西林壁"
170
+        },
171
+        {
172
+          "id": 52,
173
+          "sort": 2,
174
+          "title": "雪梅"
175
+        },
176
+        {
177
+          "id": 53,
178
+          "sort": 3,
179
+          "title": "精卫填海"
180
+        },
181
+        {
182
+          "id": 56,
183
+          "sort": 4,
184
+          "title": "凉州词"
185
+        },
186
+        {
187
+          "id": 55,
188
+          "sort": 5,
189
+          "title": "出塞"
190
+        },
191
+        {
192
+          "id": 57,
193
+          "sort": 6,
194
+          "title": "夏日绝句"
195
+        },
196
+        {
197
+          "id": 59,
198
+          "sort": 7,
199
+          "title": "王戎不取道旁李"
200
+        },
201
+        {
202
+          "id": 287,
203
+          "sort": 8,
204
+          "title": "观潮(节选)"
205
+        }
206
+      ]
207
+    },
208
+    {
209
+      "miaoguoBookId": 21,
210
+      "bookIdOld": 33,
211
+      "label": "五年级上",
212
+      "type": "ancient",
213
+      "rows": 10,
214
+      "checked": [
215
+        {
216
+          "id": 73,
217
+          "sort": 0,
218
+          "title": "示儿"
219
+        },
220
+        {
221
+          "id": 74,
222
+          "sort": 1,
223
+          "title": "题临安邸"
224
+        },
225
+        {
226
+          "id": 75,
227
+          "sort": 2,
228
+          "title": "己亥杂诗"
229
+        },
230
+        {
231
+          "id": 76,
232
+          "sort": 3,
233
+          "title": "少年中国说(节选)"
234
+        },
235
+        {
236
+          "id": 77,
237
+          "sort": 4,
238
+          "title": "山居秋暝"
239
+        },
240
+        {
241
+          "id": 78,
242
+          "sort": 5,
243
+          "title": "枫桥夜泊"
244
+        },
245
+        {
246
+          "id": 123,
247
+          "sort": 6,
248
+          "title": "早春呈水部张十八员外"
249
+        },
250
+        {
251
+          "id": 81,
252
+          "sort": 7,
253
+          "title": "敏而好学"
254
+        },
255
+        {
256
+          "id": 82,
257
+          "sort": 8,
258
+          "title": "读书有三到"
259
+        },
260
+        {
261
+          "id": 288,
262
+          "sort": 9,
263
+          "title": "白鹭"
264
+        }
265
+      ]
266
+    },
267
+    {
268
+      "miaoguoBookId": 23,
269
+      "bookIdOld": 35,
270
+      "label": "六年级上",
271
+      "type": "ancient",
272
+      "rows": 10,
273
+      "checked": [
274
+        {
275
+          "id": 99,
276
+          "sort": 0,
277
+          "title": "宿建德江"
278
+        },
279
+        {
280
+          "id": 100,
281
+          "sort": 1,
282
+          "title": "六月二十七日望湖楼醉书"
283
+        },
284
+        {
285
+          "id": 101,
286
+          "sort": 2,
287
+          "title": "西江月·夜行黄沙道中"
288
+        },
289
+        {
290
+          "id": 103,
291
+          "sort": 3,
292
+          "title": "七律·长征"
293
+        },
294
+        {
295
+          "id": 106,
296
+          "sort": 4,
297
+          "title": "浪淘沙(其一)"
298
+        },
299
+        {
300
+          "id": 107,
301
+          "sort": 5,
302
+          "title": "江南春"
303
+        },
304
+        {
305
+          "id": 108,
306
+          "sort": 6,
307
+          "title": "书湖阴先生壁"
308
+        },
309
+        {
310
+          "id": 119,
311
+          "sort": 7,
312
+          "title": "两小儿辩日"
313
+        },
314
+        {
315
+          "id": 289,
316
+          "sort": 8,
317
+          "title": "曹冲称象"
318
+        },
319
+        {
320
+          "id": 290,
321
+          "sort": 9,
322
+          "title": "少年闰土(节选)"
323
+        }
324
+      ]
325
+    },
326
+    {
327
+      "miaoguoBookId": 54,
328
+      "bookIdOld": 74,
329
+      "label": "一年级上",
330
+      "type": "daily",
331
+      "rows": 8,
332
+      "checked": [
333
+        {
334
+          "id": 1,
335
+          "sort": 0,
336
+          "title": "咏鹅"
337
+        },
338
+        {
339
+          "id": 3,
340
+          "sort": 1,
341
+          "title": "画"
342
+        },
343
+        {
344
+          "id": 266,
345
+          "sort": 2,
346
+          "title": "一模____ 一心____ 独一____\n十全____ 五颜____ 三头____"
347
+        },
348
+        {
349
+          "id": 4,
350
+          "sort": 3,
351
+          "title": "悯农(其二)"
352
+        },
353
+        {
354
+          "id": 240,
355
+          "sort": 4,
356
+          "title": "一年_______,一日_______。\n一寸_______,寸金_______。"
357
+        },
358
+        {
359
+          "id": 5,
360
+          "sort": 5,
361
+          "title": "古朗月行(节选)"
362
+        },
363
+        {
364
+          "id": 241,
365
+          "sort": 6,
366
+          "title": "种_______,种_______。\n前_______,后_______。\n千_______,始_______。\n百_______,更_______。"
367
+        },
368
+        {
369
+          "id": 6,
370
+          "sort": 7,
371
+          "title": "风"
372
+        }
373
+      ]
374
+    },
375
+    {
376
+      "miaoguoBookId": 56,
377
+      "bookIdOld": 76,
378
+      "label": "二年级上",
379
+      "type": "daily",
380
+      "rows": 8,
381
+      "checked": [
382
+        {
383
+          "id": 14,
384
+          "sort": 0,
385
+          "title": "梅花"
386
+        },
387
+        {
388
+          "id": 224,
389
+          "sort": 1,
390
+          "title": "数九歌"
391
+        },
392
+        {
393
+          "id": 15,
394
+          "sort": 2,
395
+          "title": "小儿垂钓"
396
+        },
397
+        {
398
+          "id": 222,
399
+          "sort": 3,
400
+          "title": "有_______,无_______ \n白_______,杏_______ \n清_______,近_______ \n雾_______,天_______"
401
+        },
402
+        {
403
+          "id": 225,
404
+          "sort": 4,
405
+          "title": "黑_______,白_______。\n一_______,一_______。\n书_______,学_______。"
406
+        },
407
+        {
408
+          "id": 223,
409
+          "sort": 5,
410
+          "title": "有_______ ——《后汉书》\n志_______ ——诸葛亮\n穷_______ ——王勃"
411
+        },
412
+        {
413
+          "id": 19,
414
+          "sort": 6,
415
+          "title": "夜宿山寺"
416
+        },
417
+        {
418
+          "id": 221,
419
+          "sort": 7,
420
+          "title": "己_______ ——《论语》\n与_______ ——《论语》\n不_______ ——《孟子》"
421
+        }
422
+      ]
423
+    },
424
+    {
425
+      "miaoguoBookId": 58,
426
+      "bookIdOld": 78,
427
+      "label": "三年级上",
428
+      "type": "daily",
429
+      "rows": 7,
430
+      "checked": [
431
+        {
432
+          "id": 28,
433
+          "sort": 0,
434
+          "title": "所见"
435
+        },
436
+        {
437
+          "id": 27,
438
+          "sort": 1,
439
+          "title": "舟夜书所见"
440
+        },
441
+        {
442
+          "id": 228,
443
+          "sort": 2,
444
+          "title": "不_______ ——《论语》\n爱_______ ——《墨子》\n仁_______ ——《孟子》\n与_______ ——《荀子》"
445
+        },
446
+        {
447
+          "id": 242,
448
+          "sort": 3,
449
+          "title": "人_______,泰_______。  二_______,其_______。  三_______,顶_______。"
450
+        },
451
+        {
452
+          "id": 35,
453
+          "sort": 4,
454
+          "title": "早发白帝城"
455
+        },
456
+        {
457
+          "id": 36,
458
+          "sort": 5,
459
+          "title": "采莲曲"
460
+        },
461
+        {
462
+          "id": 267,
463
+          "sort": 6,
464
+          "title": "士_______,任_______。——《论语》\n志_______,言_______。——《墨子》\n锲_______,朽_______;锲_______,金_______。——《荀子》"
465
+        }
466
+      ]
467
+    },
468
+    {
469
+      "miaoguoBookId": 60,
470
+      "bookIdOld": 80,
471
+      "label": "四年级上",
472
+      "type": "daily",
473
+      "rows": 7,
474
+      "checked": [
475
+        {
476
+          "id": 30,
477
+          "sort": 0,
478
+          "title": "赠刘景文"
479
+        },
480
+        {
481
+          "id": 229,
482
+          "sort": 1,
483
+          "title": "博_______ ——《中庸》\n智_______ ——王充\n人_______ ——韩愈\n善_______ ——方以智"
484
+        },
485
+        {
486
+          "id": 230,
487
+          "sort": 2,
488
+          "title": "立_______,_______\n二_______,_______\n夏_______,_______\n八_______,雁_______\n一_______,十_______\n八_______,十_______"
489
+        },
490
+        {
491
+          "id": 54,
492
+          "sort": 3,
493
+          "title": "嫦娥"
494
+        },
495
+        {
496
+          "id": 94,
497
+          "sort": 4,
498
+          "title": "黄鹤楼送孟浩然之广陵"
499
+        },
500
+        {
501
+          "id": 231,
502
+          "sort": 5,
503
+          "title": "尺_______,_______。\n仁_______,_______。\n机_______,_______。\n差_______,_______。\n一_______,_______。\n知_______,_______。"
504
+        },
505
+        {
506
+          "id": 58,
507
+          "sort": 6,
508
+          "title": "别董大"
509
+        }
510
+      ]
511
+    },
512
+    {
513
+      "miaoguoBookId": 62,
514
+      "bookIdOld": 82,
515
+      "label": "五年级上",
516
+      "type": "daily",
517
+      "rows": 7,
518
+      "checked": [
519
+        {
520
+          "id": 291,
521
+          "sort": 0,
522
+          "title": "卧_______ 龙_______ 龙_______ 凤_______\n如_______ 如_______ 鹤_______ 一_______\n鼠_______ 抱_______ 狼_______ 狼_______\n狡_______ 呆_______ 虾_______ 黔_______"
523
+        },
524
+        {
525
+          "id": 233,
526
+          "sort": 1,
527
+          "title": "盛_______ ——陶渊明\n莫_______ ——岳飞\n多_______ ——毛泽东"
528
+        },
529
+        {
530
+          "id": 72,
531
+          "sort": 2,
532
+          "title": "乞巧"
533
+        },
534
+        {
535
+          "id": 234,
536
+          "sort": 3,
537
+          "title": "安_______ 国_______ 太_______ 丰_______\n政_______ 人_______ 夜_______ 路_______\n多_______ 流_______ 兵_______ 生_______\n家_______ 哀_______ 民_______ 内_______"
538
+        },
539
+        {
540
+          "id": 114,
541
+          "sort": 4,
542
+          "title": "游子吟"
543
+        },
544
+        {
545
+          "id": 80,
546
+          "sort": 5,
547
+          "title": "渔歌子"
548
+        },
549
+        {
550
+          "id": 84,
551
+          "sort": 6,
552
+          "title": "观书有感(其一)"
553
+        }
554
+      ]
555
+    },
556
+    {
557
+      "miaoguoBookId": 64,
558
+      "bookIdOld": 84,
559
+      "label": "六年级上",
560
+      "type": "daily",
561
+      "rows": 7,
562
+      "checked": [
563
+        {
564
+          "id": 122,
565
+          "sort": 0,
566
+          "title": "江畔独步寻花"
567
+        },
568
+        {
569
+          "id": 236,
570
+          "sort": 1,
571
+          "title": "志_______ ——《论语》\n捐_______ ——曹植\n祖_______ ——李纲\n位_______ ——陆游"
572
+        },
573
+        {
574
+          "id": 85,
575
+          "sort": 2,
576
+          "title": "观书有感(其二)"
577
+        },
578
+        {
579
+          "id": 92,
580
+          "sort": 3,
581
+          "title": "送元二使安西"
582
+        },
583
+        {
584
+          "id": 102,
585
+          "sort": 4,
586
+          "title": "过故人庄"
587
+        },
588
+        {
589
+          "id": 237,
590
+          "sort": 5,
591
+          "title": "五行:_______\n五谷:_______\n五音:_______\n五彩:_______"
592
+        },
593
+        {
594
+          "id": 239,
595
+          "sort": 6,
596
+          "title": "无_______ ——鲁迅《答客诮》\n寄_______ ——鲁迅《自题小像》\n其_______ ——鲁迅《故乡》\n惟_______ ——鲁迅《学界的三魂》\n我_______ ——鲁迅《中国人失掉自信力了吗》"
597
+        }
598
+      ]
599
+    }
600
+  ],
601
+  "detached": [
602
+    {
603
+      "ID": 71,
604
+      "HanziBookID": 0,
605
+      "Title": "蝉"
606
+    },
607
+    {
608
+      "ID": 79,
609
+      "HanziBookID": 0,
610
+      "Title": "长相思"
611
+    },
612
+    {
613
+      "ID": 83,
614
+      "HanziBookID": 0,
615
+      "Title": "士人读书"
616
+    },
617
+    {
618
+      "ID": 104,
619
+      "HanziBookID": 0,
620
+      "Title": "春日"
621
+    },
622
+    {
623
+      "ID": 105,
624
+      "HanziBookID": 0,
625
+      "Title": "回乡偶书"
626
+    },
627
+    {
628
+      "ID": 109,
629
+      "HanziBookID": 0,
630
+      "Title": "伯牙鼓琴"
631
+    },
632
+    {
633
+      "ID": 110,
634
+      "HanziBookID": 0,
635
+      "Title": "书戴嵩画牛"
636
+    },
637
+    {
638
+      "ID": 235,
639
+      "HanziBookID": 0,
640
+      "Title": "克_______ ——《尚书》\n居_______ ——魏征\n由_______ ——司马光\n一_______ ——朱用纯"
641
+    },
642
+    {
643
+      "ID": 238,
644
+      "HanziBookID": 0,
645
+      "Title": "高_______ 天_______\n余_______ 黄_______\n轻_______ 行_______\n巧_______ 惟_______\n画_______ 笔_______\n妙_______ 栩_______"
646
+    }
647
+  ],
648
+  "metadata": [
649
+    {
650
+      "ID": 13,
651
+      "BookIDOld": 25,
652
+      "UnitNum": 1,
653
+      "WordNum": 8
654
+    },
655
+    {
656
+      "ID": 15,
657
+      "BookIDOld": 27,
658
+      "UnitNum": 1,
659
+      "WordNum": 7
660
+    },
661
+    {
662
+      "ID": 17,
663
+      "BookIDOld": 29,
664
+      "UnitNum": 1,
665
+      "WordNum": 9
666
+    },
667
+    {
668
+      "ID": 19,
669
+      "BookIDOld": 31,
670
+      "UnitNum": 1,
671
+      "WordNum": 9
672
+    },
673
+    {
674
+      "ID": 21,
675
+      "BookIDOld": 33,
676
+      "UnitNum": 1,
677
+      "WordNum": 10
678
+    },
679
+    {
680
+      "ID": 23,
681
+      "BookIDOld": 35,
682
+      "UnitNum": 1,
683
+      "WordNum": 10
684
+    },
685
+    {
686
+      "ID": 24,
687
+      "BookIDOld": 36,
688
+      "UnitNum": 1,
689
+      "WordNum": 15
690
+    },
691
+    {
692
+      "ID": 54,
693
+      "BookIDOld": 74,
694
+      "UnitNum": 1,
695
+      "WordNum": 8
696
+    },
697
+    {
698
+      "ID": 56,
699
+      "BookIDOld": 76,
700
+      "UnitNum": 1,
701
+      "WordNum": 8
702
+    },
703
+    {
704
+      "ID": 58,
705
+      "BookIDOld": 78,
706
+      "UnitNum": 1,
707
+      "WordNum": 7
708
+    },
709
+    {
710
+      "ID": 60,
711
+      "BookIDOld": 80,
712
+      "UnitNum": 1,
713
+      "WordNum": 7
714
+    },
715
+    {
716
+      "ID": 61,
717
+      "BookIDOld": 81,
718
+      "UnitNum": 1,
719
+      "WordNum": 6
720
+    },
721
+    {
722
+      "ID": 62,
723
+      "BookIDOld": 82,
724
+      "UnitNum": 1,
725
+      "WordNum": 7
726
+    },
727
+    {
728
+      "ID": 63,
729
+      "BookIDOld": 83,
730
+      "UnitNum": 1,
731
+      "WordNum": 5
732
+    },
733
+    {
734
+      "ID": 64,
735
+      "BookIDOld": 84,
736
+      "UnitNum": 1,
737
+      "WordNum": 7
738
+    }
739
+  ],
740
+  "sourceExistingIds": 80
741
+}

File diff suppressed because it is too large
+ 6361 - 0
.vscode/秒过知识库更新/backups/before_ancient_daily_2026_fall_2026-09-03T06-25-28-871Z.json


+ 418 - 0
.vscode/秒过知识库更新/dry_run_ancient_daily_2026_fall.json

@@ -0,0 +1,418 @@
1
+{
2
+  "generatedAt": "2026-09-03T06:25:39.143Z",
3
+  "mode": "dry-run",
4
+  "sourceVersion": "2026-fall",
5
+  "summary": {
6
+    "inserts": 0,
7
+    "updates": 0,
8
+    "moves": 0,
9
+    "detaches": 0,
10
+    "metadataUpdates": 0,
11
+    "databaseDeletes": 0
12
+  },
13
+  "books": [
14
+    {
15
+      "miaoguoBookId": 13,
16
+      "bookIdOld": 25,
17
+      "label": "一年级上",
18
+      "type": "ancient",
19
+      "finalRows": 8,
20
+      "titles": [
21
+        "金木水火土",
22
+        "江南",
23
+        "雪地里的小画家",
24
+        "对韵歌",
25
+        "升国旗",
26
+        "小小的船",
27
+        "两件宝",
28
+        "比尾巴"
29
+      ]
30
+    },
31
+    {
32
+      "miaoguoBookId": 15,
33
+      "bookIdOld": 27,
34
+      "label": "二年级上",
35
+      "type": "ancient",
36
+      "finalRows": 7,
37
+      "titles": [
38
+        "登鹳雀楼",
39
+        "望庐山瀑布",
40
+        "江雪",
41
+        "敕勒歌",
42
+        "田家四季歌",
43
+        "树之歌",
44
+        "日月潭(节选)"
45
+      ]
46
+    },
47
+    {
48
+      "miaoguoBookId": 17,
49
+      "bookIdOld": 29,
50
+      "label": "三年级上",
51
+      "type": "ancient",
52
+      "finalRows": 9,
53
+      "titles": [
54
+        "望洞庭",
55
+        "山行",
56
+        "夜书所见",
57
+        "鹿柴",
58
+        "望天门山",
59
+        "饮湖上初晴后雨",
60
+        "司马光",
61
+        "秋天的雨(节选)",
62
+        "大自然的声音(节选)"
63
+      ]
64
+    },
65
+    {
66
+      "miaoguoBookId": 19,
67
+      "bookIdOld": 31,
68
+      "label": "四年级上",
69
+      "type": "ancient",
70
+      "finalRows": 9,
71
+      "titles": [
72
+        "暮江吟",
73
+        "题西林壁",
74
+        "雪梅",
75
+        "精卫填海",
76
+        "凉州词",
77
+        "出塞",
78
+        "夏日绝句",
79
+        "王戎不取道旁李",
80
+        "观潮(节选)"
81
+      ]
82
+    },
83
+    {
84
+      "miaoguoBookId": 21,
85
+      "bookIdOld": 33,
86
+      "label": "五年级上",
87
+      "type": "ancient",
88
+      "finalRows": 10,
89
+      "titles": [
90
+        "示儿",
91
+        "题临安邸",
92
+        "己亥杂诗",
93
+        "少年中国说(节选)",
94
+        "山居秋暝",
95
+        "枫桥夜泊",
96
+        "早春呈水部张十八员外",
97
+        "敏而好学",
98
+        "读书有三到",
99
+        "白鹭"
100
+      ]
101
+    },
102
+    {
103
+      "miaoguoBookId": 23,
104
+      "bookIdOld": 35,
105
+      "label": "六年级上",
106
+      "type": "ancient",
107
+      "finalRows": 10,
108
+      "titles": [
109
+        "宿建德江",
110
+        "六月二十七日望湖楼醉书",
111
+        "西江月·夜行黄沙道中",
112
+        "七律·长征",
113
+        "浪淘沙(其一)",
114
+        "江南春",
115
+        "书湖阴先生壁",
116
+        "两小儿辩日",
117
+        "曹冲称象",
118
+        "少年闰土(节选)"
119
+      ]
120
+    },
121
+    {
122
+      "miaoguoBookId": 54,
123
+      "bookIdOld": 74,
124
+      "label": "一年级上",
125
+      "type": "daily",
126
+      "finalRows": 8,
127
+      "titles": [
128
+        "咏鹅",
129
+        "画",
130
+        "一模____ 一心____ 独一____\n十全____ 五颜____ 三头____",
131
+        "悯农(其二)",
132
+        "一年_______,一日_______。\n一寸_______,寸金_______。",
133
+        "古朗月行(节选)",
134
+        "种_______,种_______。\n前_______,后_______。\n千_______,始_______。\n百_______,更_______。",
135
+        "风"
136
+      ]
137
+    },
138
+    {
139
+      "miaoguoBookId": 56,
140
+      "bookIdOld": 76,
141
+      "label": "二年级上",
142
+      "type": "daily",
143
+      "finalRows": 8,
144
+      "titles": [
145
+        "梅花",
146
+        "数九歌",
147
+        "小儿垂钓",
148
+        "有_______,无_______ \n白_______,杏_______ \n清_______,近_______ \n雾_______,天_______",
149
+        "黑_______,白_______。\n一_______,一_______。\n书_______,学_______。",
150
+        "有_______ ——《后汉书》\n志_______ ——诸葛亮\n穷_______ ——王勃",
151
+        "夜宿山寺",
152
+        "己_______ ——《论语》\n与_______ ——《论语》\n不_______ ——《孟子》"
153
+      ]
154
+    },
155
+    {
156
+      "miaoguoBookId": 58,
157
+      "bookIdOld": 78,
158
+      "label": "三年级上",
159
+      "type": "daily",
160
+      "finalRows": 7,
161
+      "titles": [
162
+        "所见",
163
+        "舟夜书所见",
164
+        "不_______ ——《论语》\n爱_______ ——《墨子》\n仁_______ ——《孟子》\n与_______ ——《荀子》",
165
+        "人_______,泰_______。  二_______,其_______。  三_______,顶_______。",
166
+        "早发白帝城",
167
+        "采莲曲",
168
+        "士_______,任_______。——《论语》\n志_______,言_______。——《墨子》\n锲_______,朽_______;锲_______,金_______。——《荀子》"
169
+      ]
170
+    },
171
+    {
172
+      "miaoguoBookId": 60,
173
+      "bookIdOld": 80,
174
+      "label": "四年级上",
175
+      "type": "daily",
176
+      "finalRows": 7,
177
+      "titles": [
178
+        "赠刘景文",
179
+        "博_______ ——《中庸》\n智_______ ——王充\n人_______ ——韩愈\n善_______ ——方以智",
180
+        "立_______,_______\n二_______,_______\n夏_______,_______\n八_______,雁_______\n一_______,十_______\n八_______,十_______",
181
+        "嫦娥",
182
+        "黄鹤楼送孟浩然之广陵",
183
+        "尺_______,_______。\n仁_______,_______。\n机_______,_______。\n差_______,_______。\n一_______,_______。\n知_______,_______。",
184
+        "别董大"
185
+      ]
186
+    },
187
+    {
188
+      "miaoguoBookId": 62,
189
+      "bookIdOld": 82,
190
+      "label": "五年级上",
191
+      "type": "daily",
192
+      "finalRows": 7,
193
+      "titles": [
194
+        "卧_______ 龙_______ 龙_______ 凤_______\n如_______ 如_______ 鹤_______ 一_______\n鼠_______ 抱_______ 狼_______ 狼_______\n狡_______ 呆_______ 虾_______ 黔_______",
195
+        "盛_______ ——陶渊明\n莫_______ ——岳飞\n多_______ ——毛泽东",
196
+        "乞巧",
197
+        "安_______ 国_______ 太_______ 丰_______\n政_______ 人_______ 夜_______ 路_______\n多_______ 流_______ 兵_______ 生_______\n家_______ 哀_______ 民_______ 内_______",
198
+        "游子吟",
199
+        "渔歌子",
200
+        "观书有感(其一)"
201
+      ]
202
+    },
203
+    {
204
+      "miaoguoBookId": 64,
205
+      "bookIdOld": 84,
206
+      "label": "六年级上",
207
+      "type": "daily",
208
+      "finalRows": 7,
209
+      "titles": [
210
+        "江畔独步寻花",
211
+        "志_______ ——《论语》\n捐_______ ——曹植\n祖_______ ——李纲\n位_______ ——陆游",
212
+        "观书有感(其二)",
213
+        "送元二使安西",
214
+        "过故人庄",
215
+        "五行:_______\n五谷:_______\n五音:_______\n五彩:_______",
216
+        "无_______ ——鲁迅《答客诮》\n寄_______ ——鲁迅《自题小像》\n其_______ ——鲁迅《故乡》\n惟_______ ——鲁迅《学界的三魂》\n我_______ ——鲁迅《中国人失掉自信力了吗》"
217
+      ]
218
+    }
219
+  ],
220
+  "changes": [],
221
+  "metadataChanges": [
222
+    {
223
+      "miaoguoBookId": 13,
224
+      "bookIdOld": 25,
225
+      "from": {
226
+        "unitNum": 1,
227
+        "wordNum": 8
228
+      },
229
+      "to": {
230
+        "unitNum": 1,
231
+        "wordNum": 8
232
+      },
233
+      "changed": false
234
+    },
235
+    {
236
+      "miaoguoBookId": 15,
237
+      "bookIdOld": 27,
238
+      "from": {
239
+        "unitNum": 1,
240
+        "wordNum": 7
241
+      },
242
+      "to": {
243
+        "unitNum": 1,
244
+        "wordNum": 7
245
+      },
246
+      "changed": false
247
+    },
248
+    {
249
+      "miaoguoBookId": 17,
250
+      "bookIdOld": 29,
251
+      "from": {
252
+        "unitNum": 1,
253
+        "wordNum": 9
254
+      },
255
+      "to": {
256
+        "unitNum": 1,
257
+        "wordNum": 9
258
+      },
259
+      "changed": false
260
+    },
261
+    {
262
+      "miaoguoBookId": 19,
263
+      "bookIdOld": 31,
264
+      "from": {
265
+        "unitNum": 1,
266
+        "wordNum": 9
267
+      },
268
+      "to": {
269
+        "unitNum": 1,
270
+        "wordNum": 9
271
+      },
272
+      "changed": false
273
+    },
274
+    {
275
+      "miaoguoBookId": 21,
276
+      "bookIdOld": 33,
277
+      "from": {
278
+        "unitNum": 1,
279
+        "wordNum": 10
280
+      },
281
+      "to": {
282
+        "unitNum": 1,
283
+        "wordNum": 10
284
+      },
285
+      "changed": false
286
+    },
287
+    {
288
+      "miaoguoBookId": 23,
289
+      "bookIdOld": 35,
290
+      "from": {
291
+        "unitNum": 1,
292
+        "wordNum": 10
293
+      },
294
+      "to": {
295
+        "unitNum": 1,
296
+        "wordNum": 10
297
+      },
298
+      "changed": false
299
+    },
300
+    {
301
+      "miaoguoBookId": 24,
302
+      "bookIdOld": 36,
303
+      "from": {
304
+        "unitNum": 1,
305
+        "wordNum": 15
306
+      },
307
+      "to": {
308
+        "unitNum": 1,
309
+        "wordNum": 15
310
+      },
311
+      "changed": false
312
+    },
313
+    {
314
+      "miaoguoBookId": 54,
315
+      "bookIdOld": 74,
316
+      "from": {
317
+        "unitNum": 1,
318
+        "wordNum": 8
319
+      },
320
+      "to": {
321
+        "unitNum": 1,
322
+        "wordNum": 8
323
+      },
324
+      "changed": false
325
+    },
326
+    {
327
+      "miaoguoBookId": 56,
328
+      "bookIdOld": 76,
329
+      "from": {
330
+        "unitNum": 1,
331
+        "wordNum": 8
332
+      },
333
+      "to": {
334
+        "unitNum": 1,
335
+        "wordNum": 8
336
+      },
337
+      "changed": false
338
+    },
339
+    {
340
+      "miaoguoBookId": 58,
341
+      "bookIdOld": 78,
342
+      "from": {
343
+        "unitNum": 1,
344
+        "wordNum": 7
345
+      },
346
+      "to": {
347
+        "unitNum": 1,
348
+        "wordNum": 7
349
+      },
350
+      "changed": false
351
+    },
352
+    {
353
+      "miaoguoBookId": 60,
354
+      "bookIdOld": 80,
355
+      "from": {
356
+        "unitNum": 1,
357
+        "wordNum": 7
358
+      },
359
+      "to": {
360
+        "unitNum": 1,
361
+        "wordNum": 7
362
+      },
363
+      "changed": false
364
+    },
365
+    {
366
+      "miaoguoBookId": 61,
367
+      "bookIdOld": 81,
368
+      "from": {
369
+        "unitNum": 1,
370
+        "wordNum": 6
371
+      },
372
+      "to": {
373
+        "unitNum": 1,
374
+        "wordNum": 6
375
+      },
376
+      "changed": false
377
+    },
378
+    {
379
+      "miaoguoBookId": 62,
380
+      "bookIdOld": 82,
381
+      "from": {
382
+        "unitNum": 1,
383
+        "wordNum": 7
384
+      },
385
+      "to": {
386
+        "unitNum": 1,
387
+        "wordNum": 7
388
+      },
389
+      "changed": false
390
+    },
391
+    {
392
+      "miaoguoBookId": 63,
393
+      "bookIdOld": 83,
394
+      "from": {
395
+        "unitNum": 1,
396
+        "wordNum": 5
397
+      },
398
+      "to": {
399
+        "unitNum": 1,
400
+        "wordNum": 5
401
+      },
402
+      "changed": false
403
+    },
404
+    {
405
+      "miaoguoBookId": 64,
406
+      "bookIdOld": 84,
407
+      "from": {
408
+        "unitNum": 1,
409
+        "wordNum": 7
410
+      },
411
+      "to": {
412
+        "unitNum": 1,
413
+        "wordNum": 7
414
+      },
415
+      "changed": false
416
+    }
417
+  ]
418
+}

File diff suppressed because it is too large
+ 5272 - 0
.vscode/秒过知识库更新/research_db_snapshot_2026_fall.json


+ 50 - 0
.vscode/秒过知识库更新/research_db_snapshot_2026_fall.mjs

@@ -0,0 +1,50 @@
1
+#!/usr/bin/env node
2
+
3
+import fs from "node:fs";
4
+import path from "node:path";
5
+import { fileURLToPath } from "node:url";
6
+import mysql from "mysql2/promise";
7
+import config from "../../src/config/dev.js";
8
+
9
+const taskDir = path.dirname(fileURLToPath(import.meta.url));
10
+const outputPath = path.join(taskDir, "research_db_snapshot_2026_fall.json");
11
+const miaoguoBookIds = [13, 15, 17, 19, 21, 23, 24, 54, 56, 58, 60, 61, 62, 63, 64];
12
+const hanziBookIds = [25, 27, 29, 31, 33, 35, 36, 74, 76, 78, 80, 81, 82, 83, 84];
13
+
14
+const connection = await mysql.createConnection({
15
+  ...config.database,
16
+  charset: "utf8mb4",
17
+});
18
+
19
+try {
20
+  const [books] = await connection.query(
21
+    `SELECT ID, BookIDOld, BookName, UnitNum, WordNum
22
+       FROM MiaoguoBook
23
+      WHERE ID IN (?)
24
+      ORDER BY ID`,
25
+    [miaoguoBookIds],
26
+  );
27
+  const [targetRows] = await connection.query(
28
+    `SELECT ID, HanziBookID, Sort, Category, Tag, Title, ImgUrl,
29
+            Author, Dynasty, PeomContent, Translation, Flag
30
+       FROM AncientPoetry
31
+      WHERE HanziBookID IN (?)
32
+      ORDER BY HanziBookID, Sort, ID`,
33
+    [hanziBookIds],
34
+  );
35
+  const [allRows] = await connection.query(
36
+    `SELECT ID, HanziBookID, Sort, Category, Tag, Title, Author, Dynasty,
37
+            PeomContent, Translation, Flag
38
+       FROM AncientPoetry
39
+      ORDER BY ID`,
40
+  );
41
+
42
+  fs.writeFileSync(
43
+    outputPath,
44
+    `${JSON.stringify({ capturedAt: new Date().toISOString(), books, targetRows, allRows }, null, 2)}\n`,
45
+    "utf8",
46
+  );
47
+  console.log(JSON.stringify({ outputPath, books: books.length, targetRows: targetRows.length, allRows: allRows.length }));
48
+} finally {
49
+  await connection.end();
50
+}

File diff suppressed because it is too large
+ 41449 - 0
.vscode/秒过知识库更新/research_ocr_2026_fall.json


+ 90 - 0
.vscode/秒过知识库更新/research_ocr_2026_fall.swift

@@ -0,0 +1,90 @@
1
+import AppKit
2
+import Foundation
3
+import Vision
4
+
5
+struct OCRLine: Codable {
6
+  let text: String
7
+  let x: Double
8
+  let y: Double
9
+  let width: Double
10
+  let height: Double
11
+}
12
+
13
+struct OCRPage: Codable {
14
+  let grade: String
15
+  let file: String
16
+  let path: String
17
+  let lines: [OCRLine]
18
+}
19
+
20
+let args = CommandLine.arguments
21
+guard args.count == 3 else {
22
+  fputs("usage: research_ocr_2026_fall <source-root> <output-json>\n", stderr)
23
+  exit(2)
24
+}
25
+
26
+let sourceRoot = URL(fileURLWithPath: args[1], isDirectory: true)
27
+let outputURL = URL(fileURLWithPath: args[2])
28
+let manager = FileManager.default
29
+let allowedExtensions = Set(["png", "jpg", "jpeg"])
30
+
31
+let gradeURLs = try manager.contentsOfDirectory(
32
+  at: sourceRoot,
33
+  includingPropertiesForKeys: nil,
34
+  options: [.skipsHiddenFiles]
35
+).filter { $0.hasDirectoryPath }.sorted { $0.lastPathComponent < $1.lastPathComponent }
36
+
37
+var pages: [OCRPage] = []
38
+for gradeURL in gradeURLs {
39
+  let imageURLs = try manager.contentsOfDirectory(
40
+    at: gradeURL,
41
+    includingPropertiesForKeys: nil,
42
+    options: [.skipsHiddenFiles]
43
+  ).filter { allowedExtensions.contains($0.pathExtension.lowercased()) }
44
+    .sorted { $0.lastPathComponent < $1.lastPathComponent }
45
+
46
+  for (index, imageURL) in imageURLs.enumerated() {
47
+    guard let image = NSImage(contentsOf: imageURL),
48
+          let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
49
+      fputs("failed to load \(imageURL.path)\n", stderr)
50
+      exit(1)
51
+    }
52
+
53
+    let request = VNRecognizeTextRequest()
54
+    request.recognitionLevel = .accurate
55
+    request.usesLanguageCorrection = true
56
+    request.recognitionLanguages = ["zh-Hans", "en-US"]
57
+    request.minimumTextHeight = 0.006
58
+    try VNImageRequestHandler(cgImage: cgImage, options: [:]).perform([request])
59
+
60
+    let lines = (request.results ?? []).compactMap { observation -> OCRLine? in
61
+      guard let candidate = observation.topCandidates(1).first else { return nil }
62
+      let box = observation.boundingBox
63
+      return OCRLine(
64
+        text: candidate.string,
65
+        x: Double(box.minX),
66
+        y: Double(box.minY),
67
+        width: Double(box.width),
68
+        height: Double(box.height)
69
+      )
70
+    }.sorted {
71
+      let leftY = Int($0.y * 1000)
72
+      let rightY = Int($1.y * 1000)
73
+      if leftY != rightY { return leftY > rightY }
74
+      return $0.x < $1.x
75
+    }
76
+
77
+    pages.append(OCRPage(
78
+      grade: gradeURL.lastPathComponent,
79
+      file: imageURL.lastPathComponent,
80
+      path: imageURL.path,
81
+      lines: lines
82
+    ))
83
+    fputs("[\(gradeURL.lastPathComponent)] \(index + 1)/\(imageURLs.count) \(imageURL.lastPathComponent)\n", stderr)
84
+  }
85
+}
86
+
87
+let encoder = JSONEncoder()
88
+encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
89
+try encoder.encode(pages).write(to: outputURL, options: .atomic)
90
+fputs("wrote \(pages.count) pages to \(outputURL.path)\n", stderr)

File diff suppressed because it is too large
+ 542 - 0
.vscode/秒过知识库更新/source_ancient_daily_2026_fall.json


+ 592 - 0
.vscode/秒过知识库更新/update_ancient_daily_2026_fall.mjs

@@ -0,0 +1,592 @@
1
+#!/usr/bin/env node
2
+
3
+import assert from "node:assert/strict";
4
+import fs from "node:fs";
5
+import path from "node:path";
6
+import { fileURLToPath } from "node:url";
7
+import mysql from "mysql2/promise";
8
+import config from "../../src/config/dev.js";
9
+
10
+const taskDir = path.dirname(fileURLToPath(import.meta.url));
11
+const sourcePath = path.join(taskDir, "source_ancient_daily_2026_fall.json");
12
+const source = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
13
+const flags = new Set(process.argv.slice(2));
14
+const knownFlags = new Set(["--dry-run", "--apply", "--audit"]);
15
+for (const flag of flags) assert(knownFlags.has(flag), `不支持的参数:${flag}`);
16
+assert(
17
+  ["--dry-run", "--apply", "--audit"].filter((flag) => flags.has(flag)).length <= 1,
18
+  "--dry-run、--apply、--audit 只能选择一个",
19
+);
20
+const mode = flags.has("--apply") ? "apply" : flags.has("--audit") ? "audit" : "dry-run";
21
+
22
+function nowForFile() {
23
+  return new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
24
+}
25
+
26
+function writeJson(filePath, data) {
27
+  fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
28
+}
29
+
30
+function stringifyContent(content) {
31
+  return JSON.stringify(content);
32
+}
33
+
34
+function normalized(value) {
35
+  return value == null ? null : value;
36
+}
37
+
38
+function tagFor(book, entry) {
39
+  return book.type === "daily"
40
+    ? `${book.label},日积月累,${entry.kind}`
41
+    : `${book.label},${entry.kind}`;
42
+}
43
+
44
+function categoryFor(book, entry) {
45
+  if (book.type === "ancient") return 0;
46
+  return entry.kind === "诗词" ? 0 : 1;
47
+}
48
+
49
+function validateSource() {
50
+  assert.equal(source.version, "2026-fall");
51
+  assert.equal(source.sortBase, 0);
52
+  assert.equal(source.books.length, 12, "应包含六册古诗文和六册日积月累");
53
+
54
+  const bookIds = source.books.map((book) => book.miaoguoBookId);
55
+  const bookIdsOld = source.books.map((book) => book.bookIdOld);
56
+  assert.equal(new Set(bookIds).size, bookIds.length, "MiaoguoBook.ID 重复");
57
+  assert.equal(new Set(bookIdsOld).size, bookIdsOld.length, "目标 BookIDOld 重复");
58
+
59
+  const existingIds = [];
60
+  const newTitles = [];
61
+  for (const book of source.books) {
62
+    assert(["ancient", "daily"].includes(book.type), `${book.label} type 错误`);
63
+    assert(book.entries.length > 0, `${book.label}/${book.type} 没有条目`);
64
+    book.entries.forEach((entry, index) => {
65
+      assert(entry.kind, `${book.label}/${book.type}/Sort=${index} 缺少 kind`);
66
+      const tag = tagFor(book, entry);
67
+      assert(tag.length <= 45, `${tag} 超过 Tag 长度`);
68
+      if (entry.id != null) {
69
+        existingIds.push(entry.id);
70
+      } else {
71
+        assert(entry.title, `${book.label}/${book.type}/Sort=${index} 新增项缺少标题`);
72
+        assert(entry.content, `${book.label}/${book.type}/${entry.title} 新增项缺少正文`);
73
+        newTitles.push(entry.title);
74
+      }
75
+      if (entry.title != null) assert(entry.title.length <= 200, `${entry.title} 标题过长`);
76
+      if (entry.author != null) assert(entry.author.length <= 45, `${entry.title} Author 过长`);
77
+      if (entry.dynasty != null) assert(entry.dynasty.length <= 45, `${entry.title} Dynasty 过长`);
78
+      if (entry.content != null) {
79
+        const content = stringifyContent(entry.content);
80
+        assert(content.length <= 2000, `${entry.title ?? entry.id} PeomContent 超过 2000 字符`);
81
+        assert.deepEqual(JSON.parse(content), entry.content);
82
+      }
83
+    });
84
+  }
85
+
86
+  assert.equal(new Set(existingIds).size, existingIds.length, "来源中的 AncientPoetry.ID 重复");
87
+  assert.equal(new Set(newTitles).size, newTitles.length, "新增标题重复");
88
+  const detachIds = source.detach.map((entry) => entry.id);
89
+  assert.equal(new Set(detachIds).size, detachIds.length, "detach ID 重复");
90
+  assert(
91
+    detachIds.every((id) => !existingIds.includes(id)),
92
+    "同一 ID 不能既出现在最终清单又出现在 detach",
93
+  );
94
+
95
+  const metadataIds = source.metadata.map((entry) => entry.miaoguoBookId);
96
+  assert.equal(new Set(metadataIds).size, metadataIds.length, "metadata MiaoguoBook.ID 重复");
97
+  for (const book of source.books) {
98
+    const metadata = source.metadata.find((entry) => entry.miaoguoBookId === book.miaoguoBookId);
99
+    assert(metadata, `${book.label}/${book.type} 缺少 metadata`);
100
+    assert.equal(metadata.bookIdOld, book.bookIdOld, `${book.label}/${book.type} metadata 映射错误`);
101
+    assert.equal(metadata.wordNum, book.entries.length, `${book.label}/${book.type} WordNum 不等于最终行数`);
102
+  }
103
+}
104
+
105
+async function readState(connection, lock = false) {
106
+  const existingIds = source.books.flatMap((book) => book.entries)
107
+    .filter((entry) => entry.id != null)
108
+    .map((entry) => entry.id);
109
+  const affectedIds = [...new Set([...existingIds, ...source.detach.map((entry) => entry.id)])];
110
+  const metadataIds = source.metadata.map((entry) => entry.miaoguoBookId);
111
+  const targetBookIdsOld = source.books.map((book) => book.bookIdOld);
112
+  const lockSuffix = lock ? " FOR UPDATE" : "";
113
+
114
+  const [rows] = await connection.query(
115
+    `SELECT ID, HanziBookID, Sort, Category, Tag, Title, ImgUrl, Author, Dynasty,
116
+            PeomContent, Translation, Flag
117
+       FROM AncientPoetry
118
+      WHERE ID IN (?)${lockSuffix}`,
119
+    [affectedIds],
120
+  );
121
+  const [allRows] = await connection.query(
122
+    `SELECT ID, HanziBookID, Sort, Category, Tag, Title, ImgUrl, Author, Dynasty,
123
+            PeomContent, Translation, Flag
124
+       FROM AncientPoetry${lockSuffix}`,
125
+  );
126
+  const [books] = await connection.query(
127
+    `SELECT ID, BookIDOld, BookName, UnitNum, WordNum
128
+       FROM MiaoguoBook
129
+      WHERE ID IN (?)${lockSuffix}`,
130
+    [metadataIds],
131
+  );
132
+  const [targetRows] = await connection.query(
133
+    `SELECT ID, HanziBookID, Sort, Category, Tag, Title, ImgUrl, Author, Dynasty,
134
+            PeomContent, Translation, Flag
135
+       FROM AncientPoetry
136
+      WHERE HanziBookID IN (?)${lockSuffix}`,
137
+    [targetBookIdsOld],
138
+  );
139
+  return { rows, allRows, books, targetRows };
140
+}
141
+
142
+function materialize(state) {
143
+  const allById = new Map(state.allRows.map((row) => [row.ID, row]));
144
+  const allByTitle = new Map();
145
+  for (const row of state.allRows) {
146
+    const matches = allByTitle.get(row.Title) ?? [];
147
+    matches.push(row);
148
+    allByTitle.set(row.Title, matches);
149
+  }
150
+
151
+  const referencedIds = source.books.flatMap((book) => book.entries)
152
+    .filter((entry) => entry.id != null)
153
+    .map((entry) => entry.id);
154
+  for (const id of [...referencedIds, ...source.detach.map((entry) => entry.id)]) {
155
+    assert(allById.has(id), `AncientPoetry.ID=${id} 不存在`);
156
+  }
157
+
158
+  const desiredBooks = source.books.map((book) => ({
159
+    ...book,
160
+    entries: book.entries.map((entry, index) => {
161
+      let current = null;
162
+      if (entry.id != null) {
163
+        current = allById.get(entry.id);
164
+      } else {
165
+        const titleMatches = allByTitle.get(entry.title) ?? [];
166
+        assert(titleMatches.length <= 1, `新增标题“${entry.title}”在数据库中存在多条记录`);
167
+        if (titleMatches.length === 1) {
168
+          current = titleMatches[0];
169
+          assert.equal(
170
+            current.HanziBookID,
171
+            book.bookIdOld,
172
+            `新增标题“${entry.title}”已存在,但位于 BookIDOld=${current.HanziBookID}`,
173
+          );
174
+        }
175
+      }
176
+
177
+      return {
178
+        source: entry,
179
+        current,
180
+        desired: {
181
+          ID: current?.ID ?? null,
182
+          HanziBookID: book.bookIdOld,
183
+          Sort: source.sortBase + index,
184
+          Category: categoryFor(book, entry),
185
+          Tag: tagFor(book, entry),
186
+          Title: entry.title ?? current?.Title,
187
+          ImgUrl: current?.ImgUrl ?? null,
188
+          Author: Object.hasOwn(entry, "author") ? entry.author : normalized(current?.Author),
189
+          Dynasty: Object.hasOwn(entry, "dynasty") ? entry.dynasty : normalized(current?.Dynasty),
190
+          PeomContent: entry.content != null
191
+            ? stringifyContent(entry.content)
192
+            : current?.PeomContent,
193
+          Translation: current?.Translation ?? null,
194
+          Flag: current?.Flag ?? 0,
195
+        },
196
+      };
197
+    }),
198
+  }));
199
+
200
+  for (const book of desiredBooks) {
201
+    for (const entry of book.entries) {
202
+      assert(entry.desired.Title, `${book.label}/${book.type} 存在空标题`);
203
+      assert(entry.desired.PeomContent, `${book.label}/${book.type}/${entry.desired.Title} 存在空正文`);
204
+      JSON.parse(entry.desired.PeomContent);
205
+    }
206
+  }
207
+
208
+  const currentTargetIds = new Set(state.targetRows.map((row) => row.ID));
209
+  const desiredExistingIds = new Set(
210
+    desiredBooks.flatMap((book) => book.entries)
211
+      .filter((entry) => entry.current)
212
+      .map((entry) => entry.current.ID),
213
+  );
214
+  const detachIds = new Set(source.detach.map((entry) => entry.id));
215
+  const unexplainedTargetIds = [...currentTargetIds]
216
+    .filter((id) => !desiredExistingIds.has(id) && !detachIds.has(id));
217
+  assert.deepEqual(unexplainedTargetIds, [], `目标书存在未处理旧记录:${unexplainedTargetIds.join(",")}`);
218
+
219
+  const finalBookByExistingId = new Map(state.allRows.map((row) => [row.ID, row.HanziBookID]));
220
+  for (const book of desiredBooks) {
221
+    for (const entry of book.entries) {
222
+      if (entry.current) finalBookByExistingId.set(entry.current.ID, book.bookIdOld);
223
+    }
224
+  }
225
+  for (const entry of source.detach) finalBookByExistingId.set(entry.id, 0);
226
+
227
+  const expectedCounts = new Map();
228
+  for (const bookIdOld of finalBookByExistingId.values()) {
229
+    expectedCounts.set(bookIdOld, (expectedCounts.get(bookIdOld) ?? 0) + 1);
230
+  }
231
+  for (const book of desiredBooks) {
232
+    const inserts = book.entries.filter((entry) => !entry.current).length;
233
+    expectedCounts.set(book.bookIdOld, (expectedCounts.get(book.bookIdOld) ?? 0) + inserts);
234
+  }
235
+  for (const metadata of source.metadata) {
236
+    assert.equal(
237
+      expectedCounts.get(metadata.bookIdOld) ?? 0,
238
+      metadata.wordNum,
239
+      `BookIDOld=${metadata.bookIdOld} 模拟行数与 metadata.WordNum 不一致`,
240
+    );
241
+  }
242
+
243
+  return desiredBooks;
244
+}
245
+
246
+const compareFields = [
247
+  "HanziBookID",
248
+  "Sort",
249
+  "Category",
250
+  "Tag",
251
+  "Title",
252
+  "Author",
253
+  "Dynasty",
254
+  "PeomContent",
255
+  "Translation",
256
+  "Flag",
257
+];
258
+
259
+function buildPlan(state, desiredBooks) {
260
+  const changes = [];
261
+  for (const book of desiredBooks) {
262
+    for (const entry of book.entries) {
263
+      if (!entry.current) {
264
+        changes.push({
265
+          action: "insert",
266
+          bookIdOld: book.bookIdOld,
267
+          sort: entry.desired.Sort,
268
+          title: entry.desired.Title,
269
+        });
270
+        continue;
271
+      }
272
+      const changedFields = compareFields.filter(
273
+        (field) => normalized(entry.current[field]) !== normalized(entry.desired[field]),
274
+      );
275
+      if (changedFields.length > 0) {
276
+        changes.push({
277
+          action: entry.current.HanziBookID === entry.desired.HanziBookID ? "update" : "move",
278
+          id: entry.current.ID,
279
+          fromBookIdOld: entry.current.HanziBookID,
280
+          toBookIdOld: entry.desired.HanziBookID,
281
+          sort: entry.desired.Sort,
282
+          fromTitle: entry.current.Title,
283
+          toTitle: entry.desired.Title,
284
+          changedFields,
285
+        });
286
+      }
287
+    }
288
+  }
289
+
290
+  for (const entry of source.detach) {
291
+    const current = state.allRows.find((row) => row.ID === entry.id);
292
+    if (current.HanziBookID !== 0) {
293
+      changes.push({
294
+        action: "detach",
295
+        id: entry.id,
296
+        fromBookIdOld: current.HanziBookID,
297
+        toBookIdOld: 0,
298
+        title: current.Title,
299
+        changedFields: ["HanziBookID"],
300
+      });
301
+    }
302
+  }
303
+
304
+  const booksById = new Map(state.books.map((book) => [book.ID, book]));
305
+  const metadataChanges = source.metadata.map((desired) => {
306
+    const current = booksById.get(desired.miaoguoBookId);
307
+    assert(current, `MiaoguoBook.ID=${desired.miaoguoBookId} 不存在`);
308
+    assert.equal(current.BookIDOld, desired.bookIdOld, `MiaoguoBook.ID=${desired.miaoguoBookId} 映射不一致`);
309
+    return {
310
+      miaoguoBookId: desired.miaoguoBookId,
311
+      bookIdOld: desired.bookIdOld,
312
+      from: { unitNum: current.UnitNum, wordNum: current.WordNum },
313
+      to: { unitNum: desired.unitNum, wordNum: desired.wordNum },
314
+      changed: current.UnitNum !== desired.unitNum || current.WordNum !== desired.wordNum,
315
+    };
316
+  });
317
+
318
+  return {
319
+    generatedAt: new Date().toISOString(),
320
+    mode,
321
+    sourceVersion: source.version,
322
+    summary: {
323
+      inserts: changes.filter((entry) => entry.action === "insert").length,
324
+      updates: changes.filter((entry) => entry.action === "update").length,
325
+      moves: changes.filter((entry) => entry.action === "move").length,
326
+      detaches: changes.filter((entry) => entry.action === "detach").length,
327
+      metadataUpdates: metadataChanges.filter((entry) => entry.changed).length,
328
+      databaseDeletes: 0,
329
+    },
330
+    books: desiredBooks.map((book) => ({
331
+      miaoguoBookId: book.miaoguoBookId,
332
+      bookIdOld: book.bookIdOld,
333
+      label: book.label,
334
+      type: book.type,
335
+      finalRows: book.entries.length,
336
+      titles: book.entries.map((entry) => entry.desired.Title),
337
+    })),
338
+    changes,
339
+    metadataChanges,
340
+  };
341
+}
342
+
343
+async function applyPlan(connection, desiredBooks) {
344
+  const inserted = [];
345
+  const updated = [];
346
+  const detached = [];
347
+
348
+  for (const entry of source.detach) {
349
+    const [result] = await connection.query(
350
+      "UPDATE AncientPoetry SET HanziBookID=0 WHERE ID=? AND HanziBookID<>0",
351
+      [entry.id],
352
+    );
353
+    if (result.affectedRows > 0) detached.push(entry.id);
354
+  }
355
+
356
+  for (const book of desiredBooks) {
357
+    for (const entry of book.entries) {
358
+      const row = entry.desired;
359
+      if (entry.current) {
360
+        const [result] = await connection.query(
361
+          `UPDATE AncientPoetry
362
+              SET HanziBookID=?, Sort=?, Category=?, Tag=?, Title=?, Author=?, Dynasty=?,
363
+                  PeomContent=?, Translation=?, Flag=?
364
+            WHERE ID=?`,
365
+          [
366
+            row.HanziBookID,
367
+            row.Sort,
368
+            row.Category,
369
+            row.Tag,
370
+            row.Title,
371
+            row.Author,
372
+            row.Dynasty,
373
+            row.PeomContent,
374
+            row.Translation,
375
+            row.Flag,
376
+            entry.current.ID,
377
+          ],
378
+        );
379
+        assert.equal(result.affectedRows, 1, `更新 AncientPoetry.ID=${entry.current.ID} 失败`);
380
+        updated.push(entry.current.ID);
381
+      } else {
382
+        const [result] = await connection.query(
383
+          `INSERT INTO AncientPoetry
384
+             (HanziBookID, Sort, Category, Tag, Title, ImgUrl, Author, Dynasty,
385
+              PeomContent, Translation, Flag)
386
+           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
387
+          [
388
+            row.HanziBookID,
389
+            row.Sort,
390
+            row.Category,
391
+            row.Tag,
392
+            row.Title,
393
+            row.ImgUrl,
394
+            row.Author,
395
+            row.Dynasty,
396
+            row.PeomContent,
397
+            row.Translation,
398
+            row.Flag,
399
+          ],
400
+        );
401
+        inserted.push({ id: result.insertId, bookIdOld: row.HanziBookID, title: row.Title });
402
+      }
403
+    }
404
+  }
405
+
406
+  for (const metadata of source.metadata) {
407
+    const [result] = await connection.query(
408
+      "UPDATE MiaoguoBook SET UnitNum=?, WordNum=? WHERE ID=? AND BookIDOld=?",
409
+      [metadata.unitNum, metadata.wordNum, metadata.miaoguoBookId, metadata.bookIdOld],
410
+    );
411
+    assert.equal(result.affectedRows, 1, `更新 MiaoguoBook.ID=${metadata.miaoguoBookId} 失败`);
412
+  }
413
+
414
+  return { inserted, updated, detached };
415
+}
416
+
417
+async function audit(connection) {
418
+  const failures = [];
419
+  const bookResults = [];
420
+  const entryByExistingId = new Map(
421
+    source.books.flatMap((book) => book.entries)
422
+      .filter((entry) => entry.id != null)
423
+      .map((entry) => [entry.id, entry]),
424
+  );
425
+
426
+  for (const book of source.books) {
427
+    const [rows] = await connection.query(
428
+      `SELECT ID, HanziBookID, Sort, Category, Tag, Title, ImgUrl, Author, Dynasty,
429
+              PeomContent, Translation, Flag
430
+         FROM AncientPoetry
431
+        WHERE HanziBookID=?
432
+        ORDER BY Sort, ID`,
433
+      [book.bookIdOld],
434
+    );
435
+    const expectedSorts = book.entries.map((_, index) => source.sortBase + index);
436
+    const actualSorts = rows.map((row) => row.Sort);
437
+    if (rows.length !== book.entries.length) {
438
+      failures.push(`${book.label}/${book.type} 行数 ${rows.length} != ${book.entries.length}`);
439
+    }
440
+    if (JSON.stringify(actualSorts) !== JSON.stringify(expectedSorts)) {
441
+      failures.push(`${book.label}/${book.type} Sort 不连续:${actualSorts.join(",")}`);
442
+    }
443
+
444
+    const rowById = new Map(rows.map((row) => [row.ID, row]));
445
+    const rowByTitle = new Map(rows.map((row) => [row.Title, row]));
446
+    const checked = [];
447
+    book.entries.forEach((entry, index) => {
448
+      const row = entry.id != null ? rowById.get(entry.id) : rowByTitle.get(entry.title);
449
+      if (!row) {
450
+        failures.push(`${book.label}/${book.type} 缺少 ${entry.title ?? `ID=${entry.id}`}`);
451
+        return;
452
+      }
453
+      const expectedTitle = entry.title ?? row.Title;
454
+      const expectedCategory = categoryFor(book, entry);
455
+      const expectedTag = tagFor(book, entry);
456
+      if (row.HanziBookID !== book.bookIdOld) failures.push(`ID=${row.ID} HanziBookID 错误`);
457
+      if (row.Sort !== source.sortBase + index) failures.push(`ID=${row.ID} Sort 错误`);
458
+      if (row.Category !== expectedCategory) failures.push(`ID=${row.ID} Category 错误`);
459
+      if (row.Tag !== expectedTag) failures.push(`ID=${row.ID} Tag 错误`);
460
+      if (row.Title !== expectedTitle) failures.push(`ID=${row.ID} Title 错误`);
461
+      try {
462
+        JSON.parse(row.PeomContent);
463
+      } catch {
464
+        failures.push(`ID=${row.ID} PeomContent 不是合法 JSON`);
465
+      }
466
+      if (entry.content != null && row.PeomContent !== stringifyContent(entry.content)) {
467
+        failures.push(`ID=${row.ID} PeomContent 与来源不一致`);
468
+      }
469
+      if (Object.hasOwn(entry, "author") && normalized(row.Author) !== normalized(entry.author)) {
470
+        failures.push(`ID=${row.ID} Author 与来源不一致`);
471
+      }
472
+      if (Object.hasOwn(entry, "dynasty") && normalized(row.Dynasty) !== normalized(entry.dynasty)) {
473
+        failures.push(`ID=${row.ID} Dynasty 与来源不一致`);
474
+      }
475
+      if (entry.id == null && row.Translation != null) failures.push(`新增 ID=${row.ID} Translation 应为空`);
476
+      checked.push({ id: row.ID, sort: row.Sort, title: row.Title });
477
+    });
478
+    if (new Set(rows.map((row) => row.Title)).size !== rows.length) {
479
+      failures.push(`${book.label}/${book.type} 存在重复标题`);
480
+    }
481
+    bookResults.push({
482
+      miaoguoBookId: book.miaoguoBookId,
483
+      bookIdOld: book.bookIdOld,
484
+      label: book.label,
485
+      type: book.type,
486
+      rows: rows.length,
487
+      checked,
488
+    });
489
+  }
490
+
491
+  const [detachedRows] = await connection.query(
492
+    "SELECT ID, HanziBookID, Title FROM AncientPoetry WHERE ID IN (?) ORDER BY ID",
493
+    [source.detach.map((entry) => entry.id)],
494
+  );
495
+  for (const entry of source.detach) {
496
+    const row = detachedRows.find((candidate) => candidate.ID === entry.id);
497
+    if (!row) failures.push(`detach ID=${entry.id} 不存在`);
498
+    else if (row.HanziBookID !== 0) failures.push(`detach ID=${entry.id} HanziBookID=${row.HanziBookID}`);
499
+  }
500
+
501
+  const [books] = await connection.query(
502
+    "SELECT ID, BookIDOld, UnitNum, WordNum FROM MiaoguoBook WHERE ID IN (?) ORDER BY ID",
503
+    [source.metadata.map((entry) => entry.miaoguoBookId)],
504
+  );
505
+  for (const expected of source.metadata) {
506
+    const book = books.find((candidate) => candidate.ID === expected.miaoguoBookId);
507
+    if (!book) failures.push(`MiaoguoBook.ID=${expected.miaoguoBookId} 不存在`);
508
+    else {
509
+      if (book.BookIDOld !== expected.bookIdOld) failures.push(`MiaoguoBook.ID=${book.ID} BookIDOld 错误`);
510
+      if (book.UnitNum !== expected.unitNum) failures.push(`MiaoguoBook.ID=${book.ID} UnitNum 错误`);
511
+      if (book.WordNum !== expected.wordNum) failures.push(`MiaoguoBook.ID=${book.ID} WordNum 错误`);
512
+      const [[countRow]] = await connection.query(
513
+        "SELECT COUNT(*) AS count FROM AncientPoetry WHERE HanziBookID=?",
514
+        [expected.bookIdOld],
515
+      );
516
+      if (countRow.count !== expected.wordNum) {
517
+        failures.push(`BookIDOld=${expected.bookIdOld} 实际行数 ${countRow.count} != WordNum ${expected.wordNum}`);
518
+      }
519
+    }
520
+  }
521
+
522
+  return {
523
+    generatedAt: new Date().toISOString(),
524
+    sourceVersion: source.version,
525
+    ok: failures.length === 0,
526
+    failures,
527
+    books: bookResults,
528
+    detached: detachedRows,
529
+    metadata: books,
530
+    sourceExistingIds: [...entryByExistingId.keys()].length,
531
+  };
532
+}
533
+
534
+validateSource();
535
+const connection = await mysql.createConnection({ ...config.database, charset: "utf8mb4" });
536
+
537
+try {
538
+  if (mode === "audit") {
539
+    const auditResult = await audit(connection);
540
+    const auditPath = path.join(taskDir, "audit_ancient_daily_2026_fall.json");
541
+    writeJson(auditPath, auditResult);
542
+    assert(auditResult.ok, auditResult.failures.join("\n"));
543
+    console.log(JSON.stringify({ mode, auditPath, ok: true, books: auditResult.books.length }));
544
+    process.exitCode = 0;
545
+  } else if (mode === "dry-run") {
546
+    const state = await readState(connection);
547
+    const desiredBooks = materialize(state);
548
+    const plan = buildPlan(state, desiredBooks);
549
+    const reportPath = path.join(taskDir, "dry_run_ancient_daily_2026_fall.json");
550
+    writeJson(reportPath, plan);
551
+    console.log(JSON.stringify({ mode, reportPath, summary: plan.summary }));
552
+  } else {
553
+    const backupState = await readState(connection);
554
+    const backupPath = path.join(taskDir, "backups", `before_ancient_daily_2026_fall_${nowForFile()}.json`);
555
+    fs.mkdirSync(path.dirname(backupPath), { recursive: true });
556
+    writeJson(backupPath, { capturedAt: new Date().toISOString(), sourceVersion: source.version, ...backupState });
557
+
558
+    await connection.beginTransaction();
559
+    try {
560
+      const lockedState = await readState(connection, true);
561
+      const desiredBooks = materialize(lockedState);
562
+      const plan = buildPlan(lockedState, desiredBooks);
563
+      const applied = await applyPlan(connection, desiredBooks);
564
+      const auditResult = await audit(connection);
565
+      assert(auditResult.ok, auditResult.failures.join("\n"));
566
+      await connection.commit();
567
+
568
+      const reportPath = path.join(taskDir, "apply_ancient_daily_2026_fall.json");
569
+      writeJson(reportPath, {
570
+        appliedAt: new Date().toISOString(),
571
+        sourceVersion: source.version,
572
+        backupPath,
573
+        plan,
574
+        applied,
575
+        audit: auditResult,
576
+      });
577
+      console.log(JSON.stringify({
578
+        mode,
579
+        reportPath,
580
+        backupPath,
581
+        summary: plan.summary,
582
+        insertedIds: applied.inserted.map((entry) => entry.id),
583
+        auditOk: auditResult.ok,
584
+      }));
585
+    } catch (error) {
586
+      await connection.rollback();
587
+      throw error;
588
+    }
589
+  }
590
+} finally {
591
+  await connection.end();
592
+}

+ 121 - 0
.vscode/秒过知识库更新/古诗文与日积月累更新结果_2026年秋.md

@@ -0,0 +1,121 @@
1
+# 2026 年秋季古诗文与日积月累更新结果
2
+
3
+执行日期:2026-09-03(Asia/Shanghai)  
4
+教材来源:`/Users/chengjie/Downloads/2026年秋-语文`  
5
+执行状态:已提交;事务内审计、事务外独立审计、幂等 dry-run 均通过。
6
+
7
+## 更新结果
8
+
9
+| 类别 | MiaoguoBook.ID | BookIDOld | 数据行 | UnitNum | WordNum |
10
+|---|---:|---:|---:|---:|---:|
11
+| 一上古诗文 | 13 | 25 | 1 → 8 | 1 | 1 → 8 |
12
+| 二上古诗文 | 15 | 27 | 4 → 7 | 1 | 4 → 7 |
13
+| 三上古诗文 | 17 | 29 | 7 → 9 | 1 | 7 → 9 |
14
+| 四上古诗文 | 19 | 31 | 8 → 9 | 1 | 8 → 9 |
15
+| 五上古诗文 | 21 | 33 | 10 → 10 | 1 | 9 → 10 |
16
+| 六上古诗文 | 23 | 35 | 9 → 10 | 1 | 9 → 10 |
17
+| 一上日积月累 | 54 | 74 | 8 → 8 | 1 | 8 |
18
+| 二上日积月累 | 56 | 76 | 7 → 8 | 1 | 8 |
19
+| 三上日积月累 | 58 | 78 | 7 → 7 | 1 | 7 |
20
+| 四上日积月累 | 60 | 80 | 7 → 7 | 1 | 7 |
21
+| 五上日积月累 | 62 | 82 | 8 → 7 | 1 | 8 → 7 |
22
+| 六上日积月累 | 64 | 84 | 7 → 7 | 1 | 7 |
23
+
24
+全部 12 本目标书的 `Sort` 已统一为连续的 `0..N-1`。
25
+
26
+## 新增记录
27
+
28
+| ID | BookIDOld | 标题 |
29
+|---:|---:|---|
30
+| 275 | 25 | 金木水火土 |
31
+| 276 | 25 | 雪地里的小画家 |
32
+| 277 | 25 | 对韵歌 |
33
+| 278 | 25 | 升国旗 |
34
+| 279 | 25 | 小小的船 |
35
+| 280 | 25 | 两件宝 |
36
+| 281 | 25 | 比尾巴 |
37
+| 282 | 27 | 田家四季歌 |
38
+| 283 | 27 | 树之歌 |
39
+| 284 | 27 | 日月潭(节选) |
40
+| 285 | 29 | 秋天的雨(节选) |
41
+| 286 | 29 | 大自然的声音(节选) |
42
+| 287 | 31 | 观潮(节选) |
43
+| 288 | 33 | 白鹭 |
44
+| 289 | 35 | 曹冲称象 |
45
+| 290 | 35 | 少年闰土(节选) |
46
+| 291 | 82 | 五上动物成语(挖空标题) |
47
+
48
+共新增 17 条。现代背诵课文按教材要求只录指定自然段;《白鹭》要求全文背诵,因此标题不加“节选”。新增记录的 `Translation` 均为空。
49
+
50
+## 迁移与脱册
51
+
52
+保留原 ID 迁移 7 条:
53
+
54
+| ID | 内容 | BookIDOld 变化 |
55
+|---:|---|---:|
56
+| 123 | 早春呈水部张十八员外 | 36 → 33 |
57
+| 119 | 两小儿辩日 | 36 → 35 |
58
+| 14 | 梅花 | 0 → 76 |
59
+| 114 | 游子吟 | 83 → 82 |
60
+| 122 | 江畔独步寻花 | 81 → 84 |
61
+| 85 | 观书有感(其二) | 82 → 84 |
62
+| 92 | 送元二使安西 | 36 → 84 |
63
+
64
+新版不再包含的 9 条旧数据没有删除,已统一改为 `HanziBookID=0`:
65
+
66
+- ID=79《长相思》
67
+- ID=83《士人读书》
68
+- ID=109《伯牙鼓琴》
69
+- ID=110《书戴嵩画牛》
70
+- ID=71《蝉》
71
+- ID=235“克勤于邦……”名言组
72
+- ID=104《春日》
73
+- ID=105《回乡偶书》
74
+- ID=238 艺术成语组
75
+
76
+## 供体书元数据
77
+
78
+经典记录迁出后,以下非目标上册的 `WordNum` 也已按实际行数同步:
79
+
80
+| MiaoguoBook.ID | BookIDOld | 类别 | WordNum |
81
+|---:|---:|---|---:|
82
+| 24 | 36 | 六下古诗文 | 18 → 15 |
83
+| 61 | 81 | 四下日积月累 | 7 → 6 |
84
+| 63 | 83 | 五下日积月累 | 7 → 5(更新前实际已有 6 行) |
85
+
86
+## 主要正文校正
87
+
88
+- 《司马光砸缸》改为教材标题《司马光》。
89
+- 《少年中国说》改为《少年中国说(节选)》并按新版图片校正文段。
90
+- 五上《古人谈读书》继续沿用库内分篇结构;《敏而好学》补齐新版《论语》五则,《士人读书》脱册。
91
+- 《浪淘沙·九曲黄河万里沙》改为教材标题《浪淘沙(其一)》。
92
+- 《两小儿辩日》迁入六上,并补正“一儿曰”。
93
+- 《数九歌》补回“三九四九冰上走”中遗漏的“九”。
94
+- 二上勤学名言改为“黑发不知勤学早,白首方悔读书迟”等新版三则。
95
+- 三上改正“锲而舍之,朽木不折;锲而不舍,金石可镂”。
96
+- 四上求学名言出处由《礼记》改为图片中的《中庸》,六则俗语按新版整体替换。
97
+- 五上惜时名言去掉旧版葛洪一则;治世/乱世成语按新版顺序重新分组。
98
+- 六上爱国名言新增《论语》一则,鲁迅名言新增《自题小像》一则。
99
+
100
+## 审计结论
101
+
102
+- 新增 17、迁移 7、脱册 9、数据库删除 0。
103
+- 12 本目标书的最终行数、标题、`Category/Tag/Sort` 全部匹配结构化源数据。
104
+- 所有明确变更及新增记录的 `PeomContent` 与来源完全一致,且全部为合法 JSON。
105
+- 9 条脱册记录仍存在,且 `HanziBookID=0`。
106
+- 15 本涉及元数据的 `UnitNum/WordNum` 均与对应 `BookIDOld` 的实际行数一致。
107
+- 提交后独立运行 `--audit` 通过。
108
+- 提交后再次运行 `--dry-run`,结果为新增 0、更新 0、迁移 0、脱册 0、元数据更新 0、删除 0。
109
+
110
+## 产物与校验值
111
+
112
+- 写入前完整备份:`backups/before_ancient_daily_2026_fall_2026-09-03T06-25-28-871Z.json`
113
+- 结构化来源:`source_ancient_daily_2026_fall.json`
114
+- 来源 SHA-256:`f760473c36c43c8df67eb192c9a21c492a977ee812c1dd7ee1c926794d16d3bb`
115
+- 更新程序:`update_ancient_daily_2026_fall.mjs`
116
+- 执行报告:`apply_ancient_daily_2026_fall.json`
117
+- 独立审计:`audit_ancient_daily_2026_fall.json`
118
+- 幂等复跑报告:`dry_run_ancient_daily_2026_fall.json`
119
+- 调研清单:`古诗文与日积月累调研清单_2026年秋.md`
120
+- OCR 快照:`research_ocr_2026_fall.json`
121
+- 更新前数据库快照:`research_db_snapshot_2026_fall.json`

+ 201 - 0
.vscode/秒过知识库更新/古诗文与日积月累调研清单_2026年秋.md

@@ -0,0 +1,201 @@
1
+# 2026 年秋季古诗文与日积月累调研清单(待确认)
2
+
3
+> 状态:只读调研完成,尚未执行数据库写入。
4
+>
5
+> 图片来源:`/Users/chengjie/Downloads/2026年秋-语文`
6
+
7
+## 1. 数据范围与处理原则
8
+
9
+- 古诗文目标书:`MiaoguoBook.ID=13/15/17/19/21/23`,对应 `BookIDOld=25/27/29/31/33/35`。
10
+- 日积月累目标书:`MiaoguoBook.ID=54/56/58/60/62/64`,对应 `BookIDOld=74/76/78/80/82/84`。
11
+- 古诗、古文、现代背诵课文都放在 `AncientPoetry`;现代文 `Translation` 留空,`PeomContent` 按自然段保存为 JSON 数组。
12
+- 只修改、迁移或新增,不删除语文记录。旧教材内容若不再属于本次六册,拟将 `HanziBookID` 改成 `0` 脱册,正文仍保留。
13
+- 已存在于别册的经典内容迁移原记录,避免重复新增;迁移时同步改 `HanziBookID/Sort/Tag`。
14
+- 目标书的 `Sort` 拟统一为从 `0` 开始连续编号,顺序采用来源图片中的首次出现顺序;重复图片只计一次。
15
+- `MiaoguoBook.UnitNum` 均保持 `1`;`WordNum` 拟按最终 `AncientPoetry` 实际行数更新。
16
+
17
+## 2. 图片分段
18
+
19
+| 年级 | 日积月累图片 | 古诗文/背诵课文图片 | 备注 |
20
+|---|---:|---:|---|
21
+| 一年级上 | 10~17 | 18~33 | 18《天地人》没有“背诵课文”要求,暂不纳入 |
22
+| 二年级上 | 14~21 | 22~33 | 26~27 与 30~31 都是《田家四季歌》,拟去重 |
23
+| 三年级上 | 11~17 | 18~26 | 23~24、25~26 是现代文节选 |
24
+| 四年级上 | 11~17 | 18~26 | 24~26 是《观潮》第 3~4 自然段 |
25
+| 五年级上 | 11~17 | 18~27 | 26~27 是《白鹭》全文 |
26
+| 六年级上 | 08~14 | 15~22 | 22 是《少年闰土》第 1 自然段 |
27
+
28
+## 3. 古诗文最终清单
29
+
30
+### 一年级上:BookIDOld=25,1 → 8 条
31
+
32
+| Sort | 最终标题 | 动作 |
33
+|---:|---|---|
34
+| 0 | 金木水火土 | 新增;民间歌谣 |
35
+| 1 | 江南 | 保留现有 ID=2 |
36
+| 2 | 雪地里的小画家 | 新增;作者程宏明 |
37
+| 3 | 对韵歌 | 新增 |
38
+| 4 | 升国旗 | 新增 |
39
+| 5 | 小小的船 | 新增;作者叶圣陶 |
40
+| 6 | 两件宝 | 新增;作者陶行知 |
41
+| 7 | 比尾巴 | 新增;作者程宏明 |
42
+
43
+待确认项:《天地人》虽在本组首张图片,但图片中没有“背诵课文”要求,因此暂不计入;若纳入则最终为 9 条。
44
+
45
+### 二年级上:BookIDOld=27,4 → 7 条
46
+
47
+| Sort | 最终标题 | 动作 |
48
+|---:|---|---|
49
+| 0 | 登鹳雀楼 | 保留 |
50
+| 1 | 望庐山瀑布 | 保留 |
51
+| 2 | 江雪 | 保留;Tag 去掉误标的“日积月累” |
52
+| 3 | 敕勒歌 | 保留 |
53
+| 4 | 田家四季歌 | 新增;重复图片只生成一条 |
54
+| 5 | 树之歌 | 新增 |
55
+| 6 | 日月潭(节选) | 新增;仅第 2~4 自然段,作者吴壮达 |
56
+
57
+### 三年级上:BookIDOld=29,7 → 9 条
58
+
59
+| Sort | 最终标题 | 动作 |
60
+|---:|---|---|
61
+| 0~5 | 望洞庭、山行、夜书所见、鹿柴、望天门山、饮湖上初晴后雨 | 保留并重排;《鹿柴》Tag 去掉误标的“日积月累” |
62
+| 6 | 司马光 | 现 ID=37 从“司马光砸缸”改为教材标题“司马光” |
63
+| 7 | 秋天的雨(节选) | 新增;只录第 2 自然段,作者陶金鸿 |
64
+| 8 | 大自然的声音(节选) | 新增;只录第 2~3 自然段 |
65
+
66
+### 四年级上:BookIDOld=31,8 → 9 条
67
+
68
+| Sort | 最终标题 | 动作 |
69
+|---:|---|---|
70
+| 0~3 | 暮江吟、题西林壁、雪梅、精卫填海 | 保留并重排 |
71
+| 4~7 | 凉州词、出塞、夏日绝句、王戎不取道旁李 | 保留并按图片顺序重排 |
72
+| 8 | 观潮(节选) | 新增;只录第 3~4 自然段,作者赵宗成、朱明元 |
73
+
74
+### 五年级上:BookIDOld=33,现有 10 条,最终仍为 10 条
75
+
76
+| Sort | 最终标题 | 动作 |
77
+|---:|---|---|
78
+| 0~2 | 示儿、题临安邸、己亥杂诗 | 保留并重排 |
79
+| 3 | 少年中国说(节选) | 现 ID=76 改教材标题并按图片校正文段 |
80
+| 4~5 | 山居秋暝、枫桥夜泊 | 保留并重排 |
81
+| 6 | 早春呈水部张十八员外 | 迁移现 ID=123:BookIDOld 36 → 33,并去掉标题中的“(其一)” |
82
+| 7 | 敏而好学 | 保留现有拆分方式;补齐新版《论语》中的 5 则并调整顺序 |
83
+| 8 | 读书有三到 | 保留现有拆分方式并校正文段 |
84
+| 9 | 白鹭 | 新增;全文,作者郭沫若,不加“节选” |
85
+
86
+脱册但不删除:ID=79《长相思》、ID=83《士人读书》,拟改 `HanziBookID=0`。
87
+
88
+说明:教材目录统一写作《古人谈读书》,库内原来按篇拆成《敏而好学》《读书有三到》《士人读书》三条。新版图片只有前两篇,拟沿用库内拆分方式,去掉第三篇。
89
+
90
+### 六年级上:BookIDOld=35,9 → 10 条
91
+
92
+| Sort | 最终标题 | 动作 |
93
+|---:|---|---|
94
+| 0~3 | 宿建德江、六月二十七日望湖楼醉书、西江月·夜行黄沙道中、七律·长征 | 保留并重排 |
95
+| 4 | 浪淘沙(其一) | 现 ID=106 从“浪淘沙·九曲黄河万里沙”改为教材标题 |
96
+| 5~6 | 江南春、书湖阴先生壁 | 保留并重排 |
97
+| 7 | 两小儿辩日 | 迁移现 ID=119:BookIDOld 36 → 35,并按新版图片校正文段 |
98
+| 8 | 曹冲称象 | 新增古文 |
99
+| 9 | 少年闰土(节选) | 新增;只录第 1 自然段,作者鲁迅 |
100
+
101
+脱册但不删除:ID=109《伯牙鼓琴》、ID=110《书戴嵩画牛》,拟改 `HanziBookID=0`。
102
+
103
+## 4. 日积月累最终清单
104
+
105
+### 一年级上:BookIDOld=74,仍为 8 条
106
+
107
+顺序:咏鹅、画、数字成语、悯农(其二)、惜时名言、古朗月行(节选)、四则俗语、风。
108
+
109
+- 内容与图片总体一致。
110
+- ID=5 标题由《古朗月行》改为《古朗月行(节选)》。
111
+- 全部重新设置连续 `Sort=0~7`。
112
+
113
+### 二年级上:BookIDOld=76,7 → 8 条(`MiaoguoBook.WordNum` 当前已是 8)
114
+
115
+顺序:梅花、数九歌、小儿垂钓、山水对偶句、勤学名言、立志名言、夜宿山寺、交友处世名言。
116
+
117
+- 迁移现 ID=14《梅花》:`HanziBookID=0 → 76`。
118
+- ID=224《数九歌》修正“**三九四九冰上走**”(库内漏了一个“九”)。
119
+- ID=225 第一则由旧版“少年不知勤学苦……”替换为“**黑发不知勤学早,白首方悔读书迟。**”,并同步修改挖空标题。
120
+- ID=19《夜宿山寺》补齐日积月累 Tag。
121
+
122
+### 三年级上:BookIDOld=78,仍为 7 条
123
+
124
+顺序:所见、舟夜书所见、仁爱名言、团结俗语、早发白帝城、采莲曲、立志名言。
125
+
126
+- ID=267 第三则改为教材原文:“**锲而舍之,朽木不折;锲而不舍,金石可镂。**”
127
+- 同步修正挖空标题;全部重新设置连续 Sort。
128
+
129
+### 四年级上:BookIDOld=80,仍为 7 条
130
+
131
+顺序:赠刘景文、求学名言、秋季俗语、嫦娥、黄鹤楼送孟浩然之广陵、六则俗语、别董大。
132
+
133
+- ID=229 第一则出处由《礼记》改为图片中的《中庸》,并校正“明辨之,笃行之”。
134
+- ID=230 修正“把扇丟”为“把扇丢”。
135
+- ID=231 六则内容按新版图片替换为:
136
+  - 尺有所短,寸有所长。
137
+  - 仁者见仁,智者见智。
138
+  - 机不可失,时不再来。
139
+  - 差之毫厘,谬以千里。
140
+  - 一言既出,驷马难追。
141
+  - 知无不言,言无不尽。
142
+
143
+### 五年级上:BookIDOld=82,8 → 7 条
144
+
145
+顺序:动物成语、惜时名言、乞巧、治世/乱世成语、游子吟、渔歌子、观书有感(其一)。
146
+
147
+- 新增 1 条动物成语(Category=1,沿用挖空题格式):
148
+  - 卧虎藏龙、龙马精神、龙飞凤舞、凤毛麟角
149
+  - 如虎添翼、如鱼得水、鹤立鸡群、一马当先
150
+  - 鼠目寸光、抱头鼠窜、狼心狗肺、狼狈为奸
151
+  - 狡兔三窟、呆若木鸡、虾兵蟹将、黔驴技穷
152
+- 迁移现 ID=114《游子吟》:BookIDOld 83 → 82。
153
+- ID=233 惜时名言按新版图片去掉旧版“葛洪”一则,保留陶渊明、岳飞、毛泽东三则。
154
+- ID=234 治世/乱世成语按新版图片顺序重新分组。
155
+- 现 ID=85《观书有感二首·其二》移到六年级上日积月累。
156
+- ID=71《蝉》、ID=235“克勤于邦……”脱册但不删除,拟改 `HanziBookID=0`。
157
+- 《观书有感二首·其一》标题按图片统一为《观书有感(其一)》;其二同理。
158
+
159
+### 六年级上:BookIDOld=84,仍为 7 条
160
+
161
+顺序:江畔独步寻花、爱国名言、观书有感(其二)、送元二使安西、过故人庄、五行/五谷/五音/五彩、鲁迅名言。
162
+
163
+- 迁移 ID=122《江畔独步寻花》:BookIDOld 81 → 84。
164
+- 迁移 ID=85《观书有感二首·其二》:BookIDOld 82 → 84。
165
+- 迁移 ID=92《送元二使安西》:BookIDOld 36 → 84。
166
+- ID=236 爱国名言最前补一则:“志士仁人,无求生以害仁,有杀身以成仁。——《论语》”。
167
+- ID=239 鲁迅名言第二项补:“寄意寒星荃不察,我以我血荐轩辕。——鲁迅《自题小像》”。
168
+- ID=104《春日》、ID=105《回乡偶书》、ID=238 艺术成语脱册但不删除,拟改 `HanziBookID=0`。
169
+
170
+## 5. 连带迁移与 MiaoguoBook 元数据
171
+
172
+按“WordNum 等于最终实际行数”的口径,拟更新如下:
173
+
174
+| MiaoguoBook.ID | BookIDOld | 类别 | 当前 WordNum | 最终 WordNum | 原因 |
175
+|---:|---:|---|---:|---:|---|
176
+| 13 | 25 | 一上古诗文 | 1 | 8 | 新增现代背诵课文等 |
177
+| 15 | 27 | 二上古诗文 | 4 | 7 | 新增 3 篇背诵课文 |
178
+| 17 | 29 | 三上古诗文 | 7 | 9 | 新增 2 篇现代文节选 |
179
+| 19 | 31 | 四上古诗文 | 8 | 9 | 新增《观潮(节选)》 |
180
+| 21 | 33 | 五上古诗文 | 9 | 10 | 当前实际已有 10 行,更新后仍 10 行 |
181
+| 23 | 35 | 六上古诗文 | 9 | 10 | 两篇旧古文脱册,迁入/新增 3 篇 |
182
+| 24 | 36 | 六下古诗文 | 18 | 15 | 三条迁往本次上册 |
183
+| 61 | 81 | 四下日积月累 | 7 | 6 | 《江畔独步寻花》迁出 |
184
+| 62 | 82 | 五上日积月累 | 8 | 7 | 新版只有 7 组日积月累 |
185
+| 63 | 83 | 五下日积月累 | 7 | 5 | 当前实际只有 6 行,《游子吟》迁出后为 5 行 |
186
+
187
+其余目标书 `WordNum` 不变;所有涉及书的 `UnitNum` 保持 `1`。
188
+
189
+## 6. 预计写入统计
190
+
191
+- 新增 `AncientPoetry`:古诗文/现代背诵课文 16 条,日积月累成语 1 条,共 17 条。
192
+- 迁移现有经典记录:7 条(含目标六册之间的《观书有感(其二)》迁移)。
193
+- 脱册保留:9 条,统一改 `HanziBookID=0`,不删除正文。
194
+- 其余为标题、正文、Tag、Sort、Category 及 `MiaoguoBook` 元数据校正。
195
+- 正式执行前会先生成完整备份和 dry-run 报告;执行后逐书核对行数、Sort 连续性、JSON 合法性、标题唯一性和 donor 书 WordNum。
196
+
197
+## 7. 需要确认的三点
198
+
199
+1. 一年级上《天地人》没有“背诵课文”要求,是否确认不纳入?(当前方案:不纳入。)
200
+2. 对新版图片中已经消失的 9 条旧记录,是否确认采用 `HanziBookID=0` 脱册、保留正文的方式?
201
+3. 是否确认 `Sort` 统一为图片顺序的 `0~N-1`,`WordNum` 按最终实际数据库行数计算,并同步修正 3 本供体书的 WordNum?

+ 1 - 1
README.md

@@ -30,7 +30,7 @@ nvm use
30 30
 
31 31
 ```bash
32 32
 # 克隆仓库
33
-git clone https://scoreline.kylx365.com/kylx365/miaoguo_system_server.git
33
+git clone https://gogs.kylx365.com/kylx365/miaoguo_system_server.git
34 34
 cd miaoguo_system_server
35 35
 
36 36
 # 确保使用正确的 Node.js 版本

+ 1 - 1
src/api/index/indexController.js

@@ -137,7 +137,7 @@ export async function indexInternalData(ctx) {
137 137
         let data1 = "";
138 138
         if (ctx.req.url === "/20170411") {
139 139
             data1 = fs.readFileSync("./public/internal/20170411.html").toString();
140
-            if (cookie === validCookies[0]) data1 = data1.replace("[替换]", "<p><a href='https://scoreline.kylx365.com/kylx365_db_admin'>DB维护</a></p>");
140
+            if (cookie === validCookies[0]) data1 = data1.replace("[替换]", "<p><a href='https://www.kylx365.com/kylx365_db_admin'>DB维护</a></p>");
141 141
             else data1 = data1.replace("[替换]", "");
142 142
         } else if (ctx.req.url.indexOf("20170411List") > 0) {
143 143
             data1 = fs.readFileSync("./public/internal/20170411List.html").toString();

+ 2 - 2
src/api/virtualPay/virtualPayController.js

@@ -174,8 +174,8 @@ function calculateMemberUpdate(user, order, detail) {
174 174
     if (requestedMoment) {
175 175
         productServiceTime = requestedMoment.format('YYYY-MM-DD HH:mm:ss');
176 176
     } else {
177
-        const oldEndTime = moment(user.ProductServiceTime);
178
-        const baseTime = oldEndTime.isValid() && oldEndTime.isAfter(moment()) ? oldEndTime : moment();
177
+        const oldEndTime = parseMemberEndTime(user.ProductServiceTime);
178
+        const baseTime = oldEndTime && oldEndTime.isAfter(moment()) ? oldEndTime : moment();
179 179
         productServiceTime = baseTime.add(12, 'months').format('YYYY-MM-DD HH:mm:ss');
180 180
     }
181 181
 

+ 38 - 24
src/api/yjbdc/readerController.js

@@ -14,31 +14,45 @@ import { fileURLToPath } from 'url';
14 14
 
15 15
 const __filename = fileURLToPath(import.meta.url);
16 16
 const __dirname = path.dirname(__filename);
17
+const READER_BOOK_TITLES = [
18
+    'Strange-Life-of-a-Cat',
19
+    'Throne-of-Magical-Arcana',
20
+    'Zhui-Xu',
21
+    // 'Hidden-Assassin',
22
+    // 'Release-that-Witch',
23
+    // 'The-Legendary-Mechanic',
24
+];
25
+const READER_BOOK_TITLE_SET = new Set(READER_BOOK_TITLES);
26
+
27
+function getReaderBookTitle(ctx) {
28
+    const title = String(ctx.query.Title || '');
29
+    if (READER_BOOK_TITLE_SET.has(title)) return title;
30
+
31
+    ctx.status = 400;
32
+    ctx.body = { errcode: 400, errStr: '无效的书名' };
33
+    return null;
34
+}
35
+
36
+function getReaderChapter(ctx) {
37
+    const chapter = Number(ctx.query.Chapter);
38
+    if (Number.isInteger(chapter) && chapter > 0) return chapter;
39
+
40
+    ctx.status = 400;
41
+    ctx.body = { errcode: 400, errStr: '无效的章节号' };
42
+    return null;
43
+}
17 44
 
18 45
 export async function GetReaderBooks(ctx) {
19
-    let result = [
20
-        {
21
-            Title: "Strange-Life-of-a-Cat",
22
-        },{
23
-            Title: "Throne-of-Magical-Arcana",
24
-        },{
25
-            Title: "Zhui-Xu",
26
-        }
27
-        //,{
28
-        //     Title: "Hidden-Assassin",
29
-        // },{
30
-        //     Title: "Release-that-Witch",
31
-        // },{
32
-        //     Title: "The-Legendary-Mechanic",
33
-        // },
34
-    ];
46
+    const result = READER_BOOK_TITLES.map(Title => ({ Title }));
35 47
 
36 48
     ctx.body = { "errcode": 10000, result };
37 49
 }
38 50
 
39 51
 export async function GetReaderBooksChapter(ctx) {
52
+    const title = getReaderBookTitle(ctx);
53
+    if (!title) return;
40 54
     const param = {
41
-        Title: ctx.query.Title || "",
55
+        Title: title,
42 56
     };
43 57
 
44 58
     let result = [];
@@ -57,15 +71,15 @@ export async function GetReaderBooksChapter(ctx) {
57 71
 }
58 72
 
59 73
 export async function GetReaderBooksChapterContent(ctx) {
74
+    const title = getReaderBookTitle(ctx);
75
+    if (!title) return;
76
+    const chapter = getReaderChapter(ctx);
77
+    if (!chapter) return;
60 78
     const param = {
61
-        Title: ctx.query.Title || "",
62
-        Chapter: ctx.query.Chapter || "0",
79
+        Title: title,
80
+        Chapter: title === 'Strange-Life-of-a-Cat' ? chapter + 5 : chapter,
63 81
     };
64 82
 
65
-    if (param.Title === "Strange-Life-of-a-Cat") {
66
-        param.Chapter=param.Chapter+5; 
67
-    }
68
-
69 83
     let result = [];
70 84
 
71 85
     const {readFile} = fs.promises;
@@ -144,4 +158,4 @@ export async function GetReaderBooksChapterContent(ctx) {
144 158
     result=result.slice(1);
145 159
 
146 160
     ctx.body = { "errcode": 10000, result: result };
147
-}
161
+}

+ 1 - 1
src/api/yjbdc/yjbdcController.js

@@ -290,7 +290,7 @@ export async function YJBDCLogin(ctx) {
290 290
                 Version: param.ProgramVersion,
291 291
             };
292 292
             let result3 = await commonModel.GetProductVersionList(param2);
293
-            if (result3) {
293
+            if (result3 && result3.length > 0) {
294 294
                 if ((param2.Version == result3[0].Version && result3[0].IsShowPay <= 0)
295 295
                     || param2.Version > result3[0].Version) {
296 296
                     userList[0].IsShow = result3[0].IsShowPay;

+ 38 - 11
src/util/crypto/index.js

@@ -44,15 +44,32 @@ export function Decrypt(encryptedText, key, iv) {
44 44
         throw new Error('IV必须是16字节');
45 45
     }
46 46
 
47
-    const decipher = createDecipheriv(
48
-        'aes-128-cbc',
49
-        Buffer.from(key, 'utf8'),
50
-        Buffer.from(iv, 'utf8')
51
-    );
52
-    
53
-    let decrypted = decipher.update(encryptedText, 'base64', 'utf8');
54
-    decrypted += decipher.final('utf8');
55
-    return decrypted;
47
+    try {
48
+        const value = String(encryptedText ?? '');
49
+        if (!isBase64Ciphertext(value)) throw new Error('invalid base64 ciphertext');
50
+
51
+        const decipher = createDecipheriv(
52
+            'aes-128-cbc',
53
+            Buffer.from(key, 'utf8'),
54
+            Buffer.from(iv, 'utf8')
55
+        );
56
+
57
+        let decrypted = decipher.update(value, 'base64', 'utf8');
58
+        decrypted += decipher.final('utf8');
59
+        return decrypted;
60
+    } catch (cause) {
61
+        const error = new Error('无效的加密参数', { cause });
62
+        error.name = 'InvalidEncryptedDataError';
63
+        error.status = 400;
64
+        error.expose = true;
65
+        throw error;
66
+    }
67
+}
68
+
69
+function isBase64Ciphertext(value) {
70
+    return value.length >= 24
71
+        && value.length % 4 === 0
72
+        && /^[A-Za-z0-9+/]+={0,2}$/.test(value);
56 73
 }
57 74
 
58 75
 /**
@@ -77,10 +94,20 @@ export function decryptUrlMiddle() {
77 94
     return async (ctx, next) => {
78 95
         const apiFirstOld = "api";
79 96
         const apiFirst = "apiData";
97
+        const prefix = `/${apiFirst}/`;
80 98
         
81 99
         if (config.urlSecrets.require) {
82
-            if (ctx.request.url.includes(apiFirst)) {
83
-                const encryptedStr = decodeURIComponent(ctx.request.url.replace(`/${apiFirst}/`, ""));
100
+            if (ctx.request.url.startsWith(prefix)) {
101
+                let encryptedStr = '';
102
+                try {
103
+                    encryptedStr = decodeURIComponent(ctx.request.url.slice(prefix.length));
104
+                } catch {
105
+                    encryptedStr = '';
106
+                }
107
+                if (!isBase64Ciphertext(encryptedStr)) {
108
+                    await next();
109
+                    return;
110
+                }
84 111
                 const decryptedStr = Decrypt(
85 112
                     encryptedStr, 
86 113
                     config.urlSecrets.aes_key, 

+ 4 - 3
src/util/stringClass.js

@@ -374,9 +374,10 @@ export const stringUtils = {
374 374
     },
375 375
     //替换字符串中所有匹配项
376 376
     ReplaceAllString: (str, replaceStrFrom, replaceStrTo) => {
377
-        if (str && str.length > 0) {
378
-            const reg = new RegExp(replaceStrFrom, "g");
379
-            return str.toString().replace(reg, replaceStrTo);
377
+        if (str && str.length > 0 && replaceStrFrom !== undefined && replaceStrFrom !== null) {
378
+            const search = String(replaceStrFrom);
379
+            if (search.length === 0) return str.toString();
380
+            return str.toString().split(search).join(String(replaceStrTo ?? ''));
380 381
         }
381 382
         return str;
382 383
     },

+ 75 - 0
test/errorRegression.test.js

@@ -0,0 +1,75 @@
1
+import test from 'node:test';
2
+import assert from 'node:assert/strict';
3
+import config from '../src/config/index.js';
4
+import { Decrypt, Encrypt, decryptUrlMiddle } from '../src/util/crypto/index.js';
5
+import { stringUtils } from '../src/util/stringClass.js';
6
+import { GetReaderBooksChapter, GetReaderBooksChapterContent } from '../src/api/yjbdc/readerController.js';
7
+
8
+test('ReplaceAllString 将正则特殊字符按普通文本替换', () => {
9
+    assert.equal(stringUtils.ReplaceAllString('a+b+c', '+', '___'), 'a___b___c');
10
+    assert.equal(stringUtils.ReplaceAllString('a.b.c', '.', '-'), 'a-b-c');
11
+});
12
+
13
+test('Decrypt 正常往返并将无效密文标记为 400', () => {
14
+    const encrypted = Encrypt('Ping?UserID=1', config.urlSecrets.aes_key, config.urlSecrets.aes_iv);
15
+    assert.equal(Decrypt(encrypted, config.urlSecrets.aes_key, config.urlSecrets.aes_iv), 'Ping?UserID=1');
16
+
17
+    assert.throws(
18
+        () => Decrypt('not encrypted', config.urlSecrets.aes_key, config.urlSecrets.aes_iv),
19
+        error => error.name === 'InvalidEncryptedDataError' && error.status === 400
20
+    );
21
+    assert.throws(
22
+        () => Decrypt('AAAAAAAAAAAAAAAAAAAAAA==', config.urlSecrets.aes_key, config.urlSecrets.aes_iv),
23
+        error => error.name === 'InvalidEncryptedDataError' && error.status === 400
24
+    );
25
+});
26
+
27
+test('URL 解密中间件忽略 apiData 下的普通静态资源路径', async () => {
28
+    const previousRequire = config.urlSecrets.require;
29
+    config.urlSecrets.require = true;
30
+    const ctx = { request: { url: '/apiData/web/School/0221_02.jpeg' } };
31
+    let nextCalled = false;
32
+    try {
33
+        await decryptUrlMiddle()(ctx, async () => { nextCalled = true; });
34
+    } finally {
35
+        config.urlSecrets.require = previousRequire;
36
+    }
37
+
38
+    assert.equal(nextCalled, true);
39
+    assert.equal(ctx.request.url, '/apiData/web/School/0221_02.jpeg');
40
+});
41
+
42
+test('URL 解密中间件只重写有效的加密 API 地址', async () => {
43
+    const previousRequire = config.urlSecrets.require;
44
+    config.urlSecrets.require = true;
45
+    const encrypted = Encrypt('Ping?UserID=1', config.urlSecrets.aes_key, config.urlSecrets.aes_iv);
46
+    const ctx = { request: { url: `/apiData/${encodeURIComponent(encrypted)}` } };
47
+    try {
48
+        await decryptUrlMiddle()(ctx, async () => {});
49
+    } finally {
50
+        config.urlSecrets.require = previousRequire;
51
+    }
52
+
53
+    assert.equal(ctx.request.url, '/api/Ping?UserID=1');
54
+});
55
+
56
+test('阅读器章节接口拒绝缺失或不在书单内的 Title', async () => {
57
+    for (const controller of [GetReaderBooksChapter, GetReaderBooksChapterContent]) {
58
+        const ctx = { query: { Title: '' } };
59
+        await controller(ctx);
60
+        assert.equal(ctx.status, 400);
61
+        assert.deepEqual(ctx.body, { errcode: 400, errStr: '无效的书名' });
62
+    }
63
+});
64
+
65
+test('阅读器正文接口校验章节号并正确换算回到过去变成猫的章节', async () => {
66
+    const invalidCtx = { query: { Title: 'Strange-Life-of-a-Cat', Chapter: 'not-a-number' } };
67
+    await GetReaderBooksChapterContent(invalidCtx);
68
+    assert.equal(invalidCtx.status, 400);
69
+    assert.deepEqual(invalidCtx.body, { errcode: 400, errStr: '无效的章节号' });
70
+
71
+    const validCtx = { query: { Title: 'Strange-Life-of-a-Cat', Chapter: '1' } };
72
+    await GetReaderBooksChapterContent(validCtx);
73
+    assert.equal(validCtx.body.errcode, 10000);
74
+    assert.ok(validCtx.body.result.length > 0);
75
+});

+ 19 - 0
test/virtualPayFulfillment.test.js

@@ -138,6 +138,25 @@ test('会员有效期兼容小程序提交的斜杠日期且不触发 Moment 回
138 138
     assert.equal(result.memberUpdate.ProductServiceTime, '2032-05-14 00:00:00');
139 139
 });
140 140
 
141
+test('续费基准日期兼容数据库中的斜杠日期', async () => {
142
+    const order = paidMemberOrder({
143
+        TradeNo: 'VP_TEST_OLD_SLASH_DATE',
144
+        XMLPre: orderXml({ productId: '166_199', detail: {} })
145
+    });
146
+    const user = { ...memberUser, ProductServiceTime: '2032/05/14' };
147
+    const { connection } = createFakeConnection({ order, user });
148
+
149
+    const result = await fulfillOrder('VP_TEST_OLD_SLASH_DATE', {
150
+        openid: 'openid-test',
151
+        env: 0,
152
+        productId: '166_199',
153
+        originalPrice: 19900,
154
+        actualPrice: 19900
155
+    }, dependencies(connection));
156
+
157
+    assert.equal(result.memberUpdate.ProductServiceTime, '2033-05-14 00:00:00');
158
+});
159
+
141 160
 test('重复发货通知不会重复增加会员有效期', async () => {
142 161
     const { connection, state } = createFakeConnection({
143 162
         order: paidMemberOrder({ Status: 1 }),