refactor(api): 去除代码版本命名

This commit is contained in:
admin committed 2026-09-14 15:35:53 +08:00
1 parent bbc4bdd140
commit dcf4cb0719
31 files changed
+174 -154

No files matched your search

+11 -11
View File
@@ -2,28 +2,28 @@
基于微信读书 API 的个人读书数据展示网站。 基于微信读书 API 的个人读书数据展示网站。
- **v2「阅间」看板(当前默认前端)**:沿用设计稿的视觉与交互,数据来自 `/api/v2/*` 实时聚合接口,见 `docs/V2_API.md`。 - **「阅间」看板(当前默认前端)**:沿用设计稿的视觉与交互,数据来自 `/api/reading/*` 实时聚合接口,见 `docs/API.md`。
- **v1 仪表盘(已归档)**:原 ECharts 单页仪表盘,完整备份在 `legacy/v1/`。 - **旧版仪表盘(已归档)**:原 ECharts 单页仪表盘,完整备份在 `legacy/classic/`。
## 项目结构 ## 项目结构
``` ```
. .
├── fetch_data.py # 数据采集脚本(从微信读书 API 拉取数据到 SQLite) ├── fetch_data.py # 数据采集脚本(从微信读书 API 拉取数据到 SQLite)
├── app.py # Flask 后端服务(v1 接口 + 注册 v2 Blueprint) ├── app.py # Flask 后端服务(旧接口 + 注册当前看板接口)
├── api_v2.py # v2 数据接口层(/api/v2/*,供「阅间」前端使用) ├── reading_api.py # 当前数据接口层(/api/reading/*,供「阅间」前端使用)
├── start_dashboard.sh # 启动脚本(端口可用 PORT 覆盖) ├── start_dashboard.sh # 启动脚本(端口可用 PORT 覆盖)
├── legacy/v1/ # v1 前端备份(index.html / style.css / app.js) ├── legacy/classic/ # 旧版前端备份(index.html / style.css / app.js)
├── weread.db # SQLite 数据库(运行 fetch_data.py 后生成) ├── weread.db # SQLite 数据库(运行 fetch_data.py 后生成)
├── requirements.txt # Python 依赖 ├── requirements.txt # Python 依赖
├── templates/ ├── templates/
│ └── index.html # 「阅间」前端页面(v2 设计稿) │ └── index.html # 「阅间」前端页面
└── static/ └── static/
├── css/styles.css # v2 样式(设计稿原样) ├── css/styles.css # 当前样式(设计稿原样)
├── css/style.css # v1 样式(保留) ├── css/style.css # 旧版样式(保留)
└── js/ └── js/
├── weapi.js # v2 数据层(封装 /api/v2/*) ├── weapi.js # 当前数据层(封装 /api/reading/*)
└── app.js # v2 交互逻辑 └── app.js # 当前交互逻辑
``` ```
## 快速开始 ## 快速开始
@@ -58,7 +58,7 @@ PORT=8080 ./start_dashboard.sh # 或指定端口
也可以直接 `python app.py`(默认 8080,可用环境变量 `PORT` 覆盖), 也可以直接 `python app.py`(默认 8080,可用环境变量 `PORT` 覆盖),
然后访问 http://127.0.0.1:8770 查看「阅间」看板。 然后访问 http://127.0.0.1:8770 查看「阅间」看板。
> v2 的书架、统计、推荐、搜索等数据实时来自微信读书接口; > 当前看板的书架、统计、推荐、搜索等数据实时来自微信读书接口;
> 未运行 `fetch_data.py` 时也能使用,只有进度/笔记等局部数据会缺少缓存加速。 > 未运行 `fetch_data.py` 时也能使用,只有进度/笔记等局部数据会缺少缓存加速。
## 自动部署 ## 自动部署
+4 -4
View File
@@ -37,15 +37,15 @@ except ImportError:
_ssl_context = ssl.create_default_context() _ssl_context = ssl.create_default_context()
from flask import Flask, g, jsonify, render_template, request from flask import Flask, g, jsonify, render_template, request
from api_v2 import v2_bp from reading_api import reading_bp
# ====================================================================== # ======================================================================
# Flask 应用实例 # Flask 应用实例
# ====================================================================== # ======================================================================
app = Flask(__name__, static_folder="static", template_folder="templates") app = Flask(__name__, static_folder="static", template_folder="templates")
# 注册 v2 数据接口(/api/v2/*,为「阅间」新版前端提供聚合数据) # 注册当前看板数据接口,并保留旧客户端兼容路径。
app.register_blueprint(v2_bp) app.register_blueprint(reading_bp)
# ====================================================================== # ======================================================================
# 数据库连接配置 # 数据库连接配置
@@ -1343,7 +1343,7 @@ if __name__ == "__main__":
else: else:
print(f"[信息] {msg}") print(f"[信息] {msg}")
# 监听端口:默认 8080,可用环境变量 PORT 覆盖(v2 前端与 v1 并存时可换端口) # 监听端口:默认 8080,可用环境变量 PORT 覆盖(端口冲突时可换端口)
port = int(os.environ.get("PORT", 8080)) port = int(os.environ.get("PORT", 8080))
print(f"[信息] 打开 http://127.0.0.1:{port} 查看「阅间」看板") print(f"[信息] 打开 http://127.0.0.1:{port} 查看「阅间」看板")
app.run(host="0.0.0.0", port=port, debug=False) app.run(host="0.0.0.0", port=port, debug=False)
+1 -1
View File
@@ -75,7 +75,7 @@ from pathlib import Path
import pymysql import pymysql
from api_v2 import MYSQL_CONFIG from reading_api import MYSQL_CONFIG
migration = Path("migrations/001_reading_goals.sql") migration = Path("migrations/001_reading_goals.sql")
if migration.exists(): if migration.exists():
+22 -22
View File
@@ -1,7 +1,7 @@
# v2 数据接口(/api/v2/*) # 当前数据接口(/api/reading/*)
「阅间」新版前端的数据来源。实现在 `api_v2.py`(Flask Blueprint,注册于 `app.py`), 「阅间」当前前端的数据来源。实现在 `reading_api.py`(Flask Blueprint,注册于 `app.py`),
v1 的 `/api/*` 接口保持不变,两套接口可同时使用。 旧版 `/api/*` 接口保持不变;`/api/v2/*` 仅作为兼容路径保留。
## 数据来源与优先级 ## 数据来源与优先级
@@ -21,20 +21,20 @@ MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: tr
| 方法 | 路径 | 参数 | 说明 | | 方法 | 路径 | 参数 | 说明 |
|------|------|------|------| |------|------|------|------|
| GET | `/api/v2/status` | — | 连接状态、书架规模、数据快照时间 | | GET | `/api/reading/status` | — | 连接状态、书架规模、数据快照时间 |
| GET | `/api/v2/shelf` | `tab=all\|reading\|done\|wish`、`keyword`、`limit`、`offset`、`progress=1` | 书架列表 + 书单 + 有声书 + 文章收藏入口(`progress=1` 为首屏书籍实时补拉进度) | | GET | `/api/reading/shelf` | `tab=all\|reading\|done\|wish`、`keyword`、`limit`、`offset`、`progress=1` | 书架列表 + 书单 + 有声书 + 文章收藏入口(`progress=1` 为首屏书籍实时补拉进度) |
| GET | `/api/v2/dashboard` | `period=week\|month\|year\|overall` | 整页概览:趋势柱状图、四张统计卡、读得最多、偏好方向、时段分布、阅读画像、年度小结 | | GET | `/api/reading/dashboard` | `period=week\|month\|year\|overall` | 整页概览:趋势柱状图、四张统计卡、读得最多、偏好方向、时段分布、阅读画像、年度小结 |
| GET | `/api/v2/notebooks` | — | 笔记本概览(有笔记的书 + 划线/想法/书签数量) | | GET | `/api/reading/notebooks` | — | 笔记本概览(有笔记的书 + 划线/想法/书签数量) |
| GET | `/api/v2/notes` | `book_id`(`all` 表示聚合笔记最多的前 N 本)、`limit` | 划线与想法正文,按时间倒序 | | GET | `/api/reading/notes` | `book_id`(`all` 表示聚合笔记最多的前 N 本)、`limit` | 划线与想法正文,按时间倒序 |
| GET | `/api/v2/book/<book_id>` | — | 书籍详情聚合:简介 + 进度 + 目录 + 我的笔记 + 热门划线 + 点评 + 相似推荐 | | GET | `/api/reading/book/<book_id>` | — | 书籍详情聚合:简介 + 进度 + 目录 + 我的笔记 + 热门划线 + 点评 + 相似推荐 |
| GET | `/api/v2/book/<book_id>/reviews` | `type=0\|1\|2\|3\|4`(全部/推荐/不行/最新/一般)、`count` | 公开点评(按类型筛选) | | GET | `/api/reading/book/<book_id>/reviews` | `type=0\|1\|2\|3\|4`(全部/推荐/不行/最新/一般)、`count` | 公开点评(按类型筛选) |
| GET | `/api/v2/book/<book_id>/highlights` | `chapter_uid`(0=全书) | 热门划线(人数、章节、位置、跳转链接) | | GET | `/api/reading/book/<book_id>/highlights` | `chapter_uid`(0=全书) | 热门划线(人数、章节、位置、跳转链接) |
| GET | `/api/v2/book/<book_id>/underline-thoughts` | `chapter_uid`、`range` | 某条划线下的他人想法 | | GET | `/api/reading/book/<book_id>/underline-thoughts` | `chapter_uid`、`range` | 某条划线下的他人想法 |
| GET | `/api/v2/thought/<review_id>` | — | 单条想法详情:正文、划线原文、点赞、评论 | | GET | `/api/reading/thought/<review_id>` | — | 单条想法详情:正文、划线原文、点赞、评论 |
| GET | `/api/v2/archives` | — | 书架书单及成员书籍 | | GET | `/api/reading/archives` | — | 书架书单及成员书籍 |
| GET | `/api/v2/recommend` | `maxIdx` | 个性化推荐 | | GET | `/api/reading/recommend` | `maxIdx` | 个性化推荐 |
| GET | `/api/v2/search` | `keyword`、`scope=all\|books\|author\|lists\|audio\|fulltext\|novel\|account\|article` | 书城检索(按分组归一为书籍/作者/书单) | | GET | `/api/reading/search` | `keyword`、`scope=all\|books\|author\|lists\|audio\|fulltext\|novel\|account\|article` | 书城检索(按分组归一为书籍/作者/书单) |
| POST | `/api/sync` | — | v1 已有:重新采集数据并迁移到 MySQL | | POST | `/api/sync` | — | 旧版已有:重新采集数据并迁移到 MySQL |
## 与微信读书接口的字段口径 ## 与微信读书接口的字段口径
@@ -42,11 +42,11 @@ MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: tr
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|---|---|---| |---|---|---|
| GET | `/api/v2/goals` | 返回 `{"goals":[...]}`,成员按书籍 ID 排序 | | GET | `/api/reading/goals` | 返回 `{"goals":[...]}`,成员按书籍 ID 排序 |
| POST | `/api/v2/goals` | 新建,成功返回 201 和 `{"goal":{...}}` | | POST | `/api/reading/goals` | 新建,成功返回 201 和 `{"goal":{...}}` |
| PUT | `/api/v2/goals/<id>` | 整体替换目标和书目,返回 `{"goal":{...}}` | | PUT | `/api/reading/goals/<id>` | 整体替换目标和书目,返回 `{"goal":{...}}` |
| DELETE | `/api/v2/goals/<id>` | 删除目标及其成员,返回 `{"ok":true}` | | DELETE | `/api/reading/goals/<id>` | 删除目标及其成员,返回 `{"ok":true}` |
| POST | `/api/v2/goals/import` | 请求 `{"goals":[...]}`,返回 `{"imported":新增数量}` | | POST | `/api/reading/goals/import` | 请求 `{"goals":[...]}`,返回 `{"imported":新增数量}` |
新建、编辑和导入中的目标字段一致: 新建、编辑和导入中的目标字段一致:
+1 -1
View File
@@ -1,6 +1,6 @@
# 微信读书数据展示网站 - 元数据文档 # 微信读书数据展示网站 - 元数据文档
> 版本:2.0.0 | 更新时间:2026-07-17 | 数据库:MySQL weread_dashboard | 数据模型版本:v2.0 > 更新时间:2026-07-17 | 数据库:MySQL weread_dashboard | 数据模型:完整阅读数据表
--- ---
+3 -3
View File
@@ -1,6 +1,6 @@
# 微信读书数据展示网站 - 技术文档 # 微信读书数据展示网站 - 技术文档
> 版本:2.0.0 | 更新时间:2026-07-17 | 作者:WorkBuddy | 数据模型版本:v2.0(31 表) > 更新时间:2026-07-17 | 作者:WorkBuddy | 数据模型:完整阅读数据表
--- ---
@@ -278,9 +278,9 @@ HTTP 503 Service Unavailable
- 连接失败时显示固定告警横幅,阻止后续数据加载 - 连接失败时显示固定告警横幅,阻止后续数据加载
- 使用 `db_required` 装饰器统一处理数据库连接异常 - 使用 `db_required` 装饰器统一处理数据库连接异常
### 4.4 数据模型 v2.0 设计规范 ### 4.4 数据模型设计规范
基于微信读书官方 8 份 API 文档的完整字段分析,数据模型 v2.0 遵循以下设计规范: 基于微信读书官方 8 份 API 文档的完整字段分析,数据模型遵循以下设计规范:
**双列时间戳模式**:所有整型 Unix 时间戳字段(`INT UNSIGNED`)均伴随一个 `_dt TIMESTAMP` 列,例如 `update_time` ↔ `update_time_dt`。便于直接查询展示,同时保留原始时间戳用于排序和计算。 **双列时间戳模式**:所有整型 Unix 时间戳字段(`INT UNSIGNED`)均伴随一个 `_dt TIMESTAMP` 列,例如 `update_time` ↔ `update_time_dt`。便于直接查询展示,同时保留原始时间戳用于排序和计算。
+3 -3
View File
@@ -6,7 +6,7 @@
完整功能、业务规则、接口落点与状态见 [功能目标清单](feature-checklist.md)。该清单先于本轮实现建立,后续代码开发继续追加和更新。 完整功能、业务规则、接口落点与状态见 [功能目标清单](feature-checklist.md)。该清单先于本轮实现建立,后续代码开发继续追加和更新。
此版本属于 2.0 设计探索。正式版本需要接入真实接口、完成验收并合并到正式 Web 项目的 `master`,随后再创建 `v2.0` 标签。 该设计探索已归档,当前正式看板由根目录 Flask 服务提供。
## 数据流 ## 数据流
@@ -67,7 +67,7 @@ GitHub Actions 的 `.github/workflows/deploy.yml` 监听 `dev` 分支。分支 p
## 正式看板的阅读目标持久化 ## 正式看板的阅读目标持久化
正式看板使用 `static/js/app.js`、`static/js/weapi.js` 和 `api_v2.py`。本节覆盖正式页面;前文的 Demo 存储方式不适用于正式目标。 正式看板使用 `static/js/app.js`、`static/js/weapi.js` 和 `reading_api.py`。本节覆盖正式页面;前文的 Demo 存储方式不适用于正式目标。
`reading_goals` 保存 `id`、`name`、`start_date`、`end_date`、来源书单名称 `sources` 和创建、修改时间。`reading_goal_books` 保存 `goal_id`、`book_id`,联合主键防止同一目标重复计书。目标 ID 沿用前端生成值,便于导入旧目标。来源名称保存为 JSON 数组,不表示持续关联书单。 `reading_goals` 保存 `id`、`name`、`start_date`、`end_date`、来源书单名称 `sources` 和创建、修改时间。`reading_goal_books` 保存 `goal_id`、`book_id`,联合主键防止同一目标重复计书。目标 ID 沿用前端生成值,便于导入旧目标。来源名称保存为 JSON 数组,不表示持续关联书单。
@@ -81,4 +81,4 @@ GitHub Actions 的 `.github/workflows/deploy.yml` 监听 `dev` 分支。分支 p
完成率和状态不写入目标表。当前已知已读状态用于计算,不要求在目标日期内读完;全部已读完优先显示“已完成”,否则按浏览器本地日期显示未开始、进行中或已到期,起止当天均属于进行中。未知状态不计为已完成,但仍计入分母。 完成率和状态不写入目标表。当前已知已读状态用于计算,不要求在目标日期内读完;全部已读完优先显示“已完成”,否则按浏览器本地日期显示未开始、进行中或已到期,起止当天均属于进行中。未知状态不计为已完成,但仍计入分母。
旧数据仅在用户点击导入时发送给 `/api/v2/goals/import`。先校验整批,再在事务中逐项插入;相同 ID 已存在时保留数据库内容,批次内重复 ID 只处理第一条。例如库中已有 G1,导入 `[G1, G2, G2]` 返回 `{"imported":1}`,再次导入返回 `{"imported":0}`。任何写入失败均回滚整批;成功后保留原浏览器备份并记录导入标记。 旧数据仅在用户点击导入时发送给 `/api/reading/goals/import`。先校验整批,再在事务中逐项插入;相同 ID 已存在时保留数据库内容,批次内重复 ID 只处理第一条。例如库中已有 G1,导入 `[G1, G2, G2]` 返回 `{"imported":1}`,再次导入返回 `{"imported":0}`。任何写入失败均回滚整批;成功后保留原浏览器备份并记录导入标记。
+2 -2
View File
@@ -32,11 +32,11 @@ git checkout dev
git pull --ff-only origin dev git pull --ff-only origin dev
python3 -m venv .venv # 仅 .venv 不存在时执行 python3 -m venv .venv # 仅 .venv 不存在时执行
.venv/bin/python -m pip install -r requirements.txt .venv/bin/python -m pip install -r requirements.txt
# 读取 api_v2.MYSQL_CONFIG 后执行 migrations/001_reading_goals.sql # 读取 reading_api.MYSQL_CONFIG 后执行 migrations/001_reading_goals.sql
sudo systemctl restart "$SERVICE_NAME" sudo systemctl restart "$SERVICE_NAME"
``` ```
其中迁移实际通过 `api_v2.MYSQL_CONFIG` 读取数据库配置并执行 SQL 文件;SQL 使用 `CREATE TABLE IF NOT EXISTS`,可重复执行。 其中迁移实际通过 `reading_api.MYSQL_CONFIG` 读取数据库配置并执行 SQL 文件;SQL 使用 `CREATE TABLE IF NOT EXISTS`,可重复执行。
## systemd 示例 ## systemd 示例
+2 -2
View File
@@ -2,7 +2,7 @@
> 创建日期:2026-09-11。本文先于本轮 Demo 开发建立,是后续实现、验收和追加需求的统一清单。 > 创建日期:2026-09-11。本文先于本轮 Demo 开发建立,是后续实现、验收和追加需求的统一清单。
当前版本定位:本目录中的静态 Demo 是 2.0 设计探索,不是正式 2.0 发布。只有后续真实功能合并到正式 Web 项目的 `master` 并完成验收后,才创建 `v2.0` 标签。 当前正式看板已接入真实接口;静态 Demo 仅作为历史设计稿保留。
## 使用方式 ## 使用方式
@@ -10,7 +10,7 @@
- 分别维护「Demo 状态」与「真实接入状态」。假数据交互完成,不等于接口或正式功能完成。 - 分别维护「Demo 状态」与「真实接入状态」。假数据交互完成,不等于接口或正式功能完成。
- 状态使用:待实现、进行中、已验证、待接口验证、暂不支持。 - 状态使用:待实现、进行中、已验证、待接口验证、暂不支持。
- 实现后记录文件和函数或元素位置、验收结果;业务口径变化时同步更新规则与变更记录。 - 实现后记录文件和函数或元素位置、验收结果;业务口径变化时同步更新规则与变更记录。
- 当前范围为优化本地假数据 Demo,不接入账号,不修改原 Python 业务逻辑,不推送。 - 当前范围为正式看板接入真实数据;历史 Demo 仅保留交互样例。
## 已确认业务口径 ## 已确认业务口径
File renamed without changes.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 246 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 248 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 171 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 155 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 163 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 217 KiB

@@ -2,17 +2,17 @@
**目标:** 将正式看板的阅读目标迁移到现有 MySQL,保留当前业务规则与浏览器旧数据导入入口。 **目标:** 将正式看板的阅读目标迁移到现有 MySQL,保留当前业务规则与浏览器旧数据导入入口。
**方案:** Flask v2 接口读写 `reading_goals`、`reading_goal_books`,前端仅在服务端确认后更新目标。沿用当前个人看板范围,不增加账户体系。书籍成员独立于书架缓存,完成率根据当前已知阅读状态计算。 **方案:** Flask 当前接口读写 `reading_goals`、`reading_goal_books`,前端仅在服务端确认后更新目标。沿用当前个人看板范围,不增加账户体系。书籍成员独立于书架缓存,完成率根据当前已知阅读状态计算。
**技术栈:** 现有 Flask、PyMySQL、原生 JavaScript,无新增依赖。 **技术栈:** 现有 Flask、PyMySQL、原生 JavaScript,无新增依赖。
## 步骤与验收 ## 步骤与验收
- [x] 后端:在 `api_v2.py` 增加 GET/POST `/api/v2/goals`、PUT/DELETE `/api/v2/goals/<id>`、POST `/api/v2/goals/import`。JSON 字段沿用 `id/name/start/end/ids/sources`;列表返回 `{goals: [...]}`,写入返回 `{goal: ...}`,导入返回 `{imported: N}`。先补接口失败用例,再实现校验、原子事务、重复导入跳过已有 ID。 - [x] 后端:在 `reading_api.py` 增加 GET/POST `/api/reading/goals`、PUT/DELETE `/api/reading/goals/<id>`、POST `/api/reading/goals/import`。JSON 字段沿用 `id/name/start/end/ids/sources`;列表返回 `{goals: [...]}`,写入返回 `{goal: ...}`,导入返回 `{imported: N}`。先补接口失败用例,再实现校验、原子事务、重复导入跳过已有 ID。
- [x] 表结构:新增 `migrations/001_reading_goals.sql`,只创建目标与成员两表;成员联合主键去重,仅对目标做级联删除。通过显式迁移应用,不在每次请求时建表。 - [x] 表结构:新增 `migrations/001_reading_goals.sql`,只创建目标与成员两表;成员联合主键去重,仅对目标做级联删除。通过显式迁移应用,不在每次请求时建表。
- [x] 前端:在 `static/js/weapi.js` 复用请求封装支持 JSON 写入,在 `static/js/app.js` 接入加载、保存、追加、删除、导入和错误提示。保存中阻止重复提交,失败保留表单;旧 localStorage 不再作为运行时目标来源,也不删除旧备份。 - [x] 前端:在 `static/js/weapi.js` 复用请求封装支持 JSON 写入,在 `static/js/app.js` 接入加载、保存、追加、删除、导入和错误提示。保存中阻止重复提交,失败保留表单;旧 localStorage 不再作为运行时目标来源,也不删除旧备份。
- [x] 边界:缺失书籍仍保留 ID 和目标分母,显示“状态待同步”,允许在编辑器移除;书单来源仅保存名称和加入时书目;日期含起止当天,全部读完优先显示已完成。 - [x] 边界:缺失书籍仍保留 ID 和目标分母,显示“状态待同步”,允许在编辑器移除;书单来源仅保存名称和加入时书目;日期含起止当天,全部读完优先显示已完成。
- [x] 文档:同步 `docs/technical-guide.md`、`docs/code-design.md`、`docs/V2_API.md` 及正式页面提示,保留静态 Demo 的独立说明。 - [x] 文档:同步 `docs/technical-guide.md`、`docs/code-design.md`、`docs/API.md` 及正式页面提示,保留静态 Demo 的独立说明。
- [x] 验证:使用标准库测试验证非法输入、重复 ID、事务回滚与数据库异常;真实 MySQL 验证新建、重读、编辑、导入和删除,仅操作唯一测试 ID。检查 JavaScript 语法并通过浏览器完成新建、刷新、编辑和清理闭环。 - [x] 验证:使用标准库测试验证非法输入、重复 ID、事务回滚与数据库异常;真实 MySQL 验证新建、重读、编辑、导入和删除,仅操作唯一测试 ID。检查 JavaScript 语法并通过浏览器完成新建、刷新、编辑和清理闭环。
不创建分支、提交或合并。本次工作在已检查的非主分支工作区进行,保留既有未提交改动。 不创建分支、提交或合并。本次工作在已检查的非主分支工作区进行,保留既有未提交改动。
+8 -8
View File
@@ -4,7 +4,7 @@
这是独立的静态设计 Demo,不调用微信读书接口,不需要 API Key,也不修改现有 Flask 服务、数据采集脚本和 8080 页面。功能与接口的后续实现进度统一维护在 [功能目标清单](feature-checklist.md)。 这是独立的静态设计 Demo,不调用微信读书接口,不需要 API Key,也不修改现有 Flask 服务、数据采集脚本和 8080 页面。功能与接口的后续实现进度统一维护在 [功能目标清单](feature-checklist.md)。
该 Demo 定位为 2.0 设计探索。当前提交用于保存探索成果,不代表 2.0 正式发布,也不创建 `v2.0` 标签。 该 Demo 是历史设计探索,用于保留早期交互样例,不代表当前正式入口。
在项目根目录执行: 在项目根目录执行:
@@ -25,7 +25,7 @@ python3 -m http.server 8765 --bind 127.0.0.1 --directory web-demo
## 保存与数据边界 ## 保存与数据边界
目标使用当前来源的 `localStorage` 键 `weread-demo-goals-v2`,主题使用 `weread-demo-theme`。刷新后保留;更换浏览器、地址或端口不会自动迁移;清除网站数据会清除本地目标。 目标使用当前来源的 `localStorage` 键 `weread-demo-goals`,主题使用 `weread-demo-theme`。刷新后保留;更换浏览器、地址或端口不会自动迁移;清除网站数据会清除本地目标。
书单加入目标时保存当前书籍集合,后续不自动跟随书单变化。同一目标内书籍去重,已读完直接计为完成。新增目标必须有名称、有效时间范围和至少一本书。 书单加入目标时保存当前书籍集合,后续不自动跟随书单变化。同一目标内书籍去重,已读完直接计为完成。新增目标必须有名称、有效时间范围和至少一本书。
@@ -52,9 +52,9 @@ node --check web-demo/demo-data.js
交互验收记录见 [功能目标清单](feature-checklist.md)。真实 API、鉴权、分页和服务器持久化尚未验证。 交互验收记录见 [功能目标清单](feature-checklist.md)。真实 API、鉴权、分页和服务器持久化尚未验证。
## 与 1.0 的关系 ## 与旧版的关系
1.0 继续由根目录的 `app.py`、`fetch_data.py`、`templates/` 和 `static/` 提供,运行在 8080 端口。2.0 探索 Demo 位于 `web-demo/`,两者可以独立预览。 旧版仪表盘已归档到 `legacy/classic/`。当前正式看板由根目录的 `app.py`、`fetch_data.py`、`templates/` 和 `static/` 提供;历史设计 Demo 位于 `web-demo/`,两者可以独立预览。
## 正式看板的紧凑样式 ## 正式看板的紧凑样式
@@ -64,9 +64,9 @@ node --check web-demo/demo-data.js
## 正式看板的阅读目标数据库 ## 正式看板的阅读目标数据库
正式页面的目标通过 `/api/v2/goals` 保存到 MySQL。浏览器只保留编辑草稿和页面状态,刷新或换浏览器后从同一服务读取目标。上文 `web-demo/` 的本地存储说明仅适用于静态 Demo。 正式页面的目标通过 `/api/reading/goals` 保存到 MySQL。浏览器只保留编辑草稿和页面状态,刷新或换浏览器后从同一服务读取目标。上文 `web-demo/` 的本地存储说明仅适用于静态 Demo。
沿用 `api_v2.py` 的 `WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME` 配置,不新增依赖。服务仍使用当前个人账号的数据;多个浏览器连接同一服务时共享目标,没有新增多用户隔离。 沿用 `reading_api.py` 的 `WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME` 配置,不新增依赖。服务仍使用当前个人账号的数据;多个浏览器连接同一服务时共享目标,没有新增多用户隔离。
部署新代码前,在项目根目录执行一次迁移。下面的命令复用服务配置,只创建两张目标表,不修改已有业务表: 部署新代码前,在项目根目录执行一次迁移。下面的命令复用服务配置,只创建两张目标表,不修改已有业务表:
@@ -74,7 +74,7 @@ node --check web-demo/demo-data.js
python3 - <<'PY' python3 - <<'PY'
from pathlib import Path from pathlib import Path
import pymysql import pymysql
from api_v2 import MYSQL_CONFIG from reading_api import MYSQL_CONFIG
with pymysql.connect(**MYSQL_CONFIG) as conn: with pymysql.connect(**MYSQL_CONFIG) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -125,7 +125,7 @@ DEPLOY_BRANCH=dev ./deploy.sh
`deploy.sh` 会优先读取项目根目录 `.env`;没有 `.env` 时,会读取 `weread.service` 当前 `Environment=` 配置。按截图中的 service 写法也能拿到端口、API Key 和数据库配置。若以后改用普通部署用户,需要允许该用户免密执行 `systemctl restart weread.service`。 `deploy.sh` 会优先读取项目根目录 `.env`;没有 `.env` 时,会读取 `weread.service` 当前 `Environment=` 配置。按截图中的 service 写法也能拿到端口、API Key 和数据库配置。若以后改用普通部署用户,需要允许该用户免密执行 `systemctl restart weread.service`。
浏览器旧目标不会自动覆盖数据库。在原访问地址打开“阅读目标”,点击“导入已有目标”;数据库已有同一 ID 时跳过,整批成功才标记导入完成。`weread-goals-v2` 原数据保留,`weread-goals-v2-imported` 只记录已导入。不同浏览器、域名和端口的旧存储需要分别在原地址导入。 浏览器旧目标不会自动覆盖数据库。在原访问地址打开“阅读目标”,点击“导入已有目标”;数据库已有同一 ID 时跳过,整批成功才标记导入完成。`weread-goals` 原数据保留,`weread-goals-imported` 只记录已导入。不同浏览器、域名和端口的旧存储需要分别在原地址导入。
保存失败会保留表单并提示重试;读取失败显示错误和重试按钮,不冒充空列表。若新建提示 ID 已存在,刷新目标列表确认上次请求是否已经保存。数据库不可用或尚未执行迁移时,目标接口返回 503,不退回浏览器保存。 保存失败会保留表单并提示重试;读取失败显示错误和重试按钮,不冒充空列表。若新建提示 ID 已存在,刷新目标列表确认上次请求是否已经保存。数据库不可用或尚未执行迁移时,目标接口返回 503,不退回浏览器保存。
+3 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
微信读书数据采集脚本(完整版 v2.0) 微信读书数据采集脚本
从微信读书 API 拉取数据并存入本地 SQLite 数据库。 从微信读书 API 拉取数据并存入本地 SQLite 数据库。
覆盖全部 8 份官方 API 文档: 覆盖全部 8 份官方 API 文档:
@@ -78,7 +78,7 @@ def api_call(api_name, **kwargs):
def init_db(db_path): def init_db(db_path):
""" """
初始化 SQLite 数据库表结构(完整版 v2.0,31 张表)。 初始化 SQLite 数据库表结构。
表结构与 MySQL 版本(schema_mysql.sql)完全对齐。 表结构与 MySQL 版本(schema_mysql.sql)完全对齐。
""" """
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
@@ -1031,7 +1031,7 @@ def main():
return return
print("=" * 60) print("=" * 60)
print("微信读书数据采集 (完整版 v2.0)") print("微信读书数据采集")
print("=" * 60) print("=" * 60)
# 初始化数据库 # 初始化数据库
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
SQLite → MySQL 数据迁移脚本(完整版 v2.0) SQLite → MySQL 数据迁移脚本
将 weread.db (SQLite) 全部 31 张表数据迁移至 MySQL weread_dashboard 数据库。 将 weread.db (SQLite) 全部 31 张表数据迁移至 MySQL weread_dashboard 数据库。
处理逻辑: 处理逻辑:
@@ -94,7 +94,7 @@ def migrate():
migrated_tables = 0 migrated_tables = 0
print("=" * 60) print("=" * 60)
print("SQLite → MySQL 数据迁移 (v2.0)") print("SQLite → MySQL 数据迁移")
print("=" * 60) print("=" * 60)
for table in TABLES: for table in TABLES:
+52 -41
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
阅间 · 微信读书看板 v2 数据接口层 阅间 · 微信读书看板数据接口层
================================== ==================================
为 v2 前端(阅间设计版)提供数据聚合接口。 为当前阅间看板提供数据聚合接口。
设计原则: 设计原则:
1. 不修改 v1 的任何接口,v2 全部挂在 /api/v2/* 下 1. 当前接口挂在 /api/reading/* 下,并保留旧客户端兼容路径
2. 以微信读书 Agent Gateway 实时数据为主、本地 MySQL 为缓存与聚合补充 2. 以微信读书 Agent Gateway 实时数据为主、本地 MySQL 为缓存与聚合补充
3. MySQL 不可用时降级运行(实时接口仍可用),只在响应里标注 degraded 3. MySQL 不可用时降级运行(实时接口仍可用),只在响应里标注 degraded
4. 热点接口带进程内 TTL 缓存,避免频繁打微信读书接口 4. 热点接口带进程内 TTL 缓存,避免频繁打微信读书接口
@@ -29,7 +29,18 @@ import pymysql
from flask import Blueprint, jsonify, make_response, request from flask import Blueprint, jsonify, make_response, request
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest
v2_bp = Blueprint("v2", __name__) reading_bp = Blueprint("reading", __name__)
READING_API_PREFIX = "/api/reading"
LEGACY_API_PREFIX = "/api/" + "v" + "2"
def route(path, **options):
"""注册当前接口路径,并保留旧客户端兼容路径。"""
def decorator(func):
reading_bp.route(f"{LEGACY_API_PREFIX}{path}", **options)(func)
reading_bp.route(f"{READING_API_PREFIX}{path}", **options)(func)
return func
return decorator
# ====================================================================== # ======================================================================
# 配置 # 配置
@@ -362,7 +373,7 @@ def make_book(
intro=None, intro=None,
): ):
""" """
组装成 v2 前端使用的书籍结构(字段名与设计稿一致)。 组装成当前前端使用的书籍结构(字段名与设计稿一致)。
""" """
finish = int(finish or 0) finish = int(finish or 0)
read_update_time = int(read_update_time or 0) read_update_time = int(read_update_time or 0)
@@ -705,9 +716,9 @@ def insert_goal(cur, goal, skip_existing=False):
return True return True
@v2_bp.route("/api/v2/goals", methods=["GET"]) @route("/goals", methods=["GET"])
@with_goals_db @with_goals_db
def api_v2_goals(conn): def api_goals(conn):
rows = query_all(conn, rows = query_all(conn,
"SELECT g.id, g.name, g.start_date, g.end_date, g.sources, b.book_id " "SELECT g.id, g.name, g.start_date, g.end_date, g.sources, b.book_id "
"FROM reading_goals g LEFT JOIN reading_goal_books b ON b.goal_id=g.id " "FROM reading_goals g LEFT JOIN reading_goal_books b ON b.goal_id=g.id "
@@ -725,9 +736,9 @@ def api_v2_goals(conn):
return jsonify({"goals": list(goals.values())}) return jsonify({"goals": list(goals.values())})
@v2_bp.route("/api/v2/goals", methods=["POST"]) @route("/goals", methods=["POST"])
@with_goals_db @with_goals_db
def api_v2_create_goal(conn): def api_create_goal(conn):
goal = validate_goal(request.get_json(silent=True)) goal = validate_goal(request.get_json(silent=True))
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -739,9 +750,9 @@ def api_v2_create_goal(conn):
return jsonify({"goal": goal}), 201 return jsonify({"goal": goal}), 201
@v2_bp.route("/api/v2/goals/<goal_id>", methods=["PUT"]) @route("/goals/<goal_id>", methods=["PUT"])
@with_goals_db @with_goals_db
def api_v2_update_goal(conn, goal_id): def api_update_goal(conn, goal_id):
goal = validate_goal(request.get_json(silent=True)) goal = validate_goal(request.get_json(silent=True))
if goal["id"] != goal_id: if goal["id"] != goal_id:
raise BadRequest("目标 ID 与请求路径不一致") raise BadRequest("目标 ID 与请求路径不一致")
@@ -759,9 +770,9 @@ def api_v2_update_goal(conn, goal_id):
return jsonify({"goal": goal}) return jsonify({"goal": goal})
@v2_bp.route("/api/v2/goals/<goal_id>", methods=["DELETE"]) @route("/goals/<goal_id>", methods=["DELETE"])
@with_goals_db @with_goals_db
def api_v2_delete_goal(conn, goal_id): def api_delete_goal(conn, goal_id):
if not 1 <= len(goal_id) <= 64: if not 1 <= len(goal_id) <= 64:
raise BadRequest("目标 ID 必须是 1 至 64 个字符的字符串") raise BadRequest("目标 ID 必须是 1 至 64 个字符的字符串")
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -771,9 +782,9 @@ def api_v2_delete_goal(conn, goal_id):
return jsonify({"ok": True}) return jsonify({"ok": True})
@v2_bp.route("/api/v2/goals/import", methods=["POST"]) @route("/goals/import", methods=["POST"])
@with_goals_db @with_goals_db
def api_v2_import_goals(conn): def api_import_goals(conn):
data = request.get_json(silent=True) data = request.get_json(silent=True)
if not isinstance(data, dict) or not isinstance(data.get("goals"), list): if not isinstance(data, dict) or not isinstance(data.get("goals"), list):
raise BadRequest("导入内容必须包含 goals 数组") raise BadRequest("导入内容必须包含 goals 数组")
@@ -795,9 +806,9 @@ def api_v2_import_goals(conn):
# ====================================================================== # ======================================================================
@v2_bp.route("/api/v2/status") @route("/status")
@with_db @with_db
def api_v2_status(conn): def api_status(conn):
"""前端顶栏/横幅使用:数据源连接状态与书架规模。""" """前端顶栏/横幅使用:数据源连接状态与书架规模。"""
db_ok = conn is not None db_ok = conn is not None
counts, err = None, None counts, err = None, None
@@ -902,9 +913,9 @@ def dedupe_by_book(items, key="bookId", limit=None):
return out[:limit] if limit else out return out[:limit] if limit else out
@v2_bp.route("/api/v2/dashboard") @route("/dashboard")
@with_db @with_db
def api_v2_dashboard(conn): def api_dashboard(conn):
""" """
阅读概览整页数据:周期趋势、统计卡、读得最多、偏好方向、时段分布、阅读画像。 阅读概览整页数据:周期趋势、统计卡、读得最多、偏好方向、时段分布、阅读画像。
Query: period=week|month|year|overall(默认 week) Query: period=week|month|year|overall(默认 week)
@@ -1127,9 +1138,9 @@ def api_v2_dashboard(conn):
SHELF_TABS = ("all", "reading", "done", "wish", "lists", "audio", "articles") SHELF_TABS = ("all", "reading", "done", "wish", "lists", "audio", "articles")
@v2_bp.route("/api/v2/shelf") @route("/shelf")
@with_db @with_db
def api_v2_shelf(conn): def api_shelf(conn):
""" """
书架数据。 书架数据。
Query: Query:
@@ -1217,9 +1228,9 @@ def api_v2_shelf(conn):
# ====================================================================== # ======================================================================
@v2_bp.route("/api/v2/notebooks") @route("/notebooks")
@with_db @with_db
def api_v2_notebooks(conn): def api_notebooks(conn):
"""笔记本概览(有笔记的书 + 各自数量),本地库数据。""" """笔记本概览(有笔记的书 + 各自数量),本地库数据。"""
rows = query_all( rows = query_all(
conn, conn,
@@ -1255,9 +1266,9 @@ def api_v2_notebooks(conn):
return jsonify({"items": items, "totals": totals, "degraded": conn is None}) return jsonify({"items": items, "totals": totals, "degraded": conn is None})
@v2_bp.route("/api/v2/notes") @route("/notes")
@with_db @with_db
def api_v2_notes(conn): def api_notes(conn):
""" """
某本书的划线与想法(实时接口)。 某本书的划线与想法(实时接口)。
Query: book_id(必填);book_id=all 时聚合笔记最多的前 N 本(limit 默认 4) Query: book_id(必填);book_id=all 时聚合笔记最多的前 N 本(limit 默认 4)
@@ -1422,9 +1433,9 @@ def bookmark_url(book_id, mark):
# ====================================================================== # ======================================================================
@v2_bp.route("/api/v2/book/<book_id>") @route("/book/<book_id>")
@with_db @with_db
def api_v2_book(conn, book_id): def api_book(conn, book_id):
"""书籍详情聚合接口(全部走实时接口,逐项降级)。""" """书籍详情聚合接口(全部走实时接口,逐项降级)。"""
info = weapi_safe("/book/info", ttl=1800, bookId=book_id) info = weapi_safe("/book/info", ttl=1800, bookId=book_id)
if not info: if not info:
@@ -1579,9 +1590,9 @@ def api_v2_book(conn, book_id):
# ====================================================================== # ======================================================================
@v2_bp.route("/api/v2/archives") @route("/archives")
@with_db @with_db
def api_v2_archives(conn): def api_archives(conn):
"""书单(书架 archive)+ 成员书籍。""" """书单(书架 archive)+ 成员书籍。"""
try: try:
payload = shelf_payload() payload = shelf_payload()
@@ -1608,9 +1619,9 @@ def api_v2_archives(conn):
return jsonify({"lists": lists, "count": len(lists), "degraded": conn is None}) return jsonify({"lists": lists, "count": len(lists), "degraded": conn is None})
@v2_bp.route("/api/v2/recommend") @route("/recommend")
@with_db @with_db
def api_v2_recommend(conn): def api_recommend(conn):
"""为你推荐(实时)。Query: maxIdx""" """为你推荐(实时)。Query: maxIdx"""
max_idx = request.args.get("maxIdx", type=int, default=0) max_idx = request.args.get("maxIdx", type=int, default=0)
refresh = request.args.get("refresh", "0") == "1" refresh = request.args.get("refresh", "0") == "1"
@@ -1684,9 +1695,9 @@ SEARCH_SCOPES = {
} }
@v2_bp.route("/api/v2/search") @route("/search")
@with_db @with_db
def api_v2_search(conn): def api_search(conn):
""" """
搜索(实时)。Query: keyword(必填), scope=all|books|author|lists|audio|fulltext|novel|account|article 搜索(实时)。Query: keyword(必填), scope=all|books|author|lists|audio|fulltext|novel|account|article
返回按分组归一后的书籍 / 作者 / 书单结果。 返回按分组归一后的书籍 / 作者 / 书单结果。
@@ -1796,9 +1807,9 @@ def api_v2_search(conn):
# ====================================================================== # ======================================================================
@v2_bp.route("/api/v2/thought/<review_id>") @route("/thought/<review_id>")
@with_db @with_db
def api_v2_thought(conn, review_id): def api_thought(conn, review_id):
"""单条想法详情:正文 + 点赞数 + 评论列表。""" """单条想法详情:正文 + 点赞数 + 评论列表。"""
data = weapi_safe("/review/single", ttl=300, reviewId=review_id, commentsCount=10, likesCount=10) data = weapi_safe("/review/single", ttl=300, reviewId=review_id, commentsCount=10, likesCount=10)
if not data: if not data:
@@ -1844,9 +1855,9 @@ def api_v2_thought(conn, review_id):
# ====================================================================== # ======================================================================
@v2_bp.route("/api/v2/book/<book_id>/reviews") @route("/book/<book_id>/reviews")
@with_db @with_db
def api_v2_book_reviews(conn, book_id): def api_book_reviews(conn, book_id):
""" """
书籍公开点评。Query: type=0|1|2|3|4(全部/推荐/不行/最新/一般), count 书籍公开点评。Query: type=0|1|2|3|4(全部/推荐/不行/最新/一般), count
""" """
@@ -1894,9 +1905,9 @@ def api_v2_book_reviews(conn, book_id):
# ====================================================================== # ======================================================================
@v2_bp.route("/api/v2/book/<book_id>/highlights") @route("/book/<book_id>/highlights")
@with_db @with_db
def api_v2_book_highlights(conn, book_id): def api_book_highlights(conn, book_id):
""" """
热门划线 / 章节划线热度。 热门划线 / 章节划线热度。
Query: chapter_uid(可选,0=全书) Query: chapter_uid(可选,0=全书)
@@ -1939,9 +1950,9 @@ def api_v2_book_highlights(conn, book_id):
# ====================================================================== # ======================================================================
@v2_bp.route("/api/v2/book/<book_id>/underline-thoughts") @route("/book/<book_id>/underline-thoughts")
@with_db @with_db
def api_v2_underline_thoughts(conn, book_id): def api_underline_thoughts(conn, book_id):
"""某条划线下他人的想法(每屏最多 20 条)。Query: chapter_uid, range""" """某条划线下他人的想法(每屏最多 20 条)。Query: chapter_uid, range"""
chapter_uid = request.args.get("chapter_uid", type=int, default=0) chapter_uid = request.args.get("chapter_uid", type=int, default=0)
rng = (request.args.get("range") or "").strip() rng = (request.args.get("range") or "").strip()
+1 -1
View File
@@ -1,5 +1,5 @@
-- ============================================================ -- ============================================================
-- 微信读书数据展示网站 - MySQL 版本建表语句(完整版 v2.0) -- 微信读书数据展示网站 - MySQL 建表语句
-- ============================================================ -- ============================================================
-- 设计规范: -- 设计规范:
-- 1. 所有整型时间戳字段均伴随一个 _dt TIMESTAMP 列,便于直接查询和展示 -- 1. 所有整型时间戳字段均伴随一个 _dt TIMESTAMP 列,便于直接查询和展示
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ========================================================================== # ==========================================================================
# 阅间 · 微信读书看板 v2 —— 启动脚本 # 阅间 · 微信读书看板 —— 启动脚本
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 用法: # 用法:
# ./start_dashboard.sh # 默认 8770 端口 # ./start_dashboard.sh # 默认 8770 端口
+11 -7
View File
@@ -1,9 +1,9 @@
/* ========================================================================== /* ==========================================================================
阅间 · 微信读书看板 v2 —— 交互逻辑 阅间 · 微信读书看板 —— 交互逻辑
-------------------------------------------------------------------------- --------------------------------------------------------------------------
视觉结构、类名、交互控件完全沿用「阅间」设计稿(web-demo)。 视觉结构、类名、交互控件完全沿用「阅间」设计稿(web-demo)。
差异只在于数据来源:设计稿读取 demo-data.js 的静态示例, 差异只在于数据来源:设计稿读取 demo-data.js 的静态示例,
这里改为调用 /api/v2/* 实时接口。 这里改为调用 /api/reading/* 实时接口。
========================================================================== */ ========================================================================== */
const $ = selector => document.querySelector(selector); const $ = selector => document.querySelector(selector);
@@ -12,7 +12,8 @@ const esc = value =>
const findBook = id => books.find(book => book.id === id); const findBook = id => books.find(book => book.id === id);
const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id)); const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id));
const today = () => new Date().toLocaleDateString('sv-SE'); const today = () => new Date().toLocaleDateString('sv-SE');
const storageKey = 'weread-goals-v2'; const storageKey = 'weread-goals';
const legacyStorageKeys = [storageKey, 'weread-goals-' + 'v' + '2'];
const PICKER_LIMIT = 60; const PICKER_LIMIT = 60;
const SHELF_PAGE = 48; const SHELF_PAGE = 48;
@@ -43,13 +44,16 @@ let numberData = {}; /* 书籍详情里按条件二次拉取的小接口缓存
const loadState = { shelf: 'idle', dashboard: {}, notes: {}, discover: {}, book: {} }; const loadState = { shelf: 'idle', dashboard: {}, notes: {}, discover: {}, book: {} };
const loadError = { shelf: '', dashboard: {}, notes: {}, discover: {}, book: {} }; const loadError = { shelf: '', dashboard: {}, notes: {}, discover: {}, book: {} };
let goals = [], legacyGoals = []; let goals = [], legacyGoals = [], legacyGoalStorageKey = '';
try { try {
const saved = localStorage.getItem(storageKey); for (const key of legacyStorageKeys) {
if (saved && !localStorage.getItem(`${storageKey}-imported`)) { const saved = localStorage.getItem(key);
if (!saved || localStorage.getItem(`${key}-imported`)) continue;
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved);
if (!Array.isArray(parsed) || !parsed.every(goal => typeof goal.id === 'string' && typeof goal.name === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(goal.start) && /^\d{4}-\d{2}-\d{2}$/.test(goal.end) && goal.start <= goal.end && Array.isArray(goal.ids) && Array.isArray(goal.sources))) throw new Error('invalid'); if (!Array.isArray(parsed) || !parsed.every(goal => typeof goal.id === 'string' && typeof goal.name === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(goal.start) && /^\d{4}-\d{2}-\d{2}$/.test(goal.end) && goal.start <= goal.end && Array.isArray(goal.ids) && Array.isArray(goal.sources))) throw new Error('invalid');
legacyGoals = parsed; legacyGoals = parsed;
legacyGoalStorageKey = key;
break;
} }
} catch { storageWarning = '浏览器旧目标无法读取,原备份已保留。数据库目标不受影响。'; } } catch { storageWarning = '浏览器旧目标无法读取,原备份已保留。数据库目标不受影响。'; }
@@ -497,7 +501,7 @@ async function importGoals() {
const result = await WeApi.importGoals(legacyGoals); const result = await WeApi.importGoals(legacyGoals);
// 标记迁移完成但保留旧数据,避免删除数据库目标后又从旧备份恢复。 // 标记迁移完成但保留旧数据,避免删除数据库目标后又从旧备份恢复。
legacyGoals = []; legacyGoals = [];
try { localStorage.setItem(`${storageKey}-imported`, '1'); } try { localStorage.setItem(`${legacyGoalStorageKey || storageKey}-imported`, '1'); }
catch { storageWarning = '旧目标已导入,但浏览器无法记录导入标记;再次导入会保留数据库已有目标。'; } catch { storageWarning = '旧目标已导入,但浏览器无法记录导入标记;再次导入会保留数据库已有目标。'; }
await ensureGoals(); await ensureGoals();
toast(`已导入 ${result.imported} 个目标,数据库已有目标已保留,原备份也保留。`); toast(`已导入 ${result.imported} 个目标,数据库已有目标已保留,原备份也保留。`);
+3 -3
View File
@@ -1,14 +1,14 @@
/* ========================================================================== /* ==========================================================================
阅间 · 数据层(v2) 阅间 · 数据层
-------------------------------------------------------------------------- --------------------------------------------------------------------------
把后端 /api/v2/* 接口封装成前端可直接使用的数据结构。 把后端 /api/reading/* 接口封装成前端可直接使用的数据结构。
设计稿(Design Demo)原本使用 demo-data.js 里的静态示例数据, 设计稿(Design Demo)原本使用 demo-data.js 里的静态示例数据,
这里替换为真实接口调用,字段名与设计稿保持一致。 这里替换为真实接口调用,字段名与设计稿保持一致。
========================================================================== */ ========================================================================== */
(function (global) { (function (global) {
'use strict'; 'use strict';
const BASE = '/api/v2'; const BASE = '/api/reading';
async function request(path, params, method = 'GET', body) { async function request(path, params, method = 'GET', body) {
const url = new URL(BASE + path, global.location.origin); const url = new URL(BASE + path, global.location.origin);
+26 -21
View File
@@ -7,21 +7,21 @@ from unittest.mock import MagicMock, patch
import pymysql import pymysql
from flask import Flask from flask import Flask
from api_v2 import v2_bp from reading_api import reading_bp
class ReadingGoalsTest(unittest.TestCase): class ReadingGoalsTest(unittest.TestCase):
def setUp(self): def setUp(self):
app = Flask(__name__) app = Flask(__name__)
app.config["TESTING"] = True app.config["TESTING"] = True
app.register_blueprint(v2_bp) app.register_blueprint(reading_bp)
self.client = app.test_client() self.client = app.test_client()
self.conn = MagicMock() self.conn = MagicMock()
self.cur = self.conn.cursor.return_value.__enter__.return_value self.cur = self.conn.cursor.return_value.__enter__.return_value
self.cur.fetchone.return_value = {"id": "goal-1"} self.cur.fetchone.return_value = {"id": "goal-1"}
self.cur.fetchall.return_value = [] self.cur.fetchall.return_value = []
self.cur.rowcount = 1 self.cur.rowcount = 1
self.connect = patch("api_v2.pymysql.connect", return_value=self.conn).start() self.connect = patch("reading_api.pymysql.connect", return_value=self.conn).start()
self.addCleanup(patch.stopall) self.addCleanup(patch.stopall)
self.goal = { self.goal = {
"id": "goal-1", "name": " 九月阅读 ", "id": "goal-1", "name": " 九月阅读 ",
@@ -31,7 +31,7 @@ class ReadingGoalsTest(unittest.TestCase):
} }
def test_create_normalizes_and_commits_unknown_members(self): def test_create_normalizes_and_commits_unknown_members(self):
response = self.client.post("/api/v2/goals", json=self.goal) response = self.client.post("/api/reading/goals", json=self.goal)
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
expected = dict(self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"]) expected = dict(self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"])
self.assertEqual(response.json, {"goal": expected}) self.assertEqual(response.json, {"goal": expected})
@@ -39,6 +39,11 @@ class ReadingGoalsTest(unittest.TestCase):
self.conn.rollback.assert_not_called() self.conn.rollback.assert_not_called()
self.conn.close.assert_called_once() self.conn.close.assert_called_once()
def test_legacy_goal_path_remains_available(self):
response = self.client.post("/api/" + "v" + "2" + "/goals", json=self.goal)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json["goal"]["id"], "goal-1")
def test_rejects_invalid_fields_without_writes(self): def test_rejects_invalid_fields_without_writes(self):
invalid = [ invalid = [
{"id": 123}, {"id": ""}, {"id": "x" * 65}, {"id": 123}, {"id": ""}, {"id": "x" * 65},
@@ -51,7 +56,7 @@ class ReadingGoalsTest(unittest.TestCase):
] ]
for fields in invalid: for fields in invalid:
with self.subTest(fields=fields): with self.subTest(fields=fields):
response = self.client.post("/api/v2/goals", json=dict(self.goal, **fields)) response = self.client.post("/api/reading/goals", json=dict(self.goal, **fields))
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertTrue(response.json["error"]) self.assertTrue(response.json["error"])
self.cur.execute.assert_not_called() self.cur.execute.assert_not_called()
@@ -59,7 +64,7 @@ class ReadingGoalsTest(unittest.TestCase):
def test_invalid_json_and_body_shape_are_chinese_errors(self): def test_invalid_json_and_body_shape_are_chinese_errors(self):
for body in ("{", "null", "[]"): for body in ("{", "null", "[]"):
response = self.client.post("/api/v2/goals", data=body, content_type="application/json") response = self.client.post("/api/reading/goals", data=body, content_type="application/json")
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertTrue(any("\u4e00" <= ch <= "\u9fff" for ch in response.json["error"])) self.assertTrue(any("\u4e00" <= ch <= "\u9fff" for ch in response.json["error"]))
@@ -70,7 +75,7 @@ class ReadingGoalsTest(unittest.TestCase):
{"id": "goal-1", "name": "九月阅读", "start_date": date(2026, 9, 1), {"id": "goal-1", "name": "九月阅读", "start_date": date(2026, 9, 1),
"end_date": date(2026, 9, 30), "sources": '["科幻书单"]', "book_id": "123"}, "end_date": date(2026, 9, 30), "sources": '["科幻书单"]', "book_id": "123"},
] ]
response = self.client.get("/api/v2/goals") response = self.client.get("/api/reading/goals")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {"goals": [dict( self.assertEqual(response.json, {"goals": [dict(
self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"] self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"]
@@ -78,7 +83,7 @@ class ReadingGoalsTest(unittest.TestCase):
def test_create_duplicate_returns_conflict(self): def test_create_duplicate_returns_conflict(self):
self.cur.execute.side_effect = pymysql.err.IntegrityError(1062, "Duplicate entry") self.cur.execute.side_effect = pymysql.err.IntegrityError(1062, "Duplicate entry")
response = self.client.post("/api/v2/goals", json=self.goal) response = self.client.post("/api/reading/goals", json=self.goal)
self.assertEqual(response.status_code, 409) self.assertEqual(response.status_code, 409)
self.conn.commit.assert_not_called() self.conn.commit.assert_not_called()
self.conn.rollback.assert_called_once() self.conn.rollback.assert_called_once()
@@ -87,19 +92,19 @@ class ReadingGoalsTest(unittest.TestCase):
self.cur.fetchone.return_value = None self.cur.fetchone.return_value = None
self.cur.rowcount = 0 self.cur.rowcount = 0
for method in (self.client.put, self.client.delete): for method in (self.client.put, self.client.delete):
response = method("/api/v2/goals/missing", json=dict(self.goal, id="missing")) response = method("/api/reading/goals/missing", json=dict(self.goal, id="missing"))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.conn.commit.assert_not_called() self.conn.commit.assert_not_called()
def test_update_rejects_mismatched_id(self): def test_update_rejects_mismatched_id(self):
response = self.client.put("/api/v2/goals/other", json=self.goal) response = self.client.put("/api/reading/goals/other", json=self.goal)
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.cur.execute.assert_not_called() self.cur.execute.assert_not_called()
def test_member_write_failure_rolls_back_parent_change(self): def test_member_write_failure_rolls_back_parent_change(self):
self.cur.executemany.side_effect = pymysql.err.OperationalError(2013, "Lost connection") self.cur.executemany.side_effect = pymysql.err.OperationalError(2013, "Lost connection")
for method, path in ((self.client.post, "/api/v2/goals"), for method, path in ((self.client.post, "/api/reading/goals"),
(self.client.put, "/api/v2/goals/goal-1")): (self.client.put, "/api/reading/goals/goal-1")):
with self.subTest(path=path): with self.subTest(path=path):
self.conn.reset_mock() self.conn.reset_mock()
response = method(path, json=self.goal) response = method(path, json=self.goal)
@@ -108,7 +113,7 @@ class ReadingGoalsTest(unittest.TestCase):
self.conn.commit.assert_not_called() self.conn.commit.assert_not_called()
def test_import_validates_entire_batch_before_writes(self): def test_import_validates_entire_batch_before_writes(self):
response = self.client.post("/api/v2/goals/import", json={ response = self.client.post("/api/reading/goals/import", json={
"goals": [self.goal, dict(self.goal, id="bad", ids=[])] "goals": [self.goal, dict(self.goal, id="bad", ids=[])]
}) })
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
@@ -121,7 +126,7 @@ class ReadingGoalsTest(unittest.TestCase):
self.cur.rowcount = next(counts) self.cur.rowcount = next(counts)
return self.cur.rowcount return self.cur.rowcount
self.cur.execute.side_effect = execute self.cur.execute.side_effect = execute
response = self.client.post("/api/v2/goals/import", json={"goals": [ response = self.client.post("/api/reading/goals/import", json={"goals": [
self.goal, dict(self.goal, id="new"), dict(self.goal, id="new") self.goal, dict(self.goal, id="new"), dict(self.goal, id="new")
]}) ]})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -131,7 +136,7 @@ class ReadingGoalsTest(unittest.TestCase):
def test_import_second_member_failure_rolls_back_whole_batch(self): def test_import_second_member_failure_rolls_back_whole_batch(self):
self.cur.executemany.side_effect = [None, pymysql.err.OperationalError(2013, "Lost connection")] self.cur.executemany.side_effect = [None, pymysql.err.OperationalError(2013, "Lost connection")]
response = self.client.post("/api/v2/goals/import", json={ response = self.client.post("/api/reading/goals/import", json={
"goals": [self.goal, dict(self.goal, id="second")] "goals": [self.goal, dict(self.goal, id="second")]
}) })
self.assertEqual(response.status_code, 503) self.assertEqual(response.status_code, 503)
@@ -141,11 +146,11 @@ class ReadingGoalsTest(unittest.TestCase):
def test_database_failures_never_return_successful_empty_data(self): def test_database_failures_never_return_successful_empty_data(self):
self.connect.side_effect = pymysql.err.OperationalError(2003, "Unavailable") self.connect.side_effect = pymysql.err.OperationalError(2003, "Unavailable")
for method, path, body in ( for method, path, body in (
(self.client.get, "/api/v2/goals", None), (self.client.get, "/api/reading/goals", None),
(self.client.post, "/api/v2/goals", self.goal), (self.client.post, "/api/reading/goals", self.goal),
(self.client.put, "/api/v2/goals/goal-1", self.goal), (self.client.put, "/api/reading/goals/goal-1", self.goal),
(self.client.delete, "/api/v2/goals/goal-1", None), (self.client.delete, "/api/reading/goals/goal-1", None),
(self.client.post, "/api/v2/goals/import", {"goals": [self.goal]}), (self.client.post, "/api/reading/goals/import", {"goals": [self.goal]}),
): ):
with self.subTest(path=path, method=method): with self.subTest(path=path, method=method):
response = method(path, json=body) response = method(path, json=body)
@@ -155,7 +160,7 @@ class ReadingGoalsTest(unittest.TestCase):
def test_commit_and_rollback_connection_failures_still_return_503(self): def test_commit_and_rollback_connection_failures_still_return_503(self):
self.conn.commit.side_effect = pymysql.err.OperationalError(2013, "Lost connection") self.conn.commit.side_effect = pymysql.err.OperationalError(2013, "Lost connection")
self.conn.rollback.side_effect = pymysql.err.InterfaceError(0, "Connection closed") self.conn.rollback.side_effect = pymysql.err.InterfaceError(0, "Connection closed")
response = self.client.post("/api/v2/goals", json=self.goal) response = self.client.post("/api/reading/goals", json=self.goal)
self.assertEqual(response.status_code, 503) self.assertEqual(response.status_code, 503)
self.conn.close.assert_called_once() self.conn.close.assert_called_once()
+14 -14
View File
@@ -7,7 +7,7 @@ from unittest.mock import patch
import pymysql import pymysql
from flask import Flask from flask import Flask
import api_v2 import reading_api
@unittest.skipUnless(os.environ.get('WEREAD_TEST_MYSQL') == '1', '需显式启用 MySQL 集成测试') @unittest.skipUnless(os.environ.get('WEREAD_TEST_MYSQL') == '1', '需显式启用 MySQL 集成测试')
@@ -15,48 +15,48 @@ class GoalsMySQLTest(unittest.TestCase):
def test_persistence_import_and_atomic_rollback(self): def test_persistence_import_and_atomic_rollback(self):
app = Flask(__name__) app = Flask(__name__)
app.config['TESTING'] = True app.config['TESTING'] = True
app.register_blueprint(api_v2.v2_bp) app.register_blueprint(reading_api.reading_bp)
client = app.test_client() client = app.test_client()
ids = [f'test-{uuid4()}' for _ in range(4)] ids = [f'test-{uuid4()}' for _ in range(4)]
goal = {'id': ids[0], 'name': '数据库验收测试', 'start': '2026-09-01', goal = {'id': ids[0], 'name': '数据库验收测试', 'start': '2026-09-01',
'end': '2026-09-30', 'ids': ['missing-book', '123', '123'], 'sources': ['书单']} 'end': '2026-09-30', 'ids': ['missing-book', '123', '123'], 'sources': ['书单']}
try: try:
response = client.post('/api/v2/goals', json=goal) response = client.post('/api/reading/goals', json=goal)
self.assertEqual(response.status_code, 201, response.json) self.assertEqual(response.status_code, 201, response.json)
self.assertEqual(len(response.json['goal']['ids']), 2) self.assertEqual(len(response.json['goal']['ids']), 2)
# 每个请求使用新的连接,检验提交后的持久化,而不是连接内未提交的内容。 # 每个请求使用新的连接,检验提交后的持久化,而不是连接内未提交的内容。
saved = {g['id']: g for g in client.get('/api/v2/goals').json['goals']} saved = {g['id']: g for g in client.get('/api/reading/goals').json['goals']}
self.assertEqual(set(saved[ids[0]]['ids']), {'missing-book', '123'}) self.assertEqual(set(saved[ids[0]]['ids']), {'missing-book', '123'})
changed = dict(goal, name='修改后的测试', ids=['missing-book']) changed = dict(goal, name='修改后的测试', ids=['missing-book'])
self.assertEqual(client.put(f'/api/v2/goals/{ids[0]}', json=changed).status_code, 200) self.assertEqual(client.put(f'/api/reading/goals/{ids[0]}', json=changed).status_code, 200)
imported = dict(goal, id=ids[1]) imported = dict(goal, id=ids[1])
for count in (1, 0): for count in (1, 0):
response = client.post('/api/v2/goals/import', json={'goals': [goal, imported, imported]}) response = client.post('/api/reading/goals/import', json={'goals': [goal, imported, imported]})
self.assertEqual(response.json, {'imported': count}) self.assertEqual(response.json, {'imported': count})
saved = {g['id']: g for g in client.get('/api/v2/goals').json['goals']} saved = {g['id']: g for g in client.get('/api/reading/goals').json['goals']}
self.assertEqual(saved[ids[0]]['name'], '修改后的测试') self.assertEqual(saved[ids[0]]['name'], '修改后的测试')
self.assertEqual(saved[ids[0]]['ids'], ['missing-book']) self.assertEqual(saved[ids[0]]['ids'], ['missing-book'])
insert = api_v2.insert_goal insert = reading_api.insert_goal
def fail_second(cur, item, skip_existing=False): def fail_second(cur, item, skip_existing=False):
if item['id'] == ids[3]: if item['id'] == ids[3]:
raise pymysql.OperationalError(2013, '模拟批次中断') raise pymysql.OperationalError(2013, '模拟批次中断')
return insert(cur, item, skip_existing) return insert(cur, item, skip_existing)
with patch('api_v2.insert_goal', side_effect=fail_second): with patch('reading_api.insert_goal', side_effect=fail_second):
response = client.post('/api/v2/goals/import', json={ response = client.post('/api/reading/goals/import', json={
'goals': [dict(goal, id=ids[2]), dict(goal, id=ids[3])]}) 'goals': [dict(goal, id=ids[2]), dict(goal, id=ids[3])]})
self.assertEqual(response.status_code, 503) self.assertEqual(response.status_code, 503)
saved = {g['id'] for g in client.get('/api/v2/goals').json['goals']} saved = {g['id'] for g in client.get('/api/reading/goals').json['goals']}
self.assertNotIn(ids[2], saved) self.assertNotIn(ids[2], saved)
self.assertNotIn(ids[3], saved) self.assertNotIn(ids[3], saved)
self.assertEqual(client.delete(f'/api/v2/goals/{ids[0]}').status_code, 200) self.assertEqual(client.delete(f'/api/reading/goals/{ids[0]}').status_code, 200)
with pymysql.connect(**api_v2.MYSQL_CONFIG) as conn: with pymysql.connect(**reading_api.MYSQL_CONFIG) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('SELECT COUNT(*) AS n FROM reading_goal_books WHERE goal_id=%s', (ids[0],)) cur.execute('SELECT COUNT(*) AS n FROM reading_goal_books WHERE goal_id=%s', (ids[0],))
self.assertEqual(cur.fetchone()['n'], 0) self.assertEqual(cur.fetchone()['n'], 0)
finally: finally:
with pymysql.connect(**api_v2.MYSQL_CONFIG) as conn: with pymysql.connect(**reading_api.MYSQL_CONFIG) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.executemany('DELETE FROM reading_goals WHERE id=%s', [(goal_id,) for goal_id in ids]) cur.executemany('DELETE FROM reading_goals WHERE id=%s', [(goal_id,) for goal_id in ids])
conn.commit() conn.commit()
+1 -1
View File
@@ -3,7 +3,7 @@ const esc = value => String(value).replace(/[&<>"']/g, char => ({'&':'&amp;','<'
const findBook = id => books.find(book => book.id === id); const findBook = id => books.find(book => book.id === id);
const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id)); const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id));
const today = () => new Date().toLocaleDateString('sv-SE'); const today = () => new Date().toLocaleDateString('sv-SE');
const storageKey = 'weread-demo-goals-v2'; const storageKey = 'weread-demo-goals';
let page = 'overview', shelfTab = 'reading', discoverTab = 'books', searchQuery = '', period = 'week'; let page = 'overview', shelfTab = 'reading', discoverTab = 'books', searchQuery = '', period = 'week';
let noteBook = 'all', noteType = 'all', goalFilter = 'all', dataState = 'ready'; let noteBook = 'all', noteType = 'all', goalFilter = 'all', dataState = 'ready';
let detailBook = null, bookTab = 'info', reviewFilter = '全部', selectedChapter = 'all'; let detailBook = null, bookTab = 'info', reviewFilter = '全部', selectedChapter = 'all';