refactor(api): 去除代码版本命名
No files matched your search
@@ -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` 时也能使用,只有进度/笔记等局部数据会缺少缓存加速。
|
||||||
|
|
||||||
## 自动部署
|
## 自动部署
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
# 微信读书数据展示网站 - 元数据文档
|
# 微信读书数据展示网站 - 元数据文档
|
||||||
|
|
||||||
> 版本:2.0.0 | 更新时间:2026-07-17 | 数据库:MySQL weread_dashboard | 数据模型版本:v2.0
|
> 更新时间:2026-07-17 | 数据库:MySQL weread_dashboard | 数据模型:完整阅读数据表
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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`。便于直接查询展示,同时保留原始时间戳用于排序和计算。
|
||||||
|
|
||||||
|
|||||||
@@ -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}`。任何写入失败均回滚整批;成功后保留原浏览器备份并记录导入标记。
|
||||||
@@ -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,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 仅保留交互样例。
|
||||||
|
|
||||||
## 已确认业务口径
|
## 已确认业务口径
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 163 KiB |
|
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 语法并通过浏览器完成新建、刷新、编辑和清理闭环。
|
||||||
|
|
||||||
不创建分支、提交或合并。本次工作在已检查的非主分支工作区进行,保留既有未提交改动。
|
不创建分支、提交或合并。本次工作在已检查的非主分支工作区进行,保留既有未提交改动。
|
||||||
@@ -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,不退回浏览器保存。
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
# 初始化数据库
|
# 初始化数据库
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 微信读书数据展示网站 - MySQL 版本建表语句(完整版 v2.0)
|
-- 微信读书数据展示网站 - MySQL 建表语句
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 设计规范:
|
-- 设计规范:
|
||||||
-- 1. 所有整型时间戳字段均伴随一个 _dt TIMESTAMP 列,便于直接查询和展示
|
-- 1. 所有整型时间戳字段均伴随一个 _dt TIMESTAMP 列,便于直接查询和展示
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# 阅间 · 微信读书看板 v2 —— 启动脚本
|
# 阅间 · 微信读书看板 —— 启动脚本
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# 用法:
|
# 用法:
|
||||||
# ./start_dashboard.sh # 默认 8770 端口
|
# ./start_dashboard.sh # 默认 8770 端口
|
||||||
|
|||||||
@@ -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} 个目标,数据库已有目标已保留,原备份也保留。`);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const esc = value => String(value).replace(/[&<>"']/g, char => ({'&':'&','<'
|
|||||||
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';
|
||||||
|
|||||||