From dcf4cb07198332e2291089bbd47b6b4d249e31db Mon Sep 17 00:00:00 2001 From: kinghwj Date: Mon, 14 Sep 2026 15:35:53 +0800 Subject: [PATCH 1/4] =?UTF-8?q?refactor(api):=20=E5=8E=BB=E9=99=A4?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=89=88=E6=9C=AC=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 22 ++--- app.py | 8 +- deploy.sh | 2 +- docs/{V2_API.md => API.md} | 44 ++++----- docs/METADATA_DOC.md | 2 +- docs/TECHNICAL_DOC.md | 6 +- docs/code-design.md | 6 +- docs/deploy-dev.md | 4 +- docs/feature-checklist.md | 4 +- .../{v2-book-detail.png => book-detail.png} | Bin .../{v2-discover.png => discover.png} | Bin .../{v2-goal-editor.png => goal-editor.png} | Bin docs/screenshots/{v2-notes.png => notes.png} | Bin ...v2-overview-dark.png => overview-dark.png} | Bin .../{v2-overview.png => overview.png} | Bin docs/screenshots/{v2-shelf.png => shelf.png} | Bin .../plans/2026-09-12-reading-goals.md | 6 +- docs/technical-guide.md | 16 +-- fetch_data.py | 6 +- legacy/{v1 => classic}/static/css/style.css | 0 legacy/{v1 => classic}/static/js/app.js | 0 legacy/{v1 => classic}/templates/index.html | 0 migrate_to_mysql.py | 4 +- api_v2.py => reading_api.py | 93 ++++++++++-------- schema_mysql.sql | 2 +- start_dashboard.sh | 2 +- static/js/app.js | 18 ++-- static/js/weapi.js | 6 +- tests/test_reading_goals.py | 47 +++++---- tests/test_reading_goals_mysql.py | 28 +++--- web-demo/app.js | 2 +- 31 files changed, 174 insertions(+), 154 deletions(-) rename docs/{V2_API.md => API.md} (55%) rename docs/screenshots/{v2-book-detail.png => book-detail.png} (100%) rename docs/screenshots/{v2-discover.png => discover.png} (100%) rename docs/screenshots/{v2-goal-editor.png => goal-editor.png} (100%) rename docs/screenshots/{v2-notes.png => notes.png} (100%) rename docs/screenshots/{v2-overview-dark.png => overview-dark.png} (100%) rename docs/screenshots/{v2-overview.png => overview.png} (100%) rename docs/screenshots/{v2-shelf.png => shelf.png} (100%) rename legacy/{v1 => classic}/static/css/style.css (100%) rename legacy/{v1 => classic}/static/js/app.js (100%) rename legacy/{v1 => classic}/templates/index.html (100%) rename api_v2.py => reading_api.py (97%) diff --git a/README.md b/README.md index 190cc0b..505e421 100644 --- a/README.md +++ b/README.md @@ -2,28 +2,28 @@ 基于微信读书 API 的个人读书数据展示网站。 -- **v2「阅间」看板(当前默认前端)**:沿用设计稿的视觉与交互,数据来自 `/api/v2/*` 实时聚合接口,见 `docs/V2_API.md`。 -- **v1 仪表盘(已归档)**:原 ECharts 单页仪表盘,完整备份在 `legacy/v1/`。 +- **「阅间」看板(当前默认前端)**:沿用设计稿的视觉与交互,数据来自 `/api/reading/*` 实时聚合接口,见 `docs/API.md`。 +- **旧版仪表盘(已归档)**:原 ECharts 单页仪表盘,完整备份在 `legacy/classic/`。 ## 项目结构 ``` . ├── fetch_data.py # 数据采集脚本(从微信读书 API 拉取数据到 SQLite) -├── app.py # Flask 后端服务(v1 接口 + 注册 v2 Blueprint) -├── api_v2.py # v2 数据接口层(/api/v2/*,供「阅间」前端使用) +├── app.py # Flask 后端服务(旧接口 + 注册当前看板接口) +├── reading_api.py # 当前数据接口层(/api/reading/*,供「阅间」前端使用) ├── 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 后生成) ├── requirements.txt # Python 依赖 ├── templates/ -│ └── index.html # 「阅间」前端页面(v2 设计稿) +│ └── index.html # 「阅间」前端页面 └── static/ - ├── css/styles.css # v2 样式(设计稿原样) - ├── css/style.css # v1 样式(保留) + ├── css/styles.css # 当前样式(设计稿原样) + ├── css/style.css # 旧版样式(保留) └── js/ - ├── weapi.js # v2 数据层(封装 /api/v2/*) - └── app.js # v2 交互逻辑 + ├── weapi.js # 当前数据层(封装 /api/reading/*) + └── app.js # 当前交互逻辑 ``` ## 快速开始 @@ -58,7 +58,7 @@ PORT=8080 ./start_dashboard.sh # 或指定端口 也可以直接 `python app.py`(默认 8080,可用环境变量 `PORT` 覆盖), 然后访问 http://127.0.0.1:8770 查看「阅间」看板。 -> v2 的书架、统计、推荐、搜索等数据实时来自微信读书接口; +> 当前看板的书架、统计、推荐、搜索等数据实时来自微信读书接口; > 未运行 `fetch_data.py` 时也能使用,只有进度/笔记等局部数据会缺少缓存加速。 ## 自动部署 diff --git a/app.py b/app.py index 5cfdc84..b9b60ec 100644 --- a/app.py +++ b/app.py @@ -37,15 +37,15 @@ except ImportError: _ssl_context = ssl.create_default_context() from flask import Flask, g, jsonify, render_template, request -from api_v2 import v2_bp +from reading_api import reading_bp # ====================================================================== # Flask 应用实例 # ====================================================================== 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: print(f"[信息] {msg}") - # 监听端口:默认 8080,可用环境变量 PORT 覆盖(v2 前端与 v1 并存时可换端口) + # 监听端口:默认 8080,可用环境变量 PORT 覆盖(端口冲突时可换端口) port = int(os.environ.get("PORT", 8080)) print(f"[信息] 打开 http://127.0.0.1:{port} 查看「阅间」看板") app.run(host="0.0.0.0", port=port, debug=False) diff --git a/deploy.sh b/deploy.sh index 0f2af9e..5bbf0df 100755 --- a/deploy.sh +++ b/deploy.sh @@ -75,7 +75,7 @@ from pathlib import Path import pymysql -from api_v2 import MYSQL_CONFIG +from reading_api import MYSQL_CONFIG migration = Path("migrations/001_reading_goals.sql") if migration.exists(): diff --git a/docs/V2_API.md b/docs/API.md similarity index 55% rename from docs/V2_API.md rename to docs/API.md index 07d34b6..6c51f65 100644 --- a/docs/V2_API.md +++ b/docs/API.md @@ -1,7 +1,7 @@ -# v2 数据接口(/api/v2/*) +# 当前数据接口(/api/reading/*) -「阅间」新版前端的数据来源。实现在 `api_v2.py`(Flask Blueprint,注册于 `app.py`), -v1 的 `/api/*` 接口保持不变,两套接口可同时使用。 +「阅间」当前前端的数据来源。实现在 `reading_api.py`(Flask Blueprint,注册于 `app.py`), +旧版 `/api/*` 接口保持不变;`/api/v2/*` 仅作为兼容路径保留。 ## 数据来源与优先级 @@ -21,20 +21,20 @@ MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: tr | 方法 | 路径 | 参数 | 说明 | |------|------|------|------| -| GET | `/api/v2/status` | — | 连接状态、书架规模、数据快照时间 | -| GET | `/api/v2/shelf` | `tab=all\|reading\|done\|wish`、`keyword`、`limit`、`offset`、`progress=1` | 书架列表 + 书单 + 有声书 + 文章收藏入口(`progress=1` 为首屏书籍实时补拉进度) | -| GET | `/api/v2/dashboard` | `period=week\|month\|year\|overall` | 整页概览:趋势柱状图、四张统计卡、读得最多、偏好方向、时段分布、阅读画像、年度小结 | -| GET | `/api/v2/notebooks` | — | 笔记本概览(有笔记的书 + 划线/想法/书签数量) | -| GET | `/api/v2/notes` | `book_id`(`all` 表示聚合笔记最多的前 N 本)、`limit` | 划线与想法正文,按时间倒序 | -| GET | `/api/v2/book/` | — | 书籍详情聚合:简介 + 进度 + 目录 + 我的笔记 + 热门划线 + 点评 + 相似推荐 | -| GET | `/api/v2/book//reviews` | `type=0\|1\|2\|3\|4`(全部/推荐/不行/最新/一般)、`count` | 公开点评(按类型筛选) | -| GET | `/api/v2/book//highlights` | `chapter_uid`(0=全书) | 热门划线(人数、章节、位置、跳转链接) | -| GET | `/api/v2/book//underline-thoughts` | `chapter_uid`、`range` | 某条划线下的他人想法 | -| GET | `/api/v2/thought/` | — | 单条想法详情:正文、划线原文、点赞、评论 | -| GET | `/api/v2/archives` | — | 书架书单及成员书籍 | -| GET | `/api/v2/recommend` | `maxIdx` | 个性化推荐 | -| GET | `/api/v2/search` | `keyword`、`scope=all\|books\|author\|lists\|audio\|fulltext\|novel\|account\|article` | 书城检索(按分组归一为书籍/作者/书单) | -| POST | `/api/sync` | — | v1 已有:重新采集数据并迁移到 MySQL | +| GET | `/api/reading/status` | — | 连接状态、书架规模、数据快照时间 | +| GET | `/api/reading/shelf` | `tab=all\|reading\|done\|wish`、`keyword`、`limit`、`offset`、`progress=1` | 书架列表 + 书单 + 有声书 + 文章收藏入口(`progress=1` 为首屏书籍实时补拉进度) | +| GET | `/api/reading/dashboard` | `period=week\|month\|year\|overall` | 整页概览:趋势柱状图、四张统计卡、读得最多、偏好方向、时段分布、阅读画像、年度小结 | +| GET | `/api/reading/notebooks` | — | 笔记本概览(有笔记的书 + 划线/想法/书签数量) | +| GET | `/api/reading/notes` | `book_id`(`all` 表示聚合笔记最多的前 N 本)、`limit` | 划线与想法正文,按时间倒序 | +| GET | `/api/reading/book/` | — | 书籍详情聚合:简介 + 进度 + 目录 + 我的笔记 + 热门划线 + 点评 + 相似推荐 | +| GET | `/api/reading/book//reviews` | `type=0\|1\|2\|3\|4`(全部/推荐/不行/最新/一般)、`count` | 公开点评(按类型筛选) | +| GET | `/api/reading/book//highlights` | `chapter_uid`(0=全书) | 热门划线(人数、章节、位置、跳转链接) | +| GET | `/api/reading/book//underline-thoughts` | `chapter_uid`、`range` | 某条划线下的他人想法 | +| GET | `/api/reading/thought/` | — | 单条想法详情:正文、划线原文、点赞、评论 | +| GET | `/api/reading/archives` | — | 书架书单及成员书籍 | +| GET | `/api/reading/recommend` | `maxIdx` | 个性化推荐 | +| GET | `/api/reading/search` | `keyword`、`scope=all\|books\|author\|lists\|audio\|fulltext\|novel\|account\|article` | 书城检索(按分组归一为书籍/作者/书单) | +| POST | `/api/sync` | — | 旧版已有:重新采集数据并迁移到 MySQL | ## 与微信读书接口的字段口径 @@ -42,11 +42,11 @@ MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: tr | 方法 | 路径 | 说明 | |---|---|---| -| GET | `/api/v2/goals` | 返回 `{"goals":[...]}`,成员按书籍 ID 排序 | -| POST | `/api/v2/goals` | 新建,成功返回 201 和 `{"goal":{...}}` | -| PUT | `/api/v2/goals/` | 整体替换目标和书目,返回 `{"goal":{...}}` | -| DELETE | `/api/v2/goals/` | 删除目标及其成员,返回 `{"ok":true}` | -| POST | `/api/v2/goals/import` | 请求 `{"goals":[...]}`,返回 `{"imported":新增数量}` | +| GET | `/api/reading/goals` | 返回 `{"goals":[...]}`,成员按书籍 ID 排序 | +| POST | `/api/reading/goals` | 新建,成功返回 201 和 `{"goal":{...}}` | +| PUT | `/api/reading/goals/` | 整体替换目标和书目,返回 `{"goal":{...}}` | +| DELETE | `/api/reading/goals/` | 删除目标及其成员,返回 `{"ok":true}` | +| POST | `/api/reading/goals/import` | 请求 `{"goals":[...]}`,返回 `{"imported":新增数量}` | 新建、编辑和导入中的目标字段一致: diff --git a/docs/METADATA_DOC.md b/docs/METADATA_DOC.md index 93f2b8d..cc4dffe 100644 --- a/docs/METADATA_DOC.md +++ b/docs/METADATA_DOC.md @@ -1,6 +1,6 @@ # 微信读书数据展示网站 - 元数据文档 -> 版本:2.0.0 | 更新时间:2026-07-17 | 数据库:MySQL weread_dashboard | 数据模型版本:v2.0 +> 更新时间:2026-07-17 | 数据库:MySQL weread_dashboard | 数据模型:完整阅读数据表 --- diff --git a/docs/TECHNICAL_DOC.md b/docs/TECHNICAL_DOC.md index ba7c4e8..44f6cd4 100644 --- a/docs/TECHNICAL_DOC.md +++ b/docs/TECHNICAL_DOC.md @@ -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` 装饰器统一处理数据库连接异常 -### 4.4 数据模型 v2.0 设计规范 +### 4.4 数据模型设计规范 -基于微信读书官方 8 份 API 文档的完整字段分析,数据模型 v2.0 遵循以下设计规范: +基于微信读书官方 8 份 API 文档的完整字段分析,数据模型遵循以下设计规范: **双列时间戳模式**:所有整型 Unix 时间戳字段(`INT UNSIGNED`)均伴随一个 `_dt TIMESTAMP` 列,例如 `update_time` ↔ `update_time_dt`。便于直接查询展示,同时保留原始时间戳用于排序和计算。 diff --git a/docs/code-design.md b/docs/code-design.md index 50193b5..ff99ee8 100644 --- a/docs/code-design.md +++ b/docs/code-design.md @@ -6,7 +6,7 @@ 完整功能、业务规则、接口落点与状态见 [功能目标清单](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 数组,不表示持续关联书单。 @@ -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}`。任何写入失败均回滚整批;成功后保留原浏览器备份并记录导入标记。 diff --git a/docs/deploy-dev.md b/docs/deploy-dev.md index 86bcda9..7d6b075 100644 --- a/docs/deploy-dev.md +++ b/docs/deploy-dev.md @@ -32,11 +32,11 @@ git checkout dev git pull --ff-only origin dev python3 -m venv .venv # 仅 .venv 不存在时执行 .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" ``` -其中迁移实际通过 `api_v2.MYSQL_CONFIG` 读取数据库配置并执行 SQL 文件;SQL 使用 `CREATE TABLE IF NOT EXISTS`,可重复执行。 +其中迁移实际通过 `reading_api.MYSQL_CONFIG` 读取数据库配置并执行 SQL 文件;SQL 使用 `CREATE TABLE IF NOT EXISTS`,可重复执行。 ## systemd 示例 diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index 03597b2..e1c44ba 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -2,7 +2,7 @@ > 创建日期:2026-09-11。本文先于本轮 Demo 开发建立,是后续实现、验收和追加需求的统一清单。 -当前版本定位:本目录中的静态 Demo 是 2.0 设计探索,不是正式 2.0 发布。只有后续真实功能合并到正式 Web 项目的 `master` 并完成验收后,才创建 `v2.0` 标签。 +当前正式看板已接入真实接口;静态 Demo 仅作为历史设计稿保留。 ## 使用方式 @@ -10,7 +10,7 @@ - 分别维护「Demo 状态」与「真实接入状态」。假数据交互完成,不等于接口或正式功能完成。 - 状态使用:待实现、进行中、已验证、待接口验证、暂不支持。 - 实现后记录文件和函数或元素位置、验收结果;业务口径变化时同步更新规则与变更记录。 -- 当前范围为优化本地假数据 Demo,不接入账号,不修改原 Python 业务逻辑,不推送。 +- 当前范围为正式看板接入真实数据;历史 Demo 仅保留交互样例。 ## 已确认业务口径 diff --git a/docs/screenshots/v2-book-detail.png b/docs/screenshots/book-detail.png similarity index 100% rename from docs/screenshots/v2-book-detail.png rename to docs/screenshots/book-detail.png diff --git a/docs/screenshots/v2-discover.png b/docs/screenshots/discover.png similarity index 100% rename from docs/screenshots/v2-discover.png rename to docs/screenshots/discover.png diff --git a/docs/screenshots/v2-goal-editor.png b/docs/screenshots/goal-editor.png similarity index 100% rename from docs/screenshots/v2-goal-editor.png rename to docs/screenshots/goal-editor.png diff --git a/docs/screenshots/v2-notes.png b/docs/screenshots/notes.png similarity index 100% rename from docs/screenshots/v2-notes.png rename to docs/screenshots/notes.png diff --git a/docs/screenshots/v2-overview-dark.png b/docs/screenshots/overview-dark.png similarity index 100% rename from docs/screenshots/v2-overview-dark.png rename to docs/screenshots/overview-dark.png diff --git a/docs/screenshots/v2-overview.png b/docs/screenshots/overview.png similarity index 100% rename from docs/screenshots/v2-overview.png rename to docs/screenshots/overview.png diff --git a/docs/screenshots/v2-shelf.png b/docs/screenshots/shelf.png similarity index 100% rename from docs/screenshots/v2-shelf.png rename to docs/screenshots/shelf.png diff --git a/docs/superpowers/plans/2026-09-12-reading-goals.md b/docs/superpowers/plans/2026-09-12-reading-goals.md index 822ccae..a177f83 100644 --- a/docs/superpowers/plans/2026-09-12-reading-goals.md +++ b/docs/superpowers/plans/2026-09-12-reading-goals.md @@ -2,17 +2,17 @@ **目标:** 将正式看板的阅读目标迁移到现有 MySQL,保留当前业务规则与浏览器旧数据导入入口。 -**方案:** Flask v2 接口读写 `reading_goals`、`reading_goal_books`,前端仅在服务端确认后更新目标。沿用当前个人看板范围,不增加账户体系。书籍成员独立于书架缓存,完成率根据当前已知阅读状态计算。 +**方案:** Flask 当前接口读写 `reading_goals`、`reading_goal_books`,前端仅在服务端确认后更新目标。沿用当前个人看板范围,不增加账户体系。书籍成员独立于书架缓存,完成率根据当前已知阅读状态计算。 **技术栈:** 现有 Flask、PyMySQL、原生 JavaScript,无新增依赖。 ## 步骤与验收 -- [x] 后端:在 `api_v2.py` 增加 GET/POST `/api/v2/goals`、PUT/DELETE `/api/v2/goals/`、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/`、POST `/api/reading/goals/import`。JSON 字段沿用 `id/name/start/end/ids/sources`;列表返回 `{goals: [...]}`,写入返回 `{goal: ...}`,导入返回 `{imported: N}`。先补接口失败用例,再实现校验、原子事务、重复导入跳过已有 ID。 - [x] 表结构:新增 `migrations/001_reading_goals.sql`,只创建目标与成员两表;成员联合主键去重,仅对目标做级联删除。通过显式迁移应用,不在每次请求时建表。 - [x] 前端:在 `static/js/weapi.js` 复用请求封装支持 JSON 写入,在 `static/js/app.js` 接入加载、保存、追加、删除、导入和错误提示。保存中阻止重复提交,失败保留表单;旧 localStorage 不再作为运行时目标来源,也不删除旧备份。 - [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 语法并通过浏览器完成新建、刷新、编辑和清理闭环。 不创建分支、提交或合并。本次工作在已检查的非主分支工作区进行,保留既有未提交改动。 diff --git a/docs/technical-guide.md b/docs/technical-guide.md index a352567..ee77ee5 100644 --- a/docs/technical-guide.md +++ b/docs/technical-guide.md @@ -4,7 +4,7 @@ 这是独立的静态设计 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、鉴权、分页和服务器持久化尚未验证。 -## 与 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' from pathlib import Path import pymysql -from api_v2 import MYSQL_CONFIG +from reading_api import MYSQL_CONFIG with pymysql.connect(**MYSQL_CONFIG) as conn: 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`。 -浏览器旧目标不会自动覆盖数据库。在原访问地址打开“阅读目标”,点击“导入已有目标”;数据库已有同一 ID 时跳过,整批成功才标记导入完成。`weread-goals-v2` 原数据保留,`weread-goals-v2-imported` 只记录已导入。不同浏览器、域名和端口的旧存储需要分别在原地址导入。 +浏览器旧目标不会自动覆盖数据库。在原访问地址打开“阅读目标”,点击“导入已有目标”;数据库已有同一 ID 时跳过,整批成功才标记导入完成。`weread-goals` 原数据保留,`weread-goals-imported` 只记录已导入。不同浏览器、域名和端口的旧存储需要分别在原地址导入。 保存失败会保留表单并提示重试;读取失败显示错误和重试按钮,不冒充空列表。若新建提示 ID 已存在,刷新目标列表确认上次请求是否已经保存。数据库不可用或尚未执行迁移时,目标接口返回 503,不退回浏览器保存。 diff --git a/fetch_data.py b/fetch_data.py index 4bca0ac..549c04f 100644 --- a/fetch_data.py +++ b/fetch_data.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -微信读书数据采集脚本(完整版 v2.0) +微信读书数据采集脚本 从微信读书 API 拉取数据并存入本地 SQLite 数据库。 覆盖全部 8 份官方 API 文档: @@ -78,7 +78,7 @@ def api_call(api_name, **kwargs): def init_db(db_path): """ - 初始化 SQLite 数据库表结构(完整版 v2.0,31 张表)。 + 初始化 SQLite 数据库表结构。 表结构与 MySQL 版本(schema_mysql.sql)完全对齐。 """ conn = sqlite3.connect(db_path) @@ -1031,7 +1031,7 @@ def main(): return print("=" * 60) - print("微信读书数据采集 (完整版 v2.0)") + print("微信读书数据采集") print("=" * 60) # 初始化数据库 diff --git a/legacy/v1/static/css/style.css b/legacy/classic/static/css/style.css similarity index 100% rename from legacy/v1/static/css/style.css rename to legacy/classic/static/css/style.css diff --git a/legacy/v1/static/js/app.js b/legacy/classic/static/js/app.js similarity index 100% rename from legacy/v1/static/js/app.js rename to legacy/classic/static/js/app.js diff --git a/legacy/v1/templates/index.html b/legacy/classic/templates/index.html similarity index 100% rename from legacy/v1/templates/index.html rename to legacy/classic/templates/index.html diff --git a/migrate_to_mysql.py b/migrate_to_mysql.py index d11595a..51ef415 100644 --- a/migrate_to_mysql.py +++ b/migrate_to_mysql.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -SQLite → MySQL 数据迁移脚本(完整版 v2.0) +SQLite → MySQL 数据迁移脚本 将 weread.db (SQLite) 全部 31 张表数据迁移至 MySQL weread_dashboard 数据库。 处理逻辑: @@ -94,7 +94,7 @@ def migrate(): migrated_tables = 0 print("=" * 60) - print("SQLite → MySQL 数据迁移 (v2.0)") + print("SQLite → MySQL 数据迁移") print("=" * 60) for table in TABLES: diff --git a/api_v2.py b/reading_api.py similarity index 97% rename from api_v2.py rename to reading_api.py index 72cc365..f3e507b 100644 --- a/api_v2.py +++ b/reading_api.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """ -阅间 · 微信读书看板 v2 数据接口层 +阅间 · 微信读书看板数据接口层 ================================== -为 v2 前端(阅间设计版)提供数据聚合接口。 +为当前阅间看板提供数据聚合接口。 设计原则: - 1. 不修改 v1 的任何接口,v2 全部挂在 /api/v2/* 下 + 1. 当前接口挂在 /api/reading/* 下,并保留旧客户端兼容路径 2. 以微信读书 Agent Gateway 实时数据为主、本地 MySQL 为缓存与聚合补充 3. MySQL 不可用时降级运行(实时接口仍可用),只在响应里标注 degraded 4. 热点接口带进程内 TTL 缓存,避免频繁打微信读书接口 @@ -29,7 +29,18 @@ import pymysql from flask import Blueprint, jsonify, make_response, request 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, ): """ - 组装成 v2 前端使用的书籍结构(字段名与设计稿一致)。 + 组装成当前前端使用的书籍结构(字段名与设计稿一致)。 """ finish = int(finish 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 -@v2_bp.route("/api/v2/goals", methods=["GET"]) +@route("/goals", methods=["GET"]) @with_goals_db -def api_v2_goals(conn): +def api_goals(conn): rows = query_all(conn, "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 " @@ -725,9 +736,9 @@ def api_v2_goals(conn): return jsonify({"goals": list(goals.values())}) -@v2_bp.route("/api/v2/goals", methods=["POST"]) +@route("/goals", methods=["POST"]) @with_goals_db -def api_v2_create_goal(conn): +def api_create_goal(conn): goal = validate_goal(request.get_json(silent=True)) try: with conn.cursor() as cur: @@ -739,9 +750,9 @@ def api_v2_create_goal(conn): return jsonify({"goal": goal}), 201 -@v2_bp.route("/api/v2/goals/", methods=["PUT"]) +@route("/goals/", methods=["PUT"]) @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)) if goal["id"] != goal_id: raise BadRequest("目标 ID 与请求路径不一致") @@ -759,9 +770,9 @@ def api_v2_update_goal(conn, goal_id): return jsonify({"goal": goal}) -@v2_bp.route("/api/v2/goals/", methods=["DELETE"]) +@route("/goals/", methods=["DELETE"]) @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: raise BadRequest("目标 ID 必须是 1 至 64 个字符的字符串") with conn.cursor() as cur: @@ -771,9 +782,9 @@ def api_v2_delete_goal(conn, goal_id): return jsonify({"ok": True}) -@v2_bp.route("/api/v2/goals/import", methods=["POST"]) +@route("/goals/import", methods=["POST"]) @with_goals_db -def api_v2_import_goals(conn): +def api_import_goals(conn): data = request.get_json(silent=True) if not isinstance(data, dict) or not isinstance(data.get("goals"), list): raise BadRequest("导入内容必须包含 goals 数组") @@ -795,9 +806,9 @@ def api_v2_import_goals(conn): # ====================================================================== -@v2_bp.route("/api/v2/status") +@route("/status") @with_db -def api_v2_status(conn): +def api_status(conn): """前端顶栏/横幅使用:数据源连接状态与书架规模。""" db_ok = conn is not 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 -@v2_bp.route("/api/v2/dashboard") +@route("/dashboard") @with_db -def api_v2_dashboard(conn): +def api_dashboard(conn): """ 阅读概览整页数据:周期趋势、统计卡、读得最多、偏好方向、时段分布、阅读画像。 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") -@v2_bp.route("/api/v2/shelf") +@route("/shelf") @with_db -def api_v2_shelf(conn): +def api_shelf(conn): """ 书架数据。 Query: @@ -1217,9 +1228,9 @@ def api_v2_shelf(conn): # ====================================================================== -@v2_bp.route("/api/v2/notebooks") +@route("/notebooks") @with_db -def api_v2_notebooks(conn): +def api_notebooks(conn): """笔记本概览(有笔记的书 + 各自数量),本地库数据。""" rows = query_all( conn, @@ -1255,9 +1266,9 @@ def api_v2_notebooks(conn): return jsonify({"items": items, "totals": totals, "degraded": conn is None}) -@v2_bp.route("/api/v2/notes") +@route("/notes") @with_db -def api_v2_notes(conn): +def api_notes(conn): """ 某本书的划线与想法(实时接口)。 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/") +@route("/book/") @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) if not info: @@ -1579,9 +1590,9 @@ def api_v2_book(conn, book_id): # ====================================================================== -@v2_bp.route("/api/v2/archives") +@route("/archives") @with_db -def api_v2_archives(conn): +def api_archives(conn): """书单(书架 archive)+ 成员书籍。""" try: payload = shelf_payload() @@ -1608,9 +1619,9 @@ def api_v2_archives(conn): return jsonify({"lists": lists, "count": len(lists), "degraded": conn is None}) -@v2_bp.route("/api/v2/recommend") +@route("/recommend") @with_db -def api_v2_recommend(conn): +def api_recommend(conn): """为你推荐(实时)。Query: maxIdx""" max_idx = request.args.get("maxIdx", type=int, default=0) refresh = request.args.get("refresh", "0") == "1" @@ -1684,9 +1695,9 @@ SEARCH_SCOPES = { } -@v2_bp.route("/api/v2/search") +@route("/search") @with_db -def api_v2_search(conn): +def api_search(conn): """ 搜索(实时)。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/") +@route("/thought/") @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) if not data: @@ -1844,9 +1855,9 @@ def api_v2_thought(conn, review_id): # ====================================================================== -@v2_bp.route("/api/v2/book//reviews") +@route("/book//reviews") @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 """ @@ -1894,9 +1905,9 @@ def api_v2_book_reviews(conn, book_id): # ====================================================================== -@v2_bp.route("/api/v2/book//highlights") +@route("/book//highlights") @with_db -def api_v2_book_highlights(conn, book_id): +def api_book_highlights(conn, book_id): """ 热门划线 / 章节划线热度。 Query: chapter_uid(可选,0=全书) @@ -1939,9 +1950,9 @@ def api_v2_book_highlights(conn, book_id): # ====================================================================== -@v2_bp.route("/api/v2/book//underline-thoughts") +@route("/book//underline-thoughts") @with_db -def api_v2_underline_thoughts(conn, book_id): +def api_underline_thoughts(conn, book_id): """某条划线下他人的想法(每屏最多 20 条)。Query: chapter_uid, range""" chapter_uid = request.args.get("chapter_uid", type=int, default=0) rng = (request.args.get("range") or "").strip() diff --git a/schema_mysql.sql b/schema_mysql.sql index 3339519..6d347eb 100644 --- a/schema_mysql.sql +++ b/schema_mysql.sql @@ -1,5 +1,5 @@ -- ============================================================ --- 微信读书数据展示网站 - MySQL 版本建表语句(完整版 v2.0) +-- 微信读书数据展示网站 - MySQL 建表语句 -- ============================================================ -- 设计规范: -- 1. 所有整型时间戳字段均伴随一个 _dt TIMESTAMP 列,便于直接查询和展示 diff --git a/start_dashboard.sh b/start_dashboard.sh index a63149e..94e7865 100755 --- a/start_dashboard.sh +++ b/start_dashboard.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # ========================================================================== -# 阅间 · 微信读书看板 v2 —— 启动脚本 +# 阅间 · 微信读书看板 —— 启动脚本 # -------------------------------------------------------------------------- # 用法: # ./start_dashboard.sh # 默认 8770 端口 diff --git a/static/js/app.js b/static/js/app.js index 5fe3fa2..58c48e8 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,9 +1,9 @@ /* ========================================================================== - 阅间 · 微信读书看板 v2 —— 交互逻辑 + 阅间 · 微信读书看板 —— 交互逻辑 -------------------------------------------------------------------------- 视觉结构、类名、交互控件完全沿用「阅间」设计稿(web-demo)。 差异只在于数据来源:设计稿读取 demo-data.js 的静态示例, - 这里改为调用 /api/v2/* 实时接口。 + 这里改为调用 /api/reading/* 实时接口。 ========================================================================== */ const $ = selector => document.querySelector(selector); @@ -12,7 +12,8 @@ const esc = value => const findBook = id => books.find(book => book.id === id); const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id)); 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 SHELF_PAGE = 48; @@ -43,13 +44,16 @@ let numberData = {}; /* 书籍详情里按条件二次拉取的小接口缓存 const loadState = { shelf: 'idle', dashboard: {}, notes: {}, discover: {}, book: {} }; const loadError = { shelf: '', dashboard: {}, notes: {}, discover: {}, book: {} }; -let goals = [], legacyGoals = []; +let goals = [], legacyGoals = [], legacyGoalStorageKey = ''; try { - const saved = localStorage.getItem(storageKey); - if (saved && !localStorage.getItem(`${storageKey}-imported`)) { + for (const key of legacyStorageKeys) { + const saved = localStorage.getItem(key); + if (!saved || localStorage.getItem(`${key}-imported`)) continue; 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'); legacyGoals = parsed; + legacyGoalStorageKey = key; + break; } } catch { storageWarning = '浏览器旧目标无法读取,原备份已保留。数据库目标不受影响。'; } @@ -497,7 +501,7 @@ async function importGoals() { const result = await WeApi.importGoals(legacyGoals); // 标记迁移完成但保留旧数据,避免删除数据库目标后又从旧备份恢复。 legacyGoals = []; - try { localStorage.setItem(`${storageKey}-imported`, '1'); } + try { localStorage.setItem(`${legacyGoalStorageKey || storageKey}-imported`, '1'); } catch { storageWarning = '旧目标已导入,但浏览器无法记录导入标记;再次导入会保留数据库已有目标。'; } await ensureGoals(); toast(`已导入 ${result.imported} 个目标,数据库已有目标已保留,原备份也保留。`); diff --git a/static/js/weapi.js b/static/js/weapi.js index e08fa54..17d3e03 100644 --- a/static/js/weapi.js +++ b/static/js/weapi.js @@ -1,14 +1,14 @@ /* ========================================================================== - 阅间 · 数据层(v2) + 阅间 · 数据层 -------------------------------------------------------------------------- - 把后端 /api/v2/* 接口封装成前端可直接使用的数据结构。 + 把后端 /api/reading/* 接口封装成前端可直接使用的数据结构。 设计稿(Design Demo)原本使用 demo-data.js 里的静态示例数据, 这里替换为真实接口调用,字段名与设计稿保持一致。 ========================================================================== */ (function (global) { 'use strict'; - const BASE = '/api/v2'; + const BASE = '/api/reading'; async function request(path, params, method = 'GET', body) { const url = new URL(BASE + path, global.location.origin); diff --git a/tests/test_reading_goals.py b/tests/test_reading_goals.py index 0663b82..5909ae2 100644 --- a/tests/test_reading_goals.py +++ b/tests/test_reading_goals.py @@ -7,21 +7,21 @@ from unittest.mock import MagicMock, patch import pymysql from flask import Flask -from api_v2 import v2_bp +from reading_api import reading_bp class ReadingGoalsTest(unittest.TestCase): def setUp(self): app = Flask(__name__) app.config["TESTING"] = True - app.register_blueprint(v2_bp) + app.register_blueprint(reading_bp) self.client = app.test_client() self.conn = MagicMock() self.cur = self.conn.cursor.return_value.__enter__.return_value self.cur.fetchone.return_value = {"id": "goal-1"} self.cur.fetchall.return_value = [] 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.goal = { "id": "goal-1", "name": " 九月阅读 ", @@ -31,7 +31,7 @@ class ReadingGoalsTest(unittest.TestCase): } 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) expected = dict(self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"]) self.assertEqual(response.json, {"goal": expected}) @@ -39,6 +39,11 @@ class ReadingGoalsTest(unittest.TestCase): self.conn.rollback.assert_not_called() 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): invalid = [ {"id": 123}, {"id": ""}, {"id": "x" * 65}, @@ -51,7 +56,7 @@ class ReadingGoalsTest(unittest.TestCase): ] for fields in invalid: 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.assertTrue(response.json["error"]) 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): 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.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), "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.json, {"goals": [dict( self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"] @@ -78,7 +83,7 @@ class ReadingGoalsTest(unittest.TestCase): def test_create_duplicate_returns_conflict(self): 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.conn.commit.assert_not_called() self.conn.rollback.assert_called_once() @@ -87,19 +92,19 @@ class ReadingGoalsTest(unittest.TestCase): self.cur.fetchone.return_value = None self.cur.rowcount = 0 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.conn.commit.assert_not_called() 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.cur.execute.assert_not_called() def test_member_write_failure_rolls_back_parent_change(self): self.cur.executemany.side_effect = pymysql.err.OperationalError(2013, "Lost connection") - for method, path in ((self.client.post, "/api/v2/goals"), - (self.client.put, "/api/v2/goals/goal-1")): + for method, path in ((self.client.post, "/api/reading/goals"), + (self.client.put, "/api/reading/goals/goal-1")): with self.subTest(path=path): self.conn.reset_mock() response = method(path, json=self.goal) @@ -108,7 +113,7 @@ class ReadingGoalsTest(unittest.TestCase): self.conn.commit.assert_not_called() 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=[])] }) self.assertEqual(response.status_code, 400) @@ -121,7 +126,7 @@ class ReadingGoalsTest(unittest.TestCase): self.cur.rowcount = next(counts) return self.cur.rowcount 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.assertEqual(response.status_code, 200) @@ -131,7 +136,7 @@ class ReadingGoalsTest(unittest.TestCase): def test_import_second_member_failure_rolls_back_whole_batch(self): 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")] }) self.assertEqual(response.status_code, 503) @@ -141,11 +146,11 @@ class ReadingGoalsTest(unittest.TestCase): def test_database_failures_never_return_successful_empty_data(self): self.connect.side_effect = pymysql.err.OperationalError(2003, "Unavailable") for method, path, body in ( - (self.client.get, "/api/v2/goals", None), - (self.client.post, "/api/v2/goals", self.goal), - (self.client.put, "/api/v2/goals/goal-1", self.goal), - (self.client.delete, "/api/v2/goals/goal-1", None), - (self.client.post, "/api/v2/goals/import", {"goals": [self.goal]}), + (self.client.get, "/api/reading/goals", None), + (self.client.post, "/api/reading/goals", self.goal), + (self.client.put, "/api/reading/goals/goal-1", self.goal), + (self.client.delete, "/api/reading/goals/goal-1", None), + (self.client.post, "/api/reading/goals/import", {"goals": [self.goal]}), ): with self.subTest(path=path, method=method): response = method(path, json=body) @@ -155,7 +160,7 @@ class ReadingGoalsTest(unittest.TestCase): def test_commit_and_rollback_connection_failures_still_return_503(self): self.conn.commit.side_effect = pymysql.err.OperationalError(2013, "Lost connection") 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.conn.close.assert_called_once() diff --git a/tests/test_reading_goals_mysql.py b/tests/test_reading_goals_mysql.py index 444e544..4e15ce5 100644 --- a/tests/test_reading_goals_mysql.py +++ b/tests/test_reading_goals_mysql.py @@ -7,7 +7,7 @@ from unittest.mock import patch import pymysql from flask import Flask -import api_v2 +import reading_api @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): app = Flask(__name__) app.config['TESTING'] = True - app.register_blueprint(api_v2.v2_bp) + app.register_blueprint(reading_api.reading_bp) client = app.test_client() ids = [f'test-{uuid4()}' for _ in range(4)] goal = {'id': ids[0], 'name': '数据库验收测试', 'start': '2026-09-01', 'end': '2026-09-30', 'ids': ['missing-book', '123', '123'], 'sources': ['书单']} 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(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'}) 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]) 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}) - 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]]['ids'], ['missing-book']) - insert = api_v2.insert_goal + insert = reading_api.insert_goal def fail_second(cur, item, skip_existing=False): if item['id'] == ids[3]: raise pymysql.OperationalError(2013, '模拟批次中断') return insert(cur, item, skip_existing) - with patch('api_v2.insert_goal', side_effect=fail_second): - response = client.post('/api/v2/goals/import', json={ + with patch('reading_api.insert_goal', side_effect=fail_second): + response = client.post('/api/reading/goals/import', json={ 'goals': [dict(goal, id=ids[2]), dict(goal, id=ids[3])]}) 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[3], saved) - self.assertEqual(client.delete(f'/api/v2/goals/{ids[0]}').status_code, 200) - with pymysql.connect(**api_v2.MYSQL_CONFIG) as conn: + self.assertEqual(client.delete(f'/api/reading/goals/{ids[0]}').status_code, 200) + with pymysql.connect(**reading_api.MYSQL_CONFIG) as conn: with conn.cursor() as cur: cur.execute('SELECT COUNT(*) AS n FROM reading_goal_books WHERE goal_id=%s', (ids[0],)) self.assertEqual(cur.fetchone()['n'], 0) finally: - with pymysql.connect(**api_v2.MYSQL_CONFIG) as conn: + with pymysql.connect(**reading_api.MYSQL_CONFIG) as conn: with conn.cursor() as cur: cur.executemany('DELETE FROM reading_goals WHERE id=%s', [(goal_id,) for goal_id in ids]) conn.commit() diff --git a/web-demo/app.js b/web-demo/app.js index f720af6..987208c 100644 --- a/web-demo/app.js +++ b/web-demo/app.js @@ -3,7 +3,7 @@ const esc = value => String(value).replace(/[&<>"']/g, char => ({'&':'&','<' const findBook = id => books.find(book => book.id === id); const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id)); 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 noteBook = 'all', noteType = 'all', goalFilter = 'all', dataState = 'ready'; let detailBook = null, bookTab = 'info', reviewFilter = '全部', selectedChapter = 'all'; From 30ee66cef5409e88183473c0e55485c8a0ffb67c Mon Sep 17 00:00:00 2001 From: kinghwj Date: Mon, 14 Sep 2026 15:41:09 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(api):=20=E5=85=BC=E5=AE=B9=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E8=BF=87=E6=B8=A1=E6=9C=9F=E6=8E=A5=E5=8F=A3=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/js/weapi.js | 17 +++++++++---- tests/weapi_fallback.test.cjs | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 tests/weapi_fallback.test.cjs diff --git a/static/js/weapi.js b/static/js/weapi.js index 17d3e03..656ca01 100644 --- a/static/js/weapi.js +++ b/static/js/weapi.js @@ -9,17 +9,24 @@ 'use strict'; const BASE = '/api/reading'; + const FALLBACK_BASE = '/api/' + 'v' + '2'; async function request(path, params, method = 'GET', body) { - const url = new URL(BASE + path, global.location.origin); - Object.entries(params || {}).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value); - }); - const response = await fetch(url.toString(), { + const makeUrl = base => { + const url = new URL(base + path, global.location.origin); + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value); + }); + return url; + }; + const send = base => fetch(makeUrl(base).toString(), { method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), }); + let response = await send(BASE); + // 发布过程中可能出现静态文件已更新、Python 服务尚未重启的短暂状态。 + if (response.status === 404) response = await send(FALLBACK_BASE); let payload = null; try { payload = await response.json(); diff --git a/tests/weapi_fallback.test.cjs b/tests/weapi_fallback.test.cjs new file mode 100644 index 0000000..14736fb --- /dev/null +++ b/tests/weapi_fallback.test.cjs @@ -0,0 +1,45 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function loadWeApi(fetch) { + const context = vm.createContext({ + URL, + fetch, + location: { origin: 'https://example.test' }, + }); + context.window = context; + vm.runInContext(fs.readFileSync('static/js/weapi.js', 'utf8'), context); + return context.WeApi; +} + +function jsonResponse(status, payload) { + return { + ok: status >= 200 && status < 300, + status, + async json() { + return payload; + }, + }; +} + +test('当前接口未部署完成时自动重试兼容接口', async () => { + const calls = []; + const legacyBase = '/api/' + 'v' + '2'; + const api = loadWeApi(async url => { + calls.push(new URL(url).pathname); + if (new URL(url).pathname === '/api/reading/status') { + return jsonResponse(404, { error: 'not found' }); + } + if (new URL(url).pathname === legacyBase + '/status') { + return jsonResponse(200, { ok: true }); + } + return jsonResponse(500, { error: 'unexpected path' }); + }); + + const result = await api.status(); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ['/api/reading/status', legacyBase + '/status']); +}); From df4c3f86f0feb79f3f642dfc06f48cbe9e257809 Mon Sep 17 00:00:00 2001 From: kinghwj Date: Mon, 14 Sep 2026 15:59:27 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(maintenance):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3=E4=B8=8E=E8=AF=81=E4=B9=A6=E6=8E=92=E9=9A=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 + .../2026-09-14-maintenance-debug-guide.md | 39 +++++ docs/technical-guide.md | 6 + docs/troubleshooting.md | 147 ++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-14-maintenance-debug-guide.md create mode 100644 docs/troubleshooting.md diff --git a/README.md b/README.md index 505e421..d9363fd 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ ├── legacy/classic/ # 旧版前端备份(index.html / style.css / app.js) ├── weread.db # SQLite 数据库(运行 fetch_data.py 后生成) ├── requirements.txt # Python 依赖 +├── docs/troubleshooting.md # 日常维护与故障排查 ├── templates/ │ └── index.html # 「阅间」前端页面 └── static/ @@ -71,6 +72,8 @@ DEPLOY_BRANCH=dev ./deploy.sh 脚本会拉取指定分支,自动创建 `.venv`,安装 `requirements.txt`,执行数据库迁移,然后重启 `weread.service`。GitHub Actions 配置在 `.github/workflows/deploy.yml`,默认监听 `dev` 分支。 +运行端口、Python 环境、HTTPS 证书和接口故障的诊断方法见 [日常维护与故障排查](docs/troubleshooting.md)。 + ## 功能模块 | 模块 | 说明 | diff --git a/docs/superpowers/plans/2026-09-14-maintenance-debug-guide.md b/docs/superpowers/plans/2026-09-14-maintenance-debug-guide.md new file mode 100644 index 0000000..4e245ba --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-maintenance-debug-guide.md @@ -0,0 +1,39 @@ +# Maintenance Debug Guide Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 建立可持续维护的日常排障文档,记录本次端口与 Python CA 证书故障。 + +**Architecture:** `docs/troubleshooting.md` 保存完整故障记录;`README.md` 和 `docs/technical-guide.md` 仅提供入口与同步维护要求。运行态只保留当前项目的 8080 服务。 + +**Tech Stack:** Markdown、Git、macOS/Linux 常用诊断命令 + +--- + +### Task 1: 新增排障记录 + +**Files:** +- Create: `docs/troubleshooting.md` + +- [ ] 记录故障现象、根因链路、诊断命令、恢复步骤和预防措施。 +- [ ] 明确接口、端口、Python 环境或部署方式变化时同步更新本文档。 + +### Task 2: 增加文档入口 + +**Files:** +- Modify: `README.md` +- Modify: `docs/technical-guide.md` + +- [ ] 在 README 项目结构中加入排障文档。 +- [ ] 在技术指南中增加日常维护与排障入口及维护约束。 + +### Task 3: 验证并合并 + +**Files:** +- Verify: `docs/troubleshooting.md` +- Verify: `README.md` +- Verify: `docs/technical-guide.md` + +- [ ] 检查文档链接与 Markdown 格式。 +- [ ] 确认 8770 已关闭,8080 页面和状态接口返回 200。 +- [ ] 提交专门分支,并使用 `git merge --no-ff` 合并到 `dev`。 diff --git a/docs/technical-guide.md b/docs/technical-guide.md index ee77ee5..1e4fbd8 100644 --- a/docs/technical-guide.md +++ b/docs/technical-guide.md @@ -140,6 +140,12 @@ node --check static/js/weapi.js 真实数据库验证需先执行迁移,再显式运行 `WEREAD_TEST_MYSQL=1 python3 -m unittest discover -s tests -p 'test_reading_goals_mysql.py' -v`。测试创建唯一测试目标并清理,只操作本次生成的 ID。 +## 日常维护与故障排查 + +端口冲突、请求失败、Python 环境、HTTPS 证书和 systemd 服务的诊断步骤统一维护在 [日常维护与故障排查](troubleshooting.md)。接口路径、监听端口、Python 或 `.venv` 选择、启动脚本、systemd 配置以及部署流程发生变化时,必须同步更新该文档的运行基线;新故障按日期追加记录并保留历史结论。 + +正式服务只保留一个监听端口。首页可以打开但 API 失败时,应分别检查 `/api/reading/status`、`/api/reading/shelf` 和 `/api/reading/dashboard`,根据 404、502 或超时定位到路由、上游服务或 TLS 环境,不能只用首页 200 作为启动成功标准。 + 本次样式收紧覆盖正式看板的概览、书架和阅读目标页。顶栏、标题区、筛选区、统计卡、书架网格、右侧目标摘要、目标介绍区和目标卡片均使用覆盖规则压缩占位;书架在窄屏固定两列,避免横向溢出。本地真实数据验收覆盖 1280 px 桌面和 390 px 手机宽度:桌面顶栏 58 px,统计卡从约 200 px 开始,书架一行 7 本,目标页介绍区约 67 px 高,目标卡约 317 px 宽;手机顶栏 56 px,书架两列,目标卡约 298 px 宽,概览、书架和目标页均未出现横向溢出。 ## dev 自动部署 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..e704cab --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,147 @@ +# 日常维护与故障排查 + +本文档记录正式看板的运行基线、常用诊断方法和已确认故障。接口路径、监听端口、Python 环境、启动脚本、systemd 服务或部署流程发生变化时,必须同步更新本文档;出现新的线上或本地故障时,按日期追加记录,不覆盖历史结论。 + +## 运行基线 + +| 项目 | 当前约定 | +|---|---| +| 本地正式看板端口 | `8080` | +| 当前 API 前缀 | `/api/reading/*` | +| 兼容 API 前缀 | `/api/v2/*`,仅用于旧客户端过渡 | +| 项目 Python | 优先使用项目根目录 `.venv/bin/python` | +| 服务器服务 | `weread.service`,端口以服务中的 `PORT` 为准 | +| 服务器目录 | `/opt/weread-board/WeReadBoard` | + +同一运行环境只保留一个正式服务实例。不要同时使用 `start_server.sh` 和 `start_dashboard.sh` 启动不同端口,否则静态文件、后端路由和 Python 环境可能来自不同进程,容易产生误判。历史静态 Demo 需要临时预览时可以单独启动,用完后应及时停止。 + +## 快速排查 + +页面出现“请求失败”时,按下面的顺序检查。 + +### 检查监听端口 + +本机: + +```sh +lsof -nP -iTCP -sTCP:LISTEN +``` + +服务器: + +```sh +sudo ss -ltnp +sudo systemctl status weread.service --no-pager +``` + +确认正式服务只监听预期端口,并核对进程的启动时间与 Python 路径: + +```sh +ps -fp +lsof -a -p -d cwd +``` + +### 分层检查接口 + +本机设置了 HTTP 代理时,必须绕过代理访问本地地址: + +```sh +curl --noproxy '*' -i http://127.0.0.1:8080/ +curl --noproxy '*' -i http://127.0.0.1:8080/api/reading/status +curl --noproxy '*' -i 'http://127.0.0.1:8080/api/reading/shelf?tab=all&progress=0' +curl --noproxy '*' -i 'http://127.0.0.1:8080/api/reading/dashboard?period=week' +``` + +- 页面返回 200、API 返回 404:运行中的后端没有加载当前路由,检查部署提交与服务重启。 +- API 返回 502:路由已经加载,但数据库或微信读书上游调用失败,查看响应正文和服务日志。 +- 请求一直等待:检查上游网络、DNS、TLS 证书和接口超时,不要只修改前端路径。 + +服务器日志: + +```sh +sudo journalctl -u weread.service -n 100 --no-pager +``` + +## 2026-09-14:接口调整后页面请求失败 + +### 现象 + +- `http://127.0.0.1:8080/` 可以打开,但页面显示“请求失败”。 +- `/api/reading/shelf` 和 `/api/reading/dashboard` 返回 502。 +- 旧的 `8770` 进程仍能通过 `/api/v2/status` 返回数据。 + +### 根因 + +接口调整只修改了 Flask 路由名称和前端请求前缀,微信读书 HTTPS 请求逻辑没有变化。真正差异来自服务重启后选择了另一套 Python: + +- 旧 `8770` 进程使用 WorkBuddy Python,其默认 CA 文件为 `/private/etc/ssl/cert.pem`。 +- 新 `8080` 进程因项目没有 `.venv`,由 `start_server.sh` 选择系统 Python。 +- 该系统 Python 的默认 CA 文件为空,访问 `https://i.weread.qq.com` 时出现 `CERTIFICATE_VERIFY_FAILED`。 + +完整因果链路如下: + +```text +接口调整需要重启服务 +-> 启动脚本选择了不同的 Python +-> 新 Python 无法找到 CA 证书 +-> 微信读书 HTTPS 请求失败 +-> 聚合接口返回 502 +-> 前端显示“请求失败” +``` + +因此,接口调整是触发条件,不是 HTTPS 故障本身。兼容路径只能处理旧进程返回 404 的过渡状态;同一后端因上游 TLS 失败而返回 502 时,切换 `/api/reading/*` 或 `/api/v2/*` 都不能解决问题。 + +### 诊断证据 + +检查 Python 与默认 CA 文件: + +```sh +python3 -c "import sys, ssl; print(sys.executable); print(ssl.get_default_verify_paths())" +``` + +故障环境中,`cafile` 为 `None`,且 Python 预期的证书文件不存在。系统证书 `/etc/ssl/cert.pem` 可用,使用它重新启动后,状态、书架和概览接口均恢复为 200。 + +### 恢复方式 + +本机临时恢复: + +```sh +SSL_CERT_FILE=/etc/ssl/cert.pem PORT=8080 python3 app.py +``` + +服务器由 systemd 管理时,先确认服务器证书文件的实际路径;仅在 Python 默认 CA 路径缺失时,为 `weread.service` 配置 `SSL_CERT_FILE`,然后执行: + +```sh +sudo systemctl daemon-reload +sudo systemctl restart weread.service +``` + +不要关闭 TLS 校验,也不要在代码中使用不验证证书的 SSL Context。 + +### 预防与验收 + +1. 部署和本地启动统一使用项目 `.venv/bin/python`,不要让不同启动脚本静默选择不同解释器。 +2. 重启前检查 Python 的 `ssl.get_default_verify_paths()`,确认 CA 文件存在且可读。 +3. 每次只保留一个正式服务端口,停止旧实例后再验收新实例。 +4. 接口、端口、Python 环境、启动脚本、systemd 配置或部署方式变化时,同步更新本文档的“运行基线”和相关故障记录。 +5. 验收必须同时检查页面、`status`、`shelf` 和 `dashboard`,不能只确认首页返回 200。 + +本次本地恢复后的验收结果:正式服务仅监听 `8080`,页面及上述三个 API 均返回 200;旧 `8770` 实例和 `8766` 静态预览均已停止。 + +## 故障记录模板 + +新增故障时使用以下结构: + +```markdown +## YYYY-MM-DD:故障标题 + +### 现象 + +### 根因 + +### 诊断证据 + +### 恢复方式 + +### 预防与验收 +``` From 8171705c5fe2979a7c67e8e8c5f8b7955c3c76d5 Mon Sep 17 00:00:00 2001 From: kinghwj Date: Mon, 14 Sep 2026 16:09:02 +0800 Subject: [PATCH 4/4] =?UTF-8?q?ci(deploy):=20=E4=BB=85=E5=85=81=E8=AE=B8?= =?UTF-8?q?=20master=20=E8=87=AA=E5=8A=A8=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 6 +-- README.md | 4 +- deploy.sh | 10 ++--- docs/code-design.md | 6 +-- docs/{deploy-dev.md => deploy-master.md} | 12 +++--- docs/technical-guide.md | 8 ++-- docs/troubleshooting.md | 5 +++ tests/deploy_master_only.test.sh | 47 ++++++++++++++++++++++++ 8 files changed, 74 insertions(+), 24 deletions(-) rename docs/{deploy-dev.md => deploy-master.md} (72%) create mode 100755 tests/deploy_master_only.test.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6eb4138..3aaad50 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,14 +3,14 @@ name: Deploy WeReadBoard on: push: branches: - - dev + - master workflow_dispatch: jobs: deploy: runs-on: ubuntu-latest concurrency: - group: deploy-dev + group: deploy-master cancel-in-progress: true steps: @@ -27,7 +27,7 @@ jobs: SERVER_USER: ${{ secrets.SERVER_USER }} DEPLOY_PATH: ${{ vars.DEPLOY_PATH }} SERVICE_NAME: ${{ vars.SERVICE_NAME }} - DEPLOY_BRANCH: ${{ github.ref_name }} + DEPLOY_BRANCH: master run: | if [ -z "$SERVER_PORT" ]; then SERVER_PORT="22" diff --git a/README.md b/README.md index d9363fd..6c42184 100644 --- a/README.md +++ b/README.md @@ -67,10 +67,10 @@ PORT=8080 ./start_dashboard.sh # 或指定端口 服务器部署使用根目录的 `deploy.sh`: ```bash -DEPLOY_BRANCH=dev ./deploy.sh +DEPLOY_BRANCH=master ./deploy.sh ``` -脚本会拉取指定分支,自动创建 `.venv`,安装 `requirements.txt`,执行数据库迁移,然后重启 `weread.service`。GitHub Actions 配置在 `.github/workflows/deploy.yml`,默认监听 `dev` 分支。 +脚本只允许拉取 `master` 分支,随后自动创建 `.venv`、安装 `requirements.txt`、执行数据库迁移并重启 `weread.service`。GitHub Actions 配置在 `.github/workflows/deploy.yml`,只监听 `master` 分支,详细配置见 [master 自动部署](docs/deploy-master.md)。 运行端口、Python 环境、HTTPS 证书和接口故障的诊断方法见 [日常维护与故障排查](docs/troubleshooting.md)。 diff --git a/deploy.sh b/deploy.sh index 5bbf0df..31b2da3 100755 --- a/deploy.sh +++ b/deploy.sh @@ -4,19 +4,15 @@ set -euo pipefail PROJECT_DIR="${PROJECT_DIR:-$(cd "$(dirname "$0")" && pwd)}" REMOTE="${REMOTE:-origin}" -BRANCH="${DEPLOY_BRANCH:-${GITHUB_REF_NAME:-}}" +BRANCH="${DEPLOY_BRANCH:-master}" SERVICE_NAME="${SERVICE_NAME:-weread.service}" VENV_DIR="${VENV_DIR:-$PROJECT_DIR/.venv}" ENV_FILE="${ENV_FILE:-$PROJECT_DIR/.env}" cd "$PROJECT_DIR" -if [ -z "$BRANCH" ]; then - BRANCH="$(git branch --show-current)" -fi - -if [ -z "$BRANCH" ]; then - echo "未检测到部署分支,请设置 DEPLOY_BRANCH" +if [ "$BRANCH" != "master" ]; then + echo "拒绝部署:当前只允许部署 master 分支" exit 1 fi diff --git a/docs/code-design.md b/docs/code-design.md index ff99ee8..466e7c8 100644 --- a/docs/code-design.md +++ b/docs/code-design.md @@ -43,11 +43,11 @@ ## 部署流程 -GitHub Actions 的 `.github/workflows/deploy.yml` 监听 `dev` 分支。分支 push 后,Actions 使用仓库 Secrets 中的服务器地址、用户和 SSH 私钥登录服务器,进入 `/opt/weread-board/WeReadBoard` 后调用 `deploy.sh`。 +GitHub Actions 的 `.github/workflows/deploy.yml` 只监听 `master` 分支。分支 push 后,Actions 使用仓库 Secrets 中的服务器地址、用户和 SSH 私钥登录服务器,进入 `/opt/weread-board/WeReadBoard` 后调用 `deploy.sh`。手动触发也固定部署 `master`,部署脚本会拒绝其他分支。 -`deploy.sh` 以当前触发分支为准拉取代码,再检查项目 `.venv`。如果 `.venv/bin/python` 不存在,脚本使用服务器上的 `python3` 或 `python` 创建虚拟环境;随后用 `.venv/bin/python -m pip install -r requirements.txt` 安装依赖,执行 `migrations/001_reading_goals.sql`,最后重启 `weread.service`。 +`deploy.sh` 固定拉取 `master`,收到其他部署分支参数时直接退出,再检查项目 `.venv`。如果 `.venv/bin/python` 不存在,脚本使用服务器上的 `python3` 或 `python` 创建虚拟环境;随后用 `.venv/bin/python -m pip install -r requirements.txt` 安装依赖,执行 `migrations/001_reading_goals.sql`,最后重启 `weread.service`。 -例:用户把代码推送到 `dev` 分支,服务器原来没有 `.venv`。部署脚本会在 `/opt/weread-board/WeReadBoard/.venv` 创建环境,安装 Flask 和 PyMySQL,确认阅读目标表存在,再让 systemd 重新用 `.venv/bin/python app.py` 启动服务。 +例:用户将已验收代码合并并推送到 `master`,服务器原来没有 `.venv`。部署脚本会在 `/opt/weread-board/WeReadBoard/.venv` 创建环境,安装 Flask 和 PyMySQL,确认阅读目标表存在,再让 systemd 重新用 `.venv/bin/python app.py` 启动服务。仅推送 `dev` 不会触发部署。 ## 示例与真实接口的边界 diff --git a/docs/deploy-dev.md b/docs/deploy-master.md similarity index 72% rename from docs/deploy-dev.md rename to docs/deploy-master.md index 7d6b075..37bf9a8 100644 --- a/docs/deploy-dev.md +++ b/docs/deploy-master.md @@ -1,6 +1,6 @@ -# dev 自动部署 +# master 自动部署 -GitHub Actions workflow 位于 `.github/workflows/deploy.yml`。每次 `dev` 分支 push 后,Actions 会通过 SSH 登录服务器,在部署目录执行根目录 `deploy.sh`,拉取最新 `dev`、安装依赖、执行阅读目标表迁移并重启服务。 +GitHub Actions workflow 位于 `.github/workflows/deploy.yml`。每次 `master` 分支 push 后,Actions 会通过 SSH 登录服务器,在部署目录执行根目录 `deploy.sh`,拉取最新 `master`、安装依赖、执行阅读目标表迁移并重启服务。手动触发时也固定部署 `master`,不能选择其他分支发布。 ## GitHub 配置 @@ -20,7 +20,7 @@ GitHub Actions workflow 位于 `.github/workflows/deploy.yml`。每次 `dev` 分 ## 服务器要求 -服务器部署目录需要已经是本仓库的 Git checkout,并且能访问 `origin/dev`。运行服务的环境变量仍按现有应用配置,例如 `WEREAD_API_KEY`、`WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME`。 +服务器部署目录需要已经是本仓库的 Git checkout,并且能访问 `origin/master`。运行服务的环境变量仍按现有应用配置,例如 `WEREAD_API_KEY`、`WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME`。 如果这些变量放在项目目录的 `.env` 文件中,部署脚本会先加载 `.env`,再执行数据库迁移。SSH 用户需要能执行 `systemctl restart <服务名>`;非 root 用户需要配置免密 `sudo systemctl restart/status <服务名>`。 @@ -28,8 +28,8 @@ GitHub Actions workflow 位于 `.github/workflows/deploy.yml`。每次 `dev` 分 ```bash git fetch origin -git checkout dev -git pull --ff-only origin dev +git checkout master +git pull --ff-only origin master python3 -m venv .venv # 仅 .venv 不存在时执行 .venv/bin/python -m pip install -r requirements.txt # 读取 reading_api.MYSQL_CONFIG 后执行 migrations/001_reading_goals.sql @@ -38,6 +38,8 @@ sudo systemctl restart "$SERVICE_NAME" 其中迁移实际通过 `reading_api.MYSQL_CONFIG` 读取数据库配置并执行 SQL 文件;SQL 使用 `CREATE TABLE IF NOT EXISTS`,可重复执行。 +脚本默认部署 `master`,显式传入其他 `DEPLOY_BRANCH` 时会在拉取代码、安装依赖和重启服务之前退出。 + ## systemd 示例 ```ini diff --git a/docs/technical-guide.md b/docs/technical-guide.md index 1e4fbd8..d7773b2 100644 --- a/docs/technical-guide.md +++ b/docs/technical-guide.md @@ -100,7 +100,7 @@ SERVICE_NAME=weread.service VENV_DIR=/opt/weread-board/WeReadBoard/.venv ``` -GitHub Actions 文件位于 `.github/workflows/deploy.yml`,默认在 `dev` 分支 push 后触发,也支持手动触发。仓库需要在 `Settings -> Secrets and variables -> Actions -> Secrets` 配置: +GitHub Actions 文件位于 `.github/workflows/deploy.yml`,只在 `master` 分支 push 后触发,也支持手动触发;手动触发同样固定部署 `master`。仓库需要在 `Settings -> Secrets and variables -> Actions -> Secrets` 配置: | Secret | 说明 | |---|---| @@ -120,7 +120,7 @@ GitHub Actions 文件位于 `.github/workflows/deploy.yml`,默认在 `dev` 分 ```sh cd /opt/weread-board/WeReadBoard -DEPLOY_BRANCH=dev ./deploy.sh +DEPLOY_BRANCH=master ./deploy.sh ``` `deploy.sh` 会优先读取项目根目录 `.env`;没有 `.env` 时,会读取 `weread.service` 当前 `Environment=` 配置。按截图中的 service 写法也能拿到端口、API Key 和数据库配置。若以后改用普通部署用户,需要允许该用户免密执行 `systemctl restart weread.service`。 @@ -148,8 +148,8 @@ node --check static/js/weapi.js 本次样式收紧覆盖正式看板的概览、书架和阅读目标页。顶栏、标题区、筛选区、统计卡、书架网格、右侧目标摘要、目标介绍区和目标卡片均使用覆盖规则压缩占位;书架在窄屏固定两列,避免横向溢出。本地真实数据验收覆盖 1280 px 桌面和 390 px 手机宽度:桌面顶栏 58 px,统计卡从约 200 px 开始,书架一行 7 本,目标页介绍区约 67 px 高,目标卡约 317 px 宽;手机顶栏 56 px,书架两列,目标卡约 298 px 宽,概览、书架和目标页均未出现横向溢出。 -## dev 自动部署 +## master 自动部署 -`dev` 分支 push 后由 `.github/workflows/deploy.yml` 触发部署。Actions 通过仓库 Secrets 登录服务器,在 `DEPLOY_PATH` 指定的目录先拉取 `origin/dev`,再执行根目录 `deploy.sh`,完成加载可选 `.env`、安装 Python 依赖、执行阅读目标表迁移和重启 systemd 服务。详细配置见 `docs/deploy-dev.md`。 +`master` 分支 push 后由 `.github/workflows/deploy.yml` 触发部署。Actions 通过仓库 Secrets 登录服务器,在 `DEPLOY_PATH` 指定的目录先拉取 `origin/master`,再执行根目录 `deploy.sh`,完成加载可选 `.env`、安装 Python 依赖、执行阅读目标表迁移和重启 systemd 服务。`dev` 和其他分支不会触发部署,部署脚本也会拒绝非 `master` 分支。详细配置见 [master 自动部署](deploy-master.md)。 本次数据库验收已在 8080 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e704cab..7b5ee4c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -10,6 +10,7 @@ | 当前 API 前缀 | `/api/reading/*` | | 兼容 API 前缀 | `/api/v2/*`,仅用于旧客户端过渡 | | 项目 Python | 优先使用项目根目录 `.venv/bin/python` | +| 自动部署分支 | 仅 `master`;推送或手动触发均不部署其他分支 | | 服务器服务 | `weread.service`,端口以服务中的 `PORT` 为准 | | 服务器目录 | `/opt/weread-board/WeReadBoard` | @@ -62,6 +63,10 @@ curl --noproxy '*' -i 'http://127.0.0.1:8080/api/reading/dashboard?period=week' sudo journalctl -u weread.service -n 100 --no-pager ``` +### 检查部署分支 + +自动部署只允许 `master`。如果 Actions 没有启动,先确认提交是否已经合并并推送到 `master`;仅推送 `dev` 不会部署。服务器部署日志中的拉取目标也必须是 `origin/master`,出现其他分支时应停止发布并检查工作流与 `deploy.sh`。 + ## 2026-09-14:接口调整后页面请求失败 ### 现象 diff --git a/tests/deploy_master_only.test.sh b/tests/deploy_master_only.test.sh new file mode 100755 index 0000000..ea6e9a1 --- /dev/null +++ b/tests/deploy_master_only.test.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +WORKFLOW="$ROOT_DIR/.github/workflows/deploy.yml" + +if ! grep -q '^ - master$' "$WORKFLOW"; then + echo "部署工作流尚未监听 master" + exit 1 +fi +if grep -q '^ - dev$' "$WORKFLOW"; then + echo "部署工作流仍监听 dev" + exit 1 +fi +if ! grep -q '^ group: deploy-master$' "$WORKFLOW"; then + echo "部署并发组尚未切换到 master" + exit 1 +fi +if ! grep -q '^ DEPLOY_BRANCH: master$' "$WORKFLOW"; then + echo "手动部署分支尚未固定为 master" + exit 1 +fi + +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "$TEST_DIR"' EXIT +mkdir -p "$TEST_DIR/project" "$TEST_DIR/bin" +printf '#!/usr/bin/env bash\nexit 0\n' > "$TEST_DIR/bin/python3" +chmod +x "$TEST_DIR/bin/python3" + +set +e +OUTPUT="$( + PATH="$TEST_DIR/bin" \ + PROJECT_DIR="$TEST_DIR/project" \ + VENV_DIR="$TEST_DIR/project/.venv" \ + DEPLOY_BRANCH=dev \ + SKIP_GIT_PULL=1 \ + /bin/bash "$ROOT_DIR/deploy.sh" 2>&1 +)" +STATUS=$? +set -e + +if [ "$STATUS" -eq 0 ] || [[ "$OUTPUT" != *"只允许部署 master"* ]]; then + echo "deploy.sh 没有拒绝 dev 分支" + exit 1 +fi + +echo "master-only 部署约束验证通过"