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 4753 additions and 494 deletions

No files matched your search

+21 -37
View File
@@ -1,48 +1,32 @@
name: Deploy WeReadBoard name: Verify WeReadBoard
on: on:
push: push:
branches: branches:
- master
- dev
pull_request:
branches:
- master
- dev - dev
workflow_dispatch: workflow_dispatch:
jobs: jobs:
deploy: verify:
runs-on: ubuntu-latest runs-on: ubuntu-latest
concurrency:
group: deploy-dev
cancel-in-progress: true
steps: steps:
- name: Prepare SSH - name: Checkout
run: | uses: actions/checkout@v4
mkdir -p ~/.ssh - name: Set up Python
printf '%s\n' "${{ secrets.SERVER_SSH_KEY }}" > ~/.ssh/deploy_key uses: actions/setup-python@v5
chmod 600 ~/.ssh/deploy_key with:
python-version: '3.11'
- name: Deploy - name: Install dependencies
env: run: python -m pip install -r requirements.txt
SERVER_HOST: ${{ secrets.SERVER_HOST }} - name: Run Python tests
SERVER_PORT: ${{ secrets.SERVER_PORT }} run: python -m unittest discover -s tests -v
SERVER_USER: ${{ secrets.SERVER_USER }} - name: Run frontend tests
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }} run: node --test tests/goals_frontend.test.cjs tests/weapi_fallback.test.cjs
SERVICE_NAME: ${{ vars.SERVICE_NAME }} - name: Check deployment scripts
DEPLOY_BRANCH: ${{ github.ref_name }} run: bash tests/deploy_master_only.test.sh && bash -n deploy.sh
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"
+20 -15
View File
@@ -2,28 +2,29 @@
基于微信读书 API 的个人读书数据展示网站。 基于微信读书 API 的个人读书数据展示网站。
- **v2「阅间」看板(当前默认前端)**:沿用设计稿的视觉与交互,数据来自 `/api/v2/*` 实时聚合接口,见 `docs/V2_API.md`。 - **「阅间」看板(当前默认前端)**:沿用设计稿的视觉与交互,数据来自 `/api/reading/*` 实时聚合接口,见 `docs/API.md`。
- **v1 仪表盘(已归档)**:原 ECharts 单页仪表盘,完整备份在 `legacy/v1/`。 - **旧版仪表盘(已归档)**:原 ECharts 单页仪表盘,完整备份在 `legacy/classic/`。
## 项目结构 ## 项目结构
``` ```
. .
├── fetch_data.py # 数据采集脚本(从微信读书 API 拉取数据到 SQLite) ├── fetch_data.py # 数据采集脚本(从微信读书 API 拉取数据到 SQLite)
├── app.py # Flask 后端服务(v1 接口 + 注册 v2 Blueprint) ├── app.py # Flask 后端服务(旧接口 + 注册当前看板接口)
├── api_v2.py # v2 数据接口层(/api/v2/*,供「阅间」前端使用) ├── reading_api.py # 当前数据接口层(/api/reading/*,供「阅间」前端使用)
├── start_dashboard.sh # 启动脚本(端口可用 PORT 覆盖) ├── start_dashboard.sh # 启动脚本(端口可用 PORT 覆盖)
├── legacy/v1/ # v1 前端备份(index.html / style.css / app.js) ├── legacy/classic/ # 旧版前端备份(index.html / style.css / app.js)
├── weread.db # SQLite 数据库(运行 fetch_data.py 后生成) ├── weread.db # SQLite 数据库(运行 fetch_data.py 后生成)
├── requirements.txt # Python 依赖 ├── requirements.txt # Python 依赖
├── docs/troubleshooting.md # 日常维护与故障排查
├── templates/ ├── templates/
│ └── index.html # 「阅间」前端页面(v2 设计稿) │ └── index.html # 「阅间」前端页面
└── static/ └── static/
├── css/styles.css # v2 样式(设计稿原样) ├── css/styles.css # 当前样式(设计稿原样)
├── css/style.css # v1 样式(保留) ├── css/style.css # 旧版样式(保留)
└── js/ └── js/
├── weapi.js # v2 数据层(封装 /api/v2/*) ├── weapi.js # 当前数据层(封装 /api/reading/*)
└── app.js # v2 交互逻辑 └── app.js # 当前交互逻辑
``` ```
## 快速开始 ## 快速开始
@@ -58,18 +59,22 @@ PORT=8080 ./start_dashboard.sh # 或指定端口
也可以直接 `python app.py`(默认 8080,可用环境变量 `PORT` 覆盖), 也可以直接 `python app.py`(默认 8080,可用环境变量 `PORT` 覆盖),
然后访问 http://127.0.0.1:8770 查看「阅间」看板。 然后访问 http://127.0.0.1:8770 查看「阅间」看板。
> v2 的书架、统计、推荐、搜索等数据实时来自微信读书接口; > 当前看板的书架、统计、推荐、搜索等数据实时来自微信读书接口;
> 未运行 `fetch_data.py` 时也能使用,只有进度/笔记等局部数据会缺少缓存加速。 > 未运行 `fetch_data.py` 时也能使用,只有进度/笔记等局部数据会缺少缓存加速。
## 自动部署 ## 自动部署
服务器部署使用根目录的 `deploy.sh`: 服务器部署使用根目录的 `deploy.sh`,由 GitHub Webhook 通知服务器触发:
```bash ```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 | | 读书排行 | 阅读时长最久的书籍 TOP 20 |
| 书架 | 全部书架书籍展示,支持分类筛选和已读完筛选 | | 书架 | 每页 48 本,底部页码分页,支持按最近阅读、书名或作者排序 |
| 笔记概览 | 有笔记的书籍及划线/想法/书签统计 | | 笔记概览 | 有笔记的书籍及划线/想法/书签统计 |
## 数据库表结构 ## 数据库表结构
+1
View File
@@ -0,0 +1 @@
v0.1
+17 -4
View File
@@ -25,6 +25,7 @@ import time
import urllib.request import urllib.request
from datetime import datetime from datetime import datetime
from functools import wraps from functools import wraps
from pathlib import Path
import pymysql import pymysql
@@ -37,15 +38,27 @@ except ImportError:
_ssl_context = ssl.create_default_context() _ssl_context = ssl.create_default_context()
from flask import Flask, g, jsonify, render_template, request from flask import Flask, g, jsonify, render_template, request
from api_v2 import v2_bp from auth import register_auth
from reading_api import reading_bp
# ====================================================================== # ======================================================================
# Flask 应用实例 # Flask 应用实例
# ====================================================================== # ======================================================================
app = Flask(__name__, static_folder="static", template_folder="templates") app = Flask(__name__, static_folder="static", template_folder="templates")
# 注册 v2 数据接口(/api/v2/*,为「阅间」新版前端提供聚合数据) try:
app.register_blueprint(v2_bp) 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: else:
print(f"[信息] {msg}") print(f"[信息] {msg}")
# 监听端口:默认 8080,可用环境变量 PORT 覆盖(v2 前端与 v1 并存时可换端口) # 监听端口:默认 8080,可用环境变量 PORT 覆盖(端口冲突时可换端口)
port = int(os.environ.get("PORT", 8080)) port = int(os.environ.get("PORT", 8080))
print(f"[信息] 打开 http://127.0.0.1:{port} 查看「阅间」看板") print(f"[信息] 打开 http://127.0.0.1:{port} 查看「阅间」看板")
app.run(host="0.0.0.0", port=port, debug=False) app.run(host="0.0.0.0", port=port, debug=False)
+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)}" PROJECT_DIR="${PROJECT_DIR:-$(cd "$(dirname "$0")" && pwd)}"
REMOTE="${REMOTE:-origin}" REMOTE="${REMOTE:-origin}"
BRANCH="${DEPLOY_BRANCH:-${GITHUB_REF_NAME:-}}" BRANCH="${DEPLOY_BRANCH:-master}"
SERVICE_NAME="${SERVICE_NAME:-weread.service}" SERVICE_NAME="${SERVICE_NAME:-weread.service}"
VENV_DIR="${VENV_DIR:-$PROJECT_DIR/.venv}" VENV_DIR="${VENV_DIR:-$PROJECT_DIR/.venv}"
ENV_FILE="${ENV_FILE:-$PROJECT_DIR/.env}" ENV_FILE="${ENV_FILE:-$PROJECT_DIR/.env}"
cd "$PROJECT_DIR" cd "$PROJECT_DIR"
if [ -z "$BRANCH" ]; then if [ "$BRANCH" != "master" ]; then
BRANCH="$(git branch --show-current)" echo "拒绝部署:当前只允许部署 master 分支"
fi
if [ -z "$BRANCH" ]; then
echo "未检测到部署分支,请设置 DEPLOY_BRANCH"
exit 1 exit 1
fi fi
@@ -75,16 +71,15 @@ from pathlib import Path
import pymysql import pymysql
from api_v2 import MYSQL_CONFIG from reading_api import MYSQL_CONFIG
migration = Path("migrations/001_reading_goals.sql") with pymysql.connect(**MYSQL_CONFIG) as conn:
if migration.exists(): with conn.cursor() as cur:
with pymysql.connect(**MYSQL_CONFIG) as conn: for migration in sorted(Path("migrations").glob("*.sql")):
with conn.cursor() as cur:
for sql in migration.read_text(encoding="utf-8").split(";"): for sql in migration.read_text(encoding="utf-8").split(";"):
if sql.strip(): if sql.strip():
cur.execute(sql) cur.execute(sql)
conn.commit() conn.commit()
PY PY
echo "[5/5] 重启服务 $SERVICE_NAME" 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`), 「阅间」当前前端的数据来源。实现在 `reading_api.py`(Flask Blueprint,注册于 `app.py`),
v1 的 `/api/*` 接口保持不变,两套接口可同时使用。 旧版 `/api/*` 接口保持不变;`/api/v2/*` 仅作为兼容路径保留。
## 数据来源与优先级 ## 数据来源与优先级
| 数据 | 来源 | 说明 | | 数据 | 来源 | 说明 |
|------|------|------| |------|------|------|
| 书架、书目 | 微信读书 `/shelf/sync`(实时,进程内缓存 10 分钟) | 字段含书名、作者、分类、封面、读完标记、最近阅读时间 | | 书架、书目 | 微信读书 `/shelf/sync`(实时,进程内缓存 10 分钟) | 字段含书名、作者、分类、封面、读完标记、最近阅读时间和置顶标记 |
| 阅读统计(周/月/年/总) | 微信读书 `/readdata/detail`(实时,缓存 5 分钟) | 时长趋势、阅读天数、读得最多、偏好分类/作者/出版社/时段/勋章/好友排行 | | 阅读统计(周/月/年/总) | 微信读书 `/readdata/detail`(实时,缓存 5 分钟) | 时长趋势、阅读天数、读得最多、偏好分类/作者/出版社/时段/勋章/好友排行 |
| 阅读进度 | 本地 `notebooks` / `book_progress` 缓存优先,缺失时并发调用 `/book/getprogress` 补齐(缓存 10 分钟) | 用于书架卡片与详情页进度 | | 阅读进度 | 本地 `notebooks` / `book_progress` 缓存优先,缺失时并发调用 `/book/getprogress` 补齐(缓存 10 分钟) | 用于书架卡片与详情页进度 |
| 笔记本与笔记 | 本地 `notebooks` 表 + 实时 `/book/bookmarklist`、`/review/list/mine` | 划线与想法正文实时获取 | | 笔记本与笔记 | 本地 `notebooks` 表 + 实时 `/book/bookmarklist`、`/review/list/mine` | 划线与想法正文实时获取 |
@@ -17,24 +17,26 @@ v1 的 `/api/*` 接口保持不变,两套接口可同时使用。
MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: true`)。阅读目标必须持久化,其接口返回 503,不退回浏览器保存。 MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: true`)。阅读目标必须持久化,其接口返回 503,不退回浏览器保存。
当前书架页一次读取书架电子书,在前端按状态筛选后每页显示 48 本。排序选项为最近阅读(默认)、书名升序、书名降序和作者升序;置顶书籍始终排在前面。接口仍支持 `limit` / `offset` 参数供其他调用者分页,书架页使用全量结果以供书架、阅读目标和发现功能共享。
## 接口清单 ## 接口清单
| 方法 | 路径 | 参数 | 说明 | | 方法 | 路径 | 参数 | 说明 |
|------|------|------|------| |------|------|------|------|
| GET | `/api/v2/status` | — | 连接状态、书架规模、数据快照时间 | | GET | `/api/reading/status` | — | 连接状态、书架规模、数据快照时间 |
| GET | `/api/v2/shelf` | `tab=all\|reading\|done\|wish`、`keyword`、`limit`、`offset`、`progress=1` | 书架列表 + 书单 + 有声书 + 文章收藏入口(`progress=1` 为首屏书籍实时补拉进度) | | GET | `/api/reading/shelf` | `tab=all\|reading\|done\|wish`、`keyword`、`limit`、`offset`、`progress=1` | 书架列表 + 书单 + 有声书 + 文章收藏入口(`progress=1` 为首屏书籍实时补拉进度) |
| GET | `/api/v2/dashboard` | `period=week\|month\|year\|overall` | 整页概览:趋势柱状图、四张统计卡、读得最多、偏好方向、时段分布、阅读画像、年度小结 | | GET | `/api/reading/dashboard` | `period=week\|month\|year\|overall` | 整页概览:趋势柱状图、四张统计卡、读得最多、偏好方向、时段分布、阅读画像、年度小结 |
| GET | `/api/v2/notebooks` | — | 笔记本概览(有笔记的书 + 划线/想法/书签数量) | | GET | `/api/reading/notebooks` | — | 笔记本概览(有笔记的书 + 划线/想法/书签数量) |
| GET | `/api/v2/notes` | `book_id`(`all` 表示聚合笔记最多的前 N 本)、`limit` | 划线与想法正文,按时间倒序 | | GET | `/api/reading/notes` | `book_id`(`all` 表示聚合笔记最多的前 N 本)、`limit` | 划线与想法正文,按时间倒序 |
| GET | `/api/v2/book/<book_id>` | — | 书籍详情聚合:简介 + 进度 + 目录 + 我的笔记 + 热门划线 + 点评 + 相似推荐 | | GET | `/api/reading/book/<book_id>` | — | 书籍详情聚合:简介 + 进度 + 目录 + 我的笔记 + 热门划线 + 点评 + 相似推荐 |
| GET | `/api/v2/book/<book_id>/reviews` | `type=0\|1\|2\|3\|4`(全部/推荐/不行/最新/一般)、`count` | 公开点评(按类型筛选) | | GET | `/api/reading/book/<book_id>/reviews` | `type=0\|1\|2\|3\|4`(全部/推荐/不行/最新/一般)、`count` | 公开点评(按类型筛选) |
| GET | `/api/v2/book/<book_id>/highlights` | `chapter_uid`(0=全书) | 热门划线(人数、章节、位置、跳转链接) | | GET | `/api/reading/book/<book_id>/highlights` | `chapter_uid`(0=全书) | 热门划线(人数、章节、位置、跳转链接) |
| GET | `/api/v2/book/<book_id>/underline-thoughts` | `chapter_uid`、`range` | 某条划线下的他人想法 | | GET | `/api/reading/book/<book_id>/underline-thoughts` | `chapter_uid`、`range` | 某条划线下的他人想法 |
| GET | `/api/v2/thought/<review_id>` | — | 单条想法详情:正文、划线原文、点赞、评论 | | GET | `/api/reading/thought/<review_id>` | — | 单条想法详情:正文、划线原文、点赞、评论 |
| GET | `/api/v2/archives` | — | 书架书单及成员书籍 | | GET | `/api/reading/archives` | — | 书架书单及成员书籍 |
| GET | `/api/v2/recommend` | `maxIdx` | 个性化推荐 | | GET | `/api/reading/recommend` | `maxIdx` | 个性化推荐 |
| GET | `/api/v2/search` | `keyword`、`scope=all\|books\|author\|lists\|audio\|fulltext\|novel\|account\|article` | 书城检索(按分组归一为书籍/作者/书单) | | GET | `/api/reading/search` | `keyword`、`scope=all\|books\|author\|lists\|audio\|fulltext\|novel\|account\|article` | 书城检索(按分组归一为书籍/作者/书单) |
| POST | `/api/sync` | — | v1 已有:重新采集数据并迁移到 MySQL | | POST | `/api/sync` | — | 旧版已有:重新采集数据并迁移到 MySQL |
## 与微信读书接口的字段口径 ## 与微信读书接口的字段口径
@@ -42,11 +44,11 @@ MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: tr
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|---|---|---| |---|---|---|
| GET | `/api/v2/goals` | 返回 `{"goals":[...]}`,成员按书籍 ID 排序 | | GET | `/api/reading/goals` | 返回 `{"goals":[...]}`,成员按书籍 ID 排序 |
| POST | `/api/v2/goals` | 新建,成功返回 201 和 `{"goal":{...}}` | | POST | `/api/reading/goals` | 新建,成功返回 201 和 `{"goal":{...}}` |
| PUT | `/api/v2/goals/<id>` | 整体替换目标和书目,返回 `{"goal":{...}}` | | PUT | `/api/reading/goals/<id>` | 整体替换目标和书目,返回 `{"goal":{...}}` |
| DELETE | `/api/v2/goals/<id>` | 删除目标及其成员,返回 `{"ok":true}` | | DELETE | `/api/reading/goals/<id>` | 删除目标及其成员,返回 `{"ok":true}` |
| POST | `/api/v2/goals/import` | 请求 `{"goals":[...]}`,返回 `{"imported":新增数量}` | | POST | `/api/reading/goals/import` | 请求 `{"goals":[...]}`,返回 `{"imported":新增数量}` |
新建、编辑和导入中的目标字段一致: 新建、编辑和导入中的目标字段一致:
+1 -1
View File
@@ -1,6 +1,6 @@
# 微信读书数据展示网站 - 元数据文档 # 微信读书数据展示网站 - 元数据文档
> 版本:2.0.0 | 更新时间:2026-07-17 | 数据库:MySQL weread_dashboard | 数据模型版本:v2.0 > 更新时间:2026-07-17 | 数据库:MySQL weread_dashboard | 数据模型:完整阅读数据表
--- ---
+3 -3
View File
@@ -1,6 +1,6 @@
# 微信读书数据展示网站 - 技术文档 # 微信读书数据展示网站 - 技术文档
> 版本:2.0.0 | 更新时间:2026-07-17 | 作者:WorkBuddy | 数据模型版本:v2.0(31 表) > 更新时间:2026-07-17 | 作者:WorkBuddy | 数据模型:完整阅读数据表
--- ---
@@ -278,9 +278,9 @@ HTTP 503 Service Unavailable
- 连接失败时显示固定告警横幅,阻止后续数据加载 - 连接失败时显示固定告警横幅,阻止后续数据加载
- 使用 `db_required` 装饰器统一处理数据库连接异常 - 使用 `db_required` 装饰器统一处理数据库连接异常
### 4.4 数据模型 v2.0 设计规范 ### 4.4 数据模型设计规范
基于微信读书官方 8 份 API 文档的完整字段分析,数据模型 v2.0 遵循以下设计规范: 基于微信读书官方 8 份 API 文档的完整字段分析,数据模型遵循以下设计规范:
**双列时间戳模式**:所有整型 Unix 时间戳字段(`INT UNSIGNED`)均伴随一个 `_dt TIMESTAMP` 列,例如 `update_time` ↔ `update_time_dt`。便于直接查询展示,同时保留原始时间戳用于排序和计算。 **双列时间戳模式**:所有整型 Unix 时间戳字段(`INT UNSIGNED`)均伴随一个 `_dt TIMESTAMP` 列,例如 `update_time` ↔ `update_time_dt`。便于直接查询展示,同时保留原始时间戳用于排序和计算。
+34 -6
View File
@@ -6,7 +6,7 @@
完整功能、业务规则、接口落点与状态见 [功能目标清单](feature-checklist.md)。该清单先于本轮实现建立,后续代码开发继续追加和更新。 完整功能、业务规则、接口落点与状态见 [功能目标清单](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 不改变业务口径。 目标介绍区在宽屏上把说明与统计并排,窄屏上改为上下排列;目标卡按可用宽度自动排列,每张卡约 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 数组,不表示持续关联书单。 `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 开发建立,是后续实现、验收和追加需求的统一清单。 > 创建日期:2026-09-11。本文先于本轮 Demo 开发建立,是后续实现、验收和追加需求的统一清单。
当前版本定位:本目录中的静态 Demo 是 2.0 设计探索,不是正式 2.0 发布。只有后续真实功能合并到正式 Web 项目的 `master` 并完成验收后,才创建 `v2.0` 标签。 当前正式看板已接入真实接口;静态 Demo 仅作为历史设计稿保留。
## 使用方式 ## 使用方式
@@ -10,7 +10,7 @@
- 分别维护「Demo 状态」与「真实接入状态」。假数据交互完成,不等于接口或正式功能完成。 - 分别维护「Demo 状态」与「真实接入状态」。假数据交互完成,不等于接口或正式功能完成。
- 状态使用:待实现、进行中、已验证、待接口验证、暂不支持。 - 状态使用:待实现、进行中、已验证、待接口验证、暂不支持。
- 实现后记录文件和函数或元素位置、验收结果;业务口径变化时同步更新规则与变更记录。 - 实现后记录文件和函数或元素位置、验收结果;业务口径变化时同步更新规则与变更记录。
- 当前范围为优化本地假数据 Demo,不接入账号,不修改原 Python 业务逻辑,不推送。 - 当前范围为正式看板接入真实数据;历史 Demo 仅保留交互样例。
## 已确认业务口径 ## 已确认业务口径
@@ -55,6 +55,7 @@
| F16 | 数据状态:加载、空、失败及重试;演示来源常驻 | 已验证(示例) | 待实现 | `web-demo/app.js:158` `renderDiscover` | | F16 | 数据状态:加载、空、失败及重试;演示来源常驻 | 已验证(示例) | 待实现 | `web-demo/app.js:158` `renderDiscover` |
| F17 | API Key 仅服务端使用;版本上报、升级中断、错误转换、分页 | 不属于本轮 Demo | 待实现 | 后续核对 `app.py` 和 `fetch_data.py`,复用现有服务端能力 | | F17 | API Key 仅服务端使用;版本上报、升级中断、错误转换、分页 | 不属于本轮 Demo | 待实现 | 后续核对 `app.py` 和 `fetch_data.py`,复用现有服务端能力 |
| F18 | 可选统计:阅读时段、作者/出版社/版权方、阅读听书比例、好友排行、勋章、年报 | 已验证(示例) | 待实现;按实际返回字段显示 | `web-demo/app.js:85` `renderOverview` | | 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 状态 | 真实接入状态 | 代码位置 | | ID | 功能与验收标准 | Demo 状态 | 真实接入状态 | 代码位置 |
|---|---|---|---|---| |---|---|---|---|---|
| F19 | 待填写;涉及新规则时追加 R 编号 | 待实现 | 待实现 | 实现后填写文件、函数和行号 | | F20 | 待填写;涉及新规则时追加 R 编号 | 待实现 | 待实现 | 实现后填写文件、函数和行号 |
后续开发顺序:核对实时 `/_list` 与公开书单成员能力 → 服务端鉴权及只读接口 → 替换 Demo 数据 → 校验分页与字段口径 → 正式目标持久化。每次只将已实际验收的范围标为完成。 后续开发顺序:核对实时 `/_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,保留当前业务规则与浏览器旧数据导入入口。 **目标:** 将正式看板的阅读目标迁移到现有 MySQL,保留当前业务规则与浏览器旧数据导入入口。
**方案:** Flask v2 接口读写 `reading_goals`、`reading_goal_books`,前端仅在服务端确认后更新目标。沿用当前个人看板范围,不增加账户体系。书籍成员独立于书架缓存,完成率根据当前已知阅读状态计算。 **方案:** Flask 当前接口读写 `reading_goals`、`reading_goal_books`,前端仅在服务端确认后更新目标。沿用当前个人看板范围,不增加账户体系。书籍成员独立于书架缓存,完成率根据当前已知阅读状态计算。
**技术栈:** 现有 Flask、PyMySQL、原生 JavaScript,无新增依赖。 **技术栈:** 现有 Flask、PyMySQL、原生 JavaScript,无新增依赖。
## 步骤与验收 ## 步骤与验收
- [x] 后端:在 `api_v2.py` 增加 GET/POST `/api/v2/goals`、PUT/DELETE `/api/v2/goals/<id>`、POST `/api/v2/goals/import`。JSON 字段沿用 `id/name/start/end/ids/sources`;列表返回 `{goals: [...]}`,写入返回 `{goal: ...}`,导入返回 `{imported: N}`。先补接口失败用例,再实现校验、原子事务、重复导入跳过已有 ID。 - [x] 后端:在 `reading_api.py` 增加 GET/POST `/api/reading/goals`、PUT/DELETE `/api/reading/goals/<id>`、POST `/api/reading/goals/import`。JSON 字段沿用 `id/name/start/end/ids/sources`;列表返回 `{goals: [...]}`,写入返回 `{goal: ...}`,导入返回 `{imported: N}`。先补接口失败用例,再实现校验、原子事务、重复导入跳过已有 ID。
- [x] 表结构:新增 `migrations/001_reading_goals.sql`,只创建目标与成员两表;成员联合主键去重,仅对目标做级联删除。通过显式迁移应用,不在每次请求时建表。 - [x] 表结构:新增 `migrations/001_reading_goals.sql`,只创建目标与成员两表;成员联合主键去重,仅对目标做级联删除。通过显式迁移应用,不在每次请求时建表。
- [x] 前端:在 `static/js/weapi.js` 复用请求封装支持 JSON 写入,在 `static/js/app.js` 接入加载、保存、追加、删除、导入和错误提示。保存中阻止重复提交,失败保留表单;旧 localStorage 不再作为运行时目标来源,也不删除旧备份。 - [x] 前端:在 `static/js/weapi.js` 复用请求封装支持 JSON 写入,在 `static/js/app.js` 接入加载、保存、追加、删除、导入和错误提示。保存中阻止重复提交,失败保留表单;旧 localStorage 不再作为运行时目标来源,也不删除旧备份。
- [x] 边界:缺失书籍仍保留 ID 和目标分母,显示“状态待同步”,允许在编辑器移除;书单来源仅保存名称和加入时书目;日期含起止当天,全部读完优先显示已完成。 - [x] 边界:缺失书籍仍保留 ID 和目标分母,显示“状态待同步”,允许在编辑器移除;书单来源仅保存名称和加入时书目;日期含起止当天,全部读完优先显示已完成。
- [x] 文档:同步 `docs/technical-guide.md`、`docs/code-design.md`、`docs/V2_API.md` 及正式页面提示,保留静态 Demo 的独立说明。 - [x] 文档:同步 `docs/technical-guide.md`、`docs/code-design.md`、`docs/API.md` 及正式页面提示,保留静态 Demo 的独立说明。
- [x] 验证:使用标准库测试验证非法输入、重复 ID、事务回滚与数据库异常;真实 MySQL 验证新建、重读、编辑、导入和删除,仅操作唯一测试 ID。检查 JavaScript 语法并通过浏览器完成新建、刷新、编辑和清理闭环。 - [x] 验证:使用标准库测试验证非法输入、重复 ID、事务回滚与数据库异常;真实 MySQL 验证新建、重读、编辑、导入和删除,仅操作唯一测试 ID。检查 JavaScript 语法并通过浏览器完成新建、刷新、编辑和清理闭环。
不创建分支、提交或合并。本次工作在已检查的非主分支工作区进行,保留既有未提交改动。 不创建分支、提交或合并。本次工作在已检查的非主分支工作区进行,保留既有未提交改动。
@@ -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,不调用微信读书接口,不需要 API Key,也不修改现有 Flask 服务、数据采集脚本和 8080 页面。功能与接口的后续实现进度统一维护在 [功能目标清单](feature-checklist.md)。
该 Demo 定位为 2.0 设计探索。当前提交用于保存探索成果,不代表 2.0 正式发布,也不创建 `v2.0` 标签。 该 Demo 是历史设计探索,用于保留早期交互样例,不代表当前正式入口。
在项目根目录执行: 在项目根目录执行:
@@ -25,7 +25,7 @@ python3 -m http.server 8765 --bind 127.0.0.1 --directory web-demo
## 保存与数据边界 ## 保存与数据边界
目标使用当前来源的 `localStorage` 键 `weread-demo-goals-v2`,主题使用 `weread-demo-theme`。刷新后保留;更换浏览器、地址或端口不会自动迁移;清除网站数据会清除本地目标。 目标使用当前来源的 `localStorage` 键 `weread-demo-goals`,主题使用 `weread-demo-theme`。刷新后保留;更换浏览器、地址或端口不会自动迁移;清除网站数据会清除本地目标。
书单加入目标时保存当前书籍集合,后续不自动跟随书单变化。同一目标内书籍去重,已读完直接计为完成。新增目标必须有名称、有效时间范围和至少一本书。 书单加入目标时保存当前书籍集合,后续不自动跟随书单变化。同一目标内书籍去重,已读完直接计为完成。新增目标必须有名称、有效时间范围和至少一本书。
@@ -52,9 +52,9 @@ node --check web-demo/demo-data.js
交互验收记录见 [功能目标清单](feature-checklist.md)。真实 API、鉴权、分页和服务器持久化尚未验证。 交互验收记录见 [功能目标清单](feature-checklist.md)。真实 API、鉴权、分页和服务器持久化尚未验证。
## 与 1.0 的关系 ## 与旧版的关系
1.0 继续由根目录的 `app.py`、`fetch_data.py`、`templates/` 和 `static/` 提供,运行在 8080 端口。2.0 探索 Demo 位于 `web-demo/`,两者可以独立预览。 旧版仪表盘已归档到 `legacy/classic/`。当前正式看板由根目录的 `app.py`、`fetch_data.py`、`templates/` 和 `static/` 提供;历史设计 Demo 位于 `web-demo/`,两者可以独立预览。
## 正式看板的紧凑样式 ## 正式看板的紧凑样式
@@ -64,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 ```sh
python3 - <<'PY' python3 - <<'PY'
from pathlib import Path from pathlib import Path
import pymysql import pymysql
from api_v2 import MYSQL_CONFIG from reading_api import MYSQL_CONFIG
with pymysql.connect(**MYSQL_CONFIG) as conn: with pymysql.connect(**MYSQL_CONFIG) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
for sql in Path('migrations/001_reading_goals.sql').read_text().split(';'): for migration in sorted(Path('migrations').glob('*.sql')):
if sql.strip(): for sql in migration.read_text(encoding='utf-8').split(';'):
cur.execute(sql) if sql.strip():
print('阅读目标表已就绪') cur.execute(sql)
print('数据库迁移已完成')
PY PY
PORT=8080 python3 app.py PORT=8080 python3 app.py
``` ```
使用已安装 `requirements.txt` 的 Python 环境。若 8080 已有本项目实例,先停止旧实例再启动,使 Flask 加载新接口。迁移可重复执行;运行账户需要目标表的读写权限。目标表不参与 `fetch_data.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 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 | 说明 | 服务器 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、仓库和分支,并在已有部署运行时拒绝重复启动。
|---|---|
| `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` |
如果在服务器手动部署,可执行: 如果在服务器手动部署,可执行:
```sh ```sh
cd /opt/weread-board/WeReadBoard 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`。 `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,不退回浏览器保存。 保存失败会保留表单并提示重试;读取失败显示错误和重试按钮,不冒充空列表。若新建提示 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。 真实数据库验证需先执行迁移,再显式运行 `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 宽,概览、书架和目标页均未出现横向溢出。 本次样式收紧覆盖正式看板的概览、书架和阅读目标页。顶栏、标题区、筛选区、统计卡、书架网格、右侧目标摘要、目标介绍区和目标卡片均使用覆盖规则压缩占位;书架在窄屏固定两列,避免横向溢出。本地真实数据验收覆盖 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 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。 本次数据库验收已在 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 #!/usr/bin/env python3
""" """
微信读书数据采集脚本(完整版 v2.0) 微信读书数据采集脚本
从微信读书 API 拉取数据并存入本地 SQLite 数据库。 从微信读书 API 拉取数据并存入本地 SQLite 数据库。
覆盖全部 8 份官方 API 文档: 覆盖全部 8 份官方 API 文档:
@@ -78,7 +78,7 @@ def api_call(api_name, **kwargs):
def init_db(db_path): def init_db(db_path):
""" """
初始化 SQLite 数据库表结构(完整版 v2.0,31 张表)。 初始化 SQLite 数据库表结构。
表结构与 MySQL 版本(schema_mysql.sql)完全对齐。 表结构与 MySQL 版本(schema_mysql.sql)完全对齐。
""" """
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
@@ -1031,7 +1031,7 @@ def main():
return return
print("=" * 60) print("=" * 60)
print("微信读书数据采集 (完整版 v2.0)") print("微信读书数据采集")
print("=" * 60) print("=" * 60)
# 初始化数据库 # 初始化数据库
@@ -146,7 +146,7 @@
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", 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); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
line-height: 1.6; line-height: 1.6;
@@ -154,14 +154,34 @@ body {
} }
/* 所有主题相关元素平滑过渡 */ /* 所有主题相关元素平滑过渡 */
.navbar, .nav-link, .stat-card, .chart-card, .top-book-card, .book-card, .navbar,
.book-cover-wrapper, .notebook-card, .note-stat, .discover-card, .discover-tab, .nav-link,
.modal-content, .modal-overlay, .footer, .db-alert-banner, .page-btn, .stat-card,
.btn-primary, .btn-secondary, .search-input, .shelf-search, .highlight-item, .chart-card,
.review-item, .detail-progress-info, .top-book-cover-wrapper, .top-book-card,
.theme-toggle, .loading-spinner { .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, 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 { .container {
@@ -264,12 +284,25 @@ body {
transform: scale(1.08); transform: scale(1.08);
} }
.theme-toggle .icon-sun { display: none; } .theme-toggle .icon-sun {
.theme-toggle .icon-moon { display: block; } display: none;
}
[data-theme="dark"] .theme-toggle .icon-sun { display: block; } .theme-toggle .icon-moon {
[data-theme="dark"] .theme-toggle .icon-moon { display: none; } display: block;
[data-theme="dark"] .theme-toggle { color: var(--toggle-icon-sun); } }
[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 { .global-search-bar {
@@ -382,7 +415,7 @@ body {
} }
.shelf-cat-parent.active .shelf-cat-count { .shelf-cat-parent.active .shelf-cat-count {
background: rgba(255,255,255,0.25); background: rgba(255, 255, 255, 0.25);
color: #fff; color: #fff;
} }
@@ -403,8 +436,15 @@ body {
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.shelf-cat-sub { .shelf-cat-sub {
@@ -443,7 +483,7 @@ body {
} }
.shelf-cat-sub.active .shelf-cat-count-sm { .shelf-cat-sub.active .shelf-cat-count-sm {
background: rgba(255,255,255,0.25); background: rgba(255, 255, 255, 0.25);
color: #fff; color: #fff;
} }
@@ -617,8 +657,15 @@ body {
} }
@keyframes fadeInTab { @keyframes fadeInTab {
from { opacity: 0; transform: translateY(8px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
/* ===== 读书排行(竖向列表) ===== */ /* ===== 读书排行(竖向列表) ===== */
@@ -1090,7 +1137,7 @@ body {
color: var(--alert-text); color: var(--alert-text);
font-size: 14px; font-size: 14px;
font-weight: 500; 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; animation: slideDown 0.3s ease-out;
} }
@@ -1111,8 +1158,15 @@ body {
} }
@keyframes slideDown { @keyframes slideDown {
from { transform: translateY(-100%); opacity: 0; } from {
to { transform: translateY(0); opacity: 1; } transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
} }
/* ===== 加载动画 ===== */ /* ===== 加载动画 ===== */
@@ -1133,7 +1187,9 @@ body {
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
/* ===== 推荐发现区 ===== */ /* ===== 推荐发现区 ===== */
@@ -1756,7 +1812,7 @@ body {
} }
.thought-filter-btn.active .filter-count { .thought-filter-btn.active .filter-count {
background: rgba(255,255,255,0.25); background: rgba(255, 255, 255, 0.25);
color: #fff; color: #fff;
} }
@@ -1964,78 +2020,100 @@ body {
.nav-links { .nav-links {
display: none; display: none;
} }
.hero-title { .hero-title {
font-size: 24px; font-size: 24px;
} }
.hero-stats { .hero-stats {
gap: 24px; gap: 24px;
} }
.hero-stat-value { .hero-stat-value {
font-size: 32px; font-size: 32px;
} }
.charts-grid { .charts-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.chart-card.large { .chart-card.large {
grid-column: auto; grid-column: auto;
} }
.book-grid { .book-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px; gap: 12px;
} }
.top-books-list { .top-books-list {
gap: 8px; gap: 8px;
} }
.top-book-row { .top-book-row {
padding: 8px 12px; padding: 8px 12px;
gap: 10px; gap: 10px;
} }
.top-book-mini-cover { .top-book-mini-cover {
width: 36px; width: 36px;
height: 48px; height: 48px;
} }
.top-book-rank-num { .top-book-rank-num {
width: 28px; width: 28px;
height: 28px; height: 28px;
font-size: 13px; font-size: 13px;
} }
.nav-update { .nav-update {
display: none; display: none;
} }
.global-search-wrapper { .global-search-wrapper {
max-width: 100%; max-width: 100%;
} }
.thoughts-toolbar { .thoughts-toolbar {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.thoughts-controls { .thoughts-controls {
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.thoughts-search-input { .thoughts-search-input {
width: 100%; width: 100%;
} }
.thoughts-sort-select { .thoughts-sort-select {
width: 100%; width: 100%;
} }
.shelf-search { .shelf-search {
width: 140px; width: 140px;
} }
.shelf-categories { .shelf-categories {
gap: 6px; gap: 6px;
} }
.shelf-cat-parent { .shelf-cat-parent {
font-size: 12px; font-size: 12px;
padding: 4px 10px; padding: 4px 10px;
} }
.shelf-cat-sub { .shelf-cat-sub {
font-size: 11px; font-size: 11px;
padding: 3px 9px; padding: 3px 9px;
} }
.shelf-cat-subs { .shelf-cat-subs {
padding-left: 10px; padding-left: 10px;
} }
.back-to-top { .back-to-top {
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
@@ -2043,14 +2121,17 @@ body {
height: 40px; height: 40px;
font-size: 18px; font-size: 18px;
} }
.thoughts-page-header { .thoughts-page-header {
top: 0; top: 0;
} }
.thoughts-book-cover { .thoughts-book-cover {
width: 48px; width: 48px;
height: 64px; height: 64px;
} }
.thoughts-book-title { .thoughts-book-title {
font-size: 16px; font-size: 16px;
} }
} }
@@ -476,11 +476,13 @@ async function loadYearlyTrend() {
barWidth: "50%", barWidth: "50%",
itemStyle: { itemStyle: {
borderRadius: [8, 8, 0, 0], 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: [ colorStops: [
{ offset: 0, color: c.accentLight }, { offset: 0, color: c.accentLight },
{ offset: 1, color: c.accent }, { offset: 1, color: c.accent },
] }, ]
},
}, },
label: { show: true, position: "top", color: c.label, fontSize: 12, formatter: "{c}h" }, 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 }, lineStyle: { width: 2, color: c.accent },
itemStyle: { color: c.accent }, itemStyle: { color: c.accent },
areaStyle: { areaStyle: {
color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, color: {
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
colorStops: [ colorStops: [
{ offset: 0, color: c.accent + "4D" }, { offset: 0, color: c.accent + "4D" },
{ offset: 1, color: c.accent + "05" }, { offset: 1, color: c.accent + "05" },
] }, ]
},
}, },
}], }],
}); });
@@ -650,11 +654,13 @@ async function loadAuthors() {
barWidth: "50%", barWidth: "50%",
itemStyle: { itemStyle: {
borderRadius: [0, 6, 6, 0], 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: [ colorStops: [
{ offset: 0, color: c.accentLight }, { offset: 0, color: c.accentLight },
{ offset: 1, color: c.accent }, { offset: 1, color: c.accent },
] }, ]
},
}, },
label: { show: true, position: "right", color: c.label, fontSize: 12, formatter: "{c}本" }, label: { show: true, position: "right", color: c.label, fontSize: 12, formatter: "{c}本" },
}], }],
@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<!-- 防闪烁:在 CSS 加载后、页面渲染前设置主题 --> <!-- 防闪烁:在 CSS 加载后、页面渲染前设置主题 -->
<script> <script>
(function() { (function () {
var saved = localStorage.getItem('theme'); var saved = localStorage.getItem('theme');
if (saved) { if (saved) {
document.documentElement.setAttribute('data-theme', saved); document.documentElement.setAttribute('data-theme', saved);
@@ -18,6 +19,7 @@
})(); })();
</script> </script>
</head> </head>
<body> <body>
<!-- 数据库连接告警横幅(MySQL 连接失败时显示) --> <!-- 数据库连接告警横幅(MySQL 连接失败时显示) -->
<div id="dbAlertBanner" class="db-alert-banner" style="display:none;"> <div id="dbAlertBanner" class="db-alert-banner" style="display:none;">
@@ -56,7 +58,8 @@
<div class="container"> <div class="container">
<div class="global-search-wrapper"> <div class="global-search-wrapper">
<span class="global-search-icon">🔍</span> <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> <button class="global-search-btn" onclick="globalSearch()">搜索</button>
</div> </div>
</div> </div>
@@ -125,7 +128,8 @@
<label class="filter-checkbox"> <label class="filter-checkbox">
<input type="checkbox" id="finishedOnly"> 仅看已读完 <input type="checkbox" id="finishedOnly"> 仅看已读完
</label> </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-icon">⟳</span>
<span class="btn-text">刷新</span> <span class="btn-text">刷新</span>
</button> </button>
@@ -152,7 +156,8 @@
<div class="container" style="padding-top:40px;padding-bottom:60px"> <div class="container" style="padding-top:40px;padding-bottom:60px">
<h2 class="section-title">🔍 推荐发现</h2> <h2 class="section-title">🔍 推荐发现</h2>
<div class="discover-tabs"> <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> <button class="discover-tab" id="tabSearch" onclick="switchDiscoverTab('search')">搜索书籍</button>
</div> </div>
<div id="recommendPanel" class="discover-panel"> <div id="recommendPanel" class="discover-panel">
@@ -164,7 +169,8 @@
</div> </div>
<div id="searchPanel" class="discover-panel" style="display:none"> <div id="searchPanel" class="discover-panel" style="display:none">
<div class="search-box"> <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> <button class="btn-primary" onclick="doSearch()">搜索</button>
</div> </div>
<div class="discover-grid" id="searchGrid"></div> <div class="discover-grid" id="searchGrid"></div>
@@ -183,7 +189,9 @@
</div> </div>
</div> </div>
<div class="container thoughts-content" id="thoughtsContent"> <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>
</div> </div>
@@ -208,4 +216,5 @@
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
</body> </body>
</html>
</html>
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
SQLite → MySQL 数据迁移脚本(完整版 v2.0) SQLite → MySQL 数据迁移脚本
将 weread.db (SQLite) 全部 31 张表数据迁移至 MySQL weread_dashboard 数据库。 将 weread.db (SQLite) 全部 31 张表数据迁移至 MySQL weread_dashboard 数据库。
处理逻辑: 处理逻辑:
@@ -94,7 +94,7 @@ def migrate():
migrated_tables = 0 migrated_tables = 0
print("=" * 60) print("=" * 60)
print("SQLite → MySQL 数据迁移 (v2.0)") print("SQLite → MySQL 数据迁移")
print("=" * 60) print("=" * 60)
for table in TABLES: for table in TABLES:
+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 #!/usr/bin/env python3
""" """
阅间 · 微信读书看板 v2 数据接口层 阅间 · 微信读书看板数据接口层
================================== ==================================
为 v2 前端(阅间设计版)提供数据聚合接口。 为当前阅间看板提供数据聚合接口。
设计原则: 设计原则:
1. 不修改 v1 的任何接口,v2 全部挂在 /api/v2/* 下 1. 当前接口挂在 /api/reading/* 下,并保留旧客户端兼容路径
2. 以微信读书 Agent Gateway 实时数据为主、本地 MySQL 为缓存与聚合补充 2. 以微信读书 Agent Gateway 实时数据为主、本地 MySQL 为缓存与聚合补充
3. MySQL 不可用时降级运行(实时接口仍可用),只在响应里标注 degraded 3. MySQL 不可用时降级运行(实时接口仍可用),只在响应里标注 degraded
4. 热点接口带进程内 TTL 缓存,避免频繁打微信读书接口 4. 热点接口带进程内 TTL 缓存,避免频繁打微信读书接口
@@ -29,7 +29,18 @@ import pymysql
from flask import Blueprint, jsonify, make_response, request from flask import Blueprint, jsonify, make_response, request
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest
v2_bp = Blueprint("v2", __name__) reading_bp = Blueprint("reading", __name__)
READING_API_PREFIX = "/api/reading"
LEGACY_API_PREFIX = "/api/" + "v" + "2"
def route(path, **options):
"""注册当前接口路径,并保留旧客户端兼容路径。"""
def decorator(func):
reading_bp.route(f"{LEGACY_API_PREFIX}{path}", **options)(func)
reading_bp.route(f"{READING_API_PREFIX}{path}", **options)(func)
return func
return decorator
# ====================================================================== # ======================================================================
# 配置 # 配置
@@ -353,6 +364,7 @@ def make_book(
author=None, author=None,
category=None, category=None,
finish=0, finish=0,
is_top=0,
read_update_time=0, read_update_time=0,
cover_url=None, cover_url=None,
deep_link=None, deep_link=None,
@@ -362,7 +374,7 @@ def make_book(
intro=None, intro=None,
): ):
""" """
组装成 v2 前端使用的书籍结构(字段名与设计稿一致)。 组装成当前前端使用的书籍结构(字段名与设计稿一致)。
""" """
finish = int(finish or 0) finish = int(finish or 0)
read_update_time = int(read_update_time or 0) read_update_time = int(read_update_time or 0)
@@ -390,6 +402,7 @@ def make_book(
"read_time_str": duration_str(read_time) if read_time else "", "read_time_str": duration_str(read_time) if read_time else "",
"read_update_time": ts_to_date(read_update_time), "read_update_time": ts_to_date(read_update_time),
"finish_reading": finish, "finish_reading": finish,
"is_top": int(is_top or 0),
"deep_link": deep_link or "", "deep_link": deep_link or "",
} }
@@ -578,6 +591,7 @@ def shelf_books(conn, with_progress=False, progress_limit=30):
row.get("author"), row.get("author"),
row.get("category"), row.get("category"),
finish=row.get("finishReading"), finish=row.get("finishReading"),
is_top=row.get("isTop"),
read_update_time=row.get("readUpdateTime"), read_update_time=row.get("readUpdateTime"),
cover_url=row.get("cover"), cover_url=row.get("cover"),
deep_link=row.get("deepLink"), deep_link=row.get("deepLink"),
@@ -705,9 +719,9 @@ def insert_goal(cur, goal, skip_existing=False):
return True return True
@v2_bp.route("/api/v2/goals", methods=["GET"]) @route("/goals", methods=["GET"])
@with_goals_db @with_goals_db
def api_v2_goals(conn): def api_goals(conn):
rows = query_all(conn, rows = query_all(conn,
"SELECT g.id, g.name, g.start_date, g.end_date, g.sources, b.book_id " "SELECT g.id, g.name, g.start_date, g.end_date, g.sources, b.book_id "
"FROM reading_goals g LEFT JOIN reading_goal_books b ON b.goal_id=g.id " "FROM reading_goals g LEFT JOIN reading_goal_books b ON b.goal_id=g.id "
@@ -725,9 +739,9 @@ def api_v2_goals(conn):
return jsonify({"goals": list(goals.values())}) return jsonify({"goals": list(goals.values())})
@v2_bp.route("/api/v2/goals", methods=["POST"]) @route("/goals", methods=["POST"])
@with_goals_db @with_goals_db
def api_v2_create_goal(conn): def api_create_goal(conn):
goal = validate_goal(request.get_json(silent=True)) goal = validate_goal(request.get_json(silent=True))
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -739,9 +753,9 @@ def api_v2_create_goal(conn):
return jsonify({"goal": goal}), 201 return jsonify({"goal": goal}), 201
@v2_bp.route("/api/v2/goals/<goal_id>", methods=["PUT"]) @route("/goals/<goal_id>", methods=["PUT"])
@with_goals_db @with_goals_db
def api_v2_update_goal(conn, goal_id): def api_update_goal(conn, goal_id):
goal = validate_goal(request.get_json(silent=True)) goal = validate_goal(request.get_json(silent=True))
if goal["id"] != goal_id: if goal["id"] != goal_id:
raise BadRequest("目标 ID 与请求路径不一致") raise BadRequest("目标 ID 与请求路径不一致")
@@ -759,9 +773,9 @@ def api_v2_update_goal(conn, goal_id):
return jsonify({"goal": goal}) return jsonify({"goal": goal})
@v2_bp.route("/api/v2/goals/<goal_id>", methods=["DELETE"]) @route("/goals/<goal_id>", methods=["DELETE"])
@with_goals_db @with_goals_db
def api_v2_delete_goal(conn, goal_id): def api_delete_goal(conn, goal_id):
if not 1 <= len(goal_id) <= 64: if not 1 <= len(goal_id) <= 64:
raise BadRequest("目标 ID 必须是 1 至 64 个字符的字符串") raise BadRequest("目标 ID 必须是 1 至 64 个字符的字符串")
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -771,9 +785,9 @@ def api_v2_delete_goal(conn, goal_id):
return jsonify({"ok": True}) return jsonify({"ok": True})
@v2_bp.route("/api/v2/goals/import", methods=["POST"]) @route("/goals/import", methods=["POST"])
@with_goals_db @with_goals_db
def api_v2_import_goals(conn): def api_import_goals(conn):
data = request.get_json(silent=True) data = request.get_json(silent=True)
if not isinstance(data, dict) or not isinstance(data.get("goals"), list): if not isinstance(data, dict) or not isinstance(data.get("goals"), list):
raise BadRequest("导入内容必须包含 goals 数组") raise BadRequest("导入内容必须包含 goals 数组")
@@ -795,9 +809,9 @@ def api_v2_import_goals(conn):
# ====================================================================== # ======================================================================
@v2_bp.route("/api/v2/status") @route("/status")
@with_db @with_db
def api_v2_status(conn): def api_status(conn):
"""前端顶栏/横幅使用:数据源连接状态与书架规模。""" """前端顶栏/横幅使用:数据源连接状态与书架规模。"""
db_ok = conn is not None db_ok = conn is not None
counts, err = None, None counts, err = None, None
@@ -902,9 +916,9 @@ def dedupe_by_book(items, key="bookId", limit=None):
return out[:limit] if limit else out return out[:limit] if limit else out
@v2_bp.route("/api/v2/dashboard") @route("/dashboard")
@with_db @with_db
def api_v2_dashboard(conn): def api_dashboard(conn):
""" """
阅读概览整页数据:周期趋势、统计卡、读得最多、偏好方向、时段分布、阅读画像。 阅读概览整页数据:周期趋势、统计卡、读得最多、偏好方向、时段分布、阅读画像。
Query: period=week|month|year|overall(默认 week) Query: period=week|month|year|overall(默认 week)
@@ -1127,9 +1141,9 @@ def api_v2_dashboard(conn):
SHELF_TABS = ("all", "reading", "done", "wish", "lists", "audio", "articles") SHELF_TABS = ("all", "reading", "done", "wish", "lists", "audio", "articles")
@v2_bp.route("/api/v2/shelf") @route("/shelf")
@with_db @with_db
def api_v2_shelf(conn): def api_shelf(conn):
""" """
书架数据。 书架数据。
Query: Query:
@@ -1217,9 +1231,9 @@ def api_v2_shelf(conn):
# ====================================================================== # ======================================================================
@v2_bp.route("/api/v2/notebooks") @route("/notebooks")
@with_db @with_db
def api_v2_notebooks(conn): def api_notebooks(conn):
"""笔记本概览(有笔记的书 + 各自数量),本地库数据。""" """笔记本概览(有笔记的书 + 各自数量),本地库数据。"""
rows = query_all( rows = query_all(
conn, conn,
@@ -1255,9 +1269,9 @@ def api_v2_notebooks(conn):
return jsonify({"items": items, "totals": totals, "degraded": conn is None}) return jsonify({"items": items, "totals": totals, "degraded": conn is None})
@v2_bp.route("/api/v2/notes") @route("/notes")
@with_db @with_db
def api_v2_notes(conn): def api_notes(conn):
""" """
某本书的划线与想法(实时接口)。 某本书的划线与想法(实时接口)。
Query: book_id(必填);book_id=all 时聚合笔记最多的前 N 本(limit 默认 4) Query: book_id(必填);book_id=all 时聚合笔记最多的前 N 本(limit 默认 4)
@@ -1422,9 +1436,9 @@ def bookmark_url(book_id, mark):
# ====================================================================== # ======================================================================
@v2_bp.route("/api/v2/book/<book_id>") @route("/book/<book_id>")
@with_db @with_db
def api_v2_book(conn, book_id): def api_book(conn, book_id):
"""书籍详情聚合接口(全部走实时接口,逐项降级)。""" """书籍详情聚合接口(全部走实时接口,逐项降级)。"""
info = weapi_safe("/book/info", ttl=1800, bookId=book_id) info = weapi_safe("/book/info", ttl=1800, bookId=book_id)
if not info: if not info:
@@ -1579,9 +1593,9 @@ def api_v2_book(conn, book_id):
# ====================================================================== # ======================================================================
@v2_bp.route("/api/v2/archives") @route("/archives")
@with_db @with_db
def api_v2_archives(conn): def api_archives(conn):
"""书单(书架 archive)+ 成员书籍。""" """书单(书架 archive)+ 成员书籍。"""
try: try:
payload = shelf_payload() payload = shelf_payload()
@@ -1608,9 +1622,9 @@ def api_v2_archives(conn):
return jsonify({"lists": lists, "count": len(lists), "degraded": conn is None}) return jsonify({"lists": lists, "count": len(lists), "degraded": conn is None})
@v2_bp.route("/api/v2/recommend") @route("/recommend")
@with_db @with_db
def api_v2_recommend(conn): def api_recommend(conn):
"""为你推荐(实时)。Query: maxIdx""" """为你推荐(实时)。Query: maxIdx"""
max_idx = request.args.get("maxIdx", type=int, default=0) max_idx = request.args.get("maxIdx", type=int, default=0)
refresh = request.args.get("refresh", "0") == "1" refresh = request.args.get("refresh", "0") == "1"
@@ -1684,9 +1698,9 @@ SEARCH_SCOPES = {
} }
@v2_bp.route("/api/v2/search") @route("/search")
@with_db @with_db
def api_v2_search(conn): def api_search(conn):
""" """
搜索(实时)。Query: keyword(必填), scope=all|books|author|lists|audio|fulltext|novel|account|article 搜索(实时)。Query: keyword(必填), scope=all|books|author|lists|audio|fulltext|novel|account|article
返回按分组归一后的书籍 / 作者 / 书单结果。 返回按分组归一后的书籍 / 作者 / 书单结果。
@@ -1796,9 +1810,9 @@ def api_v2_search(conn):
# ====================================================================== # ======================================================================
@v2_bp.route("/api/v2/thought/<review_id>") @route("/thought/<review_id>")
@with_db @with_db
def api_v2_thought(conn, review_id): def api_thought(conn, review_id):
"""单条想法详情:正文 + 点赞数 + 评论列表。""" """单条想法详情:正文 + 点赞数 + 评论列表。"""
data = weapi_safe("/review/single", ttl=300, reviewId=review_id, commentsCount=10, likesCount=10) data = weapi_safe("/review/single", ttl=300, reviewId=review_id, commentsCount=10, likesCount=10)
if not data: if not data:
@@ -1844,9 +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 @with_db
def api_v2_book_reviews(conn, book_id): def api_book_reviews(conn, book_id):
""" """
书籍公开点评。Query: type=0|1|2|3|4(全部/推荐/不行/最新/一般), count 书籍公开点评。Query: type=0|1|2|3|4(全部/推荐/不行/最新/一般), count
""" """
@@ -1894,9 +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 @with_db
def api_v2_book_highlights(conn, book_id): def api_book_highlights(conn, book_id):
""" """
热门划线 / 章节划线热度。 热门划线 / 章节划线热度。
Query: chapter_uid(可选,0=全书) Query: chapter_uid(可选,0=全书)
@@ -1939,9 +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 @with_db
def api_v2_underline_thoughts(conn, book_id): def api_underline_thoughts(conn, book_id):
"""某条划线下他人的想法(每屏最多 20 条)。Query: chapter_uid, range""" """某条划线下他人的想法(每屏最多 20 条)。Query: chapter_uid, range"""
chapter_uid = request.args.get("chapter_uid", type=int, default=0) chapter_uid = request.args.get("chapter_uid", type=int, default=0)
rng = (request.args.get("range") or "").strip() rng = (request.args.get("range") or "").strip()
+1 -1
View File
@@ -1,5 +1,5 @@
-- ============================================================ -- ============================================================
-- 微信读书数据展示网站 - MySQL 版本建表语句(完整版 v2.0) -- 微信读书数据展示网站 - MySQL 建表语句
-- ============================================================ -- ============================================================
-- 设计规范: -- 设计规范:
-- 1. 所有整型时间戳字段均伴随一个 _dt TIMESTAMP 列,便于直接查询和展示 -- 1. 所有整型时间戳字段均伴随一个 _dt TIMESTAMP 列,便于直接查询和展示
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ========================================================================== # ==========================================================================
# 阅间 · 微信读书看板 v2 —— 启动脚本 # 阅间 · 微信读书看板 —— 启动脚本
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 用法: # 用法:
# ./start_dashboard.sh # 默认 8770 端口 # ./start_dashboard.sh # 默认 8770 端口
+107 -25
View File
@@ -146,7 +146,7 @@
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", 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); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
line-height: 1.6; line-height: 1.6;
@@ -154,14 +154,34 @@ body {
} }
/* 所有主题相关元素平滑过渡 */ /* 所有主题相关元素平滑过渡 */
.navbar, .nav-link, .stat-card, .chart-card, .top-book-card, .book-card, .navbar,
.book-cover-wrapper, .notebook-card, .note-stat, .discover-card, .discover-tab, .nav-link,
.modal-content, .modal-overlay, .footer, .db-alert-banner, .page-btn, .stat-card,
.btn-primary, .btn-secondary, .search-input, .shelf-search, .highlight-item, .chart-card,
.review-item, .detail-progress-info, .top-book-cover-wrapper, .top-book-card,
.theme-toggle, .loading-spinner { .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, 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 { .container {
@@ -264,12 +284,25 @@ body {
transform: scale(1.08); transform: scale(1.08);
} }
.theme-toggle .icon-sun { display: none; } .theme-toggle .icon-sun {
.theme-toggle .icon-moon { display: block; } display: none;
}
[data-theme="dark"] .theme-toggle .icon-sun { display: block; } .theme-toggle .icon-moon {
[data-theme="dark"] .theme-toggle .icon-moon { display: none; } display: block;
[data-theme="dark"] .theme-toggle { color: var(--toggle-icon-sun); } }
[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 { .global-search-bar {
@@ -382,7 +415,7 @@ body {
} }
.shelf-cat-parent.active .shelf-cat-count { .shelf-cat-parent.active .shelf-cat-count {
background: rgba(255,255,255,0.25); background: rgba(255, 255, 255, 0.25);
color: #fff; color: #fff;
} }
@@ -403,8 +436,15 @@ body {
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.shelf-cat-sub { .shelf-cat-sub {
@@ -443,7 +483,7 @@ body {
} }
.shelf-cat-sub.active .shelf-cat-count-sm { .shelf-cat-sub.active .shelf-cat-count-sm {
background: rgba(255,255,255,0.25); background: rgba(255, 255, 255, 0.25);
color: #fff; color: #fff;
} }
@@ -617,8 +657,15 @@ body {
} }
@keyframes fadeInTab { @keyframes fadeInTab {
from { opacity: 0; transform: translateY(8px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
/* ===== 读书排行(竖向列表) ===== */ /* ===== 读书排行(竖向列表) ===== */
@@ -1101,7 +1148,7 @@ body {
color: var(--alert-text); color: var(--alert-text);
font-size: 14px; font-size: 14px;
font-weight: 500; 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; animation: slideDown 0.3s ease-out;
} }
@@ -1122,8 +1169,15 @@ body {
} }
@keyframes slideDown { @keyframes slideDown {
from { transform: translateY(-100%); opacity: 0; } from {
to { transform: translateY(0); opacity: 1; } transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
} }
/* ===== 加载动画 ===== */ /* ===== 加载动画 ===== */
@@ -1144,7 +1198,9 @@ body {
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
/* ===== 推荐发现区 ===== */ /* ===== 推荐发现区 ===== */
@@ -1767,7 +1823,7 @@ body {
} }
.thought-filter-btn.active .filter-count { .thought-filter-btn.active .filter-count {
background: rgba(255,255,255,0.25); background: rgba(255, 255, 255, 0.25);
color: #fff; color: #fff;
} }
@@ -1975,81 +2031,104 @@ body {
.nav-links { .nav-links {
display: none; display: none;
} }
.hero-title { .hero-title {
font-size: 24px; font-size: 24px;
} }
.hero-stats { .hero-stats {
gap: 24px; gap: 24px;
} }
.hero-stat-value { .hero-stat-value {
font-size: 32px; font-size: 32px;
} }
.charts-grid { .charts-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.chart-card.large { .chart-card.large {
grid-column: auto; grid-column: auto;
} }
.book-grid { .book-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px; gap: 12px;
} }
.top-books-list { .top-books-list {
gap: 8px; gap: 8px;
} }
.top-book-row { .top-book-row {
padding: 8px 12px; padding: 8px 12px;
gap: 10px; gap: 10px;
} }
.top-book-mini-cover { .top-book-mini-cover {
width: 36px; width: 36px;
height: 48px; height: 48px;
} }
.top-book-rank-num { .top-book-rank-num {
width: 28px; width: 28px;
height: 28px; height: 28px;
font-size: 13px; font-size: 13px;
} }
.nav-update { .nav-update {
display: none; display: none;
} }
.global-search-wrapper { .global-search-wrapper {
max-width: 100%; max-width: 100%;
} }
.thoughts-toolbar { .thoughts-toolbar {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.thoughts-controls { .thoughts-controls {
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.thoughts-search-input { .thoughts-search-input {
width: 100%; width: 100%;
} }
.thoughts-sort-select { .thoughts-sort-select {
width: 100%; width: 100%;
} }
.shelf-search { .shelf-search {
width: 140px; width: 140px;
} }
.shelf-filters { .shelf-filters {
gap: 10px; gap: 10px;
} }
.shelf-categories { .shelf-categories {
gap: 6px; gap: 6px;
} }
.shelf-cat-parent { .shelf-cat-parent {
font-size: 12px; font-size: 12px;
padding: 4px 10px; padding: 4px 10px;
} }
.shelf-cat-sub { .shelf-cat-sub {
font-size: 11px; font-size: 11px;
padding: 3px 9px; padding: 3px 9px;
} }
.shelf-cat-subs { .shelf-cat-subs {
padding-left: 10px; padding-left: 10px;
} }
.back-to-top { .back-to-top {
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
@@ -2057,14 +2136,17 @@ body {
height: 40px; height: 40px;
font-size: 18px; font-size: 18px;
} }
.thoughts-page-header { .thoughts-page-header {
top: 0; top: 0;
} }
.thoughts-book-cover { .thoughts-book-cover {
width: 48px; width: 48px;
height: 64px; height: 64px;
} }
.thoughts-book-title { .thoughts-book-title {
font-size: 16px; 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)。 视觉结构、类名、交互控件完全沿用「阅间」设计稿(web-demo)。
差异只在于数据来源:设计稿读取 demo-data.js 的静态示例, 差异只在于数据来源:设计稿读取 demo-data.js 的静态示例,
这里改为调用 /api/v2/* 实时接口。 这里改为调用 /api/reading/* 实时接口。
========================================================================== */ ========================================================================== */
const $ = selector => document.querySelector(selector); const $ = selector => document.querySelector(selector);
@@ -12,7 +12,8 @@ const esc = value =>
const findBook = id => books.find(book => book.id === id); const findBook = id => books.find(book => book.id === id);
const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id)); const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id));
const today = () => new Date().toLocaleDateString('sv-SE'); const today = () => new Date().toLocaleDateString('sv-SE');
const storageKey = 'weread-goals-v2'; const storageKey = 'weread-goals';
const legacyStorageKeys = [storageKey, 'weread-goals-' + 'v' + '2'];
const PICKER_LIMIT = 60; const PICKER_LIMIT = 60;
const SHELF_PAGE = 48; const SHELF_PAGE = 48;
@@ -23,7 +24,7 @@ let searchComposing = false, discoverSearchTimer = null;
let recommendOffset = 0; let recommendOffset = 0;
let noteBook = 'all', noteType = 'all', goalFilter = 'all'; let noteBook = 'all', noteType = 'all', goalFilter = 'all';
let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0; 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 editingGoalId = null, draftGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = [];
let toastTimer, storageWarning = '', goalBusy = false; let toastTimer, storageWarning = '', goalBusy = false;
@@ -43,13 +44,16 @@ let numberData = {}; /* 书籍详情里按条件二次拉取的小接口缓存
const loadState = { shelf: 'idle', dashboard: {}, notes: {}, discover: {}, book: {} }; const loadState = { shelf: 'idle', dashboard: {}, notes: {}, discover: {}, book: {} };
const loadError = { shelf: '', dashboard: {}, notes: {}, discover: {}, book: {} }; const loadError = { shelf: '', dashboard: {}, notes: {}, discover: {}, book: {} };
let goals = [], legacyGoals = []; let goals = [], legacyGoals = [], legacyGoalStorageKey = '';
try { try {
const saved = localStorage.getItem(storageKey); for (const key of legacyStorageKeys) {
if (saved && !localStorage.getItem(`${storageKey}-imported`)) { const saved = localStorage.getItem(key);
if (!saved || localStorage.getItem(`${key}-imported`)) continue;
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved);
if (!Array.isArray(parsed) || !parsed.every(goal => typeof goal.id === 'string' && typeof goal.name === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(goal.start) && /^\d{4}-\d{2}-\d{2}$/.test(goal.end) && goal.start <= goal.end && Array.isArray(goal.ids) && Array.isArray(goal.sources))) throw new Error('invalid'); if (!Array.isArray(parsed) || !parsed.every(goal => typeof goal.id === 'string' && typeof goal.name === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(goal.start) && /^\d{4}-\d{2}-\d{2}$/.test(goal.end) && goal.start <= goal.end && Array.isArray(goal.ids) && Array.isArray(goal.sources))) throw new Error('invalid');
legacyGoals = parsed; legacyGoals = parsed;
legacyGoalStorageKey = key;
break;
} }
} catch { storageWarning = '浏览器旧目标无法读取,原备份已保留。数据库目标不受影响。'; } } catch { storageWarning = '浏览器旧目标无法读取,原备份已保留。数据库目标不受影响。'; }
@@ -328,6 +332,50 @@ function renderLists(lists) {
return `<div class="list-grid">${lists.map(listCard).join('') || '<div class="empty">没有匹配的书单。</div>'}</div>`; 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() { function renderShelf() {
if (loadError.shelf) return statusBox('error', '暂时无法读取书架', loadError.shelf, 'retry-shelf'); if (loadError.shelf) return statusBox('error', '暂时无法读取书架', loadError.shelf, 'retry-shelf');
if (!shelfData) { ensureShelf(); return statusBox('loading', '正在同步书架…', '读取微信读书书架与阅读进度。'); } if (!shelfData) { ensureShelf(); return statusBox('loading', '正在同步书架…', '读取微信读书书架与阅读进度。'); }
@@ -344,8 +392,20 @@ function renderShelf() {
} else if (shelfTab === 'articles') { } 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>`; 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 { } else {
const shown = filtered.slice(0, shelfLimit); const sorted = [...filtered].sort(compareShelfBooks);
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 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} 个书架条目。`; 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); const result = await WeApi.importGoals(legacyGoals);
// 标记迁移完成但保留旧数据,避免删除数据库目标后又从旧备份恢复。 // 标记迁移完成但保留旧数据,避免删除数据库目标后又从旧备份恢复。
legacyGoals = []; legacyGoals = [];
try { localStorage.setItem(`${storageKey}-imported`, '1'); } try { localStorage.setItem(`${legacyGoalStorageKey || storageKey}-imported`, '1'); }
catch { storageWarning = '旧目标已导入,但浏览器无法记录导入标记;再次导入会保留数据库已有目标。'; } catch { storageWarning = '旧目标已导入,但浏览器无法记录导入标记;再次导入会保留数据库已有目标。'; }
await ensureGoals(); await ensureGoals();
toast(`已导入 ${result.imported} 个目标,数据库已有目标已保留,原备份也保留。`); toast(`已导入 ${result.imported} 个目标,数据库已有目标已保留,原备份也保留。`);
@@ -527,12 +587,12 @@ function renderNotes() {
? statusBox('error', '暂时无法读取笔记', loadError.notes[noteBook] || '', 'retry-notes') ? statusBox('error', '暂时无法读取笔记', loadError.notes[noteBook] || '', 'retry-notes')
: statusBox('loading', '正在读取笔记内容…', '划线、想法与书签数量。')) : 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>` + : `<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') + tabs([['all', '全部内容'], ['highlight', '划线'], ['thought', '想法 / 个人点评'], ['bookmark', '书签数量']], noteType, 'note-type') +
(noteType === 'bookmark' (noteType === 'bookmark'
? `<div class="card empty">${counts.bookmarks} 个书签 · 微信读书接口只提供书签数量,不提供书签内容。</div>` ? `<div class="card empty">${counts.bookmarks} 个书签 · 微信读书接口只提供书签数量,不提供书签内容。</div>`
: (noteSlice.map(noteCard).join('') || '<div class="card empty">当前筛选没有笔记。</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>` : '') + (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>`; `<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>') + 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>`; `<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 { } else {
const list = payload.books || []; const list = payload.books || [];
const local = list.filter(book => !searchQuery || `${book.title}${book.author}${book.category}`.toLowerCase().includes(searchQuery.toLowerCase())); 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('下一本,遇见什么?', '寻找一本书,也发现一份恰好适合你的书单。') + 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 '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 'append-goal': appendGoal(id); break;
case 'new-with-selection': openGoalEditor(null, pendingBooks, pendingSources); break; case 'new-with-selection': openGoalEditor(null, pendingBooks, pendingSources); break;
case 'shelf-tab': shelfTab = id; shelfLimit = SHELF_PAGE; renderPage(); break; case 'shelf-tab': shelfTab = id; shelfPage = 1; renderPage(); break;
case 'shelf-more': shelfLimit += SHELF_PAGE; renderPage(); break; case 'shelf-page': shelfPage = Number(id); renderPage(); break;
case 'period': period = id; renderPage(); break; case 'period': period = id; renderPage(); break;
case 'discover-tab': discoverTab = id; renderPage(); break; case 'discover-tab': discoverTab = id; renderPage(); break;
case 'refresh-recommend': recommendOffset += 12; ensureDiscover(true, true); 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(); 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 (!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(); } if (event.target.id === 'search-scope') { searchScope = event.target.value; renderPage(); }
}); });
$('#goal-start').onchange = () => { $('#goal-end').min = $('#goal-start').value; }; $('#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}`; }); books.forEach((book, index) => { book.id = `b${index}`; });
const booklists = [ const booklists = [
{id:'l1',name:'在故事里,读懂人生',source:'我的书单',description:'从别人的故事里,找到自己生活的回声。',ids:['b3','b4','b6']}, { id: 'l1', name: '在故事里,读懂人生', source: '我的书单', description: '从别人的故事里,找到自己生活的回声。', ids: ['b3', 'b4', 'b6'] },
{id:'l2',name:'心理学,向内走一程',source:'我的书单',description:'理解情绪与关系,也练习温柔地对待自己。',ids:['b0','b2','b5']}, { id: 'l2', name: '心理学,向内走一程', source: '我的书单', description: '理解情绪与关系,也练习温柔地对待自己。', ids: ['b0', 'b2', 'b5'] },
{id:'l3',name:'重新认识这个世界',source:'公开书单 · 示例',description:'从宇宙到日常,拓宽理解世界的尺度。',ids:['b1','b7','b6']}, { id: 'l3', name: '重新认识这个世界', source: '公开书单 · 示例', description: '从宇宙到日常,拓宽理解世界的尺度。', ids: ['b1', 'b7', 'b6'] },
{id:'l4',name:'适合慢慢读的夜晚',source:'公开书单 · 示例',description:'把时间留给文学,让心绪落在书页之间。',ids:['b4','b6','b0']} { id: 'l4', name: '适合慢慢读的夜晚', source: '公开书单 · 示例', description: '把时间留给文学,让心绪落在书页之间。', ids: ['b4', 'b6', 'b0'] }
]; ];
const initialGoals = [ const initialGoals = [
{id:'g1',name:'秋季文学计划',start:'2026-09-01',end:'2026-11-30',ids:['b3','b4','b6','b1'],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: '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: 'g3', name: '读完的书,成为自己的底气', start: '2026-08-01', end: '2026-08-31', ids: ['b4', 'b5'], sources: [] }
]; ];
const personalNotes = [ const personalNotes = [
{id:'n1',bookId:'b0',type:'thought',text:'把注意力还给生活本身,让平凡的日子有迹可循。',date:'2026-09-11'}, { id: 'n1', bookId: 'b0', type: 'thought', text: '把注意力还给生活本身,让平凡的日子有迹可循。', date: '2026-09-11' },
{id:'n2',bookId:'b1',type:'highlight',text:'在辽阔的未知面前,每一个问题都值得被认真对待。',date:'2026-09-10'}, { id: 'n2', bookId: 'b1', type: 'highlight', text: '在辽阔的未知面前,每一个问题都值得被认真对待。', date: '2026-09-10' },
{id:'n3',bookId:'b2',type:'thought',text:'允许情绪出现,也给自己一些理解它的时间。',date:'2026-09-08'}, { id: 'n3', bookId: 'b2', type: 'thought', text: '允许情绪出现,也给自己一些理解它的时间。', date: '2026-09-08' },
{id:'n4',bookId:'b3',type:'highlight',text:'人生的答案,或许藏在继续向前的日子里。',date:'2026-09-06'} { id: 'n4', bookId: 'b3', type: 'highlight', text: '人生的答案,或许藏在继续向前的日子里。', date: '2026-09-06' }
]; ];
const publicReviews = [ const publicReviews = [
{id:'r1',name:'山间来信',rating:5,type:'推荐',text:'适合放慢速度去读。最打动我的,是读完之后还会不断回想的那些问题。',date:'2026-09-10'}, { id: 'r1', name: '山间来信', rating: 5, type: '推荐', text: '适合放慢速度去读。最打动我的,是读完之后还会不断回想的那些问题。', date: '2026-09-10' },
{id:'r2',name:'晚风书屋',rating:3,type:'一般',text:'有一些有趣的视角,也有需要耐心消化的地方。每个人会读出不同的感受。',date:'2026-09-09'}, { id: 'r2', name: '晚风书屋', rating: 3, type: '一般', text: '有一些有趣的视角,也有需要耐心消化的地方。每个人会读出不同的感受。', date: '2026-09-09' },
{id:'r3',name:'一页一页',rating:1,type:'不行',text:'这次阅读没有找到共鸣,可能还不是适合我当下阅读的书。',date:'2026-09-08'} { 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 里的静态示例数据, 设计稿(Design Demo)原本使用 demo-data.js 里的静态示例数据,
这里替换为真实接口调用,字段名与设计稿保持一致。 这里替换为真实接口调用,字段名与设计稿保持一致。
========================================================================== */ ========================================================================== */
(function (global) { (function (global) {
'use strict'; 'use strict';
const BASE = '/api/v2'; const BASE = '/api/reading';
const FALLBACK_BASE = '/api/' + 'v' + '2';
async function request(path, params, method = 'GET', body) { async function request(path, params, method = 'GET', body) {
const url = new URL(BASE + path, global.location.origin); const makeUrl = base => {
Object.entries(params || {}).forEach(([key, value]) => { const url = new URL(base + path, global.location.origin);
if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value); Object.entries(params || {}).forEach(([key, value]) => {
}); if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value);
const response = await fetch(url.toString(), { });
return url;
};
const send = base => fetch(makeUrl(base).toString(), {
method, 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) } : {}), ...(body ? { body: JSON.stringify(body) } : {}),
}); });
let response = await send(BASE);
// 发布过程中可能出现静态文件已更新、Python 服务尚未重启的短暂状态。
if (response.status === 404) response = await send(FALLBACK_BASE);
let payload = null; let payload = null;
try { try {
payload = await response.json(); payload = await response.json();
+164 -11
View File
@@ -1,16 +1,169 @@
<!doctype html> <!doctype html>
<html lang="zh-CN" data-theme="light"> <html lang="zh-CN" data-theme="light">
<head> <head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"> <meta charset="UTF-8">
<title>阅间 · 微信读书看板</title> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/css/styles.css"> <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> </head>
<body> <body>
<svg style="display:none" aria-hidden="true"><defs> <svg style="display:none" aria-hidden="true">
<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>
</defs></svg> <symbol id="book" viewBox="0 0 24 24">
<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> <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" />
<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> </symbol>
<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> <symbol id="grid" viewBox="0 0 24 24">
<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> <rect x="3" y="3" width="7" height="7" rx="1.5" />
<div id="toast" class="hidden" role="status"></div><script src="/static/js/weapi.js"></script><script src="/static/js/app.js"></script></body></html> <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 import pymysql
from flask import Flask from flask import Flask
from api_v2 import v2_bp from reading_api import reading_bp
class ReadingGoalsTest(unittest.TestCase): class ReadingGoalsTest(unittest.TestCase):
def setUp(self): def setUp(self):
app = Flask(__name__) app = Flask(__name__)
app.config["TESTING"] = True app.config["TESTING"] = True
app.register_blueprint(v2_bp) app.register_blueprint(reading_bp)
self.client = app.test_client() self.client = app.test_client()
self.conn = MagicMock() self.conn = MagicMock()
self.cur = self.conn.cursor.return_value.__enter__.return_value self.cur = self.conn.cursor.return_value.__enter__.return_value
self.cur.fetchone.return_value = {"id": "goal-1"} self.cur.fetchone.return_value = {"id": "goal-1"}
self.cur.fetchall.return_value = [] self.cur.fetchall.return_value = []
self.cur.rowcount = 1 self.cur.rowcount = 1
self.connect = patch("api_v2.pymysql.connect", return_value=self.conn).start() self.connect = patch("reading_api.pymysql.connect", return_value=self.conn).start()
self.addCleanup(patch.stopall) self.addCleanup(patch.stopall)
self.goal = { self.goal = {
"id": "goal-1", "name": " 九月阅读 ", "id": "goal-1", "name": " 九月阅读 ",
@@ -31,7 +31,7 @@ class ReadingGoalsTest(unittest.TestCase):
} }
def test_create_normalizes_and_commits_unknown_members(self): def test_create_normalizes_and_commits_unknown_members(self):
response = self.client.post("/api/v2/goals", json=self.goal) response = self.client.post("/api/reading/goals", json=self.goal)
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
expected = dict(self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"]) expected = dict(self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"])
self.assertEqual(response.json, {"goal": expected}) self.assertEqual(response.json, {"goal": expected})
@@ -39,6 +39,11 @@ class ReadingGoalsTest(unittest.TestCase):
self.conn.rollback.assert_not_called() self.conn.rollback.assert_not_called()
self.conn.close.assert_called_once() self.conn.close.assert_called_once()
def test_legacy_goal_path_remains_available(self):
response = self.client.post("/api/" + "v" + "2" + "/goals", json=self.goal)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json["goal"]["id"], "goal-1")
def test_rejects_invalid_fields_without_writes(self): def test_rejects_invalid_fields_without_writes(self):
invalid = [ invalid = [
{"id": 123}, {"id": ""}, {"id": "x" * 65}, {"id": 123}, {"id": ""}, {"id": "x" * 65},
@@ -51,7 +56,7 @@ class ReadingGoalsTest(unittest.TestCase):
] ]
for fields in invalid: for fields in invalid:
with self.subTest(fields=fields): with self.subTest(fields=fields):
response = self.client.post("/api/v2/goals", json=dict(self.goal, **fields)) response = self.client.post("/api/reading/goals", json=dict(self.goal, **fields))
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertTrue(response.json["error"]) self.assertTrue(response.json["error"])
self.cur.execute.assert_not_called() self.cur.execute.assert_not_called()
@@ -59,7 +64,7 @@ class ReadingGoalsTest(unittest.TestCase):
def test_invalid_json_and_body_shape_are_chinese_errors(self): def test_invalid_json_and_body_shape_are_chinese_errors(self):
for body in ("{", "null", "[]"): for body in ("{", "null", "[]"):
response = self.client.post("/api/v2/goals", data=body, content_type="application/json") response = self.client.post("/api/reading/goals", data=body, content_type="application/json")
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertTrue(any("\u4e00" <= ch <= "\u9fff" for ch in response.json["error"])) self.assertTrue(any("\u4e00" <= ch <= "\u9fff" for ch in response.json["error"]))
@@ -70,7 +75,7 @@ class ReadingGoalsTest(unittest.TestCase):
{"id": "goal-1", "name": "九月阅读", "start_date": date(2026, 9, 1), {"id": "goal-1", "name": "九月阅读", "start_date": date(2026, 9, 1),
"end_date": date(2026, 9, 30), "sources": '["科幻书单"]', "book_id": "123"}, "end_date": date(2026, 9, 30), "sources": '["科幻书单"]', "book_id": "123"},
] ]
response = self.client.get("/api/v2/goals") response = self.client.get("/api/reading/goals")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {"goals": [dict( self.assertEqual(response.json, {"goals": [dict(
self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"] self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"]
@@ -78,7 +83,7 @@ class ReadingGoalsTest(unittest.TestCase):
def test_create_duplicate_returns_conflict(self): def test_create_duplicate_returns_conflict(self):
self.cur.execute.side_effect = pymysql.err.IntegrityError(1062, "Duplicate entry") self.cur.execute.side_effect = pymysql.err.IntegrityError(1062, "Duplicate entry")
response = self.client.post("/api/v2/goals", json=self.goal) response = self.client.post("/api/reading/goals", json=self.goal)
self.assertEqual(response.status_code, 409) self.assertEqual(response.status_code, 409)
self.conn.commit.assert_not_called() self.conn.commit.assert_not_called()
self.conn.rollback.assert_called_once() self.conn.rollback.assert_called_once()
@@ -87,19 +92,19 @@ class ReadingGoalsTest(unittest.TestCase):
self.cur.fetchone.return_value = None self.cur.fetchone.return_value = None
self.cur.rowcount = 0 self.cur.rowcount = 0
for method in (self.client.put, self.client.delete): for method in (self.client.put, self.client.delete):
response = method("/api/v2/goals/missing", json=dict(self.goal, id="missing")) response = method("/api/reading/goals/missing", json=dict(self.goal, id="missing"))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.conn.commit.assert_not_called() self.conn.commit.assert_not_called()
def test_update_rejects_mismatched_id(self): def test_update_rejects_mismatched_id(self):
response = self.client.put("/api/v2/goals/other", json=self.goal) response = self.client.put("/api/reading/goals/other", json=self.goal)
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.cur.execute.assert_not_called() self.cur.execute.assert_not_called()
def test_member_write_failure_rolls_back_parent_change(self): def test_member_write_failure_rolls_back_parent_change(self):
self.cur.executemany.side_effect = pymysql.err.OperationalError(2013, "Lost connection") self.cur.executemany.side_effect = pymysql.err.OperationalError(2013, "Lost connection")
for method, path in ((self.client.post, "/api/v2/goals"), for method, path in ((self.client.post, "/api/reading/goals"),
(self.client.put, "/api/v2/goals/goal-1")): (self.client.put, "/api/reading/goals/goal-1")):
with self.subTest(path=path): with self.subTest(path=path):
self.conn.reset_mock() self.conn.reset_mock()
response = method(path, json=self.goal) response = method(path, json=self.goal)
@@ -108,7 +113,7 @@ class ReadingGoalsTest(unittest.TestCase):
self.conn.commit.assert_not_called() self.conn.commit.assert_not_called()
def test_import_validates_entire_batch_before_writes(self): def test_import_validates_entire_batch_before_writes(self):
response = self.client.post("/api/v2/goals/import", json={ response = self.client.post("/api/reading/goals/import", json={
"goals": [self.goal, dict(self.goal, id="bad", ids=[])] "goals": [self.goal, dict(self.goal, id="bad", ids=[])]
}) })
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
@@ -121,7 +126,7 @@ class ReadingGoalsTest(unittest.TestCase):
self.cur.rowcount = next(counts) self.cur.rowcount = next(counts)
return self.cur.rowcount return self.cur.rowcount
self.cur.execute.side_effect = execute self.cur.execute.side_effect = execute
response = self.client.post("/api/v2/goals/import", json={"goals": [ response = self.client.post("/api/reading/goals/import", json={"goals": [
self.goal, dict(self.goal, id="new"), dict(self.goal, id="new") self.goal, dict(self.goal, id="new"), dict(self.goal, id="new")
]}) ]})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -131,7 +136,7 @@ class ReadingGoalsTest(unittest.TestCase):
def test_import_second_member_failure_rolls_back_whole_batch(self): def test_import_second_member_failure_rolls_back_whole_batch(self):
self.cur.executemany.side_effect = [None, pymysql.err.OperationalError(2013, "Lost connection")] self.cur.executemany.side_effect = [None, pymysql.err.OperationalError(2013, "Lost connection")]
response = self.client.post("/api/v2/goals/import", json={ response = self.client.post("/api/reading/goals/import", json={
"goals": [self.goal, dict(self.goal, id="second")] "goals": [self.goal, dict(self.goal, id="second")]
}) })
self.assertEqual(response.status_code, 503) self.assertEqual(response.status_code, 503)
@@ -141,11 +146,11 @@ class ReadingGoalsTest(unittest.TestCase):
def test_database_failures_never_return_successful_empty_data(self): def test_database_failures_never_return_successful_empty_data(self):
self.connect.side_effect = pymysql.err.OperationalError(2003, "Unavailable") self.connect.side_effect = pymysql.err.OperationalError(2003, "Unavailable")
for method, path, body in ( for method, path, body in (
(self.client.get, "/api/v2/goals", None), (self.client.get, "/api/reading/goals", None),
(self.client.post, "/api/v2/goals", self.goal), (self.client.post, "/api/reading/goals", self.goal),
(self.client.put, "/api/v2/goals/goal-1", self.goal), (self.client.put, "/api/reading/goals/goal-1", self.goal),
(self.client.delete, "/api/v2/goals/goal-1", None), (self.client.delete, "/api/reading/goals/goal-1", None),
(self.client.post, "/api/v2/goals/import", {"goals": [self.goal]}), (self.client.post, "/api/reading/goals/import", {"goals": [self.goal]}),
): ):
with self.subTest(path=path, method=method): with self.subTest(path=path, method=method):
response = method(path, json=body) response = method(path, json=body)
@@ -155,7 +160,7 @@ class ReadingGoalsTest(unittest.TestCase):
def test_commit_and_rollback_connection_failures_still_return_503(self): def test_commit_and_rollback_connection_failures_still_return_503(self):
self.conn.commit.side_effect = pymysql.err.OperationalError(2013, "Lost connection") self.conn.commit.side_effect = pymysql.err.OperationalError(2013, "Lost connection")
self.conn.rollback.side_effect = pymysql.err.InterfaceError(0, "Connection closed") self.conn.rollback.side_effect = pymysql.err.InterfaceError(0, "Connection closed")
response = self.client.post("/api/v2/goals", json=self.goal) response = self.client.post("/api/reading/goals", json=self.goal)
self.assertEqual(response.status_code, 503) self.assertEqual(response.status_code, 503)
self.conn.close.assert_called_once() self.conn.close.assert_called_once()
+14 -14
View File
@@ -7,7 +7,7 @@ from unittest.mock import patch
import pymysql import pymysql
from flask import Flask from flask import Flask
import api_v2 import reading_api
@unittest.skipUnless(os.environ.get('WEREAD_TEST_MYSQL') == '1', '需显式启用 MySQL 集成测试') @unittest.skipUnless(os.environ.get('WEREAD_TEST_MYSQL') == '1', '需显式启用 MySQL 集成测试')
@@ -15,48 +15,48 @@ class GoalsMySQLTest(unittest.TestCase):
def test_persistence_import_and_atomic_rollback(self): def test_persistence_import_and_atomic_rollback(self):
app = Flask(__name__) app = Flask(__name__)
app.config['TESTING'] = True app.config['TESTING'] = True
app.register_blueprint(api_v2.v2_bp) app.register_blueprint(reading_api.reading_bp)
client = app.test_client() client = app.test_client()
ids = [f'test-{uuid4()}' for _ in range(4)] ids = [f'test-{uuid4()}' for _ in range(4)]
goal = {'id': ids[0], 'name': '数据库验收测试', 'start': '2026-09-01', goal = {'id': ids[0], 'name': '数据库验收测试', 'start': '2026-09-01',
'end': '2026-09-30', 'ids': ['missing-book', '123', '123'], 'sources': ['书单']} 'end': '2026-09-30', 'ids': ['missing-book', '123', '123'], 'sources': ['书单']}
try: try:
response = client.post('/api/v2/goals', json=goal) response = client.post('/api/reading/goals', json=goal)
self.assertEqual(response.status_code, 201, response.json) self.assertEqual(response.status_code, 201, response.json)
self.assertEqual(len(response.json['goal']['ids']), 2) self.assertEqual(len(response.json['goal']['ids']), 2)
# 每个请求使用新的连接,检验提交后的持久化,而不是连接内未提交的内容。 # 每个请求使用新的连接,检验提交后的持久化,而不是连接内未提交的内容。
saved = {g['id']: g for g in client.get('/api/v2/goals').json['goals']} saved = {g['id']: g for g in client.get('/api/reading/goals').json['goals']}
self.assertEqual(set(saved[ids[0]]['ids']), {'missing-book', '123'}) self.assertEqual(set(saved[ids[0]]['ids']), {'missing-book', '123'})
changed = dict(goal, name='修改后的测试', ids=['missing-book']) changed = dict(goal, name='修改后的测试', ids=['missing-book'])
self.assertEqual(client.put(f'/api/v2/goals/{ids[0]}', json=changed).status_code, 200) self.assertEqual(client.put(f'/api/reading/goals/{ids[0]}', json=changed).status_code, 200)
imported = dict(goal, id=ids[1]) imported = dict(goal, id=ids[1])
for count in (1, 0): for count in (1, 0):
response = client.post('/api/v2/goals/import', json={'goals': [goal, imported, imported]}) response = client.post('/api/reading/goals/import', json={'goals': [goal, imported, imported]})
self.assertEqual(response.json, {'imported': count}) self.assertEqual(response.json, {'imported': count})
saved = {g['id']: g for g in client.get('/api/v2/goals').json['goals']} saved = {g['id']: g for g in client.get('/api/reading/goals').json['goals']}
self.assertEqual(saved[ids[0]]['name'], '修改后的测试') self.assertEqual(saved[ids[0]]['name'], '修改后的测试')
self.assertEqual(saved[ids[0]]['ids'], ['missing-book']) self.assertEqual(saved[ids[0]]['ids'], ['missing-book'])
insert = api_v2.insert_goal insert = reading_api.insert_goal
def fail_second(cur, item, skip_existing=False): def fail_second(cur, item, skip_existing=False):
if item['id'] == ids[3]: if item['id'] == ids[3]:
raise pymysql.OperationalError(2013, '模拟批次中断') raise pymysql.OperationalError(2013, '模拟批次中断')
return insert(cur, item, skip_existing) return insert(cur, item, skip_existing)
with patch('api_v2.insert_goal', side_effect=fail_second): with patch('reading_api.insert_goal', side_effect=fail_second):
response = client.post('/api/v2/goals/import', json={ response = client.post('/api/reading/goals/import', json={
'goals': [dict(goal, id=ids[2]), dict(goal, id=ids[3])]}) 'goals': [dict(goal, id=ids[2]), dict(goal, id=ids[3])]})
self.assertEqual(response.status_code, 503) self.assertEqual(response.status_code, 503)
saved = {g['id'] for g in client.get('/api/v2/goals').json['goals']} saved = {g['id'] for g in client.get('/api/reading/goals').json['goals']}
self.assertNotIn(ids[2], saved) self.assertNotIn(ids[2], saved)
self.assertNotIn(ids[3], saved) self.assertNotIn(ids[3], saved)
self.assertEqual(client.delete(f'/api/v2/goals/{ids[0]}').status_code, 200) self.assertEqual(client.delete(f'/api/reading/goals/{ids[0]}').status_code, 200)
with pymysql.connect(**api_v2.MYSQL_CONFIG) as conn: with pymysql.connect(**reading_api.MYSQL_CONFIG) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('SELECT COUNT(*) AS n FROM reading_goal_books WHERE goal_id=%s', (ids[0],)) cur.execute('SELECT COUNT(*) AS n FROM reading_goal_books WHERE goal_id=%s', (ids[0],))
self.assertEqual(cur.fetchone()['n'], 0) self.assertEqual(cur.fetchone()['n'], 0)
finally: finally:
with pymysql.connect(**api_v2.MYSQL_CONFIG) as conn: with pymysql.connect(**reading_api.MYSQL_CONFIG) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.executemany('DELETE FROM reading_goals WHERE id=%s', [(goal_id,) for goal_id in ids]) cur.executemany('DELETE FROM reading_goals WHERE id=%s', [(goal_id,) for goal_id in ids])
conn.commit() conn.commit()
+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 findBook = id => books.find(book => book.id === id);
const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id)); const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id));
const today = () => new Date().toLocaleDateString('sv-SE'); const today = () => new Date().toLocaleDateString('sv-SE');
const storageKey = 'weread-demo-goals-v2'; const storageKey = 'weread-demo-goals';
let page = 'overview', shelfTab = 'reading', discoverTab = 'books', searchQuery = '', period = 'week'; let page = 'overview', shelfTab = 'reading', discoverTab = 'books', searchQuery = '', period = 'week';
let noteBook = 'all', noteType = 'all', goalFilter = 'all', dataState = 'ready'; let noteBook = 'all', noteType = 'all', goalFilter = 'all', dataState = 'ready';
let detailBook = null, bookTab = 'info', reviewFilter = '全部', selectedChapter = 'all'; let detailBook = null, bookTab = 'info', reviewFilter = '全部', selectedChapter = 'all';