feat(reading-goals): 持久化阅读目标并收紧看板布局

This commit is contained in:
admin committed 2026-09-12 17:16:40 +08:00
1 parent 61d3951905
commit 094e1aca3d
13 files changed
+834 -39

No files matched your search

+171 -2
View File
@@ -21,11 +21,13 @@ import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from contextlib import suppress
from datetime import date, datetime, timedelta, timezone
from functools import wraps
import pymysql
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify, make_response, request
from werkzeug.exceptions import BadRequest
v2_bp = Blueprint("v2", __name__)
@@ -621,6 +623,173 @@ def _ts(date_text):
return 0
# ======================================================================
# 接口:阅读目标(必须持久化,不使用可降级数据库入口)
# ======================================================================
def with_goals_db(f):
"""目标及成员共用事务;数据库失败必须让前端保留原表单。"""
@wraps(f)
def wrapper(*args, **kwargs):
conn = None
try:
conn = pymysql.connect(**MYSQL_CONFIG)
response = make_response(f(conn, *args, **kwargs))
if request.method != "GET":
if response.status_code < 400:
conn.commit()
else:
conn.rollback()
return response
except Exception as error:
if conn is not None:
# 断线时回滚也可能失败;仍返回原操作错误,由关闭连接释放事务。
with suppress(pymysql.MySQLError):
conn.rollback()
if isinstance(error, BadRequest):
return jsonify({"error": error.description}), 400
if isinstance(error, pymysql.MySQLError):
return jsonify({"error": "阅读目标数据库暂不可用,请稍后重试"}), 503
raise
finally:
if conn is not None:
conn.close()
return wrapper
def validate_goal(data):
if not isinstance(data, dict):
raise BadRequest("阅读目标必须是 JSON 对象")
goal_id = data.get("id")
if not isinstance(goal_id, str) or not 1 <= len(goal_id) <= 64:
raise BadRequest("目标 ID 必须是 1 至 64 个字符的字符串")
name = data.get("name")
if not isinstance(name, str) or not 1 <= len(name.strip()) <= 60:
raise BadRequest("目标名称去除首尾空格后须为 1 至 60 个字符")
dates = []
for field, label in (("start", "开始"), ("end", "结束")):
value = data.get(field)
try:
parsed = date.fromisoformat(value) if isinstance(value, str) else None
except ValueError:
parsed = None
if parsed is None or parsed.isoformat() != value or parsed.year < 1000:
raise BadRequest(f"{label}日期必须是有效的 YYYY-MM-DD 日期,年份须为 1000 至 9999")
dates.append(value)
if dates[0] > dates[1]:
raise BadRequest("结束日期不能早于开始日期")
ids = data.get("ids")
if not isinstance(ids, list) or not ids:
raise BadRequest("目标至少需要一本书,ids 必须是非空数组")
if any(not isinstance(book_id, str) or not 1 <= len(book_id) <= 64 for book_id in ids):
raise BadRequest("书籍 ID 必须是 1 至 64 个字符的字符串")
sources = data.get("sources", [])
if not isinstance(sources, list) or any(not isinstance(source, str) for source in sources):
raise BadRequest("书单来源 sources 必须是字符串数组")
return {"id": goal_id, "name": name.strip(), "start": dates[0], "end": dates[1],
"ids": list(dict.fromkeys(ids)), "sources": list(dict.fromkeys(sources))}
def insert_goal(cur, goal, skip_existing=False):
sql = "INSERT INTO reading_goals (id, name, start_date, end_date, sources) VALUES (%s,%s,%s,%s,%s)"
if skip_existing:
# 仅同 ID 已存在时跳过,不覆盖原目标;不用 INSERT IGNORE 掩盖其他写入错误。
sql += " ON DUPLICATE KEY UPDATE id=id"
cur.execute(sql, (goal["id"], goal["name"], goal["start"], goal["end"],
json.dumps(goal["sources"], ensure_ascii=False)))
if skip_existing and cur.rowcount == 0:
return False
cur.executemany("INSERT INTO reading_goal_books (goal_id, book_id) VALUES (%s,%s)",
[(goal["id"], book_id) for book_id in goal["ids"]])
return True
@v2_bp.route("/api/v2/goals", methods=["GET"])
@with_goals_db
def api_v2_goals(conn):
rows = query_all(conn,
"SELECT g.id, g.name, g.start_date, g.end_date, g.sources, b.book_id "
"FROM reading_goals g LEFT JOIN reading_goal_books b ON b.goal_id=g.id "
"ORDER BY g.created_at, g.id, b.book_id")
goals = {}
for row in rows:
goal_id = row["id"]
if goal_id not in goals:
goals[goal_id] = {"id": goal_id, "name": row["name"],
"start": row["start_date"].isoformat(), "end": row["end_date"].isoformat(),
"ids": [], "sources": json.loads(row["sources"])}
# 不关联书架缓存,已移出书架或尚未同步的书籍仍保留在目标分母中。
if row["book_id"] is not None:
goals[goal_id]["ids"].append(row["book_id"])
return jsonify({"goals": list(goals.values())})
@v2_bp.route("/api/v2/goals", methods=["POST"])
@with_goals_db
def api_v2_create_goal(conn):
goal = validate_goal(request.get_json(silent=True))
try:
with conn.cursor() as cur:
insert_goal(cur, goal)
except pymysql.err.IntegrityError as error:
if error.args[0] == 1062:
return jsonify({"error": "该目标 ID 已存在,请刷新后重试"}), 409
raise
return jsonify({"goal": goal}), 201
@v2_bp.route("/api/v2/goals/<goal_id>", methods=["PUT"])
@with_goals_db
def api_v2_update_goal(conn, goal_id):
goal = validate_goal(request.get_json(silent=True))
if goal["id"] != goal_id:
raise BadRequest("目标 ID 与请求路径不一致")
with conn.cursor() as cur:
# 锁定父记录,避免并发编辑把两次提交的书籍成员混在一起。
cur.execute("SELECT id FROM reading_goals WHERE id=%s FOR UPDATE", (goal_id,))
if cur.fetchone() is None:
return jsonify({"error": "阅读目标不存在"}), 404
cur.execute("UPDATE reading_goals SET name=%s, start_date=%s, end_date=%s, sources=%s WHERE id=%s",
(goal["name"], goal["start"], goal["end"],
json.dumps(goal["sources"], ensure_ascii=False), goal_id))
cur.execute("DELETE FROM reading_goal_books WHERE goal_id=%s", (goal_id,))
cur.executemany("INSERT INTO reading_goal_books (goal_id, book_id) VALUES (%s,%s)",
[(goal_id, book_id) for book_id in goal["ids"]])
return jsonify({"goal": goal})
@v2_bp.route("/api/v2/goals/<goal_id>", methods=["DELETE"])
@with_goals_db
def api_v2_delete_goal(conn, goal_id):
if not 1 <= len(goal_id) <= 64:
raise BadRequest("目标 ID 必须是 1 至 64 个字符的字符串")
with conn.cursor() as cur:
cur.execute("DELETE FROM reading_goals WHERE id=%s", (goal_id,))
if cur.rowcount == 0:
return jsonify({"error": "阅读目标不存在"}), 404
return jsonify({"ok": True})
@v2_bp.route("/api/v2/goals/import", methods=["POST"])
@with_goals_db
def api_v2_import_goals(conn):
data = request.get_json(silent=True)
if not isinstance(data, dict) or not isinstance(data.get("goals"), list):
raise BadRequest("导入内容必须包含 goals 数组")
# 先校验整批,重复 ID 只保留第一条;任何写入失败均回滚整个批次。
goals = [validate_goal(goal) for goal in data["goals"]]
seen = set()
imported = 0
with conn.cursor() as cur:
for goal in goals:
if goal["id"] in seen:
continue
seen.add(goal["id"])
imported += insert_goal(cur, goal, skip_existing=True)
return jsonify({"imported": imported})
# ======================================================================
# 接口:连接状态
# ======================================================================