feat: scaffold personal progress monitor MVP
This commit is contained in:
401
app/repository.py
Normal file
401
app/repository.py
Normal file
@@ -0,0 +1,401 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from copy import deepcopy
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .models import DailyRecordInput, TaskInput
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
class NotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ConflictError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(SHANGHAI).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
class JsonRepository:
|
||||
"""Single-process JSON repository with atomic same-directory replacement."""
|
||||
|
||||
def __init__(self, root: Path):
|
||||
self.root = Path(root)
|
||||
self.tasks_dir = self.root / "tasks"
|
||||
self.revisions_dir = self.root / "revisions"
|
||||
self.daily_dir = self.root / "daily"
|
||||
self.archive_dir = self.root / "archive"
|
||||
self.meta_path = self.root / "meta.json"
|
||||
self._lock = threading.RLock()
|
||||
for folder in (self.root, self.tasks_dir, self.revisions_dir, self.daily_dir, self.archive_dir):
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
if not self.meta_path.exists():
|
||||
yesterday = date.today() - timedelta(days=1)
|
||||
self._atomic_write(self.meta_path, {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"version": 1,
|
||||
"taskOrder": [],
|
||||
"lastClosedDate": yesterday.isoformat(),
|
||||
})
|
||||
|
||||
def _read(self, path: Path, default: Any = None) -> Any:
|
||||
if not path.exists():
|
||||
return deepcopy(default)
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
def _atomic_write(self, path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(value, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp_name, path)
|
||||
if os.name != "nt":
|
||||
dir_fd = os.open(path.parent, os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(dir_fd)
|
||||
finally:
|
||||
os.close(dir_fd)
|
||||
finally:
|
||||
if os.path.exists(temp_name):
|
||||
os.unlink(temp_name)
|
||||
|
||||
def _meta(self) -> dict[str, Any]:
|
||||
return self._read(self.meta_path)
|
||||
|
||||
def _task_path(self, task_id: str) -> Path:
|
||||
return self.tasks_dir / f"{task_id}.json"
|
||||
|
||||
def _daily_path(self, logical_date: date) -> Path:
|
||||
return self.daily_dir / str(logical_date.year) / f"{logical_date.isoformat()}.json"
|
||||
|
||||
def _load_task(self, task_id: str) -> dict[str, Any]:
|
||||
task = self._read(self._task_path(task_id))
|
||||
if task is None:
|
||||
raise NotFoundError("任务不存在")
|
||||
return task
|
||||
|
||||
def list_tasks(self, include_deleted: bool = False) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
meta = self._meta()
|
||||
tasks = []
|
||||
for path in self.tasks_dir.glob("*.json"):
|
||||
task = self._read(path)
|
||||
if include_deleted or not task.get("deletedAt"):
|
||||
tasks.append(task)
|
||||
positions = {task_id: index for index, task_id in enumerate(meta["taskOrder"])}
|
||||
tasks.sort(key=lambda item: (positions.get(item["taskId"], 10**9), item["createdAt"]))
|
||||
return {"version": meta["version"], "items": tasks}
|
||||
|
||||
def get_task(self, task_id: str, include_deleted: bool = False) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
task = self._load_task(task_id)
|
||||
if task.get("deletedAt") and not include_deleted:
|
||||
raise NotFoundError("任务不存在")
|
||||
return task
|
||||
|
||||
def _metric_dicts(self, payload: TaskInput, old: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||
old_ids = {item["metricId"] for item in (old or {}).get("metrics", [])}
|
||||
result = []
|
||||
seen = set()
|
||||
for metric in payload.metrics:
|
||||
metric_data = metric.model_dump(mode="json")
|
||||
metric_id = metric_data.get("metricId")
|
||||
if not metric_id:
|
||||
metric_id = str(uuid4())
|
||||
elif old is not None and metric_id not in old_ids:
|
||||
raise ValidationError("不能指定不属于该任务的 metricId")
|
||||
if metric_id in seen:
|
||||
raise ValidationError("metricId 不能重复")
|
||||
seen.add(metric_id)
|
||||
metric_data["metricId"] = metric_id
|
||||
result.append(metric_data)
|
||||
return result
|
||||
|
||||
def _write_revision(self, task: dict[str, Any]) -> None:
|
||||
path = self.revisions_dir / task["taskId"] / f"{task['revision']}.json"
|
||||
self._atomic_write(path, task)
|
||||
|
||||
def create_task(self, payload: TaskInput) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
task_id = str(uuid4())
|
||||
timestamp = now_iso()
|
||||
task = {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"taskId": task_id,
|
||||
"version": 1,
|
||||
"revision": 1,
|
||||
**payload.model_dump(mode="json", exclude={"metrics"}),
|
||||
"metrics": self._metric_dicts(payload),
|
||||
"createdAt": timestamp,
|
||||
"updatedAt": timestamp,
|
||||
"deletedAt": None,
|
||||
}
|
||||
self._atomic_write(self._task_path(task_id), task)
|
||||
self._write_revision(task)
|
||||
meta = self._meta()
|
||||
meta["taskOrder"].append(task_id)
|
||||
meta["version"] += 1
|
||||
self._atomic_write(self.meta_path, meta)
|
||||
return task
|
||||
|
||||
def update_task(self, task_id: str, payload: TaskInput, expected_version: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
old = self._load_task(task_id)
|
||||
if old.get("deletedAt"):
|
||||
raise NotFoundError("任务已删除")
|
||||
if old["version"] != expected_version:
|
||||
raise ConflictError("任务已被更新,请刷新后重试")
|
||||
timestamp = now_iso()
|
||||
task = {
|
||||
**old,
|
||||
**payload.model_dump(mode="json", exclude={"metrics"}),
|
||||
"metrics": self._metric_dicts(payload, old),
|
||||
"version": old["version"] + 1,
|
||||
"revision": old["revision"] + 1,
|
||||
"updatedAt": timestamp,
|
||||
}
|
||||
self._atomic_write(self._task_path(task_id), task)
|
||||
self._write_revision(task)
|
||||
return task
|
||||
|
||||
def _change_deleted(self, task_id: str, expected_version: int, restore: bool) -> dict[str, Any]:
|
||||
task = self._load_task(task_id)
|
||||
if task["version"] != expected_version:
|
||||
raise ConflictError("任务已被更新,请刷新后重试")
|
||||
if restore and not task.get("deletedAt"):
|
||||
return task
|
||||
if not restore and task.get("deletedAt"):
|
||||
return task
|
||||
task["deletedAt"] = None if restore else now_iso()
|
||||
task["version"] += 1
|
||||
task["revision"] += 1
|
||||
task["updatedAt"] = now_iso()
|
||||
self._atomic_write(self._task_path(task_id), task)
|
||||
self._write_revision(task)
|
||||
return task
|
||||
|
||||
def soft_delete_task(self, task_id: str, expected_version: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return self._change_deleted(task_id, expected_version, False)
|
||||
|
||||
def restore_task(self, task_id: str, expected_version: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return self._change_deleted(task_id, expected_version, True)
|
||||
|
||||
def purge_task(self, task_id: str, confirmation: str) -> None:
|
||||
with self._lock:
|
||||
task = self._load_task(task_id)
|
||||
if not task.get("deletedAt"):
|
||||
raise ValidationError("永久删除前必须先放入回收站")
|
||||
if confirmation != task_id:
|
||||
raise ValidationError("确认内容必须是 taskId")
|
||||
self._task_path(task_id).unlink()
|
||||
revision_folder = self.revisions_dir / task_id
|
||||
for path in revision_folder.glob("*.json"):
|
||||
path.unlink()
|
||||
if revision_folder.exists():
|
||||
revision_folder.rmdir()
|
||||
touched_months: set[tuple[int, int]] = set()
|
||||
for path in self.daily_dir.glob("*/*.json"):
|
||||
daily = self._read(path)
|
||||
if task_id in daily.get("records", {}):
|
||||
del daily["records"][task_id]
|
||||
daily["version"] += 1
|
||||
self._atomic_write(path, daily)
|
||||
day = date.fromisoformat(daily["date"])
|
||||
touched_months.add((day.year, day.month))
|
||||
meta = self._meta()
|
||||
meta["taskOrder"] = [item for item in meta["taskOrder"] if item != task_id]
|
||||
meta["version"] += 1
|
||||
self._atomic_write(self.meta_path, meta)
|
||||
for year, month in touched_months:
|
||||
self._rebuild_month(year, month)
|
||||
|
||||
def reorder(self, task_ids: list[str], expected_version: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
meta = self._meta()
|
||||
if meta["version"] != expected_version:
|
||||
raise ConflictError("排序已被更新,请刷新后重试")
|
||||
active_ids = {item["taskId"] for item in self.list_tasks()["items"]}
|
||||
if set(task_ids) != active_ids:
|
||||
raise ValidationError("排序列表必须包含全部未删除任务")
|
||||
deleted_ids = [item for item in meta["taskOrder"] if item not in active_ids]
|
||||
meta["taskOrder"] = task_ids + deleted_ids
|
||||
meta["version"] += 1
|
||||
self._atomic_write(self.meta_path, meta)
|
||||
return {"version": meta["version"], "taskIds": task_ids}
|
||||
|
||||
def get_daily(self, task_id: str, logical_date: date) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self._load_task(task_id)
|
||||
daily = self._read(self._daily_path(logical_date), {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"date": logical_date.isoformat(),
|
||||
"version": 1,
|
||||
"closedAt": None,
|
||||
"records": {},
|
||||
})
|
||||
return daily["records"].get(task_id, {
|
||||
"taskId": task_id,
|
||||
"taskRevision": self._load_task(task_id)["revision"],
|
||||
"version": 0,
|
||||
"note": "",
|
||||
"completedAt": None,
|
||||
"values": {},
|
||||
})
|
||||
|
||||
def upsert_daily(self, task_id: str, logical_date: date, payload: DailyRecordInput) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
task = self._load_task(task_id)
|
||||
if task.get("deletedAt"):
|
||||
raise NotFoundError("任务已删除")
|
||||
path = self._daily_path(logical_date)
|
||||
daily = self._read(path, {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"date": logical_date.isoformat(),
|
||||
"version": 1,
|
||||
"closedAt": None,
|
||||
"records": {},
|
||||
})
|
||||
old = daily["records"].get(task_id)
|
||||
current_version = old["version"] if old else 0
|
||||
if payload.expectedVersion is not None and payload.expectedVersion != current_version:
|
||||
raise ConflictError("当日记录已被更新,请刷新后重试")
|
||||
metric_map = {metric["metricId"]: metric for metric in task["metrics"]}
|
||||
values: dict[str, int] = {}
|
||||
for item in payload.values:
|
||||
metric = metric_map.get(item.metricId)
|
||||
if metric is None:
|
||||
raise ValidationError("记录包含未知指标")
|
||||
if metric["kind"] == "checkin" and item.value not in (0, 1):
|
||||
raise ValidationError("打卡指标只能是 0 或 1")
|
||||
values[item.metricId] = item.value
|
||||
record = {
|
||||
"taskId": task_id,
|
||||
"taskRevision": task["revision"],
|
||||
"version": current_version + 1,
|
||||
"note": payload.note,
|
||||
"completedAt": payload.completedAt.isoformat() if payload.completedAt else None,
|
||||
"values": values,
|
||||
"updatedAt": now_iso(),
|
||||
}
|
||||
daily["records"][task_id] = record
|
||||
daily["version"] += 1
|
||||
self._atomic_write(path, daily)
|
||||
if logical_date < datetime.now(SHANGHAI).date():
|
||||
if not daily.get("closedAt"):
|
||||
daily["closedAt"] = now_iso()
|
||||
self._atomic_write(path, daily)
|
||||
self._rebuild_month(logical_date.year, logical_date.month)
|
||||
return record
|
||||
|
||||
def history(self, task_id: str, start: date, end: date) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self._load_task(task_id)
|
||||
if end < start or (end - start).days > 730:
|
||||
raise ValidationError("历史查询范围无效或超过 730 天")
|
||||
records = []
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
daily = self._read(self._daily_path(cursor))
|
||||
if daily and task_id in daily.get("records", {}):
|
||||
records.append({"date": cursor.isoformat(), **daily["records"][task_id]})
|
||||
cursor += timedelta(days=1)
|
||||
revisions = [self._read(path) for path in sorted((self.revisions_dir / task_id).glob("*.json"), key=lambda p: int(p.stem))]
|
||||
return {"taskId": task_id, "records": records, "revisions": revisions}
|
||||
|
||||
def _rebuild_month(self, year: int, month: int) -> dict[str, Any]:
|
||||
summaries: dict[str, Any] = {}
|
||||
folder = self.daily_dir / str(year)
|
||||
for path in sorted(folder.glob(f"{year:04d}-{month:02d}-*.json")):
|
||||
daily = self._read(path)
|
||||
if not daily.get("closedAt"):
|
||||
continue
|
||||
for task_id, record in daily.get("records", {}).items():
|
||||
task_summary = summaries.setdefault(task_id, {"days": 0, "completedDays": 0, "metricTotals": {}})
|
||||
task_summary["days"] += 1
|
||||
if record.get("completedAt"):
|
||||
task_summary["completedDays"] += 1
|
||||
for metric_id, value in record.get("values", {}).items():
|
||||
task_summary["metricTotals"][metric_id] = task_summary["metricTotals"].get(metric_id, 0) + value
|
||||
archive = {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"month": f"{year:04d}-{month:02d}",
|
||||
"rebuiltAt": now_iso(),
|
||||
"tasks": summaries,
|
||||
}
|
||||
self._atomic_write(self.archive_dir / str(year) / f"{year:04d}-{month:02d}.json", archive)
|
||||
return archive
|
||||
|
||||
def close_day(self, logical_date: date) -> None:
|
||||
with self._lock:
|
||||
path = self._daily_path(logical_date)
|
||||
daily = self._read(path, {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"date": logical_date.isoformat(),
|
||||
"version": 1,
|
||||
"closedAt": None,
|
||||
"records": {},
|
||||
})
|
||||
if not daily.get("closedAt"):
|
||||
daily["closedAt"] = now_iso()
|
||||
daily["version"] += 1
|
||||
self._atomic_write(path, daily)
|
||||
self._rebuild_month(logical_date.year, logical_date.month)
|
||||
|
||||
def close_missed_days(self, today: date | None = None) -> int:
|
||||
with self._lock:
|
||||
today = today or datetime.now(SHANGHAI).date()
|
||||
meta = self._meta()
|
||||
cursor = date.fromisoformat(meta["lastClosedDate"]) + timedelta(days=1)
|
||||
last = today - timedelta(days=1)
|
||||
count = 0
|
||||
while cursor <= last:
|
||||
self.close_day(cursor)
|
||||
meta["lastClosedDate"] = cursor.isoformat()
|
||||
cursor += timedelta(days=1)
|
||||
count += 1
|
||||
if count:
|
||||
meta["version"] += 1
|
||||
self._atomic_write(self.meta_path, meta)
|
||||
return count
|
||||
|
||||
def export_all(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
tasks = [self._read(path) for path in self.tasks_dir.glob("*.json")]
|
||||
revisions = [self._read(path) for path in self.revisions_dir.glob("*/*.json")]
|
||||
daily = [self._read(path) for path in self.daily_dir.glob("*/*.json")]
|
||||
archives = [self._read(path) for path in self.archive_dir.glob("*/*.json")]
|
||||
return {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"exportedAt": now_iso(),
|
||||
"meta": self._meta(),
|
||||
"tasks": tasks,
|
||||
"revisions": revisions,
|
||||
"daily": daily,
|
||||
"archives": archives,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user