release: Elysia ToDo v1.0.0

鍏ㄦ爤涓汉淇℃伅绠$悊搴旂敤锛岄泦鎴愬緟鍔炰换鍔°€佷範鎯墦鍗°€佺邯蹇垫棩鎻愰啋銆佽祫浜ф€昏鍔熻兘銆

Made-with: Cursor
This commit is contained in:
祀梦
2026-03-14 22:21:26 +08:00
commit 2979197b1c
104 changed files with 21737 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
from fastapi import APIRouter
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, accounts
# 创建主路由
api_router = APIRouter()
# 注册子路由
api_router.include_router(tasks.router)
api_router.include_router(categories.router)
api_router.include_router(tags.router)
api_router.include_router(user_settings.router)
api_router.include_router(habits.router)
api_router.include_router(anniversaries.router)
api_router.include_router(accounts.router)

498
api/app/routers/accounts.py Normal file
View File

@@ -0,0 +1,498 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional, List
from datetime import date
from calendar import monthrange
from app.database import get_db
from app.models.account import FinancialAccount, AccountHistory, DebtInstallment
from app.schemas.account import (
AccountCreate, AccountUpdate, AccountResponse, BalanceUpdateRequest,
AccountHistoryResponse, AccountListItemResponse,
DebtInstallmentCreate, DebtInstallmentUpdate, DebtInstallmentResponse,
)
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.datetime import utcnow
from app.utils.logger import logger
router = APIRouter(prefix="/api", tags=["资产"])
def compute_installment_info(installment: DebtInstallment, today: date) -> dict:
"""计算分期计划的下次还款日期、距今天数、剩余期数"""
if installment.is_completed:
return {
"next_payment_date": None,
"days_until_payment": None,
"remaining_periods": 0,
}
remaining = installment.total_periods - installment.current_period + 1
if remaining <= 0:
return {
"next_payment_date": None,
"days_until_payment": None,
"remaining_periods": 0,
}
# 根据 start_date 和 payment_day 计算下一还款日期
payment_day = installment.payment_day
start_year = installment.start_date.year
start_month = installment.start_date.month
# 计算当前应还的期数对应的月份
period_index = installment.current_period - 1
next_month_year = start_year * 12 + (start_month - 1) + period_index
next_year = next_month_year // 12
next_month = next_month_year % 12 + 1
# 处理 payment_day 超出当月天数的情况
max_day = monthrange(next_year, next_month)[1]
actual_day = min(payment_day, max_day)
next_payment_date = date(next_year, next_month, actual_day)
# 如果计算出的日期在 start_date 之前(边界情况),使用 start_date
if next_payment_date < installment.start_date:
next_payment_date = installment.start_date
days_until = (next_payment_date - today).days
return {
"next_payment_date": next_payment_date,
"days_until_payment": days_until,
"remaining_periods": remaining,
}
# ============ 财务账户 API ============
@router.get("/accounts", response_model=List[AccountListItemResponse])
def get_accounts(db: Session = Depends(get_db)):
"""获取所有账户列表"""
try:
accounts = db.query(FinancialAccount).order_by(
FinancialAccount.sort_order.asc(),
FinancialAccount.id.asc()
).all()
result = []
for acc in accounts:
data = {
"id": acc.id,
"name": acc.name,
"account_type": acc.account_type,
"balance": acc.balance,
"icon": acc.icon,
"color": acc.color,
"sort_order": acc.sort_order,
"is_active": acc.is_active,
"description": acc.description,
"created_at": acc.created_at,
"updated_at": acc.updated_at,
}
# 附加分期计划摘要(欠款账户)
if acc.account_type == "debt":
installments = db.query(DebtInstallment).filter(
DebtInstallment.account_id == acc.id,
DebtInstallment.is_completed == False
).all()
today = date.today()
active_installments = []
for inst in installments:
info = compute_installment_info(inst, today)
active_installments.append(info)
data["installments"] = active_installments
else:
data["installments"] = []
result.append(data)
logger.info(f"获取账户列表成功,总数: {len(result)}")
return result
except Exception as e:
logger.error(f"获取账户列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取账户列表失败")
@router.post("/accounts", response_model=AccountResponse, status_code=201)
def create_account(data: AccountCreate, db: Session = Depends(get_db)):
"""创建账户"""
try:
account = FinancialAccount(
name=data.name,
account_type=data.account_type,
balance=data.balance,
icon=data.icon,
color=data.color,
sort_order=data.sort_order,
is_active=data.is_active,
description=data.description,
)
db.add(account)
db.commit()
db.refresh(account)
logger.info(f"创建账户成功: id={account.id}, name={account.name}")
return account
except Exception as e:
db.rollback()
logger.error(f"创建账户失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建账户失败")
@router.get("/accounts/{account_id}", response_model=AccountResponse)
def get_account(account_id: int, db: Session = Depends(get_db)):
"""获取单个账户"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
return account
except HTTPException:
raise
except Exception as e:
logger.error(f"获取账户失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取账户失败")
@router.put("/accounts/{account_id}", response_model=AccountResponse)
def update_account(account_id: int, data: AccountUpdate, db: Session = Depends(get_db)):
"""更新账户基本信息"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
update_data = data.model_dump(exclude_unset=True)
# 不允许通过此接口修改余额(使用专门的余额更新接口)
if "balance" in update_data:
del update_data["balance"]
for field, value in update_data.items():
setattr(account, field, value)
account.updated_at = utcnow()
db.commit()
db.refresh(account)
logger.info(f"更新账户成功: id={account_id}")
return account
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新账户失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新账户失败")
@router.delete("/accounts/{account_id}")
def delete_account(account_id: int, db: Session = Depends(get_db)):
"""删除账户(级联删除历史记录和分期计划)"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
db.delete(account)
db.commit()
logger.info(f"删除账户成功: id={account_id}")
return DeleteResponse(message="账户删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除账户失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除账户失败")
@router.post("/accounts/{account_id}/balance", response_model=AccountResponse)
def update_balance(account_id: int, data: BalanceUpdateRequest, db: Session = Depends(get_db)):
"""更新账户余额(自动记录变更历史)"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
old_balance = account.balance
new_balance = data.new_balance
change_amount = round(new_balance - old_balance, 2)
# 创建历史记录
history = AccountHistory(
account_id=account_id,
change_amount=change_amount,
balance_before=old_balance,
balance_after=new_balance,
note=data.note,
)
db.add(history)
# 更新余额
account.balance = new_balance
account.updated_at = utcnow()
db.commit()
db.refresh(account)
logger.info(f"更新余额成功: account_id={account_id}, {old_balance} -> {new_balance}")
return account
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新余额失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新余额失败")
@router.get("/accounts/{account_id}/history")
def get_account_history(
account_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)):
"""获取账户变更历史"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
total = db.query(AccountHistory).filter(
AccountHistory.account_id == account_id
).count()
records = db.query(AccountHistory).filter(
AccountHistory.account_id == account_id
).order_by(
AccountHistory.created_at.desc()
).offset((page - 1) * page_size).limit(page_size).all()
result = {
"total": total,
"page": page,
"page_size": page_size,
"records": [
{
"id": r.id,
"account_id": r.account_id,
"change_amount": r.change_amount,
"balance_before": r.balance_before,
"balance_after": r.balance_after,
"note": r.note,
"created_at": r.created_at,
}
for r in records
]
}
logger.info(f"获取账户历史成功: account_id={account_id}, total={total}")
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"获取账户历史失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取账户历史失败")
# ============ 分期还款计划 API ============
@router.get("/debt-installments", response_model=List[DebtInstallmentResponse])
def get_installments(db: Session = Depends(get_db)):
"""获取所有分期计划(含下次还款计算)"""
try:
installments = db.query(DebtInstallment).order_by(
DebtInstallment.is_completed.asc(),
DebtInstallment.next_payment_date.asc() if hasattr(DebtInstallment, 'next_payment_date') else DebtInstallment.id.asc()
).all()
today = date.today()
result = []
for inst in installments:
info = compute_installment_info(inst, today)
data = {
"id": inst.id,
"account_id": inst.account_id,
"total_amount": inst.total_amount,
"total_periods": inst.total_periods,
"current_period": inst.current_period,
"payment_day": inst.payment_day,
"payment_amount": inst.payment_amount,
"start_date": inst.start_date,
"is_completed": inst.is_completed,
"created_at": inst.created_at,
"updated_at": inst.updated_at,
**info,
}
if inst.account:
data["account_name"] = inst.account.name
data["account_icon"] = inst.account.icon
data["account_color"] = inst.account.color
result.append(data)
# 排序:未完成且临近的排前面
result.sort(key=lambda x: (
0 if not x["is_completed"] and x["days_until_payment"] is not None else 1,
0 if not x["is_completed"] else 1,
x["days_until_payment"] if x["days_until_payment"] is not None else 9999,
))
logger.info(f"获取分期计划列表成功,总数: {len(result)}")
return result
except Exception as e:
logger.error(f"获取分期计划列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取分期计划列表失败")
@router.post("/debt-installments", response_model=DebtInstallmentResponse, status_code=201)
def create_installment(data: DebtInstallmentCreate, db: Session = Depends(get_db)):
"""创建分期计划"""
try:
# 验证关联账户存在且为欠款类型
account = get_or_404(db, FinancialAccount, data.account_id, "账户")
if account.account_type != "debt":
raise HTTPException(status_code=400, detail="分期计划只能关联欠款类型的账户")
installment = DebtInstallment(
account_id=data.account_id,
total_amount=data.total_amount,
total_periods=data.total_periods,
current_period=data.current_period,
payment_day=data.payment_day,
payment_amount=data.payment_amount,
start_date=data.start_date,
is_completed=data.is_completed,
)
db.add(installment)
db.commit()
db.refresh(installment)
# 返回含计算字段的响应
today = date.today()
info = compute_installment_info(installment, today)
logger.info(f"创建分期计划成功: id={installment.id}")
return DebtInstallmentResponse(
id=installment.id,
account_id=installment.account_id,
total_amount=installment.total_amount,
total_periods=installment.total_periods,
current_period=installment.current_period,
payment_day=installment.payment_day,
payment_amount=installment.payment_amount,
start_date=installment.start_date,
is_completed=installment.is_completed,
created_at=installment.created_at,
updated_at=installment.updated_at,
**info,
account_name=account.name,
account_icon=account.icon,
account_color=account.color,
)
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"创建分期计划失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建分期计划失败")
@router.put("/debt-installments/{installment_id}", response_model=DebtInstallmentResponse)
def update_installment(installment_id: int, data: DebtInstallmentUpdate, db: Session = Depends(get_db)):
"""更新分期计划"""
try:
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(installment, field, value)
installment.updated_at = utcnow()
db.commit()
db.refresh(installment)
today = date.today()
info = compute_installment_info(installment, today)
result = DebtInstallmentResponse(
id=installment.id,
account_id=installment.account_id,
total_amount=installment.total_amount,
total_periods=installment.total_periods,
current_period=installment.current_period,
payment_day=installment.payment_day,
payment_amount=installment.payment_amount,
start_date=installment.start_date,
is_completed=installment.is_completed,
created_at=installment.created_at,
updated_at=installment.updated_at,
**info,
)
if installment.account:
result.account_name = installment.account.name
result.account_icon = installment.account.icon
result.account_color = installment.account.color
logger.info(f"更新分期计划成功: id={installment_id}")
return result
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新分期计划失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新分期计划失败")
@router.delete("/debt-installments/{installment_id}")
def delete_installment(installment_id: int, db: Session = Depends(get_db)):
"""删除分期计划"""
try:
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
db.delete(installment)
db.commit()
logger.info(f"删除分期计划成功: id={installment_id}")
return DeleteResponse(message="分期计划删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除分期计划失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除分期计划失败")
@router.patch("/debt-installments/{installment_id}/pay")
def pay_installment(installment_id: int, db: Session = Depends(get_db)):
"""标记已还一期"""
try:
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
if installment.is_completed:
raise HTTPException(status_code=400, detail="该分期计划已全部还清")
installment.current_period += 1
# 检查是否已全部还清
if installment.current_period > installment.total_periods:
installment.is_completed = True
installment.current_period = installment.total_periods
installment.updated_at = utcnow()
db.commit()
db.refresh(installment)
today = date.today()
info = compute_installment_info(installment, today)
result = DebtInstallmentResponse(
id=installment.id,
account_id=installment.account_id,
total_amount=installment.total_amount,
total_periods=installment.total_periods,
current_period=installment.current_period,
payment_day=installment.payment_day,
payment_amount=installment.payment_amount,
start_date=installment.start_date,
is_completed=installment.is_completed,
created_at=installment.created_at,
updated_at=installment.updated_at,
**info,
)
if installment.account:
result.account_name = installment.account.name
result.account_icon = installment.account.icon
result.account_color = installment.account.color
logger.info(f"分期还款成功: id={installment_id}, current_period={installment.current_period}")
return result
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"分期还款失败: {str(e)}")
raise HTTPException(status_code=500, detail="分期还款失败")

View File

@@ -0,0 +1,284 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional, List
from datetime import date
from app.database import get_db
from app.models.anniversary import Anniversary, AnniversaryCategory
from app.schemas.anniversary import (
AnniversaryCreate, AnniversaryUpdate, AnniversaryResponse,
AnniversaryCategoryCreate, AnniversaryCategoryUpdate, AnniversaryCategoryResponse,
)
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.datetime import utcnow
from app.utils.logger import logger
router = APIRouter(prefix="/api", tags=["纪念日"])
def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
"""计算纪念日的下一次日期、距今天数、周年数"""
month, day = anniversary.date.month, anniversary.date.day
if anniversary.is_recurring:
# 计算今年和明年的日期
this_year = today.year
next_date = date(this_year, month, day)
if next_date < today:
next_date = date(this_year + 1, month, day)
days_until = (next_date - today).days
year_count = None
if anniversary.year:
year_count = next_date.year - anniversary.year
return next_date, days_until, year_count
else:
# 非重复:使用原始日期(加上年份)
if anniversary.year:
target = date(anniversary.year, month, day)
if target < today:
return None, None, None
days_until = (target - today).days
return target, days_until, 0
else:
# 无年份的日期按今年算
target = date(today.year, month, day)
if target < today:
return None, None, None
days_until = (target - today).days
return target, days_until, None
def enrich_anniversary(anniversary: Anniversary, today: date) -> dict:
"""将 SQLAlchemy 模型转换为响应字典,附加计算字段"""
data = {
"id": anniversary.id,
"title": anniversary.title,
"date": anniversary.date,
"year": anniversary.year,
"category_id": anniversary.category_id,
"description": anniversary.description,
"is_recurring": anniversary.is_recurring,
"remind_days_before": anniversary.remind_days_before,
"created_at": anniversary.created_at,
"updated_at": anniversary.updated_at,
}
next_date, days_until, year_count = compute_next_info(anniversary, today)
data["next_date"] = next_date
data["days_until"] = days_until
data["year_count"] = year_count
if anniversary.category:
data["category"] = {
"id": anniversary.category.id,
"name": anniversary.category.name,
"icon": anniversary.category.icon,
"color": anniversary.category.color,
"sort_order": anniversary.category.sort_order,
}
else:
data["category"] = None
return data
# ============ 纪念日分类 API ============
@router.get("/anniversary-categories", response_model=List[AnniversaryCategoryResponse])
def get_categories(db: Session = Depends(get_db)):
"""获取纪念日分类列表"""
try:
categories = db.query(AnniversaryCategory).order_by(
AnniversaryCategory.sort_order.asc(),
AnniversaryCategory.id.asc()
).all()
logger.info(f"获取纪念日分类列表成功,总数: {len(categories)}")
return categories
except Exception as e:
logger.error(f"获取纪念日分类列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取纪念日分类列表失败")
@router.post("/anniversary-categories", response_model=AnniversaryCategoryResponse, status_code=201)
def create_category(data: AnniversaryCategoryCreate, db: Session = Depends(get_db)):
"""创建纪念日分类"""
try:
db_category = AnniversaryCategory(
name=data.name,
icon=data.icon,
color=data.color,
sort_order=data.sort_order,
)
db.add(db_category)
db.commit()
db.refresh(db_category)
logger.info(f"创建纪念日分类成功: id={db_category.id}, name={db_category.name}")
return db_category
except Exception as e:
db.rollback()
logger.error(f"创建纪念日分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建纪念日分类失败")
@router.put("/anniversary-categories/{category_id}", response_model=AnniversaryCategoryResponse)
def update_category(category_id: int, data: AnniversaryCategoryUpdate, db: Session = Depends(get_db)):
"""更新纪念日分类"""
try:
category = get_or_404(db, AnniversaryCategory, category_id, "纪念日分类")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(category, field, value)
db.commit()
db.refresh(category)
logger.info(f"更新纪念日分类成功: id={category_id}")
return category
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新纪念日分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新纪念日分类失败")
@router.delete("/anniversary-categories/{category_id}")
def delete_category(category_id: int, db: Session = Depends(get_db)):
"""删除纪念日分类"""
try:
category = get_or_404(db, AnniversaryCategory, category_id, "纪念日分类")
# 删除分类时,将其下纪念日的 category_id 设为 NULL
anniversaries = db.query(Anniversary).filter(
Anniversary.category_id == category_id
).all()
for a in anniversaries:
a.category_id = None
db.delete(category)
db.commit()
logger.info(f"删除纪念日分类成功: id={category_id}")
return DeleteResponse(message="纪念日分类删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除纪念日分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除纪念日分类失败")
# ============ 纪念日 API ============
@router.get("/anniversaries", response_model=List[AnniversaryResponse])
def get_anniversaries(
category_id: Optional[int] = Query(None, description="分类ID筛选"),
db: Session = Depends(get_db)
):
"""获取纪念日列表(包含计算字段 next_date, days_until, year_count"""
try:
query = db.query(Anniversary)
if category_id is not None:
query = query.filter(Anniversary.category_id == category_id)
anniversaries = query.order_by(
Anniversary.date.asc(),
Anniversary.title.asc()
).all()
today = date.today()
result = [enrich_anniversary(a, today) for a in anniversaries]
# 排序:即将到来的排前面,同天数的按日期排
result.sort(key=lambda x: (
0 if (x["days_until"] is not None and x["days_until"] >= 0) else 1,
x["days_until"] if x["days_until"] is not None else 9999,
))
logger.info(f"获取纪念日列表成功,总数: {len(result)}")
return result
except Exception as e:
logger.error(f"获取纪念日列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取纪念日列表失败")
@router.post("/anniversaries", response_model=AnniversaryResponse, status_code=201)
def create_anniversary(data: AnniversaryCreate, db: Session = Depends(get_db)):
"""创建纪念日"""
try:
db_anniversary = Anniversary(
title=data.title,
date=data.date,
year=data.year,
category_id=data.category_id,
description=data.description,
is_recurring=data.is_recurring,
remind_days_before=data.remind_days_before,
)
db.add(db_anniversary)
db.commit()
db.refresh(db_anniversary)
today = date.today()
next_date, days_until, year_count = compute_next_info(db_anniversary, today)
logger.info(f"创建纪念日成功: id={db_anniversary.id}, title={db_anniversary.title}")
return db_anniversary
except Exception as e:
db.rollback()
logger.error(f"创建纪念日失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建纪念日失败")
@router.get("/anniversaries/{anniversary_id}", response_model=AnniversaryResponse)
def get_anniversary(anniversary_id: int, db: Session = Depends(get_db)):
"""获取单个纪念日"""
try:
anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日")
return anniversary
except HTTPException:
raise
except Exception as e:
logger.error(f"获取纪念日失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取纪念日失败")
@router.put("/anniversaries/{anniversary_id}", response_model=AnniversaryResponse)
def update_anniversary(anniversary_id: int, data: AnniversaryUpdate, db: Session = Depends(get_db)):
"""更新纪念日"""
try:
anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if value is not None or field in data.clearable_fields:
setattr(anniversary, field, value)
anniversary.updated_at = utcnow()
db.commit()
db.refresh(anniversary)
logger.info(f"更新纪念日成功: id={anniversary_id}")
return anniversary
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新纪念日失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新纪念日失败")
@router.delete("/anniversaries/{anniversary_id}")
def delete_anniversary(anniversary_id: int, db: Session = Depends(get_db)):
"""删除纪念日"""
try:
anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日")
db.delete(anniversary)
db.commit()
logger.info(f"删除纪念日成功: id={anniversary_id}")
return DeleteResponse(message="纪念日删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除纪念日失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除纪念日失败")

View File

@@ -0,0 +1,102 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List
from app.database import get_db
from app.models import Category
from app.schemas import CategoryCreate, CategoryUpdate, CategoryResponse
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.logger import logger
router = APIRouter(prefix="/api/categories", tags=["分类"])
@router.get("", response_model=List[CategoryResponse])
def get_categories(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
db: Session = Depends(get_db)
):
"""获取分类列表"""
try:
categories = db.query(Category).offset(skip).limit(limit).all()
logger.info(f"获取分类列表成功,总数: {len(categories)}")
return categories
except Exception as e:
logger.error(f"获取分类列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取分类列表失败")
@router.post("", response_model=CategoryResponse, status_code=201)
def create_category(category_data: CategoryCreate, db: Session = Depends(get_db)):
"""创建分类"""
try:
db_category = Category(
name=category_data.name,
color=category_data.color,
icon=category_data.icon,
)
db.add(db_category)
db.commit()
db.refresh(db_category)
logger.info(f"创建分类成功: id={db_category.id}, name={db_category.name}")
return db_category
except Exception as e:
db.rollback()
logger.error(f"创建分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建分类失败")
@router.put("/{category_id}", response_model=CategoryResponse)
def update_category(category_id: int, category_data: CategoryUpdate, db: Session = Depends(get_db)):
"""更新分类"""
try:
category = get_or_404(db, Category, category_id, "分类")
update_data = category_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if value is not None:
setattr(category, field, value)
db.commit()
db.refresh(category)
logger.info(f"更新分类成功: id={category_id}")
return category
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新分类失败")
@router.delete("/{category_id}")
def delete_category(category_id: int, db: Session = Depends(get_db)):
"""删除分类"""
try:
category = get_or_404(db, Category, category_id, "分类")
# 检查是否有任务关联
if category.tasks:
logger.warning(f"分类下有关联任务,无法删除: id={category_id}")
raise HTTPException(status_code=400, detail="该分类下有关联任务,无法删除")
db.delete(category)
db.commit()
logger.info(f"删除分类成功: id={category_id}")
return DeleteResponse(message="分类删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除分类失败")

368
api/app/routers/habits.py Normal file
View File

@@ -0,0 +1,368 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, distinct
from typing import Optional, List
from datetime import date, timedelta
from app.database import get_db
from app.models.habit import Habit, HabitGroup, HabitCheckin
from app.schemas.habit import (
HabitGroupCreate, HabitGroupUpdate, HabitGroupResponse,
HabitCreate, HabitUpdate, HabitResponse,
CheckinCreate, CheckinResponse, HabitStatsResponse,
)
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.datetime import utcnow, today
from app.utils.logger import logger
router = APIRouter(tags=["习惯"])
# ============ 习惯分组 CRUD ============
habit_group_router = APIRouter(prefix="/api/habit-groups", tags=["习惯分组"])
@habit_group_router.get("", response_model=List[HabitGroupResponse])
def get_habit_groups(db: Session = Depends(get_db)):
"""获取所有习惯分组"""
try:
groups = db.query(HabitGroup).order_by(HabitGroup.sort_order, HabitGroup.id).all()
return groups
except Exception as e:
logger.error(f"获取习惯分组失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取习惯分组失败")
@habit_group_router.post("", response_model=HabitGroupResponse, status_code=201)
def create_habit_group(data: HabitGroupCreate, db: Session = Depends(get_db)):
"""创建习惯分组"""
try:
group = HabitGroup(**data.model_dump())
db.add(group)
db.commit()
db.refresh(group)
logger.info(f"创建习惯分组成功: id={group.id}, name={group.name}")
return group
except Exception as e:
db.rollback()
logger.error(f"创建习惯分组失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建习惯分组失败")
@habit_group_router.put("/{group_id}", response_model=HabitGroupResponse)
def update_habit_group(group_id: int, data: HabitGroupUpdate, db: Session = Depends(get_db)):
"""更新习惯分组"""
try:
group = get_or_404(db, HabitGroup, group_id, "习惯分组")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(group, field, value)
db.commit()
db.refresh(group)
logger.info(f"更新习惯分组成功: id={group_id}")
return group
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新习惯分组失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新习惯分组失败")
@habit_group_router.delete("/{group_id}")
def delete_habit_group(group_id: int, db: Session = Depends(get_db)):
"""删除习惯分组(习惯的 group_id 会被置空)"""
try:
group = get_or_404(db, HabitGroup, group_id, "习惯分组")
# 将该分组下所有习惯的 group_id 置空
db.query(Habit).filter(Habit.group_id == group_id).update({Habit.group_id: None})
db.delete(group)
db.commit()
logger.info(f"删除习惯分组成功: id={group_id}")
return DeleteResponse(message="习惯分组删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除习惯分组失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除习惯分组失败")
# ============ 习惯 CRUD ============
habit_router = APIRouter(prefix="/api/habits", tags=["习惯"])
@habit_router.get("", response_model=List[HabitResponse])
def get_habits(
include_archived: bool = Query(False, description="是否包含已归档的习惯"),
db: Session = Depends(get_db),
):
"""获取所有习惯"""
try:
query = db.query(Habit)
if not include_archived:
query = query.filter(Habit.is_archived == False)
habits = query.order_by(Habit.created_at.desc()).all()
return habits
except Exception as e:
logger.error(f"获取习惯列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取习惯列表失败")
@habit_router.post("", response_model=HabitResponse, status_code=201)
def create_habit(data: HabitCreate, db: Session = Depends(get_db)):
"""创建习惯"""
try:
habit = Habit(**data.model_dump())
db.add(habit)
db.commit()
db.refresh(habit)
logger.info(f"创建习惯成功: id={habit.id}, name={habit.name}")
return habit
except Exception as e:
db.rollback()
logger.error(f"创建习惯失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建习惯失败")
@habit_router.put("/{habit_id}", response_model=HabitResponse)
def update_habit(habit_id: int, data: HabitUpdate, db: Session = Depends(get_db)):
"""更新习惯"""
try:
habit = get_or_404(db, Habit, habit_id, "习惯")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if value is not None or field in data.clearable_fields:
setattr(habit, field, value)
habit.updated_at = utcnow()
db.commit()
db.refresh(habit)
logger.info(f"更新习惯成功: id={habit_id}")
return habit
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新习惯失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新习惯失败")
@habit_router.delete("/{habit_id}")
def delete_habit(habit_id: int, db: Session = Depends(get_db)):
"""删除习惯(级联删除打卡记录)"""
try:
habit = get_or_404(db, Habit, habit_id, "习惯")
db.delete(habit)
db.commit()
logger.info(f"删除习惯成功: id={habit_id}")
return DeleteResponse(message="习惯删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除习惯失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除习惯失败")
@habit_router.patch("/{habit_id}/archive", response_model=HabitResponse)
def toggle_archive_habit(habit_id: int, db: Session = Depends(get_db)):
"""切换习惯归档状态"""
try:
habit = get_or_404(db, Habit, habit_id, "习惯")
habit.is_archived = not habit.is_archived
habit.updated_at = utcnow()
db.commit()
db.refresh(habit)
logger.info(f"切换习惯归档状态成功: id={habit_id}, is_archived={habit.is_archived}")
return habit
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"切换习惯归档状态失败: {str(e)}")
raise HTTPException(status_code=500, detail="切换习惯归档状态失败")
# ============ 打卡 ============
checkin_router = APIRouter(prefix="/api/habits/{habit_id}/checkins", tags=["习惯打卡"])
@checkin_router.get("", response_model=List[CheckinResponse])
def get_checkins(
habit_id: int,
from_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
to_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
db: Session = Depends(get_db),
):
"""获取习惯打卡记录"""
try:
get_or_404(db, Habit, habit_id, "习惯")
query = db.query(HabitCheckin).filter(HabitCheckin.habit_id == habit_id)
if from_date:
query = query.filter(HabitCheckin.checkin_date >= date.fromisoformat(from_date))
if to_date:
query = query.filter(HabitCheckin.checkin_date <= date.fromisoformat(to_date))
checkins = query.order_by(HabitCheckin.checkin_date.desc()).all()
return checkins
except HTTPException:
raise
except Exception as e:
logger.error(f"获取打卡记录失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取打卡记录失败")
@checkin_router.post("", response_model=CheckinResponse)
def create_checkin(
habit_id: int,
data: Optional[CheckinCreate] = None,
db: Session = Depends(get_db),
):
"""打卡(当天 count 累加)"""
try:
habit = get_or_404(db, Habit, habit_id, "习惯")
today_date = today()
add_count = data.count if data else 1
# 查找今日已有记录
checkin = db.query(HabitCheckin).filter(
HabitCheckin.habit_id == habit_id,
HabitCheckin.checkin_date == today_date,
).first()
if checkin:
checkin.count += add_count
else:
checkin = HabitCheckin(
habit_id=habit_id,
checkin_date=today_date,
count=add_count,
)
db.add(checkin)
db.commit()
db.refresh(checkin)
logger.info(f"打卡成功: habit_id={habit_id}, date={today_date}, count={checkin.count}")
return checkin
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"打卡失败: {str(e)}")
raise HTTPException(status_code=500, detail="打卡失败")
@checkin_router.delete("")
def cancel_checkin(
habit_id: int,
count: int = Query(1, ge=1, description="取消的打卡次数"),
db: Session = Depends(get_db),
):
"""取消今日打卡count-1为0时删除记录"""
try:
habit = get_or_404(db, Habit, habit_id, "习惯")
today_date = today()
checkin = db.query(HabitCheckin).filter(
HabitCheckin.habit_id == habit_id,
HabitCheckin.checkin_date == today_date,
).first()
if not checkin:
return DeleteResponse(message="今日无打卡记录")
checkin.count = max(0, checkin.count - count)
if checkin.count <= 0:
db.delete(checkin)
db.commit()
logger.info(f"取消打卡: habit_id={habit_id}, date={today_date}")
return DeleteResponse(message="取消打卡成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"取消打卡失败: {str(e)}")
raise HTTPException(status_code=500, detail="取消打卡失败")
@checkin_router.get("/stats", response_model=HabitStatsResponse)
def get_habit_stats(habit_id: int, db: Session = Depends(get_db)):
"""获取习惯统计数据"""
try:
habit = get_or_404(db, Habit, habit_id, "习惯")
today_date = today()
# 今日打卡
today_checkin = db.query(HabitCheckin).filter(
HabitCheckin.habit_id == habit_id,
HabitCheckin.checkin_date == today_date,
).first()
today_count = today_checkin.count if today_checkin else 0
today_completed = today_count >= habit.target_count
# 所有完成打卡的日期count >= target_count
completed_dates = [
row[0]
for row in db.query(HabitCheckin.checkin_date).filter(
HabitCheckin.habit_id == habit_id,
HabitCheckin.count >= habit.target_count,
).order_by(HabitCheckin.checkin_date).all()
]
total_days = len(completed_dates)
# 计算连续天数(从今天往回推算)
current_streak = 0
check_date = today_date
# 如果今天还没完成,从昨天开始算
if not today_completed:
check_date = check_date - timedelta(days=1)
while True:
if check_date in completed_dates:
current_streak += 1
check_date -= timedelta(days=1)
else:
break
# 计算最长连续天数
longest_streak = 0
streak = 0
prev_date = None
for d in completed_dates:
if prev_date is None or d == prev_date + timedelta(days=1):
streak += 1
else:
streak = 1
longest_streak = max(longest_streak, streak)
prev_date = d
return HabitStatsResponse(
total_days=total_days,
current_streak=current_streak,
longest_streak=longest_streak,
today_count=today_count,
today_completed=today_completed,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"获取习惯统计失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取习惯统计失败")
# 将子路由组合到主路由
router.include_router(habit_group_router)
router.include_router(habit_router)
router.include_router(checkin_router)

74
api/app/routers/tags.py Normal file
View File

@@ -0,0 +1,74 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from typing import List
from app.database import get_db
from app.models import Tag
from app.schemas import TagCreate, TagResponse
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.logger import logger
router = APIRouter(prefix="/api/tags", tags=["标签"])
@router.get("", response_model=List[TagResponse])
def get_tags(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
db: Session = Depends(get_db)
):
"""获取标签列表"""
try:
tags = db.query(Tag).offset(skip).limit(limit).all()
logger.info(f"获取标签列表成功,总数: {len(tags)}")
return tags
except Exception as e:
logger.error(f"获取标签列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取标签列表失败")
@router.post("", response_model=TagResponse, status_code=201)
def create_tag(tag_data: TagCreate, db: Session = Depends(get_db)):
"""创建标签"""
try:
db_tag = Tag(name=tag_data.name)
db.add(db_tag)
db.commit()
db.refresh(db_tag)
logger.info(f"创建标签成功: id={db_tag.id}, name={db_tag.name}")
return db_tag
except IntegrityError:
db.rollback()
logger.warning(f"标签名称已存在: name={tag_data.name}")
raise HTTPException(status_code=400, detail="标签名称已存在")
except Exception as e:
db.rollback()
logger.error(f"创建标签失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建标签失败")
@router.delete("/{tag_id}")
def delete_tag(tag_id: int, db: Session = Depends(get_db)):
"""删除标签"""
try:
tag = get_or_404(db, Tag, tag_id, "标签")
db.delete(tag)
db.commit()
logger.info(f"删除标签成功: id={tag_id}")
return DeleteResponse(message="标签删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除标签失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除标签失败")

185
api/app/routers/tasks.py Normal file
View File

@@ -0,0 +1,185 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional, List
from app.database import get_db
from app.models import Task, Tag
from app.schemas import TaskCreate, TaskUpdate, TaskResponse
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.datetime import utcnow
from app.utils.logger import logger
router = APIRouter(prefix="/api/tasks", tags=["任务"])
@router.get("", response_model=List[TaskResponse])
def get_tasks(
status: Optional[str] = Query(None, description="筛选状态: all/in_progress/completed"),
category_id: Optional[int] = Query(None, description="分类ID"),
priority: Optional[str] = Query(None, description="优先级: q1/q2/q3/q4"),
sort_by: Optional[str] = Query("created_at", description="排序字段: created_at/priority/due_date"),
sort_order: Optional[str] = Query("desc", description="排序方向: asc/desc"),
db: Session = Depends(get_db)
) -> List[TaskResponse]:
"""获取任务列表(支持筛选和排序)"""
try:
query = db.query(Task)
# 状态筛选
if status == "in_progress":
query = query.filter(Task.is_completed == False)
elif status == "completed":
query = query.filter(Task.is_completed == True)
# 分类筛选
if category_id is not None:
query = query.filter(Task.category_id == category_id)
# 优先级筛选
if priority:
query = query.filter(Task.priority == priority)
# 排序
if sort_by == "priority":
order_col = Task.priority
elif sort_by == "due_date":
order_col = Task.due_date
else:
order_col = Task.created_at
if sort_order == "asc":
query = query.order_by(order_col.asc().nullslast())
else:
query = query.order_by(order_col.desc().nullslast())
tasks = query.all()
logger.info(f"获取任务列表成功,总数: {len(tasks)}")
return tasks
except Exception as e:
logger.error(f"获取任务列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取任务列表失败")
@router.post("", response_model=TaskResponse, status_code=201)
def create_task(task_data: TaskCreate, db: Session = Depends(get_db)):
"""创建任务"""
try:
# 创建任务对象
db_task = Task(
title=task_data.title,
description=task_data.description,
priority=task_data.priority,
due_date=task_data.due_date,
category_id=task_data.category_id,
)
# 添加标签
if task_data.tag_ids:
tags = db.query(Tag).filter(Tag.id.in_(task_data.tag_ids)).all()
db_task.tags = tags
db.add(db_task)
db.commit()
db.refresh(db_task)
logger.info(f"创建任务成功: id={db_task.id}, title={db_task.title}")
return db_task
except Exception as e:
db.rollback()
logger.error(f"创建任务失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建任务失败")
@router.get("/{task_id}", response_model=TaskResponse)
def get_task(task_id: int, db: Session = Depends(get_db)):
"""获取单个任务"""
try:
task = get_or_404(db, Task, task_id, "任务")
return task
except HTTPException:
raise
except Exception as e:
logger.error(f"获取任务失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取任务失败")
@router.put("/{task_id}", response_model=TaskResponse)
def update_task(task_id: int, task_data: TaskUpdate, db: Session = Depends(get_db)):
"""更新任务"""
try:
task = get_or_404(db, Task, task_id, "任务")
# exclude_unset=True 保证:前端没传的字段不会出现在 dict 中,不会意外清空
# 前端显式传了 null 的字段会出现在 dict 中,允许清空可空字段
update_data = task_data.model_dump(exclude_unset=True)
tag_ids = update_data.pop("tag_ids", None)
for field, value in update_data.items():
# 非 clearable 字段(如 title只有非 None 值才更新
# clearable 字段description, due_date, category_id允许设为 None
if value is not None or field in task_data.clearable_fields:
setattr(task, field, value)
# 更新标签
if tag_ids is not None:
tags = db.query(Tag).filter(Tag.id.in_(tag_ids)).all()
task.tags = tags
task.updated_at = utcnow()
db.commit()
db.refresh(task)
logger.info(f"更新任务成功: id={task_id}")
return task
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新任务失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新任务失败")
@router.delete("/{task_id}")
def delete_task(task_id: int, db: Session = Depends(get_db)):
"""删除任务"""
try:
task = get_or_404(db, Task, task_id, "任务")
db.delete(task)
db.commit()
logger.info(f"删除任务成功: id={task_id}")
return DeleteResponse(message="任务删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除任务失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除任务失败")
@router.patch("/{task_id}/toggle", response_model=TaskResponse)
def toggle_task(task_id: int, db: Session = Depends(get_db)):
"""切换任务完成状态"""
try:
task = get_or_404(db, Task, task_id, "任务")
task.is_completed = not task.is_completed
task.updated_at = utcnow()
db.commit()
db.refresh(task)
logger.info(f"切换任务状态成功: id={task_id}, is_completed={task.is_completed}")
return task
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"切换任务状态失败: {str(e)}")
raise HTTPException(status_code=500, detail="切换任务状态失败")

View File

@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user_settings import UserSettings
from app.schemas.user_settings import UserSettingsUpdate, UserSettingsResponse
from app.utils.datetime import utcnow
from app.utils.logger import logger
router = APIRouter(prefix="/api/user-settings", tags=["用户设置"])
@router.get("", response_model=UserSettingsResponse)
def get_user_settings(db: Session = Depends(get_db)):
"""获取用户设置(单例模式)"""
try:
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if not settings:
# 首次访问时自动创建默认设置
settings = UserSettings(id=1)
db.add(settings)
db.commit()
db.refresh(settings)
logger.info("自动创建默认用户设置")
return settings
except Exception as e:
logger.error(f"获取用户设置失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取用户设置失败")
@router.put("", response_model=UserSettingsResponse)
def update_user_settings(
data: UserSettingsUpdate,
db: Session = Depends(get_db)
):
"""更新用户设置upsert 单条记录)"""
try:
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if not settings:
settings = UserSettings(id=1)
db.add(settings)
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(settings, field, value)
settings.updated_at = utcnow()
db.commit()
db.refresh(settings)
logger.info("更新用户设置成功")
return settings
except Exception as e:
db.rollback()
logger.error(f"更新用户设置失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新用户设置失败")