feat: add data backup/import, goal step ordering, and PostgreSQL migration

- Add GET /api/backup/export and POST /api/backup/import endpoints for full data backup
- Add drag-and-drop reorder for goal steps with PUT /api/goals/{id}/steps/reorder
- Auto-assign sort_order on step creation (preserves creation order)
- Fix duplicate milestone rendering in goal detail page
- Add category management button in goal dialog
- Migrate database default from SQLite to PostgreSQL
- Fix router guard redirect loop for logged-in users on setup/login pages
- Fix ALTER TABLE ADD COLUMN crash on callable defaults (uuid.uuid4)
- Add auth status rate limiter and token version caching
- Update CLAUDE.md to reflect current architecture

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
祀梦
2026-05-18 00:02:18 +08:00
parent 0ab719500b
commit 5048de4fa1
21 changed files with 543 additions and 225 deletions

View File

@@ -15,8 +15,8 @@ COPY api/ ./api/
# 拷贝编译后的前端产物
COPY api/webui/ ./api/webui/
# 创建数据和日志目录
RUN mkdir -p api/data api/logs
# 创建日志目录
RUN mkdir -p api/logs
EXPOSE 23994

13
WebUI/src/api/backup.ts Normal file
View File

@@ -0,0 +1,13 @@
import request from './request'
export function exportBackup(): Promise<Blob> {
return request.get('/backup/export', { responseType: 'blob' })
}
export function importBackup(file: File): Promise<{ message: string; count: number }> {
const form = new FormData()
form.append('file', file)
return request.post('/backup/import', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}

View File

@@ -50,6 +50,10 @@ export function toggleStep(goalId: number, stepId: number): Promise<GoalStep> {
return patch<GoalStep>(`/goals/${goalId}/steps/${stepId}/toggle`)
}
export function reorderSteps(goalId: number, items: { id: number; sort_order: number }[]): Promise<{ message: string }> {
return put<{ message: string }>(`/goals/${goalId}/steps/reorder`, { items })
}
// ============ Reviews ============
export function createReview(goalId: number, data: GoalReviewFormData): Promise<GoalReview> {

View File

@@ -2,6 +2,7 @@
import { ref, watch } from 'vue'
import { useGoalStore } from '@/stores/useGoalStore'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useUIStore } from '@/stores/useUIStore'
import type { Goal, GoalFormData } from '@/api/types'
const props = defineProps<{
@@ -16,6 +17,7 @@ const emit = defineEmits<{
const goalStore = useGoalStore()
const categoryStore = useCategoryStore()
const uiStore = useUIStore()
const form = ref<GoalFormData>({
title: '',
@@ -119,14 +121,19 @@ const iconOptions = ['flag', 'star', 'trophy', 'aim', 'medal', 'magic', 'sunny',
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-col :span="16">
<el-form-item label="分类">
<el-select v-model="form.category_id" style="width:100%" clearable placeholder="无分类">
<el-option v-for="cat in categoryStore.categories" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
<div class="category-row">
<el-select v-model="form.category_id" style="flex:1" clearable placeholder="无分类">
<el-option v-for="cat in categoryStore.categories" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
<el-button text size="small" class="manage-cat-btn" @click="uiStore.openCategoryDialog()">
<el-icon><Setting /></el-icon>
</el-button>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :span="8">
<el-form-item label="颜色">
<div class="color-picker">
<button
@@ -165,15 +172,28 @@ const iconOptions = ['flag', 'star', 'trophy', 'aim', 'medal', 'magic', 'sunny',
</template>
<style scoped lang="scss">
.category-row {
display: flex;
align-items: center;
gap: 6px;
}
.manage-cat-btn {
flex-shrink: 0;
color: #999;
&:hover { color: var(--primary); }
}
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding-top: 4px;
.color-dot {
width: 28px;
height: 28px;
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;

View File

@@ -93,6 +93,18 @@ router.beforeEach(async (to, from) => {
const siteName = userStore.siteName || '爱莉希雅待办'
document.title = page ? `${page} - ${siteName}` : siteName
// 已登录用户访问 setup/login 页面时重定向到主页
if ((to.path === '/setup' || to.path === '/login')) {
const authStore = useAuthStore()
if (authStore.checked && authStore.isLoggedIn && !authStore.needSetup) {
return { path: '/' }
}
if (authStore.checked && authStore.needSetup && to.path === '/login') {
return { path: '/setup' }
}
return
}
if (to.meta.noAuth) return
const authStore = useAuthStore()

View File

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Plus, Delete } from '@element-plus/icons-vue'
import { useGoalStore } from '@/stores/useGoalStore'
import { useTaskStore } from '@/stores/useTaskStore'
import { reorderSteps } from '@/api/goals'
import GoalDialog from '@/components/GoalDialog.vue'
import type { Goal, GoalStep } from '@/api/types'
import { ElMessageBox, ElMessage } from 'element-plus'
@@ -43,6 +44,13 @@ const stepLabel: Record<string, string> = {
completed: '已完成',
}
function hasPhaseParent(step: GoalStep): boolean {
if (!step.parent_id) return false
return goalStore.currentGoal?.steps.some(
(s: GoalStep) => s.id === step.parent_id && s.step_type === 'phase'
) ?? false
}
const stepColor: Record<string, string> = {
pending: '#909399',
in_progress: '#E6A23C',
@@ -67,7 +75,6 @@ async function handleAddStep() {
step_type: stepType.value,
status: 'pending',
parent_id: stepParentId.value,
sort_order: 0,
})
stepTitle.value = ''
stepParentId.value = null
@@ -121,6 +128,82 @@ const unlinkedTasks = computed(() => {
return taskStore.activeTasks.filter(t => !linkedIds.has(t.id))
})
// ============ Drag & Drop ============
interface DragItem {
id: number
step_type: string
parent_id: number | null
}
const dragItem = ref<DragItem | null>(null)
const dragOverId = ref<number | null>(null)
function onDragStart(step: GoalStep) {
dragItem.value = { id: step.id, step_type: step.step_type, parent_id: step.parent_id }
}
function onDragOver(e: DragEvent, step: GoalStep) {
e.preventDefault()
if (dragItem.value && dragItem.value.id !== step.id) {
dragOverId.value = step.id
}
}
function onDragLeave() {
dragOverId.value = null
}
async function onDrop(targetStep: GoalStep) {
dragOverId.value = null
if (!dragItem.value || dragItem.value.id === targetStep.id) return
if (!goalStore.currentGoal) return
const src = dragItem.value
const currentGoal = goalStore.currentGoal
// Only allow reorder within the same parent scope
if (src.parent_id !== targetStep.parent_id) {
dragItem.value = null
return
}
const siblingSteps = currentGoal.steps.filter(
(s: GoalStep) => s.parent_id === targetStep.parent_id && s.step_type === targetStep.step_type
)
const children = targetStep.parent_id
? (currentGoal.steps.find((s: GoalStep) => s.id === targetStep.parent_id)?.children || [])
: []
// Build ordered list: remove dragged item, insert at target position
const ordered = siblingSteps.filter((s: GoalStep) => s.id !== src.id)
const targetIdx = ordered.findIndex((s: GoalStep) => s.id === targetStep.id)
ordered.splice(targetIdx, 0, currentGoal.steps.find((s: GoalStep) => s.id === src.id)!)
// Assign new sort_order values
const items = ordered.map((s: GoalStep, i: number) => ({ id: s.id, sort_order: i }))
// Optimistic local update
for (const item of items) {
const step = currentGoal.steps.find((s: GoalStep) => s.id === item.id)
if (step) (step as any).sort_order = item.sort_order
}
// Persist to backend
try {
await reorderSteps(currentGoal.id, items)
} catch {
ElMessage.error('排序更新失败')
await goalStore.fetchGoal(currentGoal.id)
}
dragItem.value = null
}
function onDragEnd() {
dragItem.value = null
dragOverId.value = null
}
const statusLabel: Record<string, string> = {
active: '进行中', paused: '已暂停', completed: '已完成', abandoned: '已放弃',
}
@@ -197,7 +280,16 @@ const statusLabel: Record<string, string> = {
<div v-for="step in goalStore.currentGoal.steps" :key="step.id" class="step-tree">
<!-- Phase -->
<div v-if="step.step_type === 'phase'" class="phase-node">
<div class="step-row" :class="step.status">
<div
class="step-row"
:class="[step.status, { 'drag-over': dragOverId === step.id, 'dragging': dragItem?.id === step.id }]"
draggable="true"
@dragstart="onDragStart(step)"
@dragover="onDragOver($event, step)"
@dragleave="onDragLeave"
@drop="onDrop(step)"
@dragend="onDragEnd"
>
<el-button
text
class="toggle-btn"
@@ -219,7 +311,16 @@ const statusLabel: Record<string, string> = {
</div>
<!-- child milestones -->
<div v-for="child in step.children" :key="child.id" class="child-milestone">
<div class="step-row" :class="child.status">
<div
class="step-row"
:class="[child.status, { 'drag-over': dragOverId === child.id, 'dragging': dragItem?.id === child.id }]"
draggable="true"
@dragstart="onDragStart(child)"
@dragover="onDragOver($event, child)"
@dragleave="onDragLeave"
@drop="onDrop(child)"
@dragend="onDragEnd"
>
<el-button
text
class="toggle-btn"
@@ -243,7 +344,17 @@ const statusLabel: Record<string, string> = {
</div>
<!-- Standalone milestone (no parent phase) -->
<div v-else class="step-row standalone" :class="step.status">
<div
v-else-if="!hasPhaseParent(step)"
class="step-row standalone"
:class="[step.status, { 'drag-over': dragOverId === step.id, 'dragging': dragItem?.id === step.id }]"
draggable="true"
@dragstart="onDragStart(step)"
@dragover="onDragOver($event, step)"
@dragleave="onDragLeave"
@drop="onDrop(step)"
@dragend="onDragEnd"
>
<el-button
text
class="toggle-btn"
@@ -420,10 +531,15 @@ section {
gap: 10px;
padding: 8px 4px;
border-radius: 6px;
transition: background 0.15s;
transition: background 0.15s, opacity 0.15s;
cursor: grab;
&:hover { background: #fafafa; }
&:active { cursor: grabbing; }
.toggle-btn { padding: 4px; }
&.dragging { opacity: 0.4; }
&.drag-over { background: #e6f7ff; border: 1px dashed #69b1ff; }
.toggle-btn { padding: 4px; flex-shrink: 0; cursor: pointer; }
.step-title { flex: 1; font-size: 14px; }
.step-date { font-size: 12px; color: #bbb; }
}

View File

@@ -17,7 +17,7 @@ const error = ref('')
const redirect = (route.query.redirect as string) || '/'
async function handleLogin() {
if (!password.value) return
if (!password.value || loading.value) return
loading.value = true
error.value = ''
try {

View File

@@ -6,8 +6,10 @@ import { useTaskStore } from '@/stores/useTaskStore'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useTagStore } from '@/stores/useTagStore'
import { useHabitStore } from '@/stores/useHabitStore'
import { useGoalStore } from '@/stores/useGoalStore'
import { useAnniversaryStore } from '@/stores/useAnniversaryStore'
import { useSyncStore, type SyncDirection } from '@/stores/useSyncStore'
import { get, post, del } from '@/api/request'
import { exportBackup, importBackup } from '@/api/backup'
import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types'
const userStore = useUserSettingsStore()
@@ -15,10 +17,13 @@ const taskStore = useTaskStore()
const categoryStore = useCategoryStore()
const tagStore = useTagStore()
const habitStore = useHabitStore()
const goalStore = useGoalStore()
const anniversaryStore = useAnniversaryStore()
const syncStore = useSyncStore()
const saving = ref(false)
const exporting = ref(false)
const importing = ref(false)
const viewOptions = [
{ label: '列表', value: 'list' },
@@ -135,32 +140,14 @@ async function startSync() {
async function exportData() {
exporting.value = true
try {
const [tasks, categories, tags, habitGroups, habits] = await Promise.all([
get<Task[]>('/tasks'),
get<Category[]>('/categories'),
get<Tag[]>('/tags'),
get<HabitGroup[]>('/habit-groups'),
get<Habit[]>('/habits', { params: { include_archived: true } })
])
const exportObj = {
version: 2,
exportedAt: new Date().toISOString(),
tasks,
categories,
tags,
habitGroups,
habits
}
const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' })
const blob = await exportBackup()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `todo-backup-${new Date().toISOString().slice(0, 10)}.json`
const now = new Date().toISOString().slice(0, 19).replace(/:/g, '-')
a.download = `elysia-backup-${now}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('数据导出成功~')
} catch {
ElMessage.error('导出失败了呢~')
@@ -169,168 +156,46 @@ async function exportData() {
}
}
const importFileRef = ref<HTMLInputElement>()
function importData() {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
importFileRef.value?.click()
}
try {
await ElMessageBox.confirm(
'导入数据会覆盖现有的所有任务、分类、标签和习惯数据,确定要继续吗?',
'确认导入',
{ confirmButtonText: '确定导入', cancelButtonText: '取消', type: 'warning' }
)
async function handleImportFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const text = await file.text()
const data = JSON.parse(text)
try {
await ElMessageBox.confirm(
'导入数据会覆盖当前的所有数据(包括任务、分类、标签、习惯、纪念日、目标等),确定要继续吗?',
'确认导入',
{ confirmButtonText: '确定导入', cancelButtonText: '取消', type: 'warning' }
)
if (!data.tasks || !Array.isArray(data.tasks)) {
ElMessage.error('数据格式不正确呢~')
return
}
importing.value = true
const result = await importBackup(file)
ElMessage.success(`数据导入成功~ 共导入 ${result.count} 条记录`)
// 先删除所有现有数据
const allTasks = await get<Task[]>('/tasks')
for (const t of allTasks) {
await del(`/tasks/${t.id}`)
}
const allCategories = await get<Category[]>('/categories')
for (const c of allCategories) {
await del(`/categories/${c.id}`)
}
const allTags = await get<Tag[]>('/tags')
for (const t of allTags) {
await del(`/tags/${t.id}`)
}
// 删除习惯数据(如果有的话)
if (data.habits && Array.isArray(data.habits)) {
const allHabits = await get<Habit[]>('/habits', { params: { include_archived: true } })
for (const h of allHabits) {
await del(`/habits/${h.id}`)
}
const allGroups = await get<HabitGroup[]>('/habit-groups')
for (const g of allGroups) {
await del(`/habit-groups/${g.id}`)
}
}
// 重新导入
if (data.categories && Array.isArray(data.categories)) {
for (const cat of data.categories) {
await post('/categories', { name: cat.name, color: cat.color, icon: cat.icon })
}
}
if (data.tags && Array.isArray(data.tags)) {
for (const tag of data.tags) {
await post('/tags', { name: tag.name })
}
}
if (data.tasks && Array.isArray(data.tasks)) {
// 建立新旧ID到名称的映射
const oldCatMap = new Map<number, string>()
const oldTagMap = new Map<number, string>()
if (data.categories) {
data.categories.forEach((c: Category) => oldCatMap.set(c.id, c.name))
}
if (data.tags) {
data.tags.forEach((t: Tag) => oldTagMap.set(t.id, t.name))
}
// 获取新建后的分类和标签
const newCategories = await get<Category[]>('/categories')
const newTags = await get<Tag[]>('/tags')
const catNameToId = new Map(newCategories.map(c => [c.name, c.id]))
const tagNameToId = new Map(newTags.map(t => [t.name, t.id]))
for (const task of data.tasks) {
const taskData: Record<string, unknown> = {
title: task.title,
description: task.description || null,
priority: task.priority,
due_date: task.due_date || null
}
if (task.category_id && oldCatMap.has(task.category_id)) {
const catName = oldCatMap.get(task.category_id)
if (catName && catNameToId.has(catName)) {
taskData.category_id = catNameToId.get(catName)!
}
}
const tagIds: number[] = []
if (task.tags && Array.isArray(task.tags)) {
for (const tag of task.tags) {
if (tagNameToId.has(tag.name)) {
tagIds.push(tagNameToId.get(tag.name)!)
}
}
}
taskData.tag_ids = tagIds
await post('/tasks', taskData)
}
}
// 导入习惯数据
if (data.habitGroups && Array.isArray(data.habitGroups)) {
for (const grp of data.habitGroups) {
await post('/habit-groups', { name: grp.name, color: grp.color, icon: grp.icon, sort_order: grp.sort_order })
}
}
if (data.habits && Array.isArray(data.habits)) {
const oldGroupMap = new Map<number, string>()
if (data.habitGroups) {
data.habitGroups.forEach((g: HabitGroup) => oldGroupMap.set(g.id, g.name))
}
const newGroups = await get<HabitGroup[]>('/habit-groups')
const groupNameToId = new Map(newGroups.map(g => [g.name, g.id]))
for (const habit of data.habits) {
const habitData: Record<string, unknown> = {
name: habit.name,
description: habit.description || null,
target_count: habit.target_count || 1,
frequency: habit.frequency || 'daily',
active_days: habit.active_days || null,
is_archived: habit.is_archived || false
}
if (habit.group_id && oldGroupMap.has(habit.group_id)) {
const grpName = oldGroupMap.get(habit.group_id)
if (grpName && groupNameToId.has(grpName)) {
habitData.group_id = groupNameToId.get(grpName)!
}
}
await post('/habits', habitData)
}
}
// 刷新数据
await Promise.all([
taskStore.fetchTasks(),
categoryStore.fetchCategories(),
tagStore.fetchTags()
])
if (data.habits || data.habitGroups) {
await habitStore.init()
}
ElMessage.success('数据导入成功~')
} catch (err) {
if ((err as { toString?: () => string })?.toString?.() !== 'cancel') {
ElMessage.error('导入失败了呢~')
}
// 刷新所有 store
await Promise.all([
taskStore.fetchTasks(),
categoryStore.fetchCategories(),
tagStore.fetchTags(),
habitStore.init(),
goalStore.fetchGoals(),
anniversaryStore.fetchCategories(),
anniversaryStore.fetchAnniversaries(),
])
} catch (err: any) {
if (err?.toString?.() !== 'cancel') {
ElMessage.error(err?.response?.data?.detail || '导入失败了呢~')
}
} finally {
importing.value = false
// 重置 file input允许重新选择同一文件
if (importFileRef.value) importFileRef.value.value = ''
}
input.click()
}
async function clearCompleted() {
@@ -458,7 +323,7 @@ async function clearCompleted() {
<div class="data-action-item">
<div class="action-info">
<span class="action-title">导出数据</span>
<span class="action-desc">将所有任务分类标签和习惯导出为 JSON 文件</span>
<span class="action-desc">将所有数据任务目标习惯纪念日分类标签导出为 JSON 文件</span>
</div>
<el-button
:loading="exporting"
@@ -473,9 +338,17 @@ async function clearCompleted() {
<div class="data-action-item">
<div class="action-info">
<span class="action-title">导入数据</span>
<span class="action-desc warning"> JSON 文件恢复数据会覆盖有数据</span>
<span class="action-desc warning"> JSON 备份文件恢复全部数据会覆盖当前所有数据</span>
</div>
<input
ref="importFileRef"
type="file"
accept=".json"
style="display:none"
@change="handleImportFile"
/>
<el-button
:loading="importing"
@click="importData"
class="action-btn"
>

View File

@@ -27,6 +27,7 @@ function validate(): string | null {
}
async function handleSetup() {
if (loading.value) return
const msg = validate()
if (msg) {
error.value = msg

View File

@@ -9,8 +9,10 @@ _logger = logging.getLogger("app.config")
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 数据库配置
DATABASE_PATH = os.path.join(_BASE_DIR, "data", "todo.db")
DATABASE_URL = f"sqlite:///{DATABASE_PATH}"
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://ToDoList:53N2PTSjMBPDy6zY@192.168.1.86:5432/ToDoList",
)
# WebUI 配置
WEBUI_PATH = os.path.join(_BASE_DIR, "webui")
@@ -49,3 +51,4 @@ def _load_jwt_secret() -> str:
JWT_SECRET = _load_jwt_secret()
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24小时
ACCESS_TOKEN_EXPIRE_SECONDS = ACCESS_TOKEN_EXPIRE_MINUTES * 60

View File

@@ -84,7 +84,11 @@ def init_db():
elif col.default is not None:
default_val = col.default.arg
ddl = f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type_str}"
if isinstance(default_val, bool):
if callable(default_val):
# callable 类型的默认值(如 uuid.uuid4无法写入 SQL DEFAULT
# 后续的 UUID 回填逻辑会处理已有记录
pass
elif isinstance(default_val, bool):
ddl += f" DEFAULT {'TRUE' if default_val else 'FALSE'}"
elif isinstance(default_val, str):
ddl += f" DEFAULT '{default_val}'"

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals, sync
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals, sync, backup
api_router = APIRouter()
@@ -12,3 +12,4 @@ api_router.include_router(habits.router)
api_router.include_router(anniversaries.router)
api_router.include_router(goals.router)
api_router.include_router(sync.router)
api_router.include_router(backup.router)

View File

@@ -1,16 +1,18 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
import time
from app.database import get_db
from app.models.user_settings import UserSettings
from app.schemas.auth import LoginRequest, LoginResponse, ChangePasswordRequest, AuthStatusResponse, SetupPasswordRequest, AuthSetupStatusResponse
from app.utils.auth import (
hash_password, verify_password, create_access_token,
get_current_user,
get_current_user, set_cached_token_version,
)
from app.utils.datetime import utcnow
from app.utils.rate_limiter import login_limiter
from app.config import ACCESS_TOKEN_EXPIRE_SECONDS
router = APIRouter(prefix="/api/auth", tags=["认证"])
@@ -32,9 +34,31 @@ def _get_or_create_settings(db: Session) -> UserSettings:
return settings
class _StatusLimiter:
MAX_REQUESTS = 30
WINDOW_SECONDS = 60
def __init__(self):
self._requests: dict[str, list[float]] = {}
def check(self, ip: str) -> bool:
now = time.time()
times = [t for t in self._requests.get(ip, []) if now - t < self.WINDOW_SECONDS]
self._requests[ip] = times
if len(times) >= self.MAX_REQUESTS:
return False
times.append(now)
return True
_status_limiter = _StatusLimiter()
@router.get("/status", response_model=AuthSetupStatusResponse)
def auth_status(db: Session = Depends(get_db)):
def auth_status(request: Request, db: Session = Depends(get_db)):
"""检查系统密码是否已设置"""
ip = _get_client_ip(request)
if not _status_limiter.check(ip):
raise HTTPException(status_code=429, detail="请求过于频繁")
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
has_password = bool(settings and settings.password_hash)
return AuthSetupStatusResponse(has_password=has_password)
@@ -68,7 +92,7 @@ def setup_password(data: SetupPasswordRequest, db: Session = Depends(get_db)):
value=token,
httponly=True,
samesite="strict",
max_age=86400,
max_age=ACCESS_TOKEN_EXPIRE_SECONDS,
path="/",
)
return response
@@ -111,7 +135,7 @@ def login(data: LoginRequest, request: Request, db: Session = Depends(get_db)):
value=token,
httponly=True,
samesite="strict",
max_age=86400,
max_age=ACCESS_TOKEN_EXPIRE_SECONDS,
path="/",
)
return response
@@ -147,6 +171,7 @@ def change_password(
settings.password_hash = hash_password(data.new_password)
settings.token_version = (settings.token_version or 0) + 1
set_cached_token_version(str(settings.id), settings.token_version)
settings.updated_at = utcnow()
db.commit()

161
api/app/routers/backup.py Normal file
View File

@@ -0,0 +1,161 @@
"""数据备份导入导出路由"""
import json
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import text
from io import BytesIO
from app.database import get_db, Base, engine
from app.utils.logger import logger
from app.utils.datetime import utcnow
router = APIRouter(prefix="/api/backup", tags=["备份"])
# 导出顺序:按依赖关系(无 FK 的先导出)
EXPORT_TABLES = [
"categories", "tags", "user_settings", "sync_settings",
"habit_groups", "anniversary_categories",
"goals", "tasks", "habits", "anniversaries",
"goal_steps", "goal_reviews", "habit_checkins",
"task_tags", "goal_tasks",
]
# 导入时的清表顺序:子表先删(避免 FK 约束报错)
TRUNCATE_ORDER = [
"task_tags", "goal_tasks",
"habit_checkins",
"goal_reviews", "goal_steps",
"tasks", "habits", "anniversaries",
"goals", "categories", "tags",
"habit_groups", "anniversary_categories",
"user_settings", "sync_settings",
]
# 导入时的插入顺序:父表先插
INSERT_ORDER = [
"categories", "tags", "user_settings", "sync_settings",
"habit_groups", "anniversary_categories",
"goals", "tasks", "habits", "anniversaries",
"goal_steps", "goal_reviews", "habit_checkins",
"task_tags", "goal_tasks",
]
def _serialize_value(val):
"""将 Python 对象转为 JSON 可序列化的值"""
if val is None:
return None
if isinstance(val, (datetime, date)):
return val.isoformat()
if isinstance(val, bytes):
return val.decode("utf-8", errors="replace")
return val
@router.get("/export")
def export_data(db: Session = Depends(get_db)):
"""导出所有数据为 JSON 备份文件"""
try:
data: dict[str, list[dict]] = {}
for table_name in EXPORT_TABLES:
rows = []
try:
result = db.execute(text(f"SELECT * FROM {table_name}"))
columns = list(result.keys())
for row in result:
rows.append({
col: _serialize_value(getattr(row, col))
for col in columns
})
except Exception:
# 表可能不存在
rows = []
data[table_name] = rows
backup = {
"metadata": {
"version": 1,
"exported_at": utcnow().isoformat(),
},
"data": data,
}
json_bytes = json.dumps(backup, ensure_ascii=False, indent=2).encode("utf-8")
filename = f"elysia-backup-{utcnow().strftime('%Y%m%d-%H%M%S')}.json"
logger.info(f"数据导出成功,共 {sum(len(v) for v in data.values())} 条记录")
return StreamingResponse(
BytesIO(json_bytes),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
except Exception as e:
logger.error(f"导出数据失败: {str(e)}")
raise HTTPException(status_code=500, detail="导出数据失败")
@router.post("/import")
async def import_data(
file: UploadFile = File(...),
db: Session = Depends(get_db),
):
"""导入备份数据(覆盖当前所有数据)"""
if not file.filename or not file.filename.endswith(".json"):
raise HTTPException(status_code=400, detail="请上传 JSON 格式的备份文件")
try:
content = await file.read()
backup = json.loads(content.decode("utf-8"))
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="备份文件格式不正确")
except Exception as e:
logger.error(f"读取备份文件失败: {str(e)}")
raise HTTPException(status_code=400, detail="读取备份文件失败")
payload = backup.get("data")
if not payload:
raise HTTPException(status_code=400, detail="备份文件内容为空")
# 验证必要表存在
for table_name in INSERT_ORDER:
if table_name not in payload:
raise HTTPException(status_code=400, detail=f"备份文件缺少表: {table_name}")
imported_count = 0
try:
# 1. 按序清空所有表
for table_name in TRUNCATE_ORDER:
try:
db.execute(text(f"DELETE FROM {table_name}"))
except Exception:
pass # 表可能不存在
db.flush()
# 2. 按序插入数据
for table_name in INSERT_ORDER:
rows = payload.get(table_name, [])
if not rows:
continue
columns = list(rows[0].keys())
col_str = ", ".join(columns)
placeholders = ", ".join([f":{c}" for c in columns])
for row_data in rows:
db.execute(
text(f"INSERT INTO {table_name} ({col_str}) VALUES ({placeholders})"),
{c: row_data[c] for c in columns},
)
imported_count += 1
db.commit()
logger.info(f"数据导入成功,共 {imported_count} 条记录")
return {"message": "数据导入成功", "count": imported_count}
except Exception as e:
db.rollback()
logger.error(f"导入数据失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"导入数据失败: {str(e)}")

View File

@@ -9,6 +9,7 @@ from app.schemas.goal import (
GoalCreate, GoalUpdate, GoalListResponse, GoalDetailResponse, GoalStatusUpdate,
GoalStepCreate, GoalStepUpdate, GoalStepResponse,
GoalReviewCreate, GoalReviewResponse,
ReorderRequest,
)
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
@@ -35,10 +36,10 @@ def recalc_progress(db: Session, goal_id: int):
def build_step_tree(steps: list[GoalStep]) -> list[dict]:
"""将扁平的 step 列表转为树形结构phase 包含子 milestone"""
"""将扁平的 step 列表转为树形结构phase 包含子 milestone,按 sort_order 排序"""
step_map = {}
roots = []
for s in steps:
for s in sorted(steps, key=lambda x: (x.sort_order or 0)):
step_map[s.id] = {
"id": s.id,
"goal_id": s.goal_id,
@@ -52,7 +53,7 @@ def build_step_tree(steps: list[GoalStep]) -> list[dict]:
"created_at": s.created_at,
"children": [],
}
for s in steps:
for s in sorted(steps, key=lambda x: (x.sort_order or 0)):
node = step_map[s.id]
if s.parent_id and s.parent_id in step_map:
step_map[s.parent_id]["children"].append(node)
@@ -206,7 +207,13 @@ def create_step(goal_id: int, data: GoalStepCreate, db: Session = Depends(get_db
"""添加阶段/里程碑"""
try:
get_or_404(db, Goal, goal_id, "目标")
step = GoalStep(goal_id=goal_id, **data.model_dump())
# 自动分配 sort_order同类步骤中取最大值 + 1
max_sort = db.query(GoalStep).filter(
GoalStep.goal_id == goal_id,
GoalStep.step_type == data.step_type,
).order_by(GoalStep.sort_order.desc()).first()
next_sort = (max_sort.sort_order + 1) if max_sort and max_sort.sort_order is not None else 0
step = GoalStep(goal_id=goal_id, sort_order=next_sort, **data.model_dump())
db.add(step)
db.commit()
db.refresh(step)
@@ -228,6 +235,31 @@ def create_step(goal_id: int, data: GoalStepCreate, db: Session = Depends(get_db
raise HTTPException(status_code=500, detail="添加步骤失败")
# ============ Reorder ============
@router.put("/{goal_id}/steps/reorder")
def reorder_steps(goal_id: int, data: ReorderRequest, db: Session = Depends(get_db)):
"""批量更新步骤排序"""
try:
get_or_404(db, Goal, goal_id, "目标")
for item in data.items:
step = db.query(GoalStep).filter(
GoalStep.id == item.id,
GoalStep.goal_id == goal_id,
).first()
if step:
step.sort_order = item.sort_order
db.commit()
logger.info(f"步骤排序更新成功: goal_id={goal_id}, count={len(data.items)}")
return {"message": "排序更新成功"}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新步骤排序失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新排序失败")
@router.put("/{goal_id}/steps/{step_id}", response_model=GoalStepResponse)
def update_step(goal_id: int, step_id: int, data: GoalStepUpdate, db: Session = Depends(get_db)):
"""更新阶段/里程碑"""

14
api/app/schemas/backup.py Normal file
View File

@@ -0,0 +1,14 @@
"""数据备份导入导出 Schema"""
from pydantic import BaseModel
from datetime import datetime, date
from typing import Optional, Any
class BackupMetadata(BaseModel):
version: int = 1
exported_at: datetime
class BackupPayload(BaseModel):
metadata: BackupMetadata
data: dict[str, list[dict[str, Any]]]

View File

@@ -120,3 +120,14 @@ class GoalDetailResponse(GoalListResponse):
class GoalStatusUpdate(BaseModel):
status: str = Field(..., pattern="^(active|paused|completed|abandoned)$")
# ============ Reorder Schema ============
class ReorderItem(BaseModel):
id: int
sort_order: int
class ReorderRequest(BaseModel):
items: list[ReorderItem]

View File

@@ -9,6 +9,16 @@ from app.config import JWT_SECRET, ACCESS_TOKEN_EXPIRE_MINUTES
ALGORITHM = "HS256"
_token_version_cache: dict[str, int] = {}
def get_cached_token_version(user_id: str) -> int | None:
return _token_version_cache.get(user_id)
def set_cached_token_version(user_id: str, version: int):
_token_version_cache[user_id] = version
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
@@ -30,6 +40,8 @@ def decode_access_token(token: str) -> dict:
def get_current_user(request: Request) -> dict:
if hasattr(request.state, "user") and request.state.user:
return request.state.user
token = request.cookies.get("access_token", "")
if not token:
raise HTTPException(status_code=401, detail="未登录")

View File

@@ -5,10 +5,10 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<script type="module" crossorigin src="/assets/index-DHLFfahW.js"></script>
<script type="module" crossorigin src="/assets/index-B7eroNyE.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-CwFI-VDq.js">
<link rel="modulepreload" crossorigin href="/assets/element-plus-C7J9BJ23.js">
<link rel="stylesheet" crossorigin href="/assets/index-DXzKHHP4.css">
<link rel="modulepreload" crossorigin href="/assets/element-plus-D84OQMcE.js">
<link rel="stylesheet" crossorigin href="/assets/index-Z-X1qCVU.css">
</head>
<body>
<div id="app"></div>

View File

@@ -1,14 +1,30 @@
services:
elysia-todo:
build: .
container_name: elysia-todo
restart: unless-stopped
ports:
- "23994:23994"
volumes:
# 挂载前端编译产物,本地修改后可立即生效
- ./api/webui:/app/api/webui:ro
# 挂载数据库文件,持久化数据
- ./api/data:/app/api/data
# 挂载日志目录,方便查看日志
- ./api/logs:/app/api/logs
services:
db:
image: postgres:18-alpine
container_name: elysia-todo-db
restart: unless-stopped
environment:
POSTGRES_USER: ToDoList
POSTGRES_PASSWORD: 53N2PTSjMBPDy6zY
POSTGRES_DB: ToDoList
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
elysia-todo:
build: .
container_name: elysia-todo
restart: unless-stopped
ports:
- "23994:23994"
environment:
DATABASE_URL: "postgresql://ToDoList:53N2PTSjMBPDy6zY@db:5432/ToDoList"
depends_on:
- db
volumes:
- ./api/webui:/app/api/webui:ro
- ./api/logs:/app/api/logs
volumes:
pgdata:

View File

@@ -5,5 +5,5 @@ pydantic==2.5.3
pydantic-settings==2.1.0
python-multipart==0.0.6
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
psycopg2-binary==2.9.11