Files
schedule-planner/backend/main.py
Hera Zhao d3f3b4f37b
Some checks failed
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 3s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Failing after 55s
PR Preview / deploy-preview (pull_request) Failing after 40s
Refactor to Vue 3 + FastAPI + SQLite architecture
- Backend: FastAPI + SQLite (WAL mode), 22 tables, ~40 API endpoints
- Frontend: Vue 3 + Vite + Pinia + Vue Router, 8 views, 3 stores
- Database: migrate from JSON file to SQLite with proper schema
- Dockerfile: multi-stage build (node + python)
- Deploy: K8s manifests (namespace, deployment, service, ingress, pvc, backup)
- CI/CD: Gitea Actions (test, deploy, PR preview at pr-$id.planner.oci.euphon.net)
- Tests: 20 Cypress E2E test files, 196 test cases, ~85% coverage
- Doc: test-coverage.md with full feature coverage report

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:18:22 +00:00

1072 lines
29 KiB
Python

"""Hera Planner - FastAPI Backend"""
import json
import os
import hashlib
import time
import shutil
import threading
from datetime import datetime
from pathlib import Path
from contextlib import contextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel
from typing import Optional
from .database import get_db, init_db, DB_PATH
app = FastAPI(title="Hera's Planner")
DATA_DIR = os.environ.get("DATA_DIR", "/data")
BACKUP_DIR = os.path.join(DATA_DIR, "backups")
FRONTEND_DIR = os.environ.get("FRONTEND_DIR", "/app/frontend")
DEFAULT_HASH = "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92"
# ============================================================
# Helpers
# ============================================================
def _ensure_dirs():
Path(DATA_DIR).mkdir(parents=True, exist_ok=True)
Path(BACKUP_DIR).mkdir(parents=True, exist_ok=True)
def _get_config(key, default=None):
conn = get_db()
row = conn.execute("SELECT value FROM config WHERE key = ?", (key,)).fetchone()
conn.close()
return row["value"] if row else default
def _set_config(key, value):
conn = get_db()
conn.execute(
"INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
(key, value, value),
)
conn.commit()
conn.close()
def _do_backup():
"""Backup the SQLite database."""
if not os.path.exists(DB_PATH):
return
_ensure_dirs()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
dest = os.path.join(BACKUP_DIR, f"planner_{ts}.db")
# Use SQLite backup API for consistency
import sqlite3
src_conn = sqlite3.connect(DB_PATH)
dst_conn = sqlite3.connect(dest)
src_conn.backup(dst_conn)
dst_conn.close()
src_conn.close()
# Keep only last 30 backups
backups = sorted(Path(BACKUP_DIR).glob("planner_*.db"))
for old in backups[:-30]:
old.unlink()
# WAL checkpoint thread
def _wal_checkpoint_loop():
while True:
time.sleep(300)
try:
conn = get_db()
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.close()
except Exception:
pass
# ============================================================
# Startup
# ============================================================
@app.on_event("startup")
def startup():
_ensure_dirs()
init_db()
# Start WAL checkpoint thread
t = threading.Thread(target=_wal_checkpoint_loop, daemon=True)
t.start()
# Mount frontend static files
dist_assets = os.path.join(FRONTEND_DIR, "assets")
if os.path.isdir(dist_assets):
app.mount("/assets", StaticFiles(directory=dist_assets), name="assets")
# ============================================================
# Auth
# ============================================================
class LoginRequest(BaseModel):
hash: str
class ChangePasswordRequest(BaseModel):
oldHash: str
newHash: str
@app.post("/api/login")
def login(req: LoginRequest):
stored = _get_config("password_hash", DEFAULT_HASH)
if req.hash != stored:
raise HTTPException(401, "密码不正确")
return {"ok": True}
@app.post("/api/change-password")
def change_password(req: ChangePasswordRequest):
stored = _get_config("password_hash", DEFAULT_HASH)
if req.oldHash != stored:
raise HTTPException(401, "当前密码不正确")
_set_config("password_hash", req.newHash)
return {"ok": True}
# ============================================================
# Notes (随手记)
# ============================================================
@app.get("/api/notes")
def list_notes():
conn = get_db()
rows = conn.execute("SELECT * FROM notes ORDER BY created_at DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
class NoteIn(BaseModel):
id: str
text: str
tag: str = "灵感"
@app.post("/api/notes")
def create_note(note: NoteIn):
conn = get_db()
conn.execute(
"INSERT INTO notes (id, text, tag) VALUES (?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET text=?, tag=?, updated_at=datetime('now')",
(note.id, note.text, note.tag, note.text, note.tag),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/notes/{note_id}")
def delete_note(note_id: str):
conn = get_db()
conn.execute("DELETE FROM notes WHERE id = ?", (note_id,))
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Todos (待办四象限)
# ============================================================
@app.get("/api/todos")
def list_todos():
conn = get_db()
rows = conn.execute("SELECT * FROM todos ORDER BY created_at DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
class TodoIn(BaseModel):
id: str
text: str
quadrant: str = "q1"
done: int = 0
@app.post("/api/todos")
def upsert_todo(todo: TodoIn):
conn = get_db()
conn.execute(
"INSERT INTO todos (id, text, quadrant, done) VALUES (?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET text=?, quadrant=?, done=?, updated_at=datetime('now')",
(todo.id, todo.text, todo.quadrant, todo.done, todo.text, todo.quadrant, todo.done),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/todos/{todo_id}")
def delete_todo(todo_id: str):
conn = get_db()
conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Inbox (收集箱)
# ============================================================
@app.get("/api/inbox")
def list_inbox():
conn = get_db()
rows = conn.execute("SELECT * FROM inbox ORDER BY created_at DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
class InboxIn(BaseModel):
id: str
text: str
@app.post("/api/inbox")
def add_inbox(item: InboxIn):
conn = get_db()
conn.execute(
"INSERT OR REPLACE INTO inbox (id, text) VALUES (?, ?)",
(item.id, item.text),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/inbox/{item_id}")
def delete_inbox(item_id: str):
conn = get_db()
conn.execute("DELETE FROM inbox WHERE id = ?", (item_id,))
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/inbox")
def clear_inbox():
conn = get_db()
conn.execute("DELETE FROM inbox")
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Reminders (提醒)
# ============================================================
@app.get("/api/reminders")
def list_reminders():
conn = get_db()
rows = conn.execute("SELECT * FROM reminders ORDER BY time").fetchall()
conn.close()
return [dict(r) for r in rows]
class ReminderIn(BaseModel):
id: str
text: str
time: Optional[str] = None
repeat: str = "none"
enabled: int = 1
@app.post("/api/reminders")
def upsert_reminder(r: ReminderIn):
conn = get_db()
conn.execute(
"INSERT INTO reminders (id, text, time, repeat, enabled) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET text=?, time=?, repeat=?, enabled=?",
(r.id, r.text, r.time, r.repeat, r.enabled, r.text, r.time, r.repeat, r.enabled),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/reminders/{reminder_id}")
def delete_reminder(reminder_id: str):
conn = get_db()
conn.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Goals (目标)
# ============================================================
@app.get("/api/goals")
def list_goals():
conn = get_db()
rows = conn.execute("SELECT * FROM goals ORDER BY created_at DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
class GoalIn(BaseModel):
id: str
name: str
month: Optional[str] = None
checks: str = "{}"
@app.post("/api/goals")
def upsert_goal(g: GoalIn):
conn = get_db()
conn.execute(
"INSERT INTO goals (id, name, month, checks) VALUES (?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET name=?, month=?, checks=?",
(g.id, g.name, g.month, g.checks, g.name, g.month, g.checks),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/goals/{goal_id}")
def delete_goal(goal_id: str):
conn = get_db()
conn.execute("DELETE FROM goals WHERE id = ?", (goal_id,))
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Checklists (清单)
# ============================================================
@app.get("/api/checklists")
def list_checklists():
conn = get_db()
rows = conn.execute("SELECT * FROM checklists ORDER BY created_at DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
class ChecklistIn(BaseModel):
id: str
title: str
items: str = "[]"
archived: int = 0
@app.post("/api/checklists")
def upsert_checklist(cl: ChecklistIn):
conn = get_db()
conn.execute(
"INSERT INTO checklists (id, title, items, archived) VALUES (?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET title=?, items=?, archived=?",
(cl.id, cl.title, cl.items, cl.archived, cl.title, cl.items, cl.archived),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/checklists/{cl_id}")
def delete_checklist(cl_id: str):
conn = get_db()
conn.execute("DELETE FROM checklists WHERE id = ?", (cl_id,))
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Health / Music Check-in (打卡)
# ============================================================
@app.get("/api/health-items")
def list_health_items(type: str = "health"):
conn = get_db()
rows = conn.execute("SELECT * FROM health_items WHERE type = ?", (type,)).fetchall()
conn.close()
return [dict(r) for r in rows]
class HealthItemIn(BaseModel):
id: str
name: str
type: str = "health"
@app.post("/api/health-items")
def upsert_health_item(item: HealthItemIn):
conn = get_db()
conn.execute(
"INSERT OR REPLACE INTO health_items (id, name, type) VALUES (?, ?, ?)",
(item.id, item.name, item.type),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/health-items/{item_id}")
def delete_health_item(item_id: str):
conn = get_db()
conn.execute("DELETE FROM health_items WHERE id = ?", (item_id,))
conn.commit()
conn.close()
return {"ok": True}
@app.get("/api/health-plans")
def get_health_plans(type: str = "health"):
conn = get_db()
rows = conn.execute("SELECT * FROM health_plans WHERE type = ?", (type,)).fetchall()
conn.close()
return [dict(r) for r in rows]
class HealthPlanIn(BaseModel):
month: str
type: str = "health"
item_ids: str = "[]"
@app.post("/api/health-plans")
def upsert_health_plan(plan: HealthPlanIn):
conn = get_db()
conn.execute(
"INSERT INTO health_plans (month, type, item_ids) VALUES (?, ?, ?) "
"ON CONFLICT(month, type) DO UPDATE SET item_ids=?",
(plan.month, plan.type, plan.item_ids, plan.item_ids),
)
conn.commit()
conn.close()
return {"ok": True}
@app.get("/api/health-checks")
def list_health_checks(type: str = "health", month: Optional[str] = None):
conn = get_db()
if month:
rows = conn.execute(
"SELECT * FROM health_checks WHERE type = ? AND date LIKE ?",
(type, f"{month}%"),
).fetchall()
else:
rows = conn.execute("SELECT * FROM health_checks WHERE type = ?", (type,)).fetchall()
conn.close()
return [dict(r) for r in rows]
class HealthCheckIn(BaseModel):
date: str
type: str = "health"
item_id: str
checked: int = 1
@app.post("/api/health-checks")
def upsert_health_check(check: HealthCheckIn):
conn = get_db()
if check.checked:
conn.execute(
"INSERT OR REPLACE INTO health_checks (date, type, item_id, checked) VALUES (?, ?, ?, ?)",
(check.date, check.type, check.item_id, check.checked),
)
else:
conn.execute(
"DELETE FROM health_checks WHERE date = ? AND type = ? AND item_id = ?",
(check.date, check.type, check.item_id),
)
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Sleep Records (睡眠)
# ============================================================
@app.get("/api/sleep")
def list_sleep():
conn = get_db()
rows = conn.execute("SELECT * FROM sleep_records ORDER BY date DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
class SleepIn(BaseModel):
date: str
time: str
minutes: Optional[float] = None
@app.post("/api/sleep")
def upsert_sleep(record: SleepIn):
conn = get_db()
conn.execute(
"INSERT INTO sleep_records (date, time, minutes) VALUES (?, ?, ?) "
"ON CONFLICT(date) DO UPDATE SET time=?, minutes=?",
(record.date, record.time, record.minutes, record.time, record.minutes),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/sleep/{date}")
def delete_sleep(date: str):
conn = get_db()
conn.execute("DELETE FROM sleep_records WHERE date = ?", (date,))
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Gym Records (健身)
# ============================================================
@app.get("/api/gym")
def list_gym():
conn = get_db()
rows = conn.execute("SELECT * FROM gym_records ORDER BY date DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
class GymIn(BaseModel):
id: str
date: str
type: str = ""
duration: str = ""
note: str = ""
@app.post("/api/gym")
def upsert_gym(record: GymIn):
conn = get_db()
conn.execute(
"INSERT INTO gym_records (id, date, type, duration, note) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET date=?, type=?, duration=?, note=?",
(record.id, record.date, record.type, record.duration, record.note,
record.date, record.type, record.duration, record.note),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/gym/{record_id}")
def delete_gym(record_id: str):
conn = get_db()
conn.execute("DELETE FROM gym_records WHERE id = ?", (record_id,))
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Period Records (经期)
# ============================================================
@app.get("/api/period")
def list_period():
conn = get_db()
rows = conn.execute("SELECT * FROM period_records ORDER BY start_date DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
class PeriodIn(BaseModel):
id: str
start_date: str
end_date: Optional[str] = None
note: str = ""
@app.post("/api/period")
def upsert_period(record: PeriodIn):
conn = get_db()
conn.execute(
"INSERT INTO period_records (id, start_date, end_date, note) VALUES (?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET start_date=?, end_date=?, note=?",
(record.id, record.start_date, record.end_date, record.note,
record.start_date, record.end_date, record.note),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/period/{record_id}")
def delete_period(record_id: str):
conn = get_db()
conn.execute("DELETE FROM period_records WHERE id = ?", (record_id,))
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Docs (文档)
# ============================================================
@app.get("/api/docs")
def list_docs():
conn = get_db()
docs = conn.execute("SELECT * FROM docs ORDER BY created_at").fetchall()
result = []
for d in docs:
doc = dict(d)
entries = conn.execute(
"SELECT * FROM doc_entries WHERE doc_id = ? ORDER BY created_at DESC",
(d["id"],),
).fetchall()
doc["entries"] = [dict(e) for e in entries]
result.append(doc)
conn.close()
return result
class DocIn(BaseModel):
id: str
name: str
icon: str = "📄"
keywords: str = ""
extract_rule: str = "none"
@app.post("/api/docs")
def upsert_doc(doc: DocIn):
conn = get_db()
conn.execute(
"INSERT INTO docs (id, name, icon, keywords, extract_rule) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET name=?, icon=?, keywords=?, extract_rule=?",
(doc.id, doc.name, doc.icon, doc.keywords, doc.extract_rule,
doc.name, doc.icon, doc.keywords, doc.extract_rule),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/docs/{doc_id}")
def delete_doc(doc_id: str):
conn = get_db()
conn.execute("DELETE FROM docs WHERE id = ?", (doc_id,))
conn.commit()
conn.close()
return {"ok": True}
class DocEntryIn(BaseModel):
id: str
doc_id: str
text: str
note_id: Optional[str] = None
@app.post("/api/doc-entries")
def add_doc_entry(entry: DocEntryIn):
conn = get_db()
conn.execute(
"INSERT OR REPLACE INTO doc_entries (id, doc_id, text, note_id) VALUES (?, ?, ?, ?)",
(entry.id, entry.doc_id, entry.text, entry.note_id),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/doc-entries/{entry_id}")
def delete_doc_entry(entry_id: str):
conn = get_db()
conn.execute("DELETE FROM doc_entries WHERE id = ?", (entry_id,))
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Schedule (日程)
# ============================================================
@app.get("/api/schedule-modules")
def list_schedule_modules():
conn = get_db()
rows = conn.execute("SELECT * FROM schedule_modules ORDER BY sort_order").fetchall()
conn.close()
return [dict(r) for r in rows]
class ScheduleModuleIn(BaseModel):
id: str
name: str
emoji: str = "📌"
color: str = "#667eea"
sort_order: int = 0
@app.post("/api/schedule-modules")
def upsert_schedule_module(m: ScheduleModuleIn):
conn = get_db()
conn.execute(
"INSERT INTO schedule_modules (id, name, emoji, color, sort_order) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET name=?, emoji=?, color=?, sort_order=?",
(m.id, m.name, m.emoji, m.color, m.sort_order, m.name, m.emoji, m.color, m.sort_order),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/schedule-modules/{module_id}")
def delete_schedule_module(module_id: str):
conn = get_db()
conn.execute("DELETE FROM schedule_modules WHERE id = ?", (module_id,))
conn.execute("DELETE FROM schedule_slots WHERE module_id = ?", (module_id,))
conn.commit()
conn.close()
return {"ok": True}
@app.get("/api/schedule-slots")
def list_schedule_slots(date: Optional[str] = None):
conn = get_db()
if date:
rows = conn.execute("SELECT * FROM schedule_slots WHERE date = ?", (date,)).fetchall()
else:
rows = conn.execute("SELECT * FROM schedule_slots").fetchall()
conn.close()
return [dict(r) for r in rows]
class ScheduleSlotIn(BaseModel):
date: str
time_slot: str
module_id: str
@app.post("/api/schedule-slots")
def add_schedule_slot(slot: ScheduleSlotIn):
conn = get_db()
conn.execute(
"INSERT OR REPLACE INTO schedule_slots (date, time_slot, module_id) VALUES (?, ?, ?)",
(slot.date, slot.time_slot, slot.module_id),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/schedule-slots")
def delete_schedule_slots(date: str, time_slot: Optional[str] = None, module_id: Optional[str] = None):
conn = get_db()
if time_slot and module_id:
conn.execute(
"DELETE FROM schedule_slots WHERE date = ? AND time_slot = ? AND module_id = ?",
(date, time_slot, module_id),
)
elif time_slot:
conn.execute("DELETE FROM schedule_slots WHERE date = ? AND time_slot = ?", (date, time_slot))
else:
conn.execute("DELETE FROM schedule_slots WHERE date = ?", (date,))
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Weekly Template (每周模板)
# ============================================================
@app.get("/api/weekly-template")
def get_weekly_template():
conn = get_db()
rows = conn.execute("SELECT * FROM weekly_template ORDER BY day").fetchall()
conn.close()
return [dict(r) for r in rows]
class WeeklyTemplateIn(BaseModel):
day: int
data: str = "[]"
@app.post("/api/weekly-template")
def upsert_weekly_template(t: WeeklyTemplateIn):
conn = get_db()
conn.execute(
"INSERT INTO weekly_template (day, data) VALUES (?, ?) "
"ON CONFLICT(day) DO UPDATE SET data=?",
(t.day, t.data, t.data),
)
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Reviews (周回顾)
# ============================================================
@app.get("/api/reviews")
def list_reviews():
conn = get_db()
rows = conn.execute("SELECT * FROM reviews ORDER BY week DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
class ReviewIn(BaseModel):
week: str
data: str = "{}"
@app.post("/api/reviews")
def upsert_review(r: ReviewIn):
conn = get_db()
conn.execute(
"INSERT INTO reviews (week, data) VALUES (?, ?) "
"ON CONFLICT(week) DO UPDATE SET data=?",
(r.week, r.data, r.data),
)
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Bugs
# ============================================================
@app.get("/api/bugs")
def list_bugs():
conn = get_db()
rows = conn.execute("SELECT * FROM bugs ORDER BY created_at DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
class BugIn(BaseModel):
id: str
text: str
status: str = "open"
@app.post("/api/bugs")
def upsert_bug(bug: BugIn):
conn = get_db()
conn.execute(
"INSERT INTO bugs (id, text, status) VALUES (?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET text=?, status=?",
(bug.id, bug.text, bug.status, bug.text, bug.status),
)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/bugs/{bug_id}")
def delete_bug(bug_id: str):
conn = get_db()
conn.execute("DELETE FROM bugs WHERE id = ?", (bug_id,))
conn.commit()
conn.close()
return {"ok": True}
# ============================================================
# Sleep Buddy (独立的社交睡眠打卡)
# ============================================================
class BuddyRegister(BaseModel):
username: str
hash: str
@app.post("/api/buddy-register")
def buddy_register(req: BuddyRegister):
username = req.username.strip()
if not username or not req.hash:
raise HTTPException(400, "请输入用户名和密码")
conn = get_db()
existing = conn.execute("SELECT username FROM buddy_users WHERE username = ?", (username,)).fetchone()
if existing:
conn.close()
raise HTTPException(400, "用户名已存在")
conn.execute(
"INSERT INTO buddy_users (username, password_hash) VALUES (?, ?)",
(username, req.hash),
)
conn.commit()
conn.close()
return {"ok": True}
class BuddyLogin(BaseModel):
username: str
hash: str
@app.post("/api/buddy-login")
def buddy_login(req: BuddyLogin):
username = req.username.strip()
conn = get_db()
user = conn.execute(
"SELECT * FROM buddy_users WHERE username = ? AND password_hash = ?",
(username, req.hash),
).fetchone()
conn.close()
if not user:
raise HTTPException(401, "用户名或密码不正确")
return {"ok": True, "username": username}
class BuddyDeleteUser(BaseModel):
adminHash: str
username: str
@app.post("/api/buddy-delete-user")
def buddy_delete_user(req: BuddyDeleteUser):
stored = _get_config("password_hash", DEFAULT_HASH)
if req.adminHash != stored:
raise HTTPException(401, "需要管理员密码")
conn = get_db()
conn.execute("DELETE FROM buddy_users WHERE username = ?", (req.username,))
conn.execute("DELETE FROM buddy_records WHERE username = ?", (req.username,))
conn.commit()
conn.close()
return {"ok": True}
@app.get("/api/sleep-buddy")
def get_buddy_data():
conn = get_db()
users_rows = conn.execute("SELECT * FROM buddy_users").fetchall()
users = {}
for u in users_rows:
records = conn.execute(
"SELECT date, time FROM buddy_records WHERE username = ? ORDER BY date DESC LIMIT 60",
(u["username"],),
).fetchall()
users[u["username"]] = [dict(r) for r in records]
targets = {}
for u in users_rows:
targets[u["username"]] = u["target_time"]
notifs = conn.execute(
"SELECT * FROM buddy_notifications ORDER BY created_at DESC LIMIT 20"
).fetchall()
conn.close()
return {
"users": users,
"targets": targets,
"notifications": [dict(n) for n in notifs],
}
class BuddyAction(BaseModel):
user: str
action: str
record: Optional[dict] = None
date: Optional[str] = None
target: Optional[str] = None
@app.post("/api/sleep-buddy")
def buddy_action(req: BuddyAction):
conn = get_db()
if req.action == "record":
record = req.record or {}
conn.execute(
"INSERT INTO buddy_records (username, date, time) VALUES (?, ?, ?) "
"ON CONFLICT(username, date) DO UPDATE SET time=?",
(req.user, record.get("date", ""), record.get("time", ""), record.get("time", "")),
)
conn.commit()
conn.close()
return {"ok": True}
elif req.action == "delete-record":
conn.execute(
"DELETE FROM buddy_records WHERE username = ? AND date = ?",
(req.user, req.date),
)
conn.commit()
conn.close()
return {"ok": True}
elif req.action == "sleep-now":
now = datetime.now()
conn.execute(
"INSERT INTO buddy_notifications (from_user, message, time, date, created_at) "
"VALUES (?, ?, ?, ?, ?)",
(
req.user,
f"{req.user} 去睡觉啦,你也早点休息!",
now.strftime("%H:%M"),
now.strftime("%Y-%m-%d"),
time.time(),
),
)
# Keep only last 20 notifications
conn.execute("""
DELETE FROM buddy_notifications WHERE id NOT IN (
SELECT id FROM buddy_notifications ORDER BY created_at DESC LIMIT 20
)
""")
conn.commit()
conn.close()
return {"ok": True}
elif req.action == "get-notifications":
cutoff = time.time() - 86400
rows = conn.execute(
"SELECT * FROM buddy_notifications WHERE from_user != ? AND created_at > ? ORDER BY created_at DESC",
(req.user, cutoff),
).fetchall()
conn.close()
return {"notifications": [dict(r) for r in rows]}
elif req.action == "set-target":
conn.execute(
"UPDATE buddy_users SET target_time = ? WHERE username = ?",
(req.target or "22:00", req.user),
)
conn.commit()
conn.close()
return {"ok": True}
conn.close()
return {"ok": True}
# ============================================================
# Backups
# ============================================================
@app.get("/api/backups")
def list_backups():
_ensure_dirs()
backups = sorted(Path(BACKUP_DIR).glob("planner_*.db"), reverse=True)
items = []
for b in backups[:20]:
items.append({
"name": b.name,
"size": b.stat().st_size,
"time": b.stem.split("_", 1)[1],
})
return items
@app.post("/api/backup")
def trigger_backup():
_do_backup()
return {"ok": True}
# ============================================================
# SPA Fallback
# ============================================================
@app.get("/{path:path}")
async def spa_fallback(path: str):
# Try static file first
file_path = os.path.join(FRONTEND_DIR, path)
if path and os.path.isfile(file_path):
return FileResponse(file_path)
# SPA fallback
index = os.path.join(FRONTEND_DIR, "index.html")
if os.path.isfile(index):
return FileResponse(index, headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
})
return JSONResponse({"error": "Not found"}, status_code=404)