release: Elysia ToDo v1.0.0
鍏ㄦ爤涓汉淇℃伅绠$悊搴旂敤锛岄泦鎴愬緟鍔炰换鍔°€佷範鎯墦鍗°€佺邯蹇垫棩鎻愰啋銆佽祫浜ф€昏鍔熻兘銆 Made-with: Cursor
This commit is contained in:
1
api/app/__init__.py
Normal file
1
api/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 爱莉希雅待办事项后端
|
||||
29
api/app/config.py
Normal file
29
api/app/config.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# 硬编码配置
|
||||
import os
|
||||
|
||||
# api 目录的绝对路径(基于本文件位置计算,不依赖工作目录)
|
||||
_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}"
|
||||
|
||||
# WebUI 配置
|
||||
WEBUI_PATH = os.path.join(_BASE_DIR, "webui")
|
||||
|
||||
# CORS 配置
|
||||
CORS_ORIGINS = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:23994",
|
||||
]
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL = "INFO"
|
||||
LOG_DIR = os.path.join(_BASE_DIR, "logs")
|
||||
|
||||
# 分页配置
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
# 服务配置
|
||||
HOST = "0.0.0.0"
|
||||
PORT = 23994
|
||||
101
api/app/database.py
Normal file
101
api/app/database.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from sqlalchemy import create_engine, inspect, text, String, Integer, Text, Boolean, Float, DateTime, Date
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
|
||||
from app.config import DATABASE_PATH, DATABASE_URL
|
||||
|
||||
# 确保 data 目录存在
|
||||
os.makedirs(os.path.dirname(DATABASE_PATH) if os.path.dirname(DATABASE_PATH) else ".", exist_ok=True)
|
||||
|
||||
# 创建引擎
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False}
|
||||
)
|
||||
|
||||
# 创建会话工厂
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# 创建基类
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""获取数据库会话"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# SQLAlchemy 类型到 SQLite 类型名的映射
|
||||
_TYPE_MAP = {
|
||||
String: "VARCHAR",
|
||||
Integer: "INTEGER",
|
||||
Text: "TEXT",
|
||||
Boolean: "BOOLEAN",
|
||||
Float: "REAL",
|
||||
DateTime: "DATETIME",
|
||||
Date: "DATE",
|
||||
}
|
||||
|
||||
|
||||
def _col_type_str(col_type) -> str:
|
||||
"""将 SQLAlchemy 列类型转为 SQLite 类型字符串"""
|
||||
if col_type.__class__ in _TYPE_MAP:
|
||||
base = _TYPE_MAP[col_type.__class__]
|
||||
else:
|
||||
base = str(col_type).split("(")[0].strip()
|
||||
|
||||
length = getattr(col_type, "length", None)
|
||||
if length:
|
||||
return f"{base}({length})"
|
||||
return base
|
||||
|
||||
|
||||
def init_db():
|
||||
"""初始化数据库表,自动补充新增的列"""
|
||||
# 导入所有模型,确保 Base.metadata 包含全部表定义
|
||||
from app.models import ( # noqa: F401
|
||||
task, category, tag, user_settings, habit, anniversary, account,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 通用自动迁移:对比 ORM 模型与实际表结构,补充缺失的列(SQLite 兼容)
|
||||
inspector = inspect(engine)
|
||||
table_names = set(inspector.get_table_names())
|
||||
|
||||
with engine.begin() as conn:
|
||||
for table_cls in Base.metadata.sorted_tables:
|
||||
table_name = table_cls.name
|
||||
if table_name not in table_names:
|
||||
continue
|
||||
|
||||
existing_cols = {c["name"] for c in inspector.get_columns(table_name)}
|
||||
|
||||
for col in table_cls.columns:
|
||||
if col.name in existing_cols:
|
||||
continue
|
||||
# 跳过无服务端默认值且不可为空的列(容易出错)
|
||||
if col.nullable is False and col.server_default is None and col.default is None:
|
||||
continue
|
||||
|
||||
sqlite_type = _col_type_str(col.type)
|
||||
|
||||
ddl = f"ALTER TABLE {table_name} ADD COLUMN {col.name} {sqlite_type}"
|
||||
|
||||
# 为可空列或已有默认值的列附加 DEFAULT
|
||||
if col.server_default is not None:
|
||||
ddl += f" DEFAULT {col.server_default.arg}"
|
||||
elif col.default is not None and col.nullable:
|
||||
default_val = col.default.arg
|
||||
if isinstance(default_val, str):
|
||||
ddl += f" DEFAULT '{default_val}'"
|
||||
elif isinstance(default_val, bool):
|
||||
ddl += f" DEFAULT {1 if default_val else 0}"
|
||||
else:
|
||||
ddl += f" DEFAULT {default_val}"
|
||||
|
||||
conn.execute(text(ddl))
|
||||
144
api/app/main.py
Normal file
144
api/app/main.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
|
||||
from app.config import CORS_ORIGINS, WEBUI_PATH, HOST, PORT
|
||||
from app.database import init_db
|
||||
from app.routers import api_router
|
||||
from app.utils.logger import logger
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
# 启动时
|
||||
logger.info("应用启动中...")
|
||||
init_db()
|
||||
logger.info("数据库初始化完成")
|
||||
yield
|
||||
# 关闭时
|
||||
logger.info("应用关闭中...")
|
||||
|
||||
|
||||
# 创建 FastAPI 应用
|
||||
app = FastAPI(
|
||||
title="爱莉希雅待办事项 API",
|
||||
description="Elysia ToDo - 个人信息管理应用",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# 配置 CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# 请求日志中间件
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
start_time = time.time()
|
||||
|
||||
# 记录请求信息
|
||||
request_method = request.method
|
||||
request_path = request.url.path
|
||||
query_params = dict(request.query_params) if request.query_params else None
|
||||
|
||||
# 构建日志信息
|
||||
log_parts = [f"请求开始 -> {request_method} {request_path}"]
|
||||
if query_params:
|
||||
log_parts.append(f"Query参数: {json.dumps(query_params, ensure_ascii=False)}")
|
||||
|
||||
# 尝试读取请求体(仅对有 body 的方法)
|
||||
body_info = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
try:
|
||||
body_bytes = await request.body()
|
||||
if body_bytes:
|
||||
try:
|
||||
body_json = json.loads(body_bytes)
|
||||
body_info = json.dumps(body_json, ensure_ascii=False)
|
||||
except json.JSONDecodeError:
|
||||
body_info = body_bytes.decode('utf-8', errors='replace')[:200]
|
||||
log_parts.append(f"Body: {body_info}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(" | ".join(log_parts))
|
||||
|
||||
# 执行请求
|
||||
response = await call_next(request)
|
||||
|
||||
# 计算耗时
|
||||
process_time = (time.time() - start_time) * 1000
|
||||
|
||||
# 记录响应信息
|
||||
logger.info(
|
||||
f"请求完成 <- {request_method} {request_path} | "
|
||||
f"状态码: {response.status_code} | 耗时: {process_time:.2f}ms"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# 全局异常处理器
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
# 使用 exc_info=True 记录完整堆栈信息
|
||||
logger.error(
|
||||
f"全局异常: {request.method} {request.url.path} | 错误: {str(exc)}",
|
||||
exc_info=True
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"success": False,
|
||||
"message": "服务器内部错误",
|
||||
"error_code": "INTERNAL_ERROR"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# 注册路由
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
# 健康检查(必须在 static mount 之前注册,否则会被静态文件拦截)
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""健康检查"""
|
||||
return {"status": "ok", "message": "服务运行正常"}
|
||||
|
||||
|
||||
# SPA 静态文件回退路由(支持前端 History 模式路由)
|
||||
if os.path.exists(WEBUI_PATH):
|
||||
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def spa_fallback(request: Request, full_path: str):
|
||||
"""SPA 回退:先尝试提供真实文件,找不到则返回 index.html"""
|
||||
file_path = os.path.join(WEBUI_PATH, full_path)
|
||||
if os.path.isfile(file_path):
|
||||
return FileResponse(file_path)
|
||||
return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
|
||||
|
||||
logger.info(f"SPA 静态文件服务已配置: {WEBUI_PATH}")
|
||||
else:
|
||||
logger.warning(f"WebUI 目录不存在: {WEBUI_PATH}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
logger.info(f"启动服务: {HOST}:{PORT}")
|
||||
uvicorn.run(app, host=HOST, port=PORT)
|
||||
14
api/app/models/__init__.py
Normal file
14
api/app/models/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from app.models.task import Task
|
||||
from app.models.category import Category
|
||||
from app.models.tag import Tag, task_tags
|
||||
from app.models.user_settings import UserSettings
|
||||
from app.models.habit import HabitGroup, Habit, HabitCheckin
|
||||
from app.models.anniversary import AnniversaryCategory, Anniversary
|
||||
from app.models.account import FinancialAccount, AccountHistory, DebtInstallment
|
||||
|
||||
__all__ = [
|
||||
"Task", "Category", "Tag", "task_tags", "UserSettings",
|
||||
"HabitGroup", "Habit", "HabitCheckin",
|
||||
"AnniversaryCategory", "Anniversary",
|
||||
"FinancialAccount", "AccountHistory", "DebtInstallment",
|
||||
]
|
||||
61
api/app/models/account.py
Normal file
61
api/app/models/account.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, Float, DateTime, ForeignKey, Date
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
from app.utils.datetime import utcnow
|
||||
|
||||
|
||||
class FinancialAccount(Base):
|
||||
"""财务账户模型"""
|
||||
__tablename__ = "financial_accounts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
account_type = Column(String(20), nullable=False, default="savings") # savings / debt
|
||||
balance = Column(Float, default=0.0)
|
||||
icon = Column(String(50), default="wallet")
|
||||
color = Column(String(20), default="#FFB7C5")
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
# 关联关系
|
||||
history_records = relationship("AccountHistory", back_populates="account", cascade="all, delete-orphan")
|
||||
debt_installments = relationship("DebtInstallment", back_populates="account", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class AccountHistory(Base):
|
||||
"""余额变更历史"""
|
||||
__tablename__ = "account_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
account_id = Column(Integer, ForeignKey("financial_accounts.id"), nullable=False)
|
||||
change_amount = Column(Float, nullable=False)
|
||||
balance_before = Column(Float, nullable=False)
|
||||
balance_after = Column(Float, nullable=False)
|
||||
note = Column(String(200), nullable=True)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
|
||||
# 关联关系
|
||||
account = relationship("FinancialAccount", back_populates="history_records")
|
||||
|
||||
|
||||
class DebtInstallment(Base):
|
||||
"""分期还款计划"""
|
||||
__tablename__ = "debt_installments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
account_id = Column(Integer, ForeignKey("financial_accounts.id"), nullable=False)
|
||||
total_amount = Column(Float, nullable=False)
|
||||
total_periods = Column(Integer, nullable=False)
|
||||
current_period = Column(Integer, nullable=False, default=1) # 1-based, 指向下一期待还
|
||||
payment_day = Column(Integer, nullable=False) # 每月还款日 1-31
|
||||
payment_amount = Column(Float, nullable=False)
|
||||
start_date = Column(Date, nullable=False)
|
||||
is_completed = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
# 关联关系
|
||||
account = relationship("FinancialAccount", back_populates="debt_installments")
|
||||
37
api/app/models/anniversary.py
Normal file
37
api/app/models/anniversary.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Date
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
from app.utils.datetime import utcnow
|
||||
|
||||
|
||||
class AnniversaryCategory(Base):
|
||||
"""纪念日分类模型"""
|
||||
__tablename__ = "anniversary_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(50), nullable=False)
|
||||
icon = Column(String(50), default="calendar")
|
||||
color = Column(String(20), default="#FFB7C5")
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
# 关联关系
|
||||
anniversaries = relationship("Anniversary", back_populates="category")
|
||||
|
||||
|
||||
class Anniversary(Base):
|
||||
"""纪念日模型"""
|
||||
__tablename__ = "anniversaries"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
date = Column(Date, nullable=False) # 月-日,年份部分可选
|
||||
year = Column(Integer, nullable=True) # 年份,用于计算第 N 个周年
|
||||
category_id = Column(Integer, ForeignKey("anniversary_categories.id"), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
is_recurring = Column(Boolean, default=True)
|
||||
remind_days_before = Column(Integer, default=3)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
# 关联关系
|
||||
category = relationship("AnniversaryCategory", back_populates="anniversaries")
|
||||
16
api/app/models/category.py
Normal file
16
api/app/models/category.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Category(Base):
|
||||
"""分类模型"""
|
||||
__tablename__ = "categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
color = Column(String(20), default="#FFB7C5") # 默认樱花粉
|
||||
icon = Column(String(50), default="folder") # 默认图标
|
||||
|
||||
# 关联关系
|
||||
tasks = relationship("Task", back_populates="category")
|
||||
55
api/app/models/habit.py
Normal file
55
api/app/models/habit.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, Date, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
from app.utils.datetime import utcnow
|
||||
|
||||
|
||||
class HabitGroup(Base):
|
||||
"""习惯分组模型"""
|
||||
__tablename__ = "habit_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
color = Column(String(20), default="#FFB7C5")
|
||||
icon = Column(String(50), default="flag")
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
# 关联关系
|
||||
habits = relationship("Habit", back_populates="group", order_by="Habit.created_at")
|
||||
|
||||
|
||||
class Habit(Base):
|
||||
"""习惯模型"""
|
||||
__tablename__ = "habits"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
group_id = Column(Integer, ForeignKey("habit_groups.id"), nullable=True)
|
||||
target_count = Column(Integer, default=1)
|
||||
frequency = Column(String(20), default="daily") # daily / weekly
|
||||
active_days = Column(String(100), nullable=True) # JSON 数组, 如 [0,2,4] 表示周一三五
|
||||
is_archived = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
# 关联关系
|
||||
group = relationship("HabitGroup", back_populates="habits")
|
||||
checkins = relationship("HabitCheckin", back_populates="habit", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class HabitCheckin(Base):
|
||||
"""习惯打卡记录模型"""
|
||||
__tablename__ = "habit_checkins"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("habit_id", "checkin_date", name="uq_habit_checkin_date"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
habit_id = Column(Integer, ForeignKey("habits.id"), nullable=False)
|
||||
checkin_date = Column(Date, nullable=False)
|
||||
count = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
|
||||
# 关联关系
|
||||
habit = relationship("Habit", back_populates="checkins")
|
||||
23
api/app/models/tag.py
Normal file
23
api/app/models/tag.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import Column, Integer, String, Table, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
# 任务-标签关联表(多对多)
|
||||
task_tags = Table(
|
||||
"task_tags",
|
||||
Base.metadata,
|
||||
Column("task_id", Integer, ForeignKey("tasks.id"), primary_key=True),
|
||||
Column("tag_id", Integer, ForeignKey("tags.id"), primary_key=True)
|
||||
)
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
"""标签模型"""
|
||||
__tablename__ = "tags"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(50), nullable=False, unique=True)
|
||||
|
||||
# 关联关系
|
||||
tasks = relationship("Task", secondary=task_tags, back_populates="tags")
|
||||
23
api/app/models/task.py
Normal file
23
api/app/models/task.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
from app.utils.datetime import utcnow
|
||||
|
||||
|
||||
class Task(Base):
|
||||
"""任务模型"""
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
priority = Column(String(20), default="q4") # q1(重要紧急), q2(重要不紧急), q3(不重要紧急), q4(不重要不紧急)
|
||||
due_date = Column(DateTime, nullable=True)
|
||||
is_completed = Column(Boolean, default=False)
|
||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
# 关联关系
|
||||
category = relationship("Category", back_populates="tasks")
|
||||
tags = relationship("Tag", secondary="task_tags", back_populates="tasks")
|
||||
36
api/app/models/user_settings.py
Normal file
36
api/app/models/user_settings.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Date
|
||||
from datetime import datetime, timezone, date
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def utcnow():
|
||||
"""统一获取 UTC 时间的工厂函数"""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class UserSettings(Base):
|
||||
"""用户设置模型(单例,始终只有一条记录 id=1)"""
|
||||
__tablename__ = "user_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, default=1)
|
||||
|
||||
# 个人信息
|
||||
nickname = Column(String(50), default="爱莉希雅")
|
||||
avatar = Column(Text, nullable=True)
|
||||
signature = Column(String(200), nullable=True)
|
||||
birthday = Column(Date, nullable=True)
|
||||
email = Column(String(100), nullable=True)
|
||||
|
||||
# 应用信息
|
||||
site_name = Column(String(50), default="爱莉希雅待办")
|
||||
|
||||
# 应用偏好
|
||||
theme = Column(String(20), default="pink")
|
||||
language = Column(String(10), default="zh-CN")
|
||||
default_view = Column(String(20), default="list")
|
||||
default_sort_by = Column(String(20), default="created_at")
|
||||
default_sort_order = Column(String(10), default="desc")
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
14
api/app/routers/__init__.py
Normal file
14
api/app/routers/__init__.py
Normal 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
498
api/app/routers/accounts.py
Normal 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="分期还款失败")
|
||||
284
api/app/routers/anniversaries.py
Normal file
284
api/app/routers/anniversaries.py
Normal 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="删除纪念日失败")
|
||||
102
api/app/routers/categories.py
Normal file
102
api/app/routers/categories.py
Normal 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
368
api/app/routers/habits.py
Normal 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
74
api/app/routers/tags.py
Normal 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
185
api/app/routers/tasks.py
Normal 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="切换任务状态失败")
|
||||
57
api/app/routers/user_settings.py
Normal file
57
api/app/routers/user_settings.py
Normal 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="更新用户设置失败")
|
||||
44
api/app/schemas/__init__.py
Normal file
44
api/app/schemas/__init__.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from app.schemas.task import (
|
||||
TaskBase,
|
||||
TaskCreate,
|
||||
TaskUpdate,
|
||||
TaskResponse,
|
||||
)
|
||||
from app.schemas.category import (
|
||||
CategoryBase,
|
||||
CategoryCreate,
|
||||
CategoryUpdate,
|
||||
CategoryResponse,
|
||||
)
|
||||
from app.schemas.tag import (
|
||||
TagBase,
|
||||
TagCreate,
|
||||
TagResponse,
|
||||
)
|
||||
from app.schemas.common import (
|
||||
DeleteResponse,
|
||||
PaginatedResponse,
|
||||
)
|
||||
from app.schemas.user_settings import (
|
||||
UserSettingsUpdate,
|
||||
UserSettingsResponse,
|
||||
)
|
||||
from app.schemas.habit import (
|
||||
HabitGroupCreate,
|
||||
HabitGroupUpdate,
|
||||
HabitGroupResponse,
|
||||
HabitCreate,
|
||||
HabitUpdate,
|
||||
HabitResponse,
|
||||
CheckinCreate,
|
||||
CheckinResponse,
|
||||
HabitStatsResponse,
|
||||
)
|
||||
from app.schemas.anniversary import (
|
||||
AnniversaryCategoryCreate,
|
||||
AnniversaryCategoryUpdate,
|
||||
AnniversaryCategoryResponse,
|
||||
AnniversaryCreate,
|
||||
AnniversaryUpdate,
|
||||
AnniversaryResponse,
|
||||
)
|
||||
133
api/app/schemas/account.py
Normal file
133
api/app/schemas/account.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import date, datetime
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
# ============ 财务账户 Schema ============
|
||||
|
||||
class AccountBase(BaseModel):
|
||||
"""账户基础模型"""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
account_type: str = Field(default="savings", pattern="^(savings|debt)$")
|
||||
balance: float = Field(default=0.0)
|
||||
icon: str = Field(default="wallet", max_length=50)
|
||||
color: str = Field(default="#FFB7C5", max_length=20)
|
||||
sort_order: int = Field(default=0)
|
||||
is_active: bool = Field(default=True)
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class AccountCreate(AccountBase):
|
||||
"""创建账户请求模型"""
|
||||
pass
|
||||
|
||||
|
||||
class AccountUpdate(BaseModel):
|
||||
"""更新账户请求模型"""
|
||||
name: Optional[str] = Field(None, max_length=100)
|
||||
account_type: Optional[str] = Field(None, pattern="^(savings|debt)$")
|
||||
balance: Optional[float] = None
|
||||
icon: Optional[str] = Field(None, max_length=50)
|
||||
color: Optional[str] = Field(None, max_length=20)
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class AccountResponse(AccountBase):
|
||||
"""账户响应模型"""
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AccountListItemResponse(BaseModel):
|
||||
"""账户列表项响应模型(含分期摘要)"""
|
||||
id: int
|
||||
name: str
|
||||
account_type: str
|
||||
balance: float
|
||||
icon: str
|
||||
color: str
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
description: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
installments: List[dict] = []
|
||||
|
||||
|
||||
class BalanceUpdateRequest(BaseModel):
|
||||
"""更新余额请求模型"""
|
||||
new_balance: float
|
||||
note: Optional[str] = Field(None, max_length=200)
|
||||
|
||||
|
||||
# ============ 账户变更历史 Schema ============
|
||||
|
||||
class AccountHistoryResponse(BaseModel):
|
||||
"""变更历史响应模型"""
|
||||
id: int
|
||||
account_id: int
|
||||
change_amount: float
|
||||
balance_before: float
|
||||
balance_after: float
|
||||
note: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ 分期还款计划 Schema ============
|
||||
|
||||
class DebtInstallmentBase(BaseModel):
|
||||
"""分期计划基础模型"""
|
||||
account_id: int
|
||||
total_amount: float
|
||||
total_periods: int = Field(..., ge=1)
|
||||
current_period: int = Field(default=1, ge=1)
|
||||
payment_day: int = Field(..., ge=1, le=31)
|
||||
payment_amount: float = Field(..., gt=0)
|
||||
start_date: date
|
||||
is_completed: bool = Field(default=False)
|
||||
|
||||
|
||||
class DebtInstallmentCreate(DebtInstallmentBase):
|
||||
"""创建分期计划请求模型"""
|
||||
pass
|
||||
|
||||
|
||||
class DebtInstallmentUpdate(BaseModel):
|
||||
"""更新分期计划请求模型"""
|
||||
account_id: Optional[int] = None
|
||||
total_amount: Optional[float] = None
|
||||
total_periods: Optional[int] = Field(None, ge=1)
|
||||
current_period: Optional[int] = Field(None, ge=1)
|
||||
payment_day: Optional[int] = Field(None, ge=1, le=31)
|
||||
payment_amount: Optional[float] = Field(None, gt=0)
|
||||
start_date: Optional[date] = None
|
||||
is_completed: Optional[bool] = None
|
||||
|
||||
|
||||
class DebtInstallmentResponse(DebtInstallmentBase):
|
||||
"""分期计划响应模型(含计算字段)"""
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# 计算字段
|
||||
next_payment_date: Optional[date] = None
|
||||
days_until_payment: Optional[int] = None
|
||||
remaining_periods: Optional[int] = None
|
||||
|
||||
# 关联账户信息
|
||||
account_name: Optional[str] = None
|
||||
account_icon: Optional[str] = None
|
||||
account_color: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
122
api/app/schemas/anniversary.py
Normal file
122
api/app/schemas/anniversary.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
def parse_date(value):
|
||||
"""解析日期字符串"""
|
||||
if value is None or value == '':
|
||||
return None
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
formats = [
|
||||
'%Y-%m-%d',
|
||||
'%Y-%m-%dT%H:%M:%S',
|
||||
'%Y-%m-%d %H:%M:%S',
|
||||
'%Y-%m-%dT%H:%M:%S.%f',
|
||||
]
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(value, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
raise ValueError(f"无法解析日期: {value}")
|
||||
|
||||
|
||||
# ============ 纪念日分类 Schema ============
|
||||
|
||||
class AnniversaryCategoryBase(BaseModel):
|
||||
"""纪念日分类基础模型"""
|
||||
name: str = Field(..., max_length=50)
|
||||
icon: str = Field(default="calendar", max_length=50)
|
||||
color: str = Field(default="#FFB7C5", max_length=20)
|
||||
sort_order: int = Field(default=0)
|
||||
|
||||
|
||||
class AnniversaryCategoryCreate(AnniversaryCategoryBase):
|
||||
"""创建纪念日分类请求模型"""
|
||||
pass
|
||||
|
||||
|
||||
class AnniversaryCategoryUpdate(BaseModel):
|
||||
"""更新纪念日分类请求模型"""
|
||||
name: Optional[str] = Field(None, max_length=50)
|
||||
icon: Optional[str] = Field(None, max_length=50)
|
||||
color: Optional[str] = Field(None, max_length=20)
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class AnniversaryCategoryResponse(AnniversaryCategoryBase):
|
||||
"""纪念日分类响应模型"""
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ 纪念日 Schema ============
|
||||
|
||||
class AnniversaryBase(BaseModel):
|
||||
"""纪念日基础模型"""
|
||||
title: str = Field(..., max_length=200)
|
||||
date: date
|
||||
year: Optional[int] = None
|
||||
category_id: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
is_recurring: bool = Field(default=True)
|
||||
remind_days_before: int = Field(default=3)
|
||||
|
||||
@field_validator('date', mode='before')
|
||||
@classmethod
|
||||
def parse_anniversary_date(cls, v):
|
||||
result = parse_date(v)
|
||||
if result is None:
|
||||
raise ValueError("纪念日日期不能为空")
|
||||
return result
|
||||
|
||||
|
||||
class AnniversaryCreate(AnniversaryBase):
|
||||
"""创建纪念日请求模型"""
|
||||
pass
|
||||
|
||||
|
||||
class AnniversaryUpdate(BaseModel):
|
||||
"""更新纪念日请求模型"""
|
||||
title: Optional[str] = Field(None, max_length=200)
|
||||
date: Optional[date] = None
|
||||
year: Optional[int] = None
|
||||
category_id: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
is_recurring: Optional[bool] = None
|
||||
remind_days_before: Optional[int] = None
|
||||
|
||||
@field_validator('date', mode='before')
|
||||
@classmethod
|
||||
def parse_anniversary_date(cls, v):
|
||||
if v is None:
|
||||
return None
|
||||
return parse_date(v)
|
||||
|
||||
@property
|
||||
def clearable_fields(self) -> set:
|
||||
"""允许被显式清空(设为 None)的字段集合"""
|
||||
return {'description', 'category_id', 'year'}
|
||||
|
||||
|
||||
class AnniversaryResponse(AnniversaryBase):
|
||||
"""纪念日响应模型"""
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
category: Optional[AnniversaryCategoryResponse] = None
|
||||
next_date: Optional[date] = None
|
||||
days_until: Optional[int] = None
|
||||
year_count: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
28
api/app/schemas/category.py
Normal file
28
api/app/schemas/category.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CategoryBase(BaseModel):
|
||||
"""分类基础模型"""
|
||||
name: str = Field(..., max_length=100)
|
||||
color: str = Field(default="#FFB7C5", max_length=20)
|
||||
icon: str = Field(default="folder", max_length=50)
|
||||
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
"""创建分类请求模型"""
|
||||
pass
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
"""更新分类请求模型"""
|
||||
name: str = Field(None, max_length=100)
|
||||
color: str = Field(None, max_length=20)
|
||||
icon: str = Field(None, max_length=50)
|
||||
|
||||
|
||||
class CategoryResponse(CategoryBase):
|
||||
"""分类响应模型"""
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
39
api/app/schemas/common.py
Normal file
39
api/app/schemas/common.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
通用响应模型
|
||||
"""
|
||||
from typing import Generic, TypeVar, List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class DeleteResponse(BaseModel):
|
||||
"""删除成功响应"""
|
||||
success: bool = Field(default=True, description="操作是否成功")
|
||||
message: str = Field(description="响应消息")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "删除成功"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""分页列表响应"""
|
||||
items: List[T] = Field(description="数据列表")
|
||||
total: int = Field(description="总记录数")
|
||||
skip: int = Field(description="跳过的记录数")
|
||||
limit: int = Field(description="返回的记录数")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"items": [],
|
||||
"total": 0,
|
||||
"skip": 0,
|
||||
"limit": 20
|
||||
}
|
||||
}
|
||||
105
api/app/schemas/habit.py
Normal file
105
api/app/schemas/habit.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
# ============ 习惯分组 Schemas ============
|
||||
|
||||
class HabitGroupBase(BaseModel):
|
||||
"""习惯分组基础模型"""
|
||||
name: str = Field(..., max_length=100)
|
||||
color: str = Field(default="#FFB7C5", max_length=20)
|
||||
icon: str = Field(default="flag", max_length=50)
|
||||
sort_order: int = Field(default=0)
|
||||
|
||||
|
||||
class HabitGroupCreate(HabitGroupBase):
|
||||
"""创建习惯分组请求模型"""
|
||||
pass
|
||||
|
||||
|
||||
class HabitGroupUpdate(BaseModel):
|
||||
"""更新习惯分组请求模型"""
|
||||
name: Optional[str] = Field(None, max_length=100)
|
||||
color: Optional[str] = Field(None, max_length=20)
|
||||
icon: Optional[str] = Field(None, max_length=50)
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class HabitGroupResponse(HabitGroupBase):
|
||||
"""习惯分组响应模型"""
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ 习惯 Schemas ============
|
||||
|
||||
class HabitBase(BaseModel):
|
||||
"""习惯基础模型"""
|
||||
name: str = Field(..., max_length=200)
|
||||
description: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
target_count: int = Field(default=1, ge=1)
|
||||
frequency: str = Field(default="daily", pattern="^(daily|weekly)$")
|
||||
active_days: Optional[str] = None
|
||||
|
||||
|
||||
class HabitCreate(HabitBase):
|
||||
"""创建习惯请求模型"""
|
||||
pass
|
||||
|
||||
|
||||
class HabitUpdate(BaseModel):
|
||||
"""更新习惯请求模型"""
|
||||
name: Optional[str] = Field(None, max_length=200)
|
||||
description: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
target_count: Optional[int] = Field(None, ge=1)
|
||||
frequency: Optional[str] = Field(None, pattern="^(daily|weekly)$")
|
||||
active_days: Optional[str] = None
|
||||
|
||||
@property
|
||||
def clearable_fields(self) -> set:
|
||||
return {"description", "group_id", "active_days"}
|
||||
|
||||
|
||||
class HabitResponse(HabitBase):
|
||||
"""习惯响应模型"""
|
||||
id: int
|
||||
is_archived: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
group: Optional[HabitGroupResponse] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ 打卡 Schemas ============
|
||||
|
||||
class CheckinCreate(BaseModel):
|
||||
"""打卡请求模型"""
|
||||
count: Optional[int] = Field(default=1, ge=1)
|
||||
|
||||
|
||||
class CheckinResponse(BaseModel):
|
||||
"""打卡记录响应模型"""
|
||||
id: int
|
||||
habit_id: int
|
||||
checkin_date: date
|
||||
count: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class HabitStatsResponse(BaseModel):
|
||||
"""习惯统计响应模型"""
|
||||
total_days: int = 0
|
||||
current_streak: int = 0
|
||||
longest_streak: int = 0
|
||||
today_count: int = 0
|
||||
today_completed: bool = False
|
||||
19
api/app/schemas/tag.py
Normal file
19
api/app/schemas/tag.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TagBase(BaseModel):
|
||||
"""标签基础模型"""
|
||||
name: str = Field(..., max_length=50)
|
||||
|
||||
|
||||
class TagCreate(TagBase):
|
||||
"""创建标签请求模型"""
|
||||
pass
|
||||
|
||||
|
||||
class TagResponse(TagBase):
|
||||
"""标签响应模型"""
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
85
api/app/schemas/task.py
Normal file
85
api/app/schemas/task.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from app.schemas.category import CategoryResponse
|
||||
from app.schemas.tag import TagResponse
|
||||
|
||||
|
||||
def parse_datetime(value):
|
||||
"""解析日期时间字符串"""
|
||||
if value is None or value == '':
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
# 尝试多种格式
|
||||
formats = [
|
||||
'%Y-%m-%dT%H:%M:%S',
|
||||
'%Y-%m-%d %H:%M:%S',
|
||||
'%Y-%m-%dT%H:%M:%S.%f',
|
||||
]
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(value, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
# 最后尝试 ISO 格式
|
||||
return datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||
|
||||
|
||||
class TaskBase(BaseModel):
|
||||
"""任务基础模型"""
|
||||
title: str = Field(..., max_length=200)
|
||||
description: Optional[str] = None
|
||||
priority: str = Field(default="q4", pattern="^(q1|q2|q3|q4)$")
|
||||
due_date: Optional[datetime] = None
|
||||
category_id: Optional[int] = None
|
||||
|
||||
@field_validator('due_date', mode='before')
|
||||
@classmethod
|
||||
def parse_due_date(cls, v):
|
||||
return parse_datetime(v)
|
||||
|
||||
|
||||
class TaskCreate(TaskBase):
|
||||
"""创建任务请求模型"""
|
||||
tag_ids: Optional[List[int]] = []
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
"""更新任务请求模型
|
||||
|
||||
通过 exclude_unset=True 区分"前端没传"和"前端传了 null":
|
||||
- 前端没传某个字段 -> model_dump 结果中不包含该 key -> 不修改
|
||||
- 前端传了 null -> model_dump 结果中包含 key: None -> 视为"清空"
|
||||
"""
|
||||
title: Optional[str] = Field(None, max_length=200)
|
||||
description: Optional[str] = None
|
||||
priority: Optional[str] = Field(None, pattern="^(q1|q2|q3|q4)$")
|
||||
due_date: Optional[datetime] = None
|
||||
is_completed: Optional[bool] = None
|
||||
category_id: Optional[int] = None
|
||||
tag_ids: Optional[List[int]] = None
|
||||
|
||||
@field_validator('due_date', mode='before')
|
||||
@classmethod
|
||||
def parse_due_date(cls, v):
|
||||
return parse_datetime(v)
|
||||
|
||||
@property
|
||||
def clearable_fields(self) -> set:
|
||||
"""允许被显式清空(设为 None)的字段集合"""
|
||||
return {'description', 'due_date', 'category_id'}
|
||||
|
||||
|
||||
class TaskResponse(TaskBase):
|
||||
"""任务响应模型"""
|
||||
id: int
|
||||
is_completed: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
category: Optional[CategoryResponse] = None
|
||||
tags: List[TagResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
39
api/app/schemas/user_settings.py
Normal file
39
api/app/schemas/user_settings.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime, date
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class UserSettingsUpdate(BaseModel):
|
||||
"""更新用户设置请求模型"""
|
||||
nickname: Optional[str] = Field(None, max_length=50)
|
||||
avatar: Optional[str] = None
|
||||
signature: Optional[str] = Field(None, max_length=200)
|
||||
birthday: Optional[date] = None
|
||||
email: Optional[str] = Field(None, max_length=100)
|
||||
site_name: Optional[str] = Field(None, max_length=50)
|
||||
theme: Optional[str] = Field(None, max_length=20)
|
||||
language: Optional[str] = Field(None, max_length=10)
|
||||
default_view: Optional[str] = Field(None, max_length=20)
|
||||
default_sort_by: Optional[str] = Field(None, max_length=20)
|
||||
default_sort_order: Optional[str] = Field(None, max_length=10)
|
||||
|
||||
|
||||
class UserSettingsResponse(BaseModel):
|
||||
"""用户设置响应模型"""
|
||||
id: int
|
||||
nickname: str
|
||||
avatar: Optional[str] = None
|
||||
signature: Optional[str] = None
|
||||
birthday: Optional[date] = None
|
||||
email: Optional[str] = None
|
||||
site_name: str
|
||||
theme: str
|
||||
language: str
|
||||
default_view: str
|
||||
default_sort_by: str
|
||||
default_sort_order: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
5
api/app/utils/__init__.py
Normal file
5
api/app/utils/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Utils 工具模块
|
||||
"""
|
||||
from app.utils.crud import get_or_404
|
||||
from app.utils.logger import logger
|
||||
34
api/app/utils/crud.py
Normal file
34
api/app/utils/crud.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
通用 CRUD 工具函数
|
||||
"""
|
||||
from typing import Type, TypeVar
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import Base
|
||||
from app.utils.logger import logger
|
||||
|
||||
|
||||
ModelType = TypeVar("ModelType", bound=Base)
|
||||
|
||||
|
||||
def get_or_404(
|
||||
db: Session,
|
||||
model: Type[ModelType],
|
||||
item_id: int,
|
||||
name: str = "资源"
|
||||
) -> ModelType:
|
||||
"""
|
||||
获取实体,不存在时抛出 404 异常
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
model: 模型类
|
||||
item_id: 实体 ID
|
||||
name: 实体名称(用于错误信息)
|
||||
"""
|
||||
item = db.query(model).filter(model.id == item_id).first()
|
||||
if not item:
|
||||
logger.warning(f"{name}不存在: id={item_id}")
|
||||
raise HTTPException(status_code=404, detail=f"{name}不存在")
|
||||
return item
|
||||
11
api/app/utils/datetime.py
Normal file
11
api/app/utils/datetime.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from datetime import datetime, timezone, date
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""统一获取 UTC 时间的工具函数"""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def today() -> date:
|
||||
"""获取当天日期"""
|
||||
return date.today()
|
||||
48
api/app/utils/logger.py
Normal file
48
api/app/utils/logger.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
|
||||
from app.config import LOG_LEVEL, LOG_DIR
|
||||
|
||||
# 确保日志目录存在
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def setup_logger(name: str) -> logging.Logger:
|
||||
"""设置日志记录器"""
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(getattr(logging, LOG_LEVEL))
|
||||
|
||||
# 避免重复添加处理器
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
# 日志格式
|
||||
formatter = logging.Formatter(
|
||||
"[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
# 控制台处理器
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 文件处理器(按日期分割)
|
||||
log_file = os.path.join(LOG_DIR, "app.log")
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
log_file,
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=30,
|
||||
encoding="utf-8"
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
# 创建日志记录器
|
||||
logger = setup_logger("app")
|
||||
16
api/webui/index.html
Normal file
16
api/webui/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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-1IMBzsQJ.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-CwFI-VDq.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/element-plus-CAICPA8-.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D3zzYHQC.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
</body>
|
||||
1
api/webui/vite.svg
Normal file
1
api/webui/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
Reference in New Issue
Block a user