Compare commits

...
25 Commits
Author SHA1 Message Date
admin f42b7cf74e merge: 合并 v0.1 版本显示到 dev 2026-10-20 15:07:05 +08:00
admin 9985f7c81a feat(version): 显示应用版本号 2026-10-20 15:06:54 +08:00
admin 646d75c318 merge: 补充 Webhook 健康检查配置 2026-10-20 14:24:27 +08:00
admin f5ffea6eb0 merge: 发布 Webhook 健康检查配置 2026-10-20 14:24:27 +08:00
admin d5456fae6b docs(deploy): 补充 Webhook 健康检查转发 2026-10-20 14:24:20 +08:00
admin 7ba7968047 merge: 合并 GitHub Webhook 部署到 master 2026-10-20 14:23:46 +08:00
admin 58a3335809 merge: 合并 GitHub Webhook 部署到 dev 2026-10-20 14:23:39 +08:00
admin 469630e7da ci(deploy): 改为 GitHub Webhook 触发部署 2026-10-20 14:23:31 +08:00
admin 69bfe3ce0e style: 格式化 CSS、JS 和 HTML 代码 2026-09-22 09:31:55 +08:00
admin 40b00a8b40 feat(shelf): 新增书架分页展示与排序选择 2026-09-21 22:25:11 +08:00
admin 58437b3acf merge: 合并 ICP 备案页脚到 master 2026-09-21 18:14:16 +08:00
admin bbed5a5b98 merge: 合并 ICP 备案页脚到 dev 2026-09-21 18:14:05 +08:00
admin fb0cf30086 feat(footer): 展示网站 ICP 备案信息 2026-09-21 18:13:34 +08:00
admin c220dcad7a merge: 合并登录权限到 master 2026-09-21 17:47:36 +08:00
admin 20651f275b merge: 合并登录权限到 dev 2026-09-21 17:47:20 +08:00
admin 810b52a051 feat(auth): 增加登录验证和五天会话 2026-09-21 17:46:43 +08:00
admin c82ef4f06e chore(release): 合并 dev 并启用 master 部署 2026-09-14 16:12:06 +08:00
admin ecd16bcefc ci(deploy): 合并 master 单分支部署配置 2026-09-14 16:09:47 +08:00
admin 8171705c5f ci(deploy): 仅允许 master 自动部署 2026-09-14 16:09:02 +08:00
admin 77e05998a5 docs(maintenance): 合并日常排障文档 2026-09-14 15:59:57 +08:00
admin df4c3f86f0 docs(maintenance): 记录端口与证书排障 2026-09-14 15:59:27 +08:00
admin d3ed683652 fix(api): 合并接口兼容热修复 2026-09-14 15:41:59 +08:00
admin 30ee66cef5 fix(api): 兼容部署过渡期接口路径 2026-09-14 15:41:09 +08:00
admin 5cf98c0c23 refactor(api): 合并接口命名清理 2026-09-14 15:36:33 +08:00
admin dcf4cb0719 refactor(api): 去除代码版本命名 2026-09-14 15:35:53 +08:00
50 changed files with 4750 additions and 491 deletions

No files matched your search

+21 -37
View File
@@ -1,48 +1,32 @@
name: Deploy WeReadBoard
name: Verify WeReadBoard
on:
push:
branches:
- master
- dev
pull_request:
branches:
- master
- dev
workflow_dispatch:
jobs:
deploy:
verify:
runs-on: ubuntu-latest
concurrency:
group: deploy-dev
cancel-in-progress: true
steps:
- name: Prepare SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SERVER_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
- name: Deploy
env:
SERVER_HOST: ${{ secrets.SERVER_HOST }}
SERVER_PORT: ${{ secrets.SERVER_PORT }}
SERVER_USER: ${{ secrets.SERVER_USER }}
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
SERVICE_NAME: ${{ vars.SERVICE_NAME }}
DEPLOY_BRANCH: ${{ github.ref_name }}
run: |
if [ -z "$SERVER_PORT" ]; then
SERVER_PORT="22"
fi
if [ -z "$DEPLOY_PATH" ]; then
DEPLOY_PATH="/opt/weread-board/WeReadBoard"
fi
if [ -z "$SERVICE_NAME" ]; then
SERVICE_NAME="weread.service"
fi
ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p "$SERVER_PORT" "$SERVER_USER@$SERVER_HOST" \
"cd '$DEPLOY_PATH' &&
git fetch origin &&
git checkout '$DEPLOY_BRANCH' &&
git pull --ff-only origin '$DEPLOY_BRANCH' &&
chmod +x ./deploy.sh &&
SKIP_GIT_PULL=1 DEPLOY_BRANCH='$DEPLOY_BRANCH' SERVICE_NAME='$SERVICE_NAME' ./deploy.sh"
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: python -m pip install -r requirements.txt
- name: Run Python tests
run: python -m unittest discover -s tests -v
- name: Run frontend tests
run: node --test tests/goals_frontend.test.cjs tests/weapi_fallback.test.cjs
- name: Check deployment scripts
run: bash tests/deploy_master_only.test.sh && bash -n deploy.sh
+20 -15
View File
@@ -2,28 +2,29 @@
基于微信读书 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 依赖
├── docs/troubleshooting.md # 日常维护与故障排查
├── 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,18 +59,22 @@ PORT=8080 ./start_dashboard.sh # 或指定端口
也可以直接 `python app.py`(默认 8080,可用环境变量 `PORT` 覆盖),
然后访问 http://127.0.0.1:8770 查看「阅间」看板。
> v2 的书架、统计、推荐、搜索等数据实时来自微信读书接口;
> 当前看板的书架、统计、推荐、搜索等数据实时来自微信读书接口;
> 未运行 `fetch_data.py` 时也能使用,只有进度/笔记等局部数据会缺少缓存加速。
## 自动部署
服务器部署使用根目录的 `deploy.sh`:
服务器部署使用根目录的 `deploy.sh`,由 GitHub Webhook 通知服务器触发:
```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 只做代码验证,不再通过 SSH 登录服务器;详细配置见 [master 自动部署](docs/deploy-master.md)。
运行端口、Python 环境、HTTPS 证书和接口故障的诊断方法见 [日常维护与故障排查](docs/troubleshooting.md)。
当前应用版本保存在根目录 `VERSION`,网页底部和登录页会显示该版本号。
## 功能模块
@@ -81,7 +86,7 @@ DEPLOY_BRANCH=dev ./deploy.sh
| 偏好分类 | 阅读分类占比饼图 |
| 偏好作者 | 阅读最多的作者排行 |
| 读书排行 | 阅读时长最久的书籍 TOP 20 |
| 书架 | 全部书架书籍展示,支持分类筛选和已读完筛选 |
| 书架 | 每页 48 本,底部页码分页,支持按最近阅读、书名或作者排序 |
| 笔记概览 | 有笔记的书籍及划线/想法/书签统计 |
## 数据库表结构
+1
View File
@@ -0,0 +1 @@
v0.1
+17 -4
View File
@@ -25,6 +25,7 @@ import time
import urllib.request
from datetime import datetime
from functools import wraps
from pathlib import Path
import pymysql
@@ -37,15 +38,27 @@ except ImportError:
_ssl_context = ssl.create_default_context()
from flask import Flask, g, jsonify, render_template, request
from api_v2 import v2_bp
from auth import register_auth
from reading_api import reading_bp
# ======================================================================
# Flask 应用实例
# ======================================================================
app = Flask(__name__, static_folder="static", template_folder="templates")
# 注册 v2 数据接口(/api/v2/*,为「阅间」新版前端提供聚合数据)
app.register_blueprint(v2_bp)
try:
APP_VERSION = Path(__file__).with_name("VERSION").read_text(encoding="utf-8").strip()
except FileNotFoundError:
APP_VERSION = "unknown"
@app.context_processor
def inject_app_version():
return {"app_version": APP_VERSION}
# 注册当前看板数据接口,并保留旧客户端兼容路径。
app.register_blueprint(reading_bp)
register_auth(app)
# ======================================================================
# 数据库连接配置
@@ -1343,7 +1356,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)
+186
View File
@@ -0,0 +1,186 @@
import hmac
import os
import secrets
from datetime import timedelta
import pymysql
from flask import Blueprint, current_app, jsonify, redirect, render_template, request, session, url_for
from werkzeug.security import check_password_hash, generate_password_hash
from reading_api import MYSQL_CONFIG
auth_bp = Blueprint("auth", __name__)
_DUMMY_HASH = generate_password_hash("not-a-real-password")
def get_account():
with pymysql.connect(**MYSQL_CONFIG) as conn:
with conn.cursor() as cur:
cur.execute("SELECT username, password_hash FROM weread_auth WHERE id = 1")
return cur.fetchone()
def create_account(username, password_hash):
try:
with pymysql.connect(**MYSQL_CONFIG) as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO weread_auth (id, username, password_hash) VALUES (1, %s, %s)",
(username, password_hash),
)
conn.commit()
return True
except pymysql.IntegrityError:
return False
def valid_csrf(token):
expected = session.get("csrf_token", "")
return bool(expected and token and hmac.compare_digest(expected.encode(), token.encode()))
def csrf_token():
if not current_app.secret_key:
return ""
if "csrf_token" not in session:
session["csrf_token"] = secrets.token_urlsafe(32)
return session["csrf_token"]
def start_session(username):
session.clear()
session.permanent = True
session["auth_user"] = username
csrf_token()
def database_error():
return render_template(
"login.html", error="登录数据库暂时不可用,请稍后重试。", unavailable=True,
), 503
@auth_bp.get("/login")
def login_page():
if not current_app.secret_key:
return render_template(
"login.html", error="服务未配置会话密钥,请联系管理员。", unavailable=True,
), 503
try:
initialized = get_account() is not None
except pymysql.MySQLError:
return database_error()
return render_template(
"login.html",
initialized=initialized,
setup_enabled=bool(current_app.config.get("AUTH_SETUP_TOKEN")),
error=request.args.get("error", ""),
)
@auth_bp.post("/auth/initialize")
def initialize_account():
if not valid_csrf(request.form.get("csrf_token")):
return render_template(
"login.html", initialized=False, setup_enabled=bool(current_app.config.get("AUTH_SETUP_TOKEN")),
error="页面已过期,请刷新后重试。",
), 400
try:
if get_account() is not None:
return redirect(url_for("auth.login_page"))
except pymysql.MySQLError:
return database_error()
setup_token = current_app.config.get("AUTH_SETUP_TOKEN", "")
supplied_token = request.form.get("setup_token", "")
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not setup_token or not hmac.compare_digest(setup_token.encode(), supplied_token.encode()):
return render_template("login.html", initialized=False, setup_enabled=bool(setup_token), error="初始化口令无效或尚未配置。"), 403
if not username or len(username) > 64 or len(password) < 12 or len(password) > 256:
return render_template(
"login.html", initialized=False, setup_enabled=True,
error="用户名需为 1-64 个字符,密码需为 12-256 个字符。",
), 400
try:
if not create_account(username, generate_password_hash(password)):
return redirect(url_for("auth.login_page"))
except pymysql.MySQLError:
return database_error()
current_app.config["AUTH_SETUP_TOKEN"] = ""
start_session(username)
return redirect("/")
@auth_bp.post("/auth/login")
def login():
if not valid_csrf(request.form.get("csrf_token")):
return render_template("login.html", initialized=True, error="页面已过期,请刷新后重试。"), 400
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if len(password) > 256:
return render_template("login.html", initialized=True, error="账号或密码不正确。"), 401
try:
account = get_account()
except pymysql.MySQLError:
return database_error()
stored_hash = account["password_hash"] if account else _DUMMY_HASH
password_matches = check_password_hash(stored_hash, password)
username_matches = account and hmac.compare_digest(account["username"].encode(), username.encode())
if not account or not username_matches or not password_matches:
return render_template("login.html", initialized=True, error="账号或密码不正确。"), 401
start_session(account["username"])
return redirect("/")
@auth_bp.post("/auth/logout")
def logout():
if not valid_csrf(request.form.get("csrf_token")):
return "页面已过期,请刷新后重试。", 400
session.clear()
return redirect(url_for("auth.login_page"))
def register_auth(app):
app.config.update(
SECRET_KEY=os.environ.get("WEREAD_SESSION_SECRET") or None,
AUTH_SETUP_TOKEN=os.environ.get("WEREAD_AUTH_SETUP_TOKEN", ""),
PERMANENT_SESSION_LIFETIME=timedelta(days=5),
SESSION_REFRESH_EACH_REQUEST=False,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=(
os.environ.get("WEREAD_SESSION_COOKIE_SECURE", "1").lower()
not in {"0", "false", "no"}
),
)
app.register_blueprint(auth_bp)
app.context_processor(lambda: {"csrf_token": csrf_token})
@app.before_request
def require_login_and_csrf():
if request.endpoint == "static":
return None
if not app.secret_key:
if request.endpoint == "auth.login_page":
return None
if request.path.startswith("/api/"):
return jsonify(error="服务未配置会话密钥"), 503
return render_template(
"login.html", unavailable=True, error="服务未配置会话密钥,请联系管理员。",
), 503
if request.endpoint in {"auth.login_page", "auth.login", "auth.initialize_account", "auth.logout"}:
return None
if not session.get("auth_user"):
if request.path.startswith("/api/"):
return jsonify(error="需要登录"), 401
return redirect(url_for("auth.login_page"))
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
if not valid_csrf(request.headers.get("X-CSRF-Token")):
if request.path.startswith("/api/"):
return jsonify(error="CSRF token 无效"), 400
return "页面已过期,请刷新后重试。", 400
return None
+8 -13
View File
@@ -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
@@ -75,16 +71,15 @@ 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():
with pymysql.connect(**MYSQL_CONFIG) as conn:
with conn.cursor() as cur:
with pymysql.connect(**MYSQL_CONFIG) as conn:
with conn.cursor() as cur:
for migration in sorted(Path("migrations").glob("*.sql")):
for sql in migration.read_text(encoding="utf-8").split(";"):
if sql.strip():
cur.execute(sql)
conn.commit()
conn.commit()
PY
echo "[5/5] 重启服务 $SERVICE_NAME"
+12
View File
@@ -0,0 +1,12 @@
# 放入现有 laohang.space HTTPS server 块中。
location = /github/webhook {
proxy_pass http://127.0.0.1:9001/github/webhook;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location = /github/webhook-health {
proxy_pass http://127.0.0.1:9001/health;
proxy_set_header Host $host;
}
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=WeReadBoard GitHub Webhook Receiver
After=network.target
[Service]
Type=simple
User=weread
WorkingDirectory=/opt/weread-board/WeReadBoard
EnvironmentFile=/etc/weread-webhook.env
ExecStart=/opt/weread-board/WeReadBoard/.venv/bin/python deploy_webhook.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""接收 GitHub push Webhook,并触发服务器上的固定部署脚本。"""
import hmac
import os
import subprocess
import threading
from hashlib import sha256
from flask import Flask, jsonify, request
DEFAULT_PROJECT_DIR = "/opt/weread-board/WeReadBoard"
DEFAULT_DEPLOY_SCRIPT = "deploy.sh"
DEFAULT_REPOSITORY = "KingHWJ/WeReadBoard"
DEFAULT_BRANCH = "master"
def create_app(config=None):
app = Flask(__name__)
app.config.from_mapping(
WEBHOOK_SECRET=os.environ.get("GITHUB_WEBHOOK_SECRET", ""),
REPOSITORY=os.environ.get("GITHUB_REPOSITORY", DEFAULT_REPOSITORY),
BRANCH=DEFAULT_BRANCH,
PROJECT_DIR=os.environ.get("DEPLOY_PROJECT_DIR", DEFAULT_PROJECT_DIR),
DEPLOY_SCRIPT=os.environ.get("DEPLOY_SCRIPT", DEFAULT_DEPLOY_SCRIPT),
)
if config:
app.config.update(config)
if not app.config["WEBHOOK_SECRET"]:
raise RuntimeError("GITHUB_WEBHOOK_SECRET 未配置")
if app.config["BRANCH"] != DEFAULT_BRANCH:
raise RuntimeError("Webhook 只允许部署 master 分支")
deployment_lock = threading.Lock()
deployment_process = None
def signature_valid(body, signature):
if not signature or not signature.startswith("sha256="):
return False
expected = "sha256=" + hmac.new(
app.config["WEBHOOK_SECRET"].encode("utf-8"), body, sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
def start_deployment():
nonlocal deployment_process
with deployment_lock:
if deployment_process is not None and deployment_process.poll() is None:
return False
env = os.environ.copy()
env["DEPLOY_BRANCH"] = DEFAULT_BRANCH
script = app.config["DEPLOY_SCRIPT"]
if not os.path.isabs(script):
script = os.path.join(app.config["PROJECT_DIR"], script)
deployment_process = subprocess.Popen(
[script],
cwd=app.config["PROJECT_DIR"],
env=env,
start_new_session=True,
)
return True
@app.get("/health")
def health():
return jsonify(ok=True)
@app.post("/github/webhook")
def github_webhook():
body = request.get_data(cache=True)
if not signature_valid(body, request.headers.get("X-Hub-Signature-256", "")):
return jsonify(error="签名无效"), 401
if request.headers.get("X-GitHub-Event") != "push":
return jsonify(error="只接受 push 事件"), 202
payload = request.get_json(silent=True) or {}
repository = (payload.get("repository") or {}).get("full_name")
if repository != app.config["REPOSITORY"]:
return jsonify(error="仓库不匹配"), 403
if payload.get("ref") != f"refs/heads/{app.config['BRANCH']}" or payload.get("deleted"):
return jsonify(error="分支不匹配"), 202
if not start_deployment():
return jsonify(status="already_deploying"), 202
return jsonify(status="accepted"), 202
return app
if __name__ == "__main__":
application = create_app()
application.run(
host=os.environ.get("WEBHOOK_HOST", "127.0.0.1"),
port=int(os.environ.get("WEBHOOK_PORT", "9001")),
)
+25 -23
View File
@@ -1,13 +1,13 @@
# v2 数据接口(/api/v2/*)
# 当前数据接口(/api/reading/*)
「阅间」新版前端的数据来源。实现在 `api_v2.py`(Flask Blueprint,注册于 `app.py`),
v1 的 `/api/*` 接口保持不变,两套接口可同时使用。
「阅间」当前前端的数据来源。实现在 `reading_api.py`(Flask Blueprint,注册于 `app.py`),
旧版 `/api/*` 接口保持不变;`/api/v2/*` 仅作为兼容路径保留。
## 数据来源与优先级
| 数据 | 来源 | 说明 |
|------|------|------|
| 书架、书目 | 微信读书 `/shelf/sync`(实时,进程内缓存 10 分钟) | 字段含书名、作者、分类、封面、读完标记、最近阅读时间 |
| 书架、书目 | 微信读书 `/shelf/sync`(实时,进程内缓存 10 分钟) | 字段含书名、作者、分类、封面、读完标记、最近阅读时间和置顶标记 |
| 阅读统计(周/月/年/总) | 微信读书 `/readdata/detail`(实时,缓存 5 分钟) | 时长趋势、阅读天数、读得最多、偏好分类/作者/出版社/时段/勋章/好友排行 |
| 阅读进度 | 本地 `notebooks` / `book_progress` 缓存优先,缺失时并发调用 `/book/getprogress` 补齐(缓存 10 分钟) | 用于书架卡片与详情页进度 |
| 笔记本与笔记 | 本地 `notebooks` 表 + 实时 `/book/bookmarklist`、`/review/list/mine` | 划线与想法正文实时获取 |
@@ -17,24 +17,26 @@ v1 的 `/api/*` 接口保持不变,两套接口可同时使用。
MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: true`)。阅读目标必须持久化,其接口返回 503,不退回浏览器保存。
当前书架页一次读取书架电子书,在前端按状态筛选后每页显示 48 本。排序选项为最近阅读(默认)、书名升序、书名降序和作者升序;置顶书籍始终排在前面。接口仍支持 `limit` / `offset` 参数供其他调用者分页,书架页使用全量结果以供书架、阅读目标和发现功能共享。
## 接口清单
| 方法 | 路径 | 参数 | 说明 |
|------|------|------|------|
| 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/<book_id>` | — | 书籍详情聚合:简介 + 进度 + 目录 + 我的笔记 + 热门划线 + 点评 + 相似推荐 |
| GET | `/api/v2/book/<book_id>/reviews` | `type=0\|1\|2\|3\|4`(全部/推荐/不行/最新/一般)、`count` | 公开点评(按类型筛选) |
| GET | `/api/v2/book/<book_id>/highlights` | `chapter_uid`(0=全书) | 热门划线(人数、章节、位置、跳转链接) |
| GET | `/api/v2/book/<book_id>/underline-thoughts` | `chapter_uid`、`range` | 某条划线下的他人想法 |
| GET | `/api/v2/thought/<review_id>` | — | 单条想法详情:正文、划线原文、点赞、评论 |
| 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/<book_id>` | — | 书籍详情聚合:简介 + 进度 + 目录 + 我的笔记 + 热门划线 + 点评 + 相似推荐 |
| GET | `/api/reading/book/<book_id>/reviews` | `type=0\|1\|2\|3\|4`(全部/推荐/不行/最新/一般)、`count` | 公开点评(按类型筛选) |
| GET | `/api/reading/book/<book_id>/highlights` | `chapter_uid`(0=全书) | 热门划线(人数、章节、位置、跳转链接) |
| GET | `/api/reading/book/<book_id>/underline-thoughts` | `chapter_uid`、`range` | 某条划线下的他人想法 |
| GET | `/api/reading/thought/<review_id>` | — | 单条想法详情:正文、划线原文、点赞、评论 |
| 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 +44,11 @@ MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: tr
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/v2/goals` | 返回 `{"goals":[...]}`,成员按书籍 ID 排序 |
| POST | `/api/v2/goals` | 新建,成功返回 201 和 `{"goal":{...}}` |
| PUT | `/api/v2/goals/<id>` | 整体替换目标和书目,返回 `{"goal":{...}}` |
| DELETE | `/api/v2/goals/<id>` | 删除目标及其成员,返回 `{"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/<id>` | 整体替换目标和书目,返回 `{"goal":{...}}` |
| DELETE | `/api/reading/goals/<id>` | 删除目标及其成员,返回 `{"ok":true}` |
| POST | `/api/reading/goals/import` | 请求 `{"goals":[...]}`,返回 `{"imported":新增数量}` |
新建、编辑和导入中的目标字段一致:
+1 -1
View File
@@ -1,6 +1,6 @@
# 微信读书数据展示网站 - 元数据文档
> 版本:2.0.0 | 更新时间:2026-07-17 | 数据库:MySQL weread_dashboard | 数据模型版本:v2.0
> 更新时间:2026-07-17 | 数据库:MySQL weread_dashboard | 数据模型:完整阅读数据表
---
+3 -3
View File
@@ -1,6 +1,6 @@
# 微信读书数据展示网站 - 技术文档
> 版本:2.0.0 | 更新时间:2026-07-17 | 作者:WorkBuddy | 数据模型版本:v2.0(31 表)
> 更新时间:2026-07-17 | 作者:WorkBuddy | 数据模型:完整阅读数据表
---
@@ -278,9 +278,9 @@ HTTP 503 Service Unavailable
- 连接失败时显示固定告警横幅,阻止后续数据加载
- 使用 `db_required` 装饰器统一处理数据库连接异常
### 4.4 数据模型 v2.0 设计规范
### 4.4 数据模型设计规范
基于微信读书官方 8 份 API 文档的完整字段分析,数据模型 v2.0 遵循以下设计规范:
基于微信读书官方 8 份 API 文档的完整字段分析,数据模型遵循以下设计规范:
**双列时间戳模式**:所有整型 Unix 时间戳字段(`INT UNSIGNED`)均伴随一个 `_dt TIMESTAMP` 列,例如 `update_time` ↔ `update_time_dt`。便于直接查询展示,同时保留原始时间戳用于排序和计算。
+34 -6
View File
@@ -6,7 +6,7 @@
完整功能、业务规则、接口落点与状态见 [功能目标清单](feature-checklist.md)。该清单先于本轮实现建立,后续代码开发继续追加和更新。
此版本属于 2.0 设计探索。正式版本需要接入真实接口、完成验收并合并到正式 Web 项目的 `master`,随后再创建 `v2.0` 标签。
该设计探索已归档,当前正式看板由根目录 Flask 服务提供。
## 数据流
@@ -43,11 +43,31 @@
## 部署流程
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 后由 GitHub Webhook 向 `https://laohang.space/github/webhook` 发送 JSON,服务器上的 `deploy_webhook.py` 校验 HMAC-SHA256、仓库和 `master` 分支,再在固定目录调用 `deploy.sh`。Webhook 不接受请求体中的命令,部署脚本路径由服务器环境变量固定。
`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/*.sql`,最后重启 `weread.service`。
例:用户把代码推送到 `dev` 分支,服务器原来没有 `.venv`。部署脚本会在 `/opt/weread-board/WeReadBoard/.venv` 创建环境,安装 Flask 和 PyMySQL,确认阅读目标表存在,再让 systemd 重新用 `.venv/bin/python app.py` 启动服务。
例:用户将已验收代码合并并推送到 `master`,GitHub 发送签名请求;服务器确认仓库为 `KingHWJ/WeReadBoard` 且 ref 为 `refs/heads/master` 后,在 `/opt/weread-board/WeReadBoard/.venv` 创建环境,安装 Flask 和 PyMySQL,确认阅读目标表存在,再让 systemd 重新用 `.venv/bin/python app.py` 启动服务。仅推送 `dev` 不会触发部署,错误签名也不会执行脚本。
## 正式看板登录认证
`auth.py` 注册全站请求拦截和登录路由。`/` 与所有 `/api/*` 数据接口都需要 `session["auth_user"]`;静态资源、`/login`、`/auth/login` 和首次初始化路由是唯一公开入口。页面请求未认证时重定向到登录页,API 返回 401,避免前端遮罩之外仍可直接读取数据。旧 `/api/v2/*` 路由同样由 Flask 全局拦截。
账号表 `weread_auth` 固定使用 `id=1` 保存单管理员的用户名和 Werkzeug 密码哈希。数据库无账号时,只有提交服务端 `WEREAD_AUTH_SETUP_TOKEN` 才能插入首个账号;例如提交用户名 `reader`、一次性口令正确、密码 `a-long-private-password`,数据库保存的是哈希而不是密码。插入成功后创建永久 Flask 会话,并清空当前进程中的初始化口令。后续登录从 MySQL 取哈希校验;用户名或密码错误统一返回“账号或密码不正确”。
会话签名密钥从 `WEREAD_SESSION_SECRET` 读取且必须稳定配置;缺失时服务对动态页面和 API 以 503 失败关闭。永久会话时长是固定 5 天,不因每次请求续期。Cookie 为 HttpOnly、SameSite=Lax,并默认 Secure;仅本机 HTTP 预览可以关闭 Secure。生产站点须使用 HTTPS。前端 JSON 写请求从页面 meta 标签读取 CSRF token 并放入 `X-CSRF-Token` 请求头,表单请求使用隐藏字段。
## 网站备案页脚
正式看板页脚保留数据状态,并展示可点击的 ICP 备案号,链接至工信部备案查询平台。窄屏时隐藏次要的数据快照,备案号仍可见。
例:正式域名为 `laohang.space`、备案号为 `浙ICP备202607916号-1`,首页页脚显示该备案号;点击后打开 `https://beian.miit.gov.cn/`。截图里的公安联网备案数据码不是公安备案号,不作为备案号码展示。
## 应用版本显示
根目录 `VERSION` 保存面向用户的版本号,例如输入 `v0.1`,Flask 在启动时读取并通过模板上下文注入 `app_version`。首页页脚和登录页显示“版本 v0.1”。版本号不包含分支名或提交号;发布新版本时修改 `VERSION` 后随 `master` 一起部署。
应用未内置登录尝试限流;公网部署应在可信反向代理上为 `POST /auth/login` 设置频率限制,且不要直接信任未校验的 `X-Forwarded-For`。
## 示例与真实接口的边界
@@ -65,9 +85,17 @@ GitHub Actions 的 `.github/workflows/deploy.yml` 监听 `dev` 分支。分支 p
目标介绍区在宽屏上把说明与统计并排,窄屏上改为上下排列;目标卡按可用宽度自动排列,每张卡约 280 px 起步,并缩小封面预览和上下间距。例如,一个包含 6 本书、已读完 2 本的月度目标仍展示原有完成数和进度,只减少卡片占位。页面渲染、筛选、目标计算和数据流保持由现有 JavaScript 实现,CSS 不改变业务口径。
## 正式看板书架排序与分页
正式看板从 `/api/reading/shelf` 读取电子书列表。前端按当前书架状态筛选后排序,每页展示 48 本;用户切换状态或排序时回到第 1 页,点击底部页码时只替换当前书籍网格。排序框右对齐显示在页码列表上方。
排序默认按最近阅读时间倒序;还可按书名升序、书名降序或作者升序。四种方式都先将 `is_top` 置顶书籍排在前面。接口把微信读书的 `isTop` 映射为 `is_top`,前端排序只使用筛选结果的副本,避免改变其他页面共享的书架顺序。
例:筛选“全部电子书”共 101 本,选择“书名升序”并打开第 2 页。页面只显示排序后第 49 至 96 本,在网格下方显示第 1、2、3 页;切换到“最近阅读”后回到第 1 页,并按最近阅读日期倒序显示前 48 本,置顶书籍仍排在该页最前面。
## 正式看板的阅读目标持久化
正式看板使用 `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 +109,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}`。任何写入失败均回滚整批;成功后保留原浏览器备份并记录导入标记。
-65
View File
@@ -1,65 +0,0 @@
# dev 自动部署
GitHub Actions workflow 位于 `.github/workflows/deploy.yml`。每次 `dev` 分支 push 后,Actions 会通过 SSH 登录服务器,在部署目录执行根目录 `deploy.sh`,拉取最新 `dev`、安装依赖、执行阅读目标表迁移并重启服务。
## GitHub 配置
已配置的仓库 Secrets:
- `SERVER_HOST`
- `SERVER_PORT`
- `SERVER_SSH_KEY`
- `SERVER_USER`
可选再配置两个仓库 Variables:
- `DEPLOY_PATH`:服务器上的项目目录,例如 `/opt/WeReadBoard`
- `SERVICE_NAME`:systemd 服务名,例如 `weread.service`
未配置 Variables 时,workflow 默认使用 `/opt/weread-board/WeReadBoard` 和 `weread.service`。
## 服务器要求
服务器部署目录需要已经是本仓库的 Git checkout,并且能访问 `origin/dev`。运行服务的环境变量仍按现有应用配置,例如 `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 <服务名>`。
`deploy.sh` 会执行:
```bash
git fetch origin
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
sudo systemctl restart "$SERVICE_NAME"
```
其中迁移实际通过 `api_v2.MYSQL_CONFIG` 读取数据库配置并执行 SQL 文件;SQL 使用 `CREATE TABLE IF NOT EXISTS`,可重复执行。
## systemd 示例
```ini
[Unit]
Description=WeReadBoard
After=network.target
[Service]
WorkingDirectory=/opt/weread-board/WeReadBoard
Environment=WEREAD_API_KEY=wrk-xxxxxxxx
Environment=WEREAD_DB_HOST=127.0.0.1
Environment=WEREAD_DB_PORT=3306
Environment=WEREAD_DB_USER=weread
Environment=WEREAD_DB_PASSWORD=change-me
Environment=WEREAD_DB_NAME=weread
Environment=PORT=8770
ExecStart=/opt/weread-board/WeReadBoard/.venv/bin/python app.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
如果服务名或部署目录不同,在仓库 Variables 中调整 `SERVICE_NAME` 和 `DEPLOY_PATH`。
+89
View File
@@ -0,0 +1,89 @@
# master Webhook 自动部署
部署链路是:GitHub `master` push → GitHub Webhook → 服务器签名校验 → 服务器本地执行 `deploy.sh`。GitHub Actions 文件 `.github/workflows/deploy.yml` 现在只负责验证,不再通过 SSH 登录服务器。
## GitHub Webhook 配置
进入仓库 `Settings -> Webhooks -> Add webhook`,配置 Payload URL `https://laohang.space/github/webhook`、Content type `application/json`、Secret(与服务器 `GITHUB_WEBHOOK_SECRET` 完全一致),事件选择 `Just the push event` 并勾选 `Active`。只允许 `KingHWJ/WeReadBoard` 的 `master` push 触发部署。
Webhook 服务配置文件 `/etc/weread-webhook.env`:
```ini
GITHUB_WEBHOOK_SECRET=服务器生成的随机密钥
GITHUB_REPOSITORY=KingHWJ/WeReadBoard
DEPLOY_PROJECT_DIR=/opt/weread-board/WeReadBoard
DEPLOY_SCRIPT=/opt/weread-board/WeReadBoard/deploy.sh
WEBHOOK_HOST=127.0.0.1
WEBHOOK_PORT=9001
```
复制 `deploy/weread-webhook.service` 到 `/etc/systemd/system/`,并执行:
```bash
python3 -c 'import secrets; print(secrets.token_urlsafe(48))'
sudo chmod 600 /etc/weread-webhook.env
sudo systemctl daemon-reload
sudo systemctl enable --now weread-webhook.service
sudo journalctl -u weread-webhook.service -f
```
## 服务器要求
服务器部署目录需要已经是本仓库的 Git checkout,并且能访问 `origin/master`。运行服务的环境变量仍按现有应用配置,例如 `WEREAD_API_KEY`、`WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME`。
如果这些变量放在项目目录的 `.env` 文件中,部署脚本会先加载 `.env`,再执行数据库迁移。Webhook 运行用户需要能执行 `systemctl restart <服务名>`;非 root 用户需要配置免密 `sudo systemctl restart/status <服务名>`。
`deploy.sh` 会执行:
```bash
git fetch origin
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/*.sql
sudo systemctl restart "$SERVICE_NAME"
```
其中迁移实际通过 `reading_api.MYSQL_CONFIG` 读取数据库配置并执行 SQL 文件;SQL 使用 `CREATE TABLE IF NOT EXISTS`,可重复执行。
脚本默认部署 `master`,显式传入其他 `DEPLOY_BRANCH` 时会在拉取代码、安装依赖和重启服务之前退出。
## systemd 示例
```ini
[Unit]
Description=WeReadBoard
After=network.target
[Service]
WorkingDirectory=/opt/weread-board/WeReadBoard
Environment=WEREAD_API_KEY=wrk-xxxxxxxx
Environment=WEREAD_DB_HOST=127.0.0.1
Environment=WEREAD_DB_PORT=3306
Environment=WEREAD_DB_USER=weread
Environment=WEREAD_DB_PASSWORD=change-me
Environment=WEREAD_DB_NAME=weread
Environment=PORT=8770
Environment=WEREAD_SESSION_SECRET=<服务器生成的随机密钥>
Environment=WEREAD_AUTH_SETUP_TOKEN=<仅首次初始化使用的随机口令>
Environment=WEREAD_SESSION_COOKIE_SECURE=1
ExecStart=/opt/weread-board/WeReadBoard/.venv/bin/python app.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
如果服务名或部署目录不同,在仓库 Variables 中调整 `SERVICE_NAME` 和 `DEPLOY_PATH`。
登录认证还需配置 `WEREAD_SESSION_SECRET` 和首次使用的 `WEREAD_AUTH_SETUP_TOKEN`。在服务器分别运行两次 `python3 -c 'import secrets; print(secrets.token_urlsafe(48))'` 生成不同的值。迁移自动创建 `weread_auth` 表;首次访问 `/login` 时输入初始化口令、管理员账号和至少 12 位密码。初始化成功后,从 systemd 配置中删除 `WEREAD_AUTH_SETUP_TOKEN` 并重启服务。数据库中的密码只保存哈希。
`WEREAD_SESSION_COOKIE_SECURE=1` 要求浏览器通过 HTTPS 访问。仅本机 HTTP 验收可临时设为 `0`,不要在公网 HTTP 下传输登录密码。
## Nginx HTTPS 转发
将 `deploy/nginx-github-webhook.conf` 的两个 `location` 放入 `laohang.space` 的 HTTPS server 块,然后执行 `sudo nginx -t && sudo systemctl reload nginx`。Webhook 服务只监听 `127.0.0.1:9001`,不要对公网开放 9001 端口。健康检查地址为 `https://laohang.space/github/webhook-health`。
`.github/workflows/deploy.yml` 只执行 Python、前端和部署脚本检查。Webhook 模式不再需要 `SERVER_HOST`、`SERVER_PORT`、`SERVER_USER`、`SERVER_SSH_KEY` 这些 Actions Secrets。
+4 -3
View File
@@ -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 仅保留交互样例。
## 已确认业务口径
@@ -55,6 +55,7 @@
| F16 | 数据状态:加载、空、失败及重试;演示来源常驻 | 已验证(示例) | 待实现 | `web-demo/app.js:158` `renderDiscover` |
| F17 | API Key 仅服务端使用;版本上报、升级中断、错误转换、分页 | 不属于本轮 Demo | 待实现 | 后续核对 `app.py` 和 `fetch_data.py`,复用现有服务端能力 |
| F18 | 可选统计:阅读时段、作者/出版社/版权方、阅读听书比例、好友排行、勋章、年报 | 已验证(示例) | 待实现;按实际返回字段显示 | `web-demo/app.js:85` `renderOverview` |
| F19 | 单管理员登录保护所有页面/API;首次初始化需一次性口令;密码哈希存 MySQL;会话固定 5 天 | 不适用 | 已实现,真实 MySQL 初始化待部署验证 | `auth.py`、`migrations/002_site_auth.sql`、`templates/login.html` |
## 全部文档接口的落点
@@ -122,6 +123,6 @@
| ID | 功能与验收标准 | Demo 状态 | 真实接入状态 | 代码位置 |
|---|---|---|---|---|
| F19 | 待填写;涉及新规则时追加 R 编号 | 待实现 | 待实现 | 实现后填写文件、函数和行号 |
| F20 | 待填写;涉及新规则时追加 R 编号 | 待实现 | 待实现 | 实现后填写文件、函数和行号 |
后续开发顺序:核对实时 `/_list` 与公开书单成员能力 → 服务端鉴权及只读接口 → 替换 Demo 数据 → 校验分页与字段口径 → 正式目标持久化。每次只将已实际验收的范围标为完成。
File renamed without changes.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 246 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 248 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 171 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 155 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 163 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 217 KiB

@@ -2,17 +2,17 @@
**目标:** 将正式看板的阅读目标迁移到现有 MySQL,保留当前业务规则与浏览器旧数据导入入口。
**方案:** 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/<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] 前端:在 `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 语法并通过浏览器完成新建、刷新、编辑和清理闭环。
不创建分支、提交或合并。本次工作在已检查的非主分支工作区进行,保留既有未提交改动。
@@ -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`。
+53 -32
View File
@@ -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,33 +64,54 @@ 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` 配置,不新增依赖。服务仍使用当前个人账号的数据;多个浏览器连接同一服务时共享目标,没有新增多用户隔离。
部署新代码前,在项目根目录执行一次迁移。下面的命令复用服务配置,只创建两张目标表,不修改已有业务表:
手动部署新代码时,在项目根目录按文件名顺序执行迁移。下面命令复用服务配置,幂等创建项目迁移定义的表:
```sh
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:
for sql in Path('migrations/001_reading_goals.sql').read_text().split(';'):
if sql.strip():
cur.execute(sql)
print('阅读目标表已就绪')
for migration in sorted(Path('migrations').glob('*.sql')):
for sql in migration.read_text(encoding='utf-8').split(';'):
if sql.strip():
cur.execute(sql)
print('数据库迁移已完成')
PY
PORT=8080 python3 app.py
```
使用已安装 `requirements.txt` 的 Python 环境。若 8080 已有本项目实例,先停止旧实例再启动,使 Flask 加载新接口。迁移可重复执行;运行账户需要目标表的读写权限。目标表不参与 `fetch_data.py` 的采集覆盖。
## GitHub 自动部署
## 网站登录认证
服务器部署使用根目录的 `deploy.sh`。脚本会先拉取指定分支,再检测 Python 环境;若项目 `.venv` 不存在,会自动用系统 `python3` 或 `python` 创建 `.venv`,随后安装 `requirements.txt`、执行阅读目标迁移,并重启 systemd 服务。
正式页面和所有 `/api/*` 接口(包括 `/api/reading/*`、旧 `/api/v2/*` 与健康检查)都要求登录。未登录访问页面会跳转 `/login`;未登录直接请求 API 返回 401。CSS、JavaScript 等静态资源不包含个人数据,可公开加载。登录变更请求使用 CSRF token。
账号保存在 MySQL 的 `weread_auth` 表,密码以 Werkzeug 单向哈希存储。部署脚本会按文件名顺序执行 `migrations/*.sql`,其中 `002_site_auth.sql` 创建账号表。表中只允许一个管理员账号。
服务启动需要配置稳定的 `WEREAD_SESSION_SECRET`,用于签名会话;会话固定有效 5 天,Cookie 设置为 HttpOnly、SameSite=Lax,且 `WEREAD_SESSION_COOKIE_SECURE` 默认开启。生产环境必须通过 HTTPS 访问;仅本机 HTTP 预览时可设为 `WEREAD_SESSION_COOKIE_SECURE=0`。
首次部署还需在 systemd 环境中临时配置 `WEREAD_AUTH_SETUP_TOKEN`。可在服务器生成两个独立随机值:
```sh
python3 -c 'import secrets; print(secrets.token_urlsafe(48))'
```
将一个结果配置为 `WEREAD_SESSION_SECRET`,另一个配置为 `WEREAD_AUTH_SETUP_TOKEN`,重启服务后访问 `/login`。数据库尚无账号时,页面要求一次性口令、管理员用户名和至少 12 位密码;成功后只将密码哈希写入 MySQL,并关闭当前进程的初始化入口。然后从 systemd 配置中移除 `WEREAD_AUTH_SETUP_TOKEN` 并再次重启。若数据库已有账号,初始化表单不会开放。
不要把上述值写入 Git、浏览器代码或公开聊天。初始化口令不能省略:没有它,任何公网访问者都可能抢先创建管理员账号。当前应用不提供密码找回入口;需由管理员在服务器侧重置数据库中的哈希。
应用本身未实现登录尝试限流。公网部署还应在 Nginx 或其他可信入口对 `POST /auth/login` 配置频率限制,不能信任客户端自行传入的转发 IP。
## GitHub Webhook 自动部署
服务器部署使用根目录的 `deploy.sh`。脚本会先拉取指定分支,再检测 Python 环境;若项目 `.venv` 不存在,会自动用系统 `python3` 或 `python` 创建 `.venv`,随后安装 `requirements.txt`、按顺序执行 `migrations/*.sql`,并重启 systemd 服务。
当前服务器配置可按下列默认值使用:
@@ -100,32 +121,24 @@ 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`,现在只负责测试和部署脚本检查。生产部署由 GitHub Webhook 触发:仓库 `Settings -> Webhooks` 的 Payload URL 配置为 `https://laohang.space/github/webhook`,Secret 与服务器 `/etc/weread-webhook.env` 中的 `GITHUB_WEBHOOK_SECRET` 一致,事件只选择 push。
| Secret | 说明 |
|---|---|
| `SERVER_HOST` | 服务器 IP |
| `SERVER_PORT` | SSH 端口,未配置时按 22 处理 |
| `SERVER_USER` | SSH 登录用户 |
| `SERVER_SSH_KEY` | GitHub Actions 登录服务器用的私钥 |
可选配置放在 `Settings -> Secrets and variables -> Actions -> Variables`:
| Variable | 说明 |
|---|---|
| `DEPLOY_PATH` | 可选,默认 `/opt/weread-board/WeReadBoard` |
| `SERVICE_NAME` | 可选,默认 `weread.service` |
服务器 Webhook 服务使用 `/etc/weread-webhook.env`,至少配置 `GITHUB_WEBHOOK_SECRET`、`GITHUB_REPOSITORY=KingHWJ/WeReadBoard`、`DEPLOY_PROJECT_DIR` 和 `DEPLOY_SCRIPT`。代码内固定只允许 `master`,不接受环境变量覆盖。Nginx 将 HTTPS 请求转发至本机 `127.0.0.1:9001`;Webhook 会校验 HMAC-SHA256、仓库和分支,并在已有部署运行时拒绝重复启动。
如果在服务器手动部署,可执行:
```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`。
浏览器旧目标不会自动覆盖数据库。在原访问地址打开“阅读目标”,点击“导入已有目标”;数据库已有同一 ID 时跳过,整批成功才标记导入完成。`weread-goals-v2` 原数据保留,`weread-goals-v2-imported` 只记录已导入。不同浏览器、域名和端口的旧存储需要分别在原地址导入。
浏览器旧目标不会自动覆盖数据库。在原访问地址打开“阅读目标”,点击“导入已有目标”;数据库已有同一 ID 时跳过,整批成功才标记导入完成。`weread-goals` 原数据保留,`weread-goals-imported` 只记录已导入。不同浏览器、域名和端口的旧存储需要分别在原地址导入。
正式网站域名 `laohang.space` 的页脚展示工信部 ICP 备案号 `浙ICP备202607916号-1`,点击后跳转至工信部备案查询平台。公安联网备案数据码不是公安备案号,因此不在页脚作为公安备案信息展示。
网站页脚和登录页显示版本文件 `VERSION` 的内容,例如 `v0.1`。发布新版本时修改该文件并随代码合并到 `master`;部署后可直接在网页底部确认当前应用版本。
保存失败会保留表单并提示重试;读取失败显示错误和重试按钮,不冒充空列表。若新建提示 ID 已存在,刷新目标列表确认上次请求是否已经保存。数据库不可用或尚未执行迁移时,目标接口返回 503,不退回浏览器保存。
@@ -140,10 +153,18 @@ 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 自动部署
当前书架电子书列表每页显示 48 本,页码导航位于列表下方;翻页会替换当前页内容。右侧排序框提供最近阅读、书名升序、书名降序和作者升序,置顶书籍始终优先;切换书架状态或排序会回到第 1 页。此功能使用已加载的书架数据,不需要数据库迁移。
`dev` 分支 push 后由 `.github/workflows/deploy.yml` 触发部署。Actions 通过仓库 Secrets 登录服务器,在 `DEPLOY_PATH` 指定的目录先拉取 `origin/dev`,再执行根目录 `deploy.sh`,完成加载可选 `.env`、安装 Python 依赖、执行阅读目标表迁移和重启 systemd 服务。详细配置见 `docs/deploy-dev.md`。
## master Webhook 自动部署
`master` 分支 push 后由 GitHub Webhook 通知服务器。服务器校验签名后,在固定项目目录执行根目录 `deploy.sh`,完成拉取 `origin/master`、加载可选 `.env`、安装 Python 依赖、执行全部数据库迁移和重启 systemd 服务。`dev` 和其他分支不会部署,部署脚本也会拒绝非 `master` 分支。详细配置见 [master Webhook 自动部署](deploy-master.md)。
本次数据库验收已在 8080 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。
+171
View File
@@ -0,0 +1,171 @@
# 日常维护与故障排查
本文档记录正式看板的运行基线、常用诊断方法和已确认故障。接口路径、监听端口、Python 环境、启动脚本、systemd 服务或部署流程发生变化时,必须同步更新本文档;出现新的线上或本地故障时,按日期追加记录,不覆盖历史结论。
## 运行基线
| 项目 | 当前约定 |
|---|---|
| 本地正式看板端口 | `8080` |
| 当前 API 前缀 | `/api/reading/*` |
| 兼容 API 前缀 | `/api/v2/*`,仅用于旧客户端过渡 |
| 项目 Python | 优先使用项目根目录 `.venv/bin/python` |
| 自动部署分支 | 仅 `master`;推送或手动触发均不部署其他分支 |
| 服务器服务 | `weread.service`,端口以服务中的 `PORT` 为准 |
| 服务器目录 | `/opt/weread-board/WeReadBoard` |
同一运行环境只保留一个正式服务实例。不要同时使用 `start_server.sh` 和 `start_dashboard.sh` 启动不同端口,否则静态文件、后端路由和 Python 环境可能来自不同进程,容易产生误判。历史静态 Demo 需要临时预览时可以单独启动,用完后应及时停止。
## 快速排查
### 登录与会话
- 登录页返回 503:检查 systemd 是否配置 `WEREAD_SESSION_SECRET`,并确认数据库迁移已创建 `weread_auth` 表。
- 首次设置提示初始化口令无效:确认 `WEREAD_AUTH_SETUP_TOKEN` 已在服务环境中配置;账号创建成功后应从服务配置移除该值。
- 输入正确密码后仍回到登录页:公网必须通过 HTTPS 访问,检查 Cookie 的 `Secure` 标记与 `WEREAD_SESSION_COOKIE_SECURE` 设置;本机 HTTP 预览才设为 `0`。
- 页面可打开但 API 返回 401:登录 Cookie 未随请求发送,先重新登录并确认页面与 API 使用同一主机名和协议。
- 登录后写操作返回 CSRF 错误:刷新页面获取新的 token 后重试,不要跨域代理前端与 API。
页面出现“请求失败”时,按下面的顺序检查。
### 检查监听端口
本机:
```sh
lsof -nP -iTCP -sTCP:LISTEN
```
服务器:
```sh
sudo ss -ltnp
sudo systemctl status weread.service --no-pager
```
确认正式服务只监听预期端口,并核对进程的启动时间与 Python 路径:
```sh
ps -fp <PID>
lsof -a -p <PID> -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
```
### 检查部署分支
自动部署只允许 `master`。先检查 GitHub Webhook 的 Recent Deliveries、服务器 `weread-webhook.service` 日志和 Nginx HTTPS 转发;仅推送 `dev` 不会部署。服务器部署日志中的拉取目标必须是 `origin/master`,出现其他分支时应停止发布并检查 Webhook 环境变量与 `deploy.sh`。
Webhook 排查命令:
```bash
sudo systemctl status weread-webhook.service --no-pager
sudo journalctl -u weread-webhook.service -n 100 --no-pager
sudo nginx -t
curl -fsS https://laohang.space/github/webhook-health
```
GitHub 显示签名错误时,确认 Webhook Secret 与 `/etc/weread-webhook.env` 完全一致;显示 202 但没有部署时,确认仓库全名、`refs/heads/master` 和部署锁状态。不要通过请求体传入 Shell 命令。
## 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:故障标题
### 现象
### 根因
### 诊断证据
### 恢复方式
### 预防与验收
```
+3 -3
View File
@@ -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)
# 初始化数据库
@@ -146,7 +146,7 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
@@ -154,14 +154,34 @@ body {
}
/* 所有主题相关元素平滑过渡 */
.navbar, .nav-link, .stat-card, .chart-card, .top-book-card, .book-card,
.book-cover-wrapper, .notebook-card, .note-stat, .discover-card, .discover-tab,
.modal-content, .modal-overlay, .footer, .db-alert-banner, .page-btn,
.btn-primary, .btn-secondary, .search-input, .shelf-search, .highlight-item,
.review-item, .detail-progress-info, .top-book-cover-wrapper,
.theme-toggle, .loading-spinner {
.navbar,
.nav-link,
.stat-card,
.chart-card,
.top-book-card,
.book-card,
.book-cover-wrapper,
.notebook-card,
.note-stat,
.discover-card,
.discover-tab,
.modal-content,
.modal-overlay,
.footer,
.db-alert-banner,
.page-btn,
.btn-primary,
.btn-secondary,
.search-input,
.shelf-search,
.highlight-item,
.review-item,
.detail-progress-info,
.top-book-cover-wrapper,
.theme-toggle,
.loading-spinner {
transition: background-color 0.3s ease, border-color 0.3s ease,
color 0.3s ease, box-shadow 0.3s ease, transform 0.2s ease;
color 0.3s ease, box-shadow 0.3s ease, transform 0.2s ease;
}
.container {
@@ -264,12 +284,25 @@ body {
transform: scale(1.08);
}
.theme-toggle .icon-sun { display: none; }
.theme-toggle .icon-moon { display: block; }
.theme-toggle .icon-sun {
display: none;
}
[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
[data-theme="dark"] .theme-toggle { color: var(--toggle-icon-sun); }
.theme-toggle .icon-moon {
display: block;
}
[data-theme="dark"] .theme-toggle .icon-sun {
display: block;
}
[data-theme="dark"] .theme-toggle .icon-moon {
display: none;
}
[data-theme="dark"] .theme-toggle {
color: var(--toggle-icon-sun);
}
/* ===== 顶部常驻搜索框 ===== */
.global-search-bar {
@@ -382,7 +415,7 @@ body {
}
.shelf-cat-parent.active .shelf-cat-count {
background: rgba(255,255,255,0.25);
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
@@ -403,8 +436,15 @@ body {
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.shelf-cat-sub {
@@ -443,7 +483,7 @@ body {
}
.shelf-cat-sub.active .shelf-cat-count-sm {
background: rgba(255,255,255,0.25);
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
@@ -617,8 +657,15 @@ body {
}
@keyframes fadeInTab {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ===== 读书排行(竖向列表) ===== */
@@ -1090,7 +1137,7 @@ body {
color: var(--alert-text);
font-size: 14px;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
animation: slideDown 0.3s ease-out;
}
@@ -1111,8 +1158,15 @@ body {
}
@keyframes slideDown {
from { transform: translateY(-100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* ===== 加载动画 ===== */
@@ -1133,7 +1187,9 @@ body {
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
/* ===== 推荐发现区 ===== */
@@ -1756,7 +1812,7 @@ body {
}
.thought-filter-btn.active .filter-count {
background: rgba(255,255,255,0.25);
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
@@ -1964,78 +2020,100 @@ body {
.nav-links {
display: none;
}
.hero-title {
font-size: 24px;
}
.hero-stats {
gap: 24px;
}
.hero-stat-value {
font-size: 32px;
}
.charts-grid {
grid-template-columns: 1fr;
}
.chart-card.large {
grid-column: auto;
}
.book-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
}
.top-books-list {
gap: 8px;
}
.top-book-row {
padding: 8px 12px;
gap: 10px;
}
.top-book-mini-cover {
width: 36px;
height: 48px;
}
.top-book-rank-num {
width: 28px;
height: 28px;
font-size: 13px;
}
.nav-update {
display: none;
}
.global-search-wrapper {
max-width: 100%;
}
.thoughts-toolbar {
flex-direction: column;
align-items: stretch;
}
.thoughts-controls {
flex-direction: column;
gap: 8px;
}
.thoughts-search-input {
width: 100%;
}
.thoughts-sort-select {
width: 100%;
}
.shelf-search {
width: 140px;
}
.shelf-categories {
gap: 6px;
}
.shelf-cat-parent {
font-size: 12px;
padding: 4px 10px;
}
.shelf-cat-sub {
font-size: 11px;
padding: 3px 9px;
}
.shelf-cat-subs {
padding-left: 10px;
}
.back-to-top {
bottom: 20px;
right: 20px;
@@ -2043,13 +2121,16 @@ body {
height: 40px;
font-size: 18px;
}
.thoughts-page-header {
top: 0;
}
.thoughts-book-cover {
width: 48px;
height: 64px;
}
.thoughts-book-title {
font-size: 16px;
}
@@ -476,11 +476,13 @@ async function loadYearlyTrend() {
barWidth: "50%",
itemStyle: {
borderRadius: [8, 8, 0, 0],
color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1,
color: {
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: c.accentLight },
{ offset: 1, color: c.accent },
] },
]
},
},
label: { show: true, position: "top", color: c.label, fontSize: 12, formatter: "{c}h" },
}],
@@ -538,11 +540,13 @@ async function loadTimeDistribution() {
lineStyle: { width: 2, color: c.accent },
itemStyle: { color: c.accent },
areaStyle: {
color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1,
color: {
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: c.accent + "4D" },
{ offset: 1, color: c.accent + "05" },
] },
]
},
},
}],
});
@@ -650,11 +654,13 @@ async function loadAuthors() {
barWidth: "50%",
itemStyle: {
borderRadius: [0, 6, 6, 0],
color: { type: "linear", x: 0, y: 0, x2: 1, y2: 0,
color: {
type: "linear", x: 0, y: 0, x2: 1, y2: 0,
colorStops: [
{ offset: 0, color: c.accentLight },
{ offset: 1, color: c.accent },
] },
]
},
},
label: { show: true, position: "right", color: c.label, fontSize: 12, formatter: "{c}本" },
}],
@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -8,7 +9,7 @@
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<!-- 防闪烁:在 CSS 加载后、页面渲染前设置主题 -->
<script>
(function() {
(function () {
var saved = localStorage.getItem('theme');
if (saved) {
document.documentElement.setAttribute('data-theme', saved);
@@ -18,6 +19,7 @@
})();
</script>
</head>
<body>
<!-- 数据库连接告警横幅(MySQL 连接失败时显示) -->
<div id="dbAlertBanner" class="db-alert-banner" style="display:none;">
@@ -56,7 +58,8 @@
<div class="container">
<div class="global-search-wrapper">
<span class="global-search-icon">🔍</span>
<input type="text" id="globalSearchInput" class="global-search-input" placeholder="搜索图书..." onkeydown="if(event.key==='Enter')globalSearch()">
<input type="text" id="globalSearchInput" class="global-search-input" placeholder="搜索图书..."
onkeydown="if(event.key==='Enter')globalSearch()">
<button class="global-search-btn" onclick="globalSearch()">搜索</button>
</div>
</div>
@@ -125,7 +128,8 @@
<label class="filter-checkbox">
<input type="checkbox" id="finishedOnly"> 仅看已读完
</label>
<button class="shelf-refresh-btn" id="shelfRefreshBtn" onclick="refreshShelf()" title="重新加载书架数据">
<button class="shelf-refresh-btn" id="shelfRefreshBtn" onclick="refreshShelf()"
title="重新加载书架数据">
<span class="btn-icon">⟳</span>
<span class="btn-text">刷新</span>
</button>
@@ -152,7 +156,8 @@
<div class="container" style="padding-top:40px;padding-bottom:60px">
<h2 class="section-title">🔍 推荐发现</h2>
<div class="discover-tabs">
<button class="discover-tab active" id="tabRecommend" onclick="switchDiscoverTab('recommend')">为你推荐</button>
<button class="discover-tab active" id="tabRecommend"
onclick="switchDiscoverTab('recommend')">为你推荐</button>
<button class="discover-tab" id="tabSearch" onclick="switchDiscoverTab('search')">搜索书籍</button>
</div>
<div id="recommendPanel" class="discover-panel">
@@ -164,7 +169,8 @@
</div>
<div id="searchPanel" class="discover-panel" style="display:none">
<div class="search-box">
<input type="text" id="searchInput" class="search-input" placeholder="输入书名、作者或关键词..." onkeydown="if(event.key==='Enter')doSearch()">
<input type="text" id="searchInput" class="search-input" placeholder="输入书名、作者或关键词..."
onkeydown="if(event.key==='Enter')doSearch()">
<button class="btn-primary" onclick="doSearch()">搜索</button>
</div>
<div class="discover-grid" id="searchGrid"></div>
@@ -183,7 +189,9 @@
</div>
</div>
<div class="container thoughts-content" id="thoughtsContent">
<div class="loading"><div class="loading-spinner"></div></div>
<div class="loading">
<div class="loading-spinner"></div>
</div>
</div>
</div>
@@ -208,4 +216,5 @@
<script src="/static/js/app.js"></script>
</body>
</html>
+2 -2
View File
@@ -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:
+10
View File
@@ -0,0 +1,10 @@
-- 单管理员账号;密码仅保存 Werkzeug 生成的单向哈希。
CREATE TABLE IF NOT EXISTS weread_auth (
id TINYINT UNSIGNED NOT NULL,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_weread_auth_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='阅间访问账号';
+55 -41
View File
@@ -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
# ======================================================================
# 配置
@@ -353,6 +364,7 @@ def make_book(
author=None,
category=None,
finish=0,
is_top=0,
read_update_time=0,
cover_url=None,
deep_link=None,
@@ -362,7 +374,7 @@ def make_book(
intro=None,
):
"""
组装成 v2 前端使用的书籍结构(字段名与设计稿一致)。
组装成当前前端使用的书籍结构(字段名与设计稿一致)。
"""
finish = int(finish or 0)
read_update_time = int(read_update_time or 0)
@@ -390,6 +402,7 @@ def make_book(
"read_time_str": duration_str(read_time) if read_time else "",
"read_update_time": ts_to_date(read_update_time),
"finish_reading": finish,
"is_top": int(is_top or 0),
"deep_link": deep_link or "",
}
@@ -578,6 +591,7 @@ def shelf_books(conn, with_progress=False, progress_limit=30):
row.get("author"),
row.get("category"),
finish=row.get("finishReading"),
is_top=row.get("isTop"),
read_update_time=row.get("readUpdateTime"),
cover_url=row.get("cover"),
deep_link=row.get("deepLink"),
@@ -705,9 +719,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 +739,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 +753,9 @@ def api_v2_create_goal(conn):
return jsonify({"goal": goal}), 201
@v2_bp.route("/api/v2/goals/<goal_id>", methods=["PUT"])
@route("/goals/<goal_id>", 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 +773,9 @@ def api_v2_update_goal(conn, goal_id):
return jsonify({"goal": goal})
@v2_bp.route("/api/v2/goals/<goal_id>", methods=["DELETE"])
@route("/goals/<goal_id>", 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 +785,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 +809,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 +916,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 +1141,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 +1231,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 +1269,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 +1436,9 @@ def bookmark_url(book_id, mark):
# ======================================================================
@v2_bp.route("/api/v2/book/<book_id>")
@route("/book/<book_id>")
@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 +1593,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 +1622,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 +1698,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 +1810,9 @@ def api_v2_search(conn):
# ======================================================================
@v2_bp.route("/api/v2/thought/<review_id>")
@route("/thought/<review_id>")
@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 +1858,9 @@ def api_v2_thought(conn, review_id):
# ======================================================================
@v2_bp.route("/api/v2/book/<book_id>/reviews")
@route("/book/<book_id>/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 +1908,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
def api_v2_book_highlights(conn, book_id):
def api_book_highlights(conn, book_id):
"""
热门划线 / 章节划线热度。
Query: chapter_uid(可选,0=全书)
@@ -1939,9 +1953,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
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()
+1 -1
View File
@@ -1,5 +1,5 @@
-- ============================================================
-- 微信读书数据展示网站 - MySQL 版本建表语句(完整版 v2.0)
-- 微信读书数据展示网站 - MySQL 建表语句
-- ============================================================
-- 设计规范:
-- 1. 所有整型时间戳字段均伴随一个 _dt TIMESTAMP 列,便于直接查询和展示
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# ==========================================================================
# 阅间 · 微信读书看板 v2 —— 启动脚本
# 阅间 · 微信读书看板 —— 启动脚本
# --------------------------------------------------------------------------
# 用法:
# ./start_dashboard.sh # 默认 8770 端口
+106 -24
View File
@@ -146,7 +146,7 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
@@ -154,14 +154,34 @@ body {
}
/* 所有主题相关元素平滑过渡 */
.navbar, .nav-link, .stat-card, .chart-card, .top-book-card, .book-card,
.book-cover-wrapper, .notebook-card, .note-stat, .discover-card, .discover-tab,
.modal-content, .modal-overlay, .footer, .db-alert-banner, .page-btn,
.btn-primary, .btn-secondary, .search-input, .shelf-search, .highlight-item,
.review-item, .detail-progress-info, .top-book-cover-wrapper,
.theme-toggle, .loading-spinner {
.navbar,
.nav-link,
.stat-card,
.chart-card,
.top-book-card,
.book-card,
.book-cover-wrapper,
.notebook-card,
.note-stat,
.discover-card,
.discover-tab,
.modal-content,
.modal-overlay,
.footer,
.db-alert-banner,
.page-btn,
.btn-primary,
.btn-secondary,
.search-input,
.shelf-search,
.highlight-item,
.review-item,
.detail-progress-info,
.top-book-cover-wrapper,
.theme-toggle,
.loading-spinner {
transition: background-color 0.3s ease, border-color 0.3s ease,
color 0.3s ease, box-shadow 0.3s ease, transform 0.2s ease;
color 0.3s ease, box-shadow 0.3s ease, transform 0.2s ease;
}
.container {
@@ -264,12 +284,25 @@ body {
transform: scale(1.08);
}
.theme-toggle .icon-sun { display: none; }
.theme-toggle .icon-moon { display: block; }
.theme-toggle .icon-sun {
display: none;
}
[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
[data-theme="dark"] .theme-toggle { color: var(--toggle-icon-sun); }
.theme-toggle .icon-moon {
display: block;
}
[data-theme="dark"] .theme-toggle .icon-sun {
display: block;
}
[data-theme="dark"] .theme-toggle .icon-moon {
display: none;
}
[data-theme="dark"] .theme-toggle {
color: var(--toggle-icon-sun);
}
/* ===== 顶部常驻搜索框 ===== */
.global-search-bar {
@@ -382,7 +415,7 @@ body {
}
.shelf-cat-parent.active .shelf-cat-count {
background: rgba(255,255,255,0.25);
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
@@ -403,8 +436,15 @@ body {
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.shelf-cat-sub {
@@ -443,7 +483,7 @@ body {
}
.shelf-cat-sub.active .shelf-cat-count-sm {
background: rgba(255,255,255,0.25);
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
@@ -617,8 +657,15 @@ body {
}
@keyframes fadeInTab {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ===== 读书排行(竖向列表) ===== */
@@ -1101,7 +1148,7 @@ body {
color: var(--alert-text);
font-size: 14px;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
animation: slideDown 0.3s ease-out;
}
@@ -1122,8 +1169,15 @@ body {
}
@keyframes slideDown {
from { transform: translateY(-100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* ===== 加载动画 ===== */
@@ -1144,7 +1198,9 @@ body {
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
/* ===== 推荐发现区 ===== */
@@ -1767,7 +1823,7 @@ body {
}
.thought-filter-btn.active .filter-count {
background: rgba(255,255,255,0.25);
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
@@ -1975,81 +2031,104 @@ body {
.nav-links {
display: none;
}
.hero-title {
font-size: 24px;
}
.hero-stats {
gap: 24px;
}
.hero-stat-value {
font-size: 32px;
}
.charts-grid {
grid-template-columns: 1fr;
}
.chart-card.large {
grid-column: auto;
}
.book-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
}
.top-books-list {
gap: 8px;
}
.top-book-row {
padding: 8px 12px;
gap: 10px;
}
.top-book-mini-cover {
width: 36px;
height: 48px;
}
.top-book-rank-num {
width: 28px;
height: 28px;
font-size: 13px;
}
.nav-update {
display: none;
}
.global-search-wrapper {
max-width: 100%;
}
.thoughts-toolbar {
flex-direction: column;
align-items: stretch;
}
.thoughts-controls {
flex-direction: column;
gap: 8px;
}
.thoughts-search-input {
width: 100%;
}
.thoughts-sort-select {
width: 100%;
}
.shelf-search {
width: 140px;
}
.shelf-filters {
gap: 10px;
}
.shelf-categories {
gap: 6px;
}
.shelf-cat-parent {
font-size: 12px;
padding: 4px 10px;
}
.shelf-cat-sub {
font-size: 11px;
padding: 3px 9px;
}
.shelf-cat-subs {
padding-left: 10px;
}
.back-to-top {
bottom: 20px;
right: 20px;
@@ -2057,13 +2136,16 @@ body {
height: 40px;
font-size: 18px;
}
.thoughts-page-header {
top: 0;
}
.thoughts-book-cover {
width: 48px;
height: 64px;
}
.thoughts-book-title {
font-size: 16px;
}
+2935 -88
View File
File diff suppressed because it is too large. Load diff
+80 -19
View File
@@ -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;
@@ -23,7 +24,7 @@ let searchComposing = false, discoverSearchTimer = null;
let recommendOffset = 0;
let noteBook = 'all', noteType = 'all', goalFilter = 'all';
let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0;
let shelfLimit = SHELF_PAGE, chapterLimit = 30, searchScope = 'store', notesLimit = 30;
let shelfPage = 1, shelfSort = 'recent', chapterLimit = 30, searchScope = 'store', notesLimit = 30;
let editingGoalId = null, draftGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = [];
let toastTimer, storageWarning = '', goalBusy = false;
@@ -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 = '浏览器旧目标无法读取,原备份已保留。数据库目标不受影响。'; }
@@ -328,6 +332,50 @@ function renderLists(lists) {
return `<div class="list-grid">${lists.map(listCard).join('') || '<div class="empty">没有匹配的书单。</div>'}</div>`;
}
function compareShelfBooks(a, b) {
const pinOrder = Number(b.is_top || 0) - Number(a.is_top || 0);
if (pinOrder) return pinOrder;
const titleOrder = String(a.title || '').localeCompare(String(b.title || ''), 'zh-CN');
const authorOrder = String(a.author || '').localeCompare(String(b.author || ''), 'zh-CN');
const idOrder = String(a.id || '').localeCompare(String(b.id || ''));
if (shelfSort === 'title_asc') return titleOrder || idOrder;
if (shelfSort === 'title_desc') return -titleOrder || idOrder;
if (shelfSort === 'author_asc') return authorOrder || titleOrder || idOrder;
const dateA = String(a.read_update_time || '');
const dateB = String(b.read_update_time || '');
if (dateA !== dateB) {
if (!dateA) return 1;
if (!dateB) return -1;
return dateB.localeCompare(dateA);
}
return titleOrder || idOrder;
}
function renderShelfPagination(current, total) {
if (total <= 1) return '';
const maxButtons = 7;
let start = Math.max(1, current - 3);
let end = Math.min(total, start + maxButtons - 1);
start = Math.max(1, end - maxButtons + 1);
let html = `<nav class="shelf-pagination" aria-label="书架分页">`;
html += `<button type="button" class="page-btn" ${current <= 1 ? 'disabled' : ''} data-action="shelf-page" data-id="${current - 1}">上一页</button>`;
if (start > 1) {
html += `<button type="button" class="page-btn" data-action="shelf-page" data-id="1">1</button>`;
if (start > 2) html += '<span class="page-info" aria-hidden="true">…</span>';
}
for (let i = start; i <= end; i++) {
html += `<button type="button" class="page-btn ${i === current ? 'active' : ''}" ${i === current ? 'aria-current="page"' : ''} data-action="shelf-page" data-id="${i}">${i}</button>`;
}
if (end < total) {
if (end < total - 1) html += '<span class="page-info" aria-hidden="true">…</span>';
html += `<button type="button" class="page-btn" data-action="shelf-page" data-id="${total}">${total}</button>`;
}
html += `<button type="button" class="page-btn" ${current >= total ? 'disabled' : ''} data-action="shelf-page" data-id="${current + 1}">下一页</button></nav>`;
return html;
}
function renderShelf() {
if (loadError.shelf) return statusBox('error', '暂时无法读取书架', loadError.shelf, 'retry-shelf');
if (!shelfData) { ensureShelf(); return statusBox('loading', '正在同步书架…', '读取微信读书书架与阅读进度。'); }
@@ -344,8 +392,20 @@ function renderShelf() {
} else if (shelfTab === 'articles') {
content = `<article class="card media-card"><div class="media-icon">▤</div><div><h3>${esc((shelfData.mp && shelfData.mp.title) || '文章收藏')}</h3><p>这是书架中的收藏入口,共 ${counts.mp ? 1 : 0} 个。</p><p class="hint">书架接口不提供入口内文章列表;可在发现页按「文章 / 公众号」检索。</p><button class="text-button" data-action="article-search">去发现页搜索 →</button></div></article>`;
} else {
const shown = filtered.slice(0, shelfLimit);
content = `<div class="card panel"><div class="books full-books">${shown.map(bookCard).join('') || '<div class="empty">这里还没有对应的书。</div>'}</div>${filtered.length > shown.length ? `<button class="text-button section-space" data-action="shelf-more">+ 继续加载(还有 ${filtered.length - shown.length} 本)</button>` : ''}</div>`;
const sorted = [...filtered].sort(compareShelfBooks);
const totalPages = Math.ceil(sorted.length / SHELF_PAGE);
const currentPage = totalPages ? Math.min(shelfPage, totalPages) : 1;
shelfPage = currentPage;
const shown = sorted.slice((currentPage - 1) * SHELF_PAGE, currentPage * SHELF_PAGE);
const pageSummary = totalPages ? `共 ${sorted.length} 本 · 第 ${currentPage}/${totalPages} 页` : `共 ${sorted.length} 本`;
const sortOptions = [
['recent', '最近阅读'],
['title_asc', '书名升序'],
['title_desc', '书名降序'],
['author_asc', '作者升序'],
];
const sortControl = sortOptions.map(([value, label]) => `<option value="${value}" ${shelfSort === value ? 'selected' : ''}>${label}</option>`).join('');
content = `<div class="shelf-toolbar"><span class="hint">${pageSummary}</span><label class="shelf-sort-control" for="shelf-sort">排序 <select id="shelf-sort" aria-label="书架排序">${sortControl}</select></label></div><div class="card panel"><div class="books full-books">${shown.map(bookCard).join('') || '<div class="empty">这里还没有对应的书。</div>'}</div></div>${renderShelfPagination(currentPage, totalPages)}`;
}
const sub = `${counts.ebooks || 0} 本电子书 · ${counts.albums || 0} 个有声专辑 · ${counts.mp ? 1 : 0} 个文章收藏入口,共 ${counts.total || 0} 个书架条目。`;
@@ -497,7 +557,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} 个目标,数据库已有目标已保留,原备份也保留。`);
@@ -527,12 +587,12 @@ function renderNotes() {
? statusBox('error', '暂时无法读取笔记', loadError.notes[noteBook] || '', 'retry-notes')
: statusBox('loading', '正在读取笔记内容…', '划线、想法与书签数量。'))
: `<div class="metrics-row"><span><strong>${counts.highlights + counts.thoughts + counts.bookmarks}</strong>条笔记</span><span>划线 ${counts.highlights}</span><span>想法 ${counts.thoughts}</span><span>书签 ${counts.bookmarks}</span></div>` +
tabs([['all', '全部内容'], ['highlight', '划线'], ['thought', '想法 / 个人点评'], ['bookmark', '书签数量']], noteType, 'note-type') +
(noteType === 'bookmark'
? `<div class="card empty">${counts.bookmarks} 个书签 · 微信读书接口只提供书签数量,不提供书签内容。</div>`
: (noteSlice.map(noteCard).join('') || '<div class="card empty">当前筛选没有笔记。</div>')) +
(noteType !== 'bookmark' && shownNotes.length > noteSlice.length ? `<button class="text-button section-space" data-action="notes-more">+ 继续加载(还有 ${shownNotes.length - noteSlice.length} 条)</button>` : '') +
`<p class="hint section-space">${noteBook === 'all' ? `「全部笔记本」聚合了笔记最多的 ${payload.aggregated_books || 0} 本,切换左侧笔记本可查看单本全部内容。` : '内容来自微信读书划线与个人想法。'}总笔记数包含书签;导出包含划线和想法,不含公开点评及书签内容。</p>`;
tabs([['all', '全部内容'], ['highlight', '划线'], ['thought', '想法 / 个人点评'], ['bookmark', '书签数量']], noteType, 'note-type') +
(noteType === 'bookmark'
? `<div class="card empty">${counts.bookmarks} 个书签 · 微信读书接口只提供书签数量,不提供书签内容。</div>`
: (noteSlice.map(noteCard).join('') || '<div class="card empty">当前筛选没有笔记。</div>')) +
(noteType !== 'bookmark' && shownNotes.length > noteSlice.length ? `<button class="text-button section-space" data-action="notes-more">+ 继续加载(还有 ${shownNotes.length - noteSlice.length} 条)</button>` : '') +
`<p class="hint section-space">${noteBook === 'all' ? `「全部笔记本」聚合了笔记最多的 ${payload.aggregated_books || 0} 本,切换左侧笔记本可查看单本全部内容。` : '内容来自微信读书划线与个人想法。'}总笔记数包含书签;导出包含划线和想法,不含公开点评及书签内容。</p>`;
return welcome('把读过的,留在心里', `${notebooksData.totals.books} 本书留下过笔记,共 ${notebooksData.totals.notes} 条。`, '<button class="secondary" data-action="export-notes">↓ 导出当前书目笔记</button>') +
`<div class="notebook-grid"><aside class="card panel notebook-menu"><button data-action="notebook" data-id="all" class="${noteBook === 'all' ? 'active' : ''}"><b>全部笔记本</b><small>${notebooksData.totals.books} 本书 · ${notebooksData.totals.notes} 条笔记</small></button>${menuBooks.map(item => `<button data-action="notebook" data-id="${esc(item.id)}" class="${noteBook === item.id ? 'active' : ''}"><b>${esc(item.title)}</b><small>${item.total_notes} 条 · 含书签 ${item.bookmark_count}</small></button>`).join('')}</aside><div>${body}</div></div>`;
@@ -596,7 +656,7 @@ function renderDiscover() {
} else {
const list = payload.books || [];
const local = list.filter(book => !searchQuery || `${book.title}${book.author}${book.category}`.toLowerCase().includes(searchQuery.toLowerCase()));
content = `<div class="card panel"><div class="books full-books">${(payload.local ? local.slice(0, shelfLimit) : list).map(searchCard).join('') || '<div class="empty">没有找到相关书籍,试试其他书名或作者。</div>'}</div></div>`;
content = `<div class="card panel"><div class="books full-books">${(payload.local ? local.slice(0, SHELF_PAGE) : list).map(searchCard).join('') || '<div class="empty">没有找到相关书籍,试试其他书名或作者。</div>'}</div></div>`;
}
return welcome('下一本,遇见什么?', '寻找一本书,也发现一份恰好适合你的书单。') +
@@ -705,8 +765,8 @@ function handleAction(button) {
case 'add-list': { const list = booklists.find(l => l.id === id); if (list) chooseTarget(list.ids, [list.name]); break; }
case 'append-goal': appendGoal(id); break;
case 'new-with-selection': openGoalEditor(null, pendingBooks, pendingSources); break;
case 'shelf-tab': shelfTab = id; shelfLimit = SHELF_PAGE; renderPage(); break;
case 'shelf-more': shelfLimit += SHELF_PAGE; renderPage(); break;
case 'shelf-tab': shelfTab = id; shelfPage = 1; renderPage(); break;
case 'shelf-page': shelfPage = Number(id); renderPage(); break;
case 'period': period = id; renderPage(); break;
case 'discover-tab': discoverTab = id; renderPage(); break;
case 'refresh-recommend': recommendOffset += 12; ensureDiscover(true, true); break;
@@ -754,6 +814,7 @@ document.addEventListener('change', event => {
selectedChapter = event.target.value; bookTab = 'popular'; renderBookDetail();
if (!numberData[`hl:${detailBook}:${selectedChapter}`]) WeApi.highlights(detailBook, selectedChapter).then(res => { numberData[`hl:${detailBook}:${selectedChapter}`] = res; renderBookDetail(); }).catch(error => toast(error.message));
}
if (event.target.id === 'shelf-sort') { shelfSort = event.target.value; shelfPage = 1; renderPage(); }
if (event.target.id === 'search-scope') { searchScope = event.target.value; renderPage(); }
});
$('#goal-start').onchange = () => { $('#goal-end').min = $('#goal-start').value; };
+15 -15
View File
@@ -1,25 +1,25 @@
// 所有数据均为设计示例,不来自真实账号。
const books = [{title:'也许你该找个人聊聊',short:'也许你该\n找个人\n聊聊',author:'洛莉·戈特利布',en:'MAYBE YOU SHOULD TALK TO SOMEONE',cover:'c1',progress:68,status:'reading',score:'9.1',desc:'一位心理治疗师的回忆录。跟随几个普通人的故事,理解关系、失去与成长,也重新认识自己。'},{title:'三体',short:'三体',author:'刘慈欣',en:'THE THREE-BODY PROBLEM',cover:'c2',progress:42,status:'reading',score:'9.4',desc:'从一段隐秘的往事出发,走进人类与宇宙文明的宏大故事。科学、想象与命运交织,打开另一种观看世界的尺度。'},{title:'蛤蟆先生去看心理医生',short:'蛤蟆先生\n去看\n心理医生',author:'罗伯特·戴博德',en:'COUNSELLING FOR TOADS',cover:'c3',progress:85,status:'reading',score:'8.8',desc:'借由蛤蟆先生与心理咨询师的对话,探索情绪与行为背后的原因。这是一次关于理解自己、为自己负责的旅程。'},{title:'人生海海',short:'人生\n海海',author:'麦家',en:'LIFE IS A VAST SEA',cover:'c4',progress:26,status:'reading',score:'9.0',desc:'在复杂的时代与人情之中,讲述一个人的秘密与命运。人生如海,有暗流,也有让人继续前行的力量。'},{title:'活着',short:'活着',author:'余华',en:'TO LIVE',cover:'c3',progress:100,status:'done',score:'9.5',desc:'一个人与时代相遇的故事,关于生命的韧性与日常的重量。'},{title:'被讨厌的勇气',short:'被讨厌\n的勇气',author:'岸见一郎 / 古贺史健',en:'THE COURAGE TO BE DISLIKED',cover:'c1',progress:100,status:'done',score:'8.6',desc:'以对话的方式讨论人际关系、自我接纳与选择的自由。'},{title:'悉达多',short:'悉达多',author:'赫尔曼·黑塞',en:'SIDDHARTHA',cover:'c4',progress:0,status:'wish',score:'9.2',desc:'一个寻找自我的人,穿过不同的人生经验,在河流边学会倾听。'},{title:'置身事内',short:'置身\n事内',author:'兰小欢',en:'INSIDE THE SYSTEM',cover:'c2',progress:0,status:'wish',score:'9.3',desc:'从具体的经济生活出发,理解政府与经济发展的关系。'}];
const books = [{ title: '也许你该找个人聊聊', short: '也许你该\n找个人\n聊聊', author: '洛莉·戈特利布', en: 'MAYBE YOU SHOULD TALK TO SOMEONE', cover: 'c1', progress: 68, status: 'reading', score: '9.1', desc: '一位心理治疗师的回忆录。跟随几个普通人的故事,理解关系、失去与成长,也重新认识自己。' }, { title: '三体', short: '三体', author: '刘慈欣', en: 'THE THREE-BODY PROBLEM', cover: 'c2', progress: 42, status: 'reading', score: '9.4', desc: '从一段隐秘的往事出发,走进人类与宇宙文明的宏大故事。科学、想象与命运交织,打开另一种观看世界的尺度。' }, { title: '蛤蟆先生去看心理医生', short: '蛤蟆先生\n去看\n心理医生', author: '罗伯特·戴博德', en: 'COUNSELLING FOR TOADS', cover: 'c3', progress: 85, status: 'reading', score: '8.8', desc: '借由蛤蟆先生与心理咨询师的对话,探索情绪与行为背后的原因。这是一次关于理解自己、为自己负责的旅程。' }, { title: '人生海海', short: '人生\n海海', author: '麦家', en: 'LIFE IS A VAST SEA', cover: 'c4', progress: 26, status: 'reading', score: '9.0', desc: '在复杂的时代与人情之中,讲述一个人的秘密与命运。人生如海,有暗流,也有让人继续前行的力量。' }, { title: '活着', short: '活着', author: '余华', en: 'TO LIVE', cover: 'c3', progress: 100, status: 'done', score: '9.5', desc: '一个人与时代相遇的故事,关于生命的韧性与日常的重量。' }, { title: '被讨厌的勇气', short: '被讨厌\n的勇气', author: '岸见一郎 / 古贺史健', en: 'THE COURAGE TO BE DISLIKED', cover: 'c1', progress: 100, status: 'done', score: '8.6', desc: '以对话的方式讨论人际关系、自我接纳与选择的自由。' }, { title: '悉达多', short: '悉达多', author: '赫尔曼·黑塞', en: 'SIDDHARTHA', cover: 'c4', progress: 0, status: 'wish', score: '9.2', desc: '一个寻找自我的人,穿过不同的人生经验,在河流边学会倾听。' }, { title: '置身事内', short: '置身\n事内', author: '兰小欢', en: 'INSIDE THE SYSTEM', cover: 'c2', progress: 0, status: 'wish', score: '9.3', desc: '从具体的经济生活出发,理解政府与经济发展的关系。' }];
books.forEach((book, index) => { book.id = `b${index}`; });
const booklists = [
{id:'l1',name:'在故事里,读懂人生',source:'我的书单',description:'从别人的故事里,找到自己生活的回声。',ids:['b3','b4','b6']},
{id:'l2',name:'心理学,向内走一程',source:'我的书单',description:'理解情绪与关系,也练习温柔地对待自己。',ids:['b0','b2','b5']},
{id:'l3',name:'重新认识这个世界',source:'公开书单 · 示例',description:'从宇宙到日常,拓宽理解世界的尺度。',ids:['b1','b7','b6']},
{id:'l4',name:'适合慢慢读的夜晚',source:'公开书单 · 示例',description:'把时间留给文学,让心绪落在书页之间。',ids:['b4','b6','b0']}
{ id: 'l1', name: '在故事里,读懂人生', source: '我的书单', description: '从别人的故事里,找到自己生活的回声。', ids: ['b3', 'b4', 'b6'] },
{ id: 'l2', name: '心理学,向内走一程', source: '我的书单', description: '理解情绪与关系,也练习温柔地对待自己。', ids: ['b0', 'b2', 'b5'] },
{ id: 'l3', name: '重新认识这个世界', source: '公开书单 · 示例', description: '从宇宙到日常,拓宽理解世界的尺度。', ids: ['b1', 'b7', 'b6'] },
{ id: 'l4', name: '适合慢慢读的夜晚', source: '公开书单 · 示例', description: '把时间留给文学,让心绪落在书页之间。', ids: ['b4', 'b6', 'b0'] }
];
const initialGoals = [
{id:'g1',name:'秋季文学计划',start:'2026-09-01',end:'2026-11-30',ids:['b3','b4','b6','b1'],sources:['在故事里,读懂人生']},
{id:'g2',name:'给内心一点生长的空间',start:'2026-09-01',end:'2026-10-31',ids:['b0','b2','b5'],sources:['心理学,向内走一程']},
{id:'g3',name:'读完的书,成为自己的底气',start:'2026-08-01',end:'2026-08-31',ids:['b4','b5'],sources:[]}
{ id: 'g1', name: '秋季文学计划', start: '2026-09-01', end: '2026-11-30', ids: ['b3', 'b4', 'b6', 'b1'], sources: ['在故事里,读懂人生'] },
{ id: 'g2', name: '给内心一点生长的空间', start: '2026-09-01', end: '2026-10-31', ids: ['b0', 'b2', 'b5'], sources: ['心理学,向内走一程'] },
{ id: 'g3', name: '读完的书,成为自己的底气', start: '2026-08-01', end: '2026-08-31', ids: ['b4', 'b5'], sources: [] }
];
const personalNotes = [
{id:'n1',bookId:'b0',type:'thought',text:'把注意力还给生活本身,让平凡的日子有迹可循。',date:'2026-09-11'},
{id:'n2',bookId:'b1',type:'highlight',text:'在辽阔的未知面前,每一个问题都值得被认真对待。',date:'2026-09-10'},
{id:'n3',bookId:'b2',type:'thought',text:'允许情绪出现,也给自己一些理解它的时间。',date:'2026-09-08'},
{id:'n4',bookId:'b3',type:'highlight',text:'人生的答案,或许藏在继续向前的日子里。',date:'2026-09-06'}
{ id: 'n1', bookId: 'b0', type: 'thought', text: '把注意力还给生活本身,让平凡的日子有迹可循。', date: '2026-09-11' },
{ id: 'n2', bookId: 'b1', type: 'highlight', text: '在辽阔的未知面前,每一个问题都值得被认真对待。', date: '2026-09-10' },
{ id: 'n3', bookId: 'b2', type: 'thought', text: '允许情绪出现,也给自己一些理解它的时间。', date: '2026-09-08' },
{ id: 'n4', bookId: 'b3', type: 'highlight', text: '人生的答案,或许藏在继续向前的日子里。', date: '2026-09-06' }
];
const publicReviews = [
{id:'r1',name:'山间来信',rating:5,type:'推荐',text:'适合放慢速度去读。最打动我的,是读完之后还会不断回想的那些问题。',date:'2026-09-10'},
{id:'r2',name:'晚风书屋',rating:3,type:'一般',text:'有一些有趣的视角,也有需要耐心消化的地方。每个人会读出不同的感受。',date:'2026-09-09'},
{id:'r3',name:'一页一页',rating:1,type:'不行',text:'这次阅读没有找到共鸣,可能还不是适合我当下阅读的书。',date:'2026-09-08'}
{ id: 'r1', name: '山间来信', rating: 5, type: '推荐', text: '适合放慢速度去读。最打动我的,是读完之后还会不断回想的那些问题。', date: '2026-09-10' },
{ id: 'r2', name: '晚风书屋', rating: 3, type: '一般', text: '有一些有趣的视角,也有需要耐心消化的地方。每个人会读出不同的感受。', date: '2026-09-09' },
{ id: 'r3', name: '一页一页', rating: 1, type: '不行', text: '这次阅读没有找到共鸣,可能还不是适合我当下阅读的书。', date: '2026-09-08' }
];
+20 -9
View File
@@ -1,25 +1,36 @@
/* ==========================================================================
阅间 · 数据层(v2)
阅间 · 数据层
--------------------------------------------------------------------------
把后端 /api/v2/* 接口封装成前端可直接使用的数据结构。
把后端 /api/reading/* 接口封装成前端可直接使用的数据结构。
设计稿(Design Demo)原本使用 demo-data.js 里的静态示例数据,
这里替换为真实接口调用,字段名与设计稿保持一致。
========================================================================== */
(function (global) {
'use strict';
const BASE = '/api/v2';
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' } : {}) },
headers: {
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...(['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) ? { 'X-CSRF-Token': global.document.querySelector('meta[name="csrf-token"]')?.content || '' } : {}),
},
...(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();
+164 -11
View File
@@ -1,16 +1,169 @@
<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>阅间 · 微信读书看板</title>
<link rel="stylesheet" href="/static/css/styles.css">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>阅间 · 微信读书看板</title>
<link rel="stylesheet" href="/static/css/styles.css">
<style>
.footer {
flex-wrap: wrap;
gap: 12px
}
.footer-meta {
display: flex;
align-items: center;
gap: 12px
}
.app-version {
white-space: nowrap
}
.icp-link {
color: inherit;
text-decoration: none
}
.icp-link:hover {
color: var(--accent)
}
@media(max-width:600px) {
.footer-state {
display: none
}
}
</style>
</head>
<body>
<svg style="display:none" aria-hidden="true"><defs>
<symbol id="book" viewBox="0 0 24 24"><path d="M3 4c4-1 6 0 9 2 3-2 5-3 9-2v15c-4-1-6 0-9 2-3-2-5-3-9-2zM12 6v15"/></symbol><symbol id="grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></symbol><symbol id="note" viewBox="0 0 24 24"><path d="M13 4H5v16h14v-8M11 13l1-4 7-7 3 3-7 7z"/></symbol><symbol id="search-icon" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6.5"/><path d="m15 15 6 6"/></symbol><symbol id="chart" viewBox="0 0 24 24"><path d="M4 3v17h17M8 15v-4m5 4V7m5 8V4"/></symbol><symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 6v6l4 2"/></symbol><symbol id="moon" viewBox="0 0 24 24"><path d="M20 14A9 9 0 0 1 10 3a9 9 0 1 0 10 11Z"/></symbol><symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/></symbol><symbol id="sync" viewBox="0 0 24 24"><path d="M20 7v5h-5M4 17v-5h5M6 6a8 8 0 0 1 14 6M4 12a8 8 0 0 0 14 6"/></symbol><symbol id="flag" viewBox="0 0 24 24"><path d="M5 21V3c5-4 9 4 14 0v11c-5 4-9-4-14 0"/></symbol>
</defs></svg>
<aside class="sidebar"><div class="logo"><div class="logo-mark"><svg><use href="#book"/></svg></div><div>阅间<small>READING SPACE</small></div></div><div class="navlabel">我的阅读空间</div><nav class="nav" aria-label="主导航"><button class="active" data-page="overview" title="阅读概览"><svg><use href="#grid"/></svg><span>阅读概览</span></button><button data-page="shelf" title="我的书架"><svg><use href="#book"/></svg><span>我的书架</span><em>8</em></button><button data-page="goals" title="阅读目标"><svg><use href="#flag"/></svg><span>阅读目标</span></button><button data-page="notes" title="笔记与划线"><svg><use href="#note"/></svg><span>笔记与划线</span></button><button data-page="discover" title="发现好书"><svg><use href="#search-icon"/></svg><span>发现好书</span></button></nav><div class="sidebar-bottom"><div class="connection"><span class="dot"></span>微信读书 · 个人空间<small>让每一页,都留下痕迹</small></div><div class="profile"><div class="avatar">读</div><div>我的阅读空间<small>数据来自你的微信读书账号</small></div></div></div></aside>
<main class="main"><header class="topbar"><div class="breadcrumb">我的空间 <span>/ &nbsp; <b id="crumb" style="font-weight:400">阅读概览</b></span></div><div class="top-actions"><label class="searchbox"><svg><use href="#search-icon"/></svg><input id="global-search" placeholder="搜索书籍、书单…" aria-label="全局搜索"><span class="key">⌘ K</span></label><button class="iconbtn" id="theme" aria-label="切换至暗色主题"><svg><use href="#moon"/></svg></button><div class="avatar">L</div></div></header><div class="demo-banner"><span><span class="dot"></span><span id="conn-state">正在连接微信读书…</span></span><span>阅读目标跨浏览器保存</span></div><div id="page-content"></div><footer class="footer"><span>阅间 · 与好书相伴,与自己相遇</span><span id="footer-state">数据来自微信读书个人账号</span></footer></main>
<dialog id="detail" aria-label="内容详情"><div class="dialog-top"><h2 id="detail-title">书籍详情</h2><button class="iconbtn" data-action="close-detail" aria-label="关闭详情">✕</button></div><div id="detail-content"></div></dialog>
<dialog id="goal-editor" aria-label="编辑阅读目标"><form id="goal-form"><div class="dialog-top"><div><div class="eyebrow">MAKE ROOM FOR READING</div><h2 id="editor-title">新建阅读目标</h2></div><button type="button" class="iconbtn" data-action="close-editor" aria-label="关闭目标编辑">✕</button></div><label class="field">目标名称<input id="goal-name" maxlength="60" placeholder="例如:秋季文学计划" required></label><div class="date-fields"><label class="field">开始日期<input id="goal-start" type="date" required></label><label class="field">结束日期<input id="goal-end" type="date" required></label></div><div class="section-head picker-head"><h3>想读些什么?</h3><span id="selection-count" class="pill"></span></div><p class="hint">选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。</p><label class="field"><input id="picker-search" placeholder="筛选书名、作者或书单…" aria-label="筛选目标书目"></label><div id="goal-picker"></div><p id="goal-error" class="form-error" role="alert"></p><div class="editor-footer"><span>保存后可在其他浏览器查看</span><button class="primary" type="submit">保存阅读目标</button></div></form></dialog>
<div id="toast" class="hidden" role="status"></div><script src="/static/js/weapi.js"></script><script src="/static/js/app.js"></script></body></html>
<svg style="display:none" aria-hidden="true">
<defs>
<symbol id="book" viewBox="0 0 24 24">
<path d="M3 4c4-1 6 0 9 2 3-2 5-3 9-2v15c-4-1-6 0-9 2-3-2-5-3-9-2zM12 6v15" />
</symbol>
<symbol id="grid" viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</symbol>
<symbol id="note" viewBox="0 0 24 24">
<path d="M13 4H5v16h14v-8M11 13l1-4 7-7 3 3-7 7z" />
</symbol>
<symbol id="search-icon" viewBox="0 0 24 24">
<circle cx="10" cy="10" r="6.5" />
<path d="m15 15 6 6" />
</symbol>
<symbol id="chart" viewBox="0 0 24 24">
<path d="M4 3v17h17M8 15v-4m5 4V7m5 8V4" />
</symbol>
<symbol id="clock" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" />
<path d="M12 6v6l4 2" />
</symbol>
<symbol id="moon" viewBox="0 0 24 24">
<path d="M20 14A9 9 0 0 1 10 3a9 9 0 1 0 10 11Z" />
</symbol>
<symbol id="sun" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1" />
</symbol>
<symbol id="sync" viewBox="0 0 24 24">
<path d="M20 7v5h-5M4 17v-5h5M6 6a8 8 0 0 1 14 6M4 12a8 8 0 0 0 14 6" />
</symbol>
<symbol id="flag" viewBox="0 0 24 24">
<path d="M5 21V3c5-4 9 4 14 0v11c-5 4-9-4-14 0" />
</symbol>
</defs>
</svg>
<aside class="sidebar">
<div class="logo">
<div class="logo-mark"><svg>
<use href="#book" />
</svg></div>
<div>阅间<small>READING SPACE</small></div>
</div>
<div class="navlabel">我的阅读空间</div>
<nav class="nav" aria-label="主导航"><button class="active" data-page="overview" title="阅读概览"><svg>
<use href="#grid" />
</svg><span>阅读概览</span></button><button data-page="shelf" title="我的书架"><svg>
<use href="#book" />
</svg><span>我的书架</span><em>8</em></button><button data-page="goals" title="阅读目标"><svg>
<use href="#flag" />
</svg><span>阅读目标</span></button><button data-page="notes" title="笔记与划线"><svg>
<use href="#note" />
</svg><span>笔记与划线</span></button><button data-page="discover" title="发现好书"><svg>
<use href="#search-icon" />
</svg><span>发现好书</span></button></nav>
<div class="sidebar-bottom">
<div class="connection"><span class="dot"></span>微信读书 · 个人空间<small>让每一页,都留下痕迹</small></div>
<div class="profile">
<div class="avatar">读</div>
<div>我的阅读空间<small>数据来自你的微信读书账号</small></div>
</div>
</div>
</aside>
<main class="main">
<header class="topbar">
<div class="breadcrumb">我的空间 <span>/ &nbsp; <b id="crumb" style="font-weight:400">阅读概览</b></span></div>
<div class="top-actions"><label class="searchbox"><svg>
<use href="#search-icon" />
</svg><input id="global-search" placeholder="搜索书籍、书单…" aria-label="全局搜索"><span class="key">⌘
K</span></label><button class="iconbtn" id="theme" aria-label="切换至暗色主题"><svg>
<use href="#moon" />
</svg></button>
<div class="avatar">L</div>
<form method="post" action="{{ url_for('auth.logout') }}"><input type="hidden" name="csrf_token"
value="{{ csrf_token() }}"><button class="text-button logout-button" type="submit">退出</button>
</form>
</div>
</header>
<div class="demo-banner"><span><span class="dot"></span><span
id="conn-state">正在连接微信读书…</span></span><span>阅读目标跨浏览器保存</span></div>
<div id="page-content"></div>
<footer class="footer"><span>阅间 · 与好书相伴,与自己相遇</span>
<div class="footer-meta"><span class="footer-state" id="footer-state">数据来自微信读书个人账号</span><span class="app-version">版本 {{ app_version }}</span><a class="icp-link"
href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">浙ICP备202607916号-1</a>
</div>
</footer>
</main>
<dialog id="detail" aria-label="内容详情">
<div class="dialog-top">
<h2 id="detail-title">书籍详情</h2><button class="iconbtn" data-action="close-detail"
aria-label="关闭详情">✕</button>
</div>
<div id="detail-content"></div>
</dialog>
<dialog id="goal-editor" aria-label="编辑阅读目标">
<form id="goal-form">
<div class="dialog-top">
<div>
<div class="eyebrow">MAKE ROOM FOR READING</div>
<h2 id="editor-title">新建阅读目标</h2>
</div><button type="button" class="iconbtn" data-action="close-editor" aria-label="关闭目标编辑">✕</button>
</div><label class="field">目标名称<input id="goal-name" maxlength="60" placeholder="例如:秋季文学计划"
required></label>
<div class="date-fields"><label class="field">开始日期<input id="goal-start" type="date" required></label><label
class="field">结束日期<input id="goal-end" type="date" required></label></div>
<div class="section-head picker-head">
<h3>想读些什么?</h3><span id="selection-count" class="pill"></span>
</div>
<p class="hint">选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。</p><label class="field"><input id="picker-search"
placeholder="筛选书名、作者或书单…" aria-label="筛选目标书目"></label>
<div id="goal-picker"></div>
<p id="goal-error" class="form-error" role="alert"></p>
<div class="editor-footer"><span>保存后可在其他浏览器查看</span><button class="primary" type="submit">保存阅读目标</button>
</div>
</form>
</dialog>
<div id="toast" class="hidden" role="status"></div>
<script src="/static/js/weapi.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>登录 · 阅间</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
</head>
<body class="login-page">
<main class="login-shell">
<div class="logo login-logo">
<div class="logo-mark"><svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 4c4-1 6 0 8 2 2-2 4-3 8-2v15c-4-1-6 0-8 2-2-2-4-3-8-2zM12 6v15" fill="none" stroke="currentColor"
stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
</svg></div>
<div>阅间<small>READING SPACE</small></div>
</div>
<section class="login-panel card">
<div class="eyebrow">YOUR READING SPACE</div>
<h1>{{ '服务暂不可用' if unavailable else ('首次设置' if not initialized else '欢迎回来') }}</h1>
<p>{{ '请检查服务配置或数据库连接。' if unavailable else ('创建管理员账号以启用访问保护。' if not initialized else '登录后继续你的阅读旅程。') }}</p>
{% if error %}<div class="login-error" role="alert">{{ error }}</div>{% endif %}
{% if not unavailable and not initialized %}
{% if not setup_enabled %}<div class="login-error" role="alert">尚未配置一次性初始化口令,请联系管理员。</div>{% endif %}
<form method="post" action="{{ url_for('auth.initialize_account') }}" class="login-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>一次性初始化口令<input name="setup_token" type="password" autocomplete="off" required autofocus></label>
<label>管理员账号<input name="username" maxlength="64" autocomplete="username" required></label>
<label>设置密码<input name="password" type="password" minlength="12" maxlength="256" autocomplete="new-password"
required></label>
<button class="primary" type="submit" {% if not setup_enabled %}disabled{% endif %}>创建账号并进入</button>
</form>
{% elif not unavailable %}
<form method="post" action="{{ url_for('auth.login') }}" class="login-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>账号<input name="username" maxlength="64" autocomplete="username" required autofocus></label>
<label>密码<input name="password" type="password" autocomplete="current-password" required></label>
<button class="primary" type="submit">登录</button>
</form>
{% endif %}
</section>
<p class="login-foot">阅间 · 与好书相伴,与自己相遇 · 版本 {{ app_version }}</p>
</main>
</body>
</html>
+47
View File
@@ -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 'deploy_webhook.py' "$ROOT_DIR/deploy/weread-webhook.service"; then
echo "Webhook systemd 服务未配置"
exit 1
fi
if ! grep -q 'DEFAULT_BRANCH = "master"' "$ROOT_DIR/deploy_webhook.py"; then
echo "Webhook 未固定 master 分支"
exit 1
fi
if grep -q 'SERVER_SSH_KEY\|ssh -i' "$WORKFLOW"; then
echo "验证工作流仍包含服务器 SSH 部署"
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 部署约束验证通过"
+173
View File
@@ -0,0 +1,173 @@
import re
import importlib
import os
import unittest
from datetime import timedelta
from unittest.mock import patch
from flask import Flask, jsonify
import auth
class AuthTests(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__, template_folder="../templates")
self.app.config.update(
SECRET_KEY="test-secret",
AUTH_SETUP_TOKEN="one-time-token",
SESSION_COOKIE_SECURE=False,
)
self.accounts = {}
@self.app.get("/private")
def private_page():
return "private"
@self.app.get("/api/private")
def private_api():
return jsonify(ok=True)
self.create_patcher = patch.object(
auth, "create_account", side_effect=self.create_account
)
self.account_patcher = patch.object(
auth, "get_account", side_effect=lambda: self.accounts.get("account")
)
self.create_patcher.start()
self.account_patcher.start()
self.addCleanup(self.create_patcher.stop)
self.addCleanup(self.account_patcher.stop)
auth.register_auth(self.app)
self.app.config.update(
SECRET_KEY="test-secret",
AUTH_SETUP_TOKEN="one-time-token",
SESSION_COOKIE_SECURE=False,
)
self.client = self.app.test_client()
def create_account(self, username, password_hash):
if "account" in self.accounts:
return False
self.accounts["account"] = {
"username": username,
"password_hash": password_hash,
}
return True
@staticmethod
def csrf(response):
match = re.search(rb'name="csrf_token" value="([^"]+)"', response.data)
if not match:
raise AssertionError("login page did not contain a CSRF token")
return match.group(1).decode()
def test_anonymous_page_and_api_are_blocked(self):
page = self.client.get("/private")
api = self.client.get("/api/private")
self.assertEqual(page.status_code, 302)
self.assertEqual(page.location, "/login")
self.assertEqual(api.status_code, 401)
self.assertEqual(api.json["error"], "需要登录")
def test_current_and_legacy_reading_apis_are_blocked_by_real_app_guard(self):
with patch.dict(os.environ, {"WEREAD_SESSION_SECRET": "test-secret"}):
web_app = importlib.import_module("app").app
web_app.config.update(SECRET_KEY="test-secret", SESSION_COOKIE_SECURE=False)
with patch("app.pymysql.connect", side_effect=RuntimeError("test avoids database access")):
client = web_app.test_client()
self.assertEqual(client.get("/").status_code, 302)
self.assertEqual(client.get("/api/reading/status").status_code, 401)
self.assertEqual(client.get("/api/v2/status").status_code, 401)
def test_home_footer_displays_icp_record_link(self):
with patch.dict(os.environ, {"WEREAD_SESSION_SECRET": "test-secret"}):
web_app = importlib.import_module("app").app
web_app.config.update(SECRET_KEY="test-secret", SESSION_COOKIE_SECURE=False)
client = web_app.test_client()
with client.session_transaction() as session:
session["auth_user"] = "reader"
response = client.get("/")
self.assertEqual(response.status_code, 200)
self.assertIn(b"https://beian.miit.gov.cn/", response.data)
self.assertIn("浙ICP备202607916号-1".encode(), response.data)
self.assertIn("版本 v0.1".encode(), response.data)
def test_first_account_requires_setup_token_and_is_stored_as_hash(self):
page = self.client.get("/login")
csrf = self.csrf(page)
denied = self.client.post("/auth/initialize", data={
"csrf_token": csrf,
"setup_token": "wrong-token",
"username": "reader",
"password": "long-enough-password",
})
self.assertEqual(denied.status_code, 403)
self.assertNotIn("account", self.accounts)
created = self.client.post("/auth/initialize", data={
"csrf_token": csrf,
"setup_token": "one-time-token",
"username": "reader",
"password": "long-enough-password",
})
self.assertEqual(created.status_code, 302)
saved = self.accounts["account"]
self.assertEqual(saved["username"], "reader")
self.assertNotEqual(saved["password_hash"], "long-enough-password")
self.assertTrue(auth.check_password_hash(saved["password_hash"], "long-enough-password"))
with self.client.session_transaction() as session:
self.assertTrue(session.permanent)
self.assertEqual(session["auth_user"], "reader")
self.assertEqual(self.app.permanent_session_lifetime, timedelta(days=5))
def test_login_rejects_wrong_password_and_accepts_matching_hash(self):
saved_hash = auth.generate_password_hash("long-enough-password")
self.accounts["account"] = {"username": "阅读者", "password_hash": saved_hash}
page = self.client.get("/login")
csrf = self.csrf(page)
denied = self.client.post("/auth/login", data={
"csrf_token": csrf,
"username": "阅读者",
"password": "wrong-password",
})
self.assertEqual(denied.status_code, 401)
accepted = self.client.post("/auth/login", data={
"csrf_token": csrf,
"username": "阅读者",
"password": "long-enough-password",
})
self.assertEqual(accepted.status_code, 302)
self.assertEqual(self.client.get("/private").data, b"private")
def test_missing_session_secret_fails_closed(self):
self.app.config["SECRET_KEY"] = None
response = self.client.get("/login")
self.assertEqual(response.status_code, 503)
self.assertIn("服务未配置会话密钥".encode(), response.data)
def test_mutating_api_requires_csrf_token(self):
@self.app.post("/api/write")
def write_api():
return jsonify(ok=True)
self.client.get("/login")
self.accounts["account"] = {
"username": "reader",
"password_hash": auth.generate_password_hash("long-enough-password"),
}
# Simulate a session established through the regular authenticated login flow.
with self.client.session_transaction() as session:
session.permanent = True
session["auth_user"] = "reader"
session["csrf_token"] = "test-csrf"
self.assertEqual(self.client.post("/api/write").status_code, 400)
self.assertEqual(self.client.post("/api/write", headers={"X-CSRF-Token": "test-csrf"}).status_code, 200)
if __name__ == "__main__":
unittest.main()
+26 -21
View File
@@ -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()
+14 -14
View File
@@ -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()
+74
View File
@@ -0,0 +1,74 @@
import hashlib
import hmac
import json
import unittest
from unittest.mock import patch
from deploy_webhook import create_app
class WebhookTest(unittest.TestCase):
def setUp(self):
self.process = None
self.app = create_app({
"TESTING": True,
"WEBHOOK_SECRET": "webhook-secret",
"REPOSITORY": "KingHWJ/WeReadBoard",
"BRANCH": "master",
"PROJECT_DIR": "/srv/weread",
"DEPLOY_SCRIPT": "deploy.sh",
})
self.client = self.app.test_client()
@staticmethod
def signed(payload):
body = json.dumps(payload, separators=(",", ":")).encode()
signature = hmac.new(b"webhook-secret", body, hashlib.sha256).hexdigest()
return body, {
"Content-Type": "application/json",
"X-GitHub-Event": "push",
"X-Hub-Signature-256": f"sha256={signature}",
}
def test_health_is_available(self):
self.assertEqual(self.client.get("/health").status_code, 200)
def test_invalid_signature_is_rejected(self):
response = self.client.post(
"/github/webhook",
data=b"{}",
headers={"X-GitHub-Event": "push", "X-Hub-Signature-256": "sha256=bad"},
)
self.assertEqual(response.status_code, 401)
@patch("deploy_webhook.subprocess.Popen")
def test_only_matching_master_push_starts_deployment(self, popen):
payload = {"ref": "refs/heads/master", "repository": {"full_name": "KingHWJ/WeReadBoard"}}
body, headers = self.signed(payload)
response = self.client.post("/github/webhook", data=body, headers=headers)
self.assertEqual(response.status_code, 202)
self.assertEqual(response.json["status"], "accepted")
popen.assert_called_once()
self.assertEqual(popen.call_args.args[0], ["/srv/weread/deploy.sh"])
@patch("deploy_webhook.subprocess.Popen")
def test_dev_push_does_not_start_deployment(self, popen):
payload = {"ref": "refs/heads/dev", "repository": {"full_name": "KingHWJ/WeReadBoard"}}
body, headers = self.signed(payload)
response = self.client.post("/github/webhook", data=body, headers=headers)
self.assertEqual(response.status_code, 202)
popen.assert_not_called()
@patch("deploy_webhook.subprocess.Popen")
def test_duplicate_push_is_not_started_while_deploying(self, popen):
popen.return_value.poll.return_value = None
payload = {"ref": "refs/heads/master", "repository": {"full_name": "KingHWJ/WeReadBoard"}}
body, headers = self.signed(payload)
self.assertEqual(self.client.post("/github/webhook", data=body, headers=headers).json["status"], "accepted")
response = self.client.post("/github/webhook", data=body, headers=headers)
self.assertEqual(response.json["status"], "already_deploying")
popen.assert_called_once()
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -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']);
});
+1 -1
View File
@@ -3,7 +3,7 @@ const esc = value => String(value).replace(/[&<>"']/g, char => ({'&':'&amp;','<'
const findBook = id => books.find(book => book.id === id);
const 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';