feat: add standalone memos
ci / gitleaks (push) Successful in 10s
ci / docker (push) Successful in 3m38s

This commit is contained in:
2026-09-12 10:22:35 +08:00
parent e07a1de1f5
commit 31c2218edc
18 changed files with 1063 additions and 13 deletions
+15
View File
@@ -239,6 +239,21 @@ class Attachment(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class Memo(Base):
__tablename__ = "memos"
__table_args__ = (
Index("ix_memos_user_deleted_updated", "user_id", "deleted_at", "updated_at", "id"),
)
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str] = mapped_column(Text, default="")
version: Mapped[int] = mapped_column(Integer, default=1)
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
deleted_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
+203 -3
View File
@@ -32,6 +32,7 @@ from .models import (
Habit,
HabitLog,
HabitPause,
Memo,
RecurrenceException,
RecurrenceTemplate,
Task,
@@ -48,6 +49,172 @@ def audit(db: AsyncSession, user_id: UUID, action: str, entity_type: str, entity
db.add(AuditLog(user_id=user_id, action=action, entity_type=entity_type, entity_id=entity_id, details=details))
class MemoCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
content: str = ""
@field_validator("title", mode="before")
@classmethod
def clean_title(cls, value: str) -> str:
return value.strip()
class MemoUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
content: str | None = None
version: int = Field(ge=1)
@field_validator("title", mode="before")
@classmethod
def clean_title(cls, value: str | None) -> str | None:
if value is None:
return value
return value.strip()
@model_validator(mode="after")
def reject_nulls(self):
for field in ("title", "content"):
if field in self.model_fields_set and getattr(self, field) is None:
raise ValueError(f"{field} cannot be null")
return self
class MemoOut(BaseModel):
model_config = {"from_attributes": True}
id: UUID
title: str
content: str
version: int
created_at: datetime
updated_at: datetime
deleted_at: datetime | None
class MemoListItem(BaseModel):
id: UUID
title: str
excerpt: str
version: int
created_at: datetime
updated_at: datetime
deleted_at: datetime | None
def _memo_excerpt(content: str) -> str:
collapsed = " ".join(content.split())
return collapsed if len(collapsed) <= 120 else collapsed[:119] + ""
@router.get("/memos")
async def list_memos(
scope: str = Query("active", pattern="^(active|trash)$"),
q: str = "",
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
filters = [Memo.user_id == user.id]
filters.append(Memo.deleted_at.is_(None) if scope == "active" else Memo.deleted_at.is_not(None))
query = q.strip()
if query:
pattern = f"%{query}%"
filters.append((Memo.title.ilike(pattern)) | (Memo.content.ilike(pattern)))
total = await db.scalar(select(func.count()).select_from(Memo).where(*filters))
rows = (await db.scalars(
select(Memo).where(*filters).order_by(Memo.updated_at.desc(), Memo.id.desc())
.offset((page - 1) * page_size).limit(page_size)
)).all()
items = [MemoListItem(
id=row.id, title=row.title, excerpt=_memo_excerpt(row.content), version=row.version,
created_at=row.created_at, updated_at=row.updated_at, deleted_at=row.deleted_at,
) for row in rows]
return {"items": items, "total": total or 0, "page": page, "page_size": page_size}
@router.post("/memos", response_model=MemoOut, status_code=201)
async def create_memo(payload: MemoCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = Memo(user_id=user.id, title=payload.title, content=payload.content)
db.add(row)
await db.flush()
audit(db, user.id, "create", "memo", row.id, fields=["title"])
await db.commit()
await db.refresh(row)
return row
@router.patch("/memos/{memo_id}", response_model=MemoOut)
async def update_memo(
memo_id: UUID, payload: MemoUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)
):
existing = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
if not existing:
raise HTTPException(404, "备忘录不存在")
if existing.deleted_at is not None:
raise HTTPException(409, "已删除的备忘录不能编辑")
values = payload.model_dump(exclude={"version"}, exclude_unset=True)
now = utcnow()
result = await db.execute(
update(Memo).where(
Memo.id == memo_id, Memo.user_id == user.id, Memo.deleted_at.is_(None), Memo.version == payload.version
).values(**values, version=Memo.version + 1, updated_at=now)
)
if result.rowcount != 1:
raise HTTPException(409, "备忘录版本冲突")
audit(db, user.id, "update", "memo", memo_id, fields=sorted(values))
await db.commit()
return await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
@router.post("/memos/{memo_id}/restore", response_model=MemoOut)
async def restore_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
if not row:
raise HTTPException(404, "备忘录不存在")
if row.deleted_at is not None:
row.deleted_at = None
row.updated_at = utcnow()
audit(db, user.id, "restore", "memo", row.id)
await db.commit()
await db.refresh(row)
return row
@router.delete("/memos/{memo_id}/purge", status_code=204)
async def purge_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
if not row:
raise HTTPException(404, "备忘录不存在")
if row.deleted_at is None:
raise HTTPException(409, "只能永久删除回收站中的备忘录")
audit(db, user.id, "purge", "memo", row.id)
await db.delete(row)
await db.commit()
return Response(status_code=204)
@router.delete("/memos/{memo_id}", status_code=204)
async def delete_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
if not row:
raise HTTPException(404, "备忘录不存在")
if row.deleted_at is None:
now = utcnow()
row.deleted_at = now
row.updated_at = now
audit(db, user.id, "delete", "memo", row.id)
await db.commit()
return Response(status_code=204)
@router.get("/memos/{memo_id}", response_model=MemoOut)
async def get_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
if not row:
raise HTTPException(404, "备忘录不存在")
return row
class RecurrenceCreate(BaseModel):
task_id: UUID
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
@@ -1101,7 +1268,7 @@ def _serialize_export_value(value):
return value
def _export_payload(folders, lists, tasks, recurrences, habits, countdowns):
def _export_payload(folders, lists, tasks, recurrences, habits, countdowns, memos):
def serialize(row, fields):
return {field: _serialize_export_value(getattr(row, field)) for field in fields}
@@ -1114,6 +1281,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns):
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences],
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits],
"countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
"memos": [serialize(x, ["id", "title", "content", "version", "created_at", "updated_at", "deleted_at"]) for x in memos],
}
@@ -1124,7 +1292,8 @@ async def _load_export_rows(user, db):
habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all())
countdowns = list((await db.scalars(select(Countdown).where(Countdown.user_id == user.id))).all())
recurrences = list((await db.scalars(select(RecurrenceTemplate).where(RecurrenceTemplate.user_id == user.id))).all())
return folders, lists, tasks, recurrences, habits, countdowns
memos = list((await db.scalars(select(Memo).where(Memo.user_id == user.id))).all())
return folders, lists, tasks, recurrences, habits, countdowns, memos
@router.get("/export")
@@ -1138,7 +1307,7 @@ async def export_csv(user: User = Depends(current_user), db: AsyncSession = Depe
output = io.StringIO()
writer = csv.writer(output, lineterminator="\n")
writer.writerow(["entity", "data"])
for entity in ("folders", "lists", "tasks", "recurrences", "habits", "countdowns"):
for entity in ("folders", "lists", "tasks", "recurrences", "habits", "countdowns", "memos"):
for row in payload[entity]:
writer.writerow([entity, json.dumps(row, ensure_ascii=False, separators=(",", ":"))])
content = "\ufeff" + output.getvalue()
@@ -1165,6 +1334,7 @@ async def restore_csv(
"recurrences": [],
"habits": [],
"countdowns": [],
"memos": [],
}
try:
for row in csv.DictReader(io.StringIO(text)):
@@ -1246,6 +1416,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
raise HTTPException(422, "不支持的备份版本")
parsed_countdowns = _validate_countdown_backups(payload)
if mode == "replace":
await db.execute(delete(Memo).where(Memo.user_id == user.id))
await db.execute(delete(Countdown).where(Countdown.user_id == user.id))
await db.execute(delete(Task).where(Task.user_id == user.id))
await db.execute(delete(Habit).where(Habit.user_id == user.id))
@@ -1372,6 +1543,35 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
existing_countdown_ids.add(row_id)
occupied_countdown_ids[row_id] = user.id
restored += 1
existing_memo_ids = set((await db.scalars(select(Memo.id).where(Memo.user_id == user.id))).all())
for raw in payload.get("memos", []):
try:
source_id = UUID(raw["id"])
title = raw["title"].strip()
content = raw.get("content", "")
version = raw.get("version", 1)
if not title or len(title) > 200 or not isinstance(content, str):
raise ValueError
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
raise ValueError
created_at = datetime.fromisoformat(raw["created_at"])
updated_at = datetime.fromisoformat(raw["updated_at"])
deleted_at = datetime.fromisoformat(raw["deleted_at"]) if raw.get("deleted_at") else None
except (KeyError, TypeError, ValueError) as exc:
raise HTTPException(422, "无效的备忘录备份数据") from exc
occupied_user = await db.scalar(select(Memo.user_id).where(Memo.id == source_id))
row_id = source_id
while occupied_user not in (None, user.id):
row_id = uuid5(user.id, str(row_id))
occupied_user = await db.scalar(select(Memo.user_id).where(Memo.id == row_id))
if mode == "merge" and row_id in existing_memo_ids:
continue
db.add(Memo(
id=row_id, user_id=user.id, title=title, content=content, version=version,
created_at=created_at, updated_at=updated_at, deleted_at=deleted_at,
))
existing_memo_ids.add(row_id)
restored += 1
audit(db, user.id, "restore", "backup", count=restored, mode=mode)
await db.commit()
return {"restored": restored, "mode": mode}