mirror of
https://github.com/KingHWJ/WeReadBoard.git
synced 2026-11-03 18:24:58 +08:00
feat(reading-goals): 持久化阅读目标并收紧看板布局
This commit is contained in:
1 parent
61d3951905
commit
094e1aca3d
13 files changed
+834
-39
No files matched your search
@@ -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})
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 接口:连接状态
|
||||
# ======================================================================
|
||||
|
||||
Reference in new issue
Block a user