commit ffca168dbe6b994e76ff39f34b6aed17b481b87f Author: OP Date: Sun Jun 28 18:06:56 2026 +0800 v2.1: 新增通知提醒功能和安全增强 功能: - 通知提醒系统: 还款日提醒、逾期提醒、系统消息横幅、登录时通知 - 通知中心UI: 铃铛图标 + 下拉列表 + 未读徽章 - 定时任务: APScheduler 每小时检查即将到期的还款 - 邮件通知: SMTP 发送还款提醒和安全警告 - 异常登录通知: 3/5/10次失败触发网页和邮件通知 安全: - 登录安全告警: 异常登录次数达到阈值时发送通知 - 密码重置链接: 一次性使用, 5分钟有效期 - 登录频率限制: IP级和账户级限制 技术: - 后端: FastAPI + SQLAlchemy + PostgreSQL - 前端: 纯 HTML/CSS/JS - 部署: Docker Compose (新增 build 配置) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..42c34e6 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# 数据库配置 +POSTGRES_DB=debt_manager +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres + +# 后端配置 +DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/debt_manager +REDIS_URL=redis://redis:6379/0 +SECRET_KEY=change-this-in-production-please +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# 管理员账户 +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=admin123 + +# SMTP 邮件配置(留空则不发送邮件) +SMTP_HOST=smtp.example.com +SMTP_PORT=465 +SMTP_USER=your@email.com +SMTP_PASSWORD=your-password +SMTP_FROM=no-reply@example.com +SMTP_USE_TLS=true + +# 通知配置 +NOTIFICATION_CHECK_INTERVAL_MINUTES=60 +REMINDER_DAYS_BEFORE=3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..871a9e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +*.tar +*.tar.gz +.env +__pycache__/ +*.pyc +node_modules/ +pgdata/ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..645c0be --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,76 @@ +# 债务管理系统 - 部署指南 + +## 环境要求 +- Docker 20.10+ +- Docker Compose 2.0+ +- 至少 1GB 可用内存 + +## 快速部署 + +```bash +# 1. 解压项目 +tar -xzf debt-manager.tar.gz +cd debt-manager + +# 2. 启动所有服务 +docker compose up -d + +# 3. 等待启动完成(约30秒) +docker compose ps + +# 4. 访问系统 +# 前台: http://localhost:806 +# 后台: http://localhost:806/admin.html +# API文档: http://localhost:8000/docs +``` + +## 默认账户 + +| 账号 | 密码 | 角色 | +|------|------|------| +| admin | admin123 | 管理员 | +| test | test123 | 用户 | + +## 端口说明 + +| 服务 | 端口 | 说明 | +|------|------|------| +| 前端 | 806 | 用户界面 | +| 后端 | 8000 | API接口 | +| 数据库 | 54326 | PostgreSQL | + +如需修改端口,编辑 `docker-compose.yml` 中的 ports 映射。 + +## 数据备份 + +```bash +# 备份数据库 +docker compose exec db pg_dump -U postgres debt_manager > backup.sql + +# 恢复数据库 +docker compose exec -T db psql -U postgres debt_manager < backup.sql +``` + +## 停止/重启 + +```bash +# 停止 +docker compose down + +# 重启 +docker compose restart + +# 重建(代码更新后) +docker compose up -d --build +``` + +## 常见问题 + +**Q: 启动后前端打不开?** +A: 等待30秒让后端启动完成,然后刷新页面。 + +**Q: 新用户注册后看不到?** +A: 管理员需先登录前台,再访问后台管理页面审批。 + +**Q: 如何修改管理员密码?** +A: 登录前台 → 右上角"修改密码"。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa6201b --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# 债务管理系统 + +个人/小团队债务记录、跟踪和管理平台。 + +## 技术栈 + +- **前端**: 纯 HTML/CSS/JS(无框架依赖) +- **后端**: Python FastAPI + SQLAlchemy (async) +- **数据库**: PostgreSQL 15 +- **部署**: Docker Compose + +## 功能 + +### 前台 +- 用户注册/登录(JWT 双 token) +- 借款管理:CRUD + 分组展示 + 一键还款 +- 月度还款计划:按月汇总 + 复选框标记已还 +- 支持:等额本息、等额本金、先息后本 +- 利率/总利息互算 +- 数据导入/导出(CSV) +- 修改密码 + +### 后台 +- 用户管理(审批/禁用/启用/删除/重置密码) +- 用户组管理(同组共享数据) +- 系统数据总览 + +## 快速开始 + +```bash +# 解压项目 +tar -xzf debt-manager.tar.gz +cd debt-manager + +# 启动所有服务 +docker compose up -d + +# 访问 +# 前台: http://localhost:806 +# 后台: http://localhost:806/admin.html +# API文档: http://localhost:8000/docs +``` + +## 默认账户 + +| 账号 | 密码 | 角色 | +|------|------|------| +| admin | admin123 | 管理员 | +| test | test123 | 用户 | + +## 端口说明 + +| 服务 | 端口 | 说明 | +|------|------|------| +| 前端 | 806 | 用户界面 | +| 后端 | 8000 | API接口 | +| 数据库 | 54326 | PostgreSQL | + +## 项目结构 + +``` +debt-manager/ +├── backend/ # FastAPI 后端 +│ ├── app/ +│ │ ├── api/ # API 路由 +│ │ ├── core/ # 配置、数据库、认证 +│ │ ├── models/ # SQLAlchemy 模型 +│ │ ├── schemas/ # Pydantic 模型 +│ │ └── services/ # 业务逻辑 +│ └── Dockerfile +├── frontend/ # 前端页面 +│ ├── index.html # 主页面 +│ ├── admin.html # 管理后台 +│ └── Dockerfile +├── docker-compose.yml +├── .env +└── DEPLOY.md # 部署指南 +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..534cbc6 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..2ec646b --- /dev/null +++ b/backend/app/api/admin.py @@ -0,0 +1,429 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, update +from pydantic import BaseModel +from app.core.database import get_db +from app.models.models import User, Debt, AuditLog, Group, SystemSetting +from app.schemas.schemas import ( + AdminUserResponse, AdminDashboard, SystemSettingUpdate, + GroupCreate, GroupResponse, GroupMemberAdd, EmailServiceConfig, +) +from app.core.deps import require_admin +from app.core.security import hash_password + +router = APIRouter(prefix="/admin", tags=["管理后台"]) + + +@router.get("/dashboard", response_model=AdminDashboard) +async def admin_dashboard( + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + users_count = await db.execute(select(func.count(User.id))) + pending_count = await db.execute(select(func.count(User.id)).where(User.status == "pending")) + active_count = await db.execute(select(func.count(User.id)).where(User.status == "active")) + disabled_count = await db.execute(select(func.count(User.id)).where(User.status == "disabled")) + + return AdminDashboard( + total_users=users_count.scalar(), + active_debts=0, + total_amount=0, + overdue_count=0, + rate_distribution={}, + status_distribution={"pending": pending_count.scalar(), "active": active_count.scalar(), "disabled": disabled_count.scalar()}, + ) + + +@router.get("/users", response_model=list[AdminUserResponse]) +async def list_users( + page: int = Query(1, ge=1), + size: int = Query(50, ge=1, le=200), + search: str = "", + status_filter: str = "", + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + q = select(User) + if search: + q = q.where(User.email.ilike(f"%{search}%") | User.nickname.ilike(f"%{search}%")) + if status_filter: + q = q.where(User.status == status_filter) + q = q.order_by(User.created_at.desc()).offset((page - 1) * size).limit(size) + result = await db.execute(q) + users = result.scalars().all() + + groups_result = await db.execute(select(Group)) + groups_map = {g.id: g.name for g in groups_result.scalars().all()} + + out = [] + for u in users: + dc = await db.execute(select(func.count(Debt.id)).where(Debt.user_id == u.id)) + ta = await db.execute(select(func.coalesce(func.sum(Debt.amount), 0)).where(Debt.user_id == u.id)) + ur = AdminUserResponse( + id=u.id, account=u.email, nickname=u.nickname, + real_email=u.real_email or "", + role=u.role, status=u.status, group_id=u.group_id, + created_at=u.created_at, debt_count=dc.scalar(), + total_amount=ta.scalar(), group_name=groups_map.get(u.group_id, ""), + ) + out.append(ur) + return out + + +@router.put("/users/{user_id}/status") +async def set_user_status( + user_id: int, + status: str = "", + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if status: + user.status = status + else: + user.status = "active" if user.status != "active" else "disabled" + await db.commit() + return {"message": f"用户状态已更新为 {user.status}"} + + +@router.delete("/users/{user_id}") +async def delete_user( + user_id: int, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if user.role == "admin": + raise HTTPException(status_code=400, detail="不能删除管理员") + debts_result = await db.execute(select(Debt).where(Debt.user_id == user_id)) + for d in debts_result.scalars().all(): + await db.delete(d) + await db.delete(user) + await db.commit() + return {"message": "用户已删除"} + + +@router.get("/users/{user_id}/debts") +async def view_user_debts( + user_id: int, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Debt).where(Debt.user_id == user_id)) + return [{"id": d.id, "creditor": d.creditor_name, "amount": d.amount, "status": d.status} for d in result.scalars().all()] + + +@router.get("/logs") +async def get_audit_logs( + page: int = Query(1, ge=1), + size: int = Query(50, ge=1, le=200), + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + q = select(AuditLog).order_by(AuditLog.created_at.desc()).offset((page - 1) * size).limit(size) + result = await db.execute(q) + return [{"id": l.id, "user_id": l.user_id, "action": l.action, "details": l.details, "created_at": l.created_at.isoformat()} for l in result.scalars().all()] + + +@router.get("/groups", response_model=list[GroupResponse]) +async def list_groups( + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Group).order_by(Group.created_at.desc())) + groups = result.scalars().all() + out = [] + for g in groups: + mc = await db.execute(select(func.count(User.id)).where(User.group_id == g.id)) + gr = GroupResponse.model_validate(g) + gr.member_count = mc.scalar() + out.append(gr) + return out + + +@router.post("/groups", response_model=GroupResponse, status_code=201) +async def create_group( + req: GroupCreate, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + group = Group(name=req.name, description=req.description) + db.add(group) + await db.commit() + await db.refresh(group) + gr = GroupResponse.model_validate(group) + gr.member_count = 0 + return gr + + +@router.delete("/groups/{group_id}") +async def delete_group( + group_id: int, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Group).where(Group.id == group_id)) + group = result.scalar_one_or_none() + if not group: + raise HTTPException(status_code=404, detail="组不存在") + await db.execute(select(User).where(User.group_id == group_id).update({"group_id": None})) + await db.delete(group) + await db.commit() + return {"message": "组已删除"} + + +@router.post("/groups/{group_id}/members") +async def add_group_member( + group_id: int, + req: GroupMemberAdd, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + group = await db.execute(select(Group).where(Group.id == group_id)) + if not group.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="组不存在") + user = await db.execute(select(User).where(User.id == req.user_id)) + u = user.scalar_one_or_none() + if not u: + raise HTTPException(status_code=404, detail="用户不存在") + u.group_id = group_id + await db.commit() + return {"message": f"用户 {u.email} 已加入组"} + + +@router.delete("/groups/{group_id}/members/{user_id}") +async def remove_group_member( + group_id: int, + user_id: int, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User).where(User.id == user_id, User.group_id == group_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="用户不在此组中") + user.group_id = None + await db.commit() + return {"message": f"用户 {user.email} 已从组中移除"} + + +class AdminPasswordChange(BaseModel): + new_password: str + + +class AdminUserCreate(BaseModel): + account: str + password: str + nickname: str = "" + real_email: str = "" + role: str = "user" + group_id: int | None = None + + +class AdminUserUpdate(BaseModel): + account: str | None = None + nickname: str | None = None + real_email: str | None = None + role: str | None = None + group_id: int | None = None + + +@router.post("/users", response_model=AdminUserResponse, status_code=201) +async def create_user( + req: AdminUserCreate, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + account = req.account.strip().lower() + existing = await db.execute(select(User).where(User.email == account)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="账号已存在") + user = User( + email=account, password_hash=hash_password(req.password), + nickname=req.nickname, real_email=req.real_email, role=req.role, status="active", + group_id=req.group_id, + ) + db.add(user) + await db.commit() + await db.refresh(user) + return AdminUserResponse( + id=user.id, account=user.email, nickname=user.nickname, + role=user.role, status=user.status, group_id=user.group_id, + created_at=user.created_at, debt_count=0, total_amount=0, group_name="", + ) + + +@router.put("/users/{user_id}") +async def update_user( + user_id: int, + req: AdminUserUpdate, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if req.account is not None: + account = req.account.strip().lower() + existing = await db.execute(select(User).where(User.email == account, User.id != user_id)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="账号已存在") + user.email = account + if req.nickname is not None: + user.nickname = req.nickname + if req.real_email is not None: + user.real_email = req.real_email + if req.role is not None: + user.role = req.role + if req.group_id is not None or req.group_id == "": + user.group_id = req.group_id if req.group_id else None + await db.commit() + return {"message": "用户信息已更新"} + + +RESET_TOKEN_TTL_HOURS = 24 + + +@router.put("/users/{user_id}/password") +async def admin_reset_password( + user_id: int, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + import secrets + from datetime import datetime, timedelta + from app.models.models import SystemSetting + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if not user.real_email: + raise HTTPException(status_code=400, detail="该用户未绑定邮箱,无法发送重置链接") + token = secrets.token_urlsafe(32) + expires_at = (datetime.utcnow() + timedelta(hours=RESET_TOKEN_TTL_HOURS)).isoformat() + key = f"pwd_reset:{user.id}" + result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) + setting = result.scalar_one_or_none() + value = f"{token}|{expires_at}" + if setting: + setting.value = value + else: + db.add(SystemSetting(key=key, value=value)) + await db.commit() + from app.services.email_service import send_password_reset_link_email, get_site_url + site_url = await get_site_url() + reset_url = f"{site_url}/reset-password.html?token={token}&uid={user.id}" + await send_password_reset_link_email(user, reset_url, RESET_TOKEN_TTL_HOURS, f"管理员 {admin.email}") + return {"message": f"重置链接已发送至 {user.real_email}"} + + +SMTP_KEYS = ["smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_use_tls"] + + +@router.get("/email-config", response_model=EmailServiceConfig) +async def get_email_config( + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(SystemSetting).where(SystemSetting.key.in_(SMTP_KEYS))) + settings = {r.key: r.value for r in result.scalars().all()} + return EmailServiceConfig( + smtp_host=settings.get("smtp_host", ""), + smtp_port=int(settings.get("smtp_port", "465")), + smtp_user=settings.get("smtp_user", ""), + smtp_password=settings.get("smtp_password", ""), + smtp_from=settings.get("smtp_from", ""), + smtp_use_tls=settings.get("smtp_use_tls", "true") == "true", + ) + + +@router.put("/email-config") +async def update_email_config( + req: EmailServiceConfig, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + values = { + "smtp_host": req.smtp_host, + "smtp_port": str(req.smtp_port), + "smtp_user": req.smtp_user, + "smtp_password": req.smtp_password, + "smtp_from": req.smtp_from, + "smtp_use_tls": "true" if req.smtp_use_tls else "false", + } + for key, value in values.items(): + result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) + setting = result.scalar_one_or_none() + if setting: + setting.value = value + else: + db.add(SystemSetting(key=key, value=value)) + await db.commit() + return {"message": "邮件服务配置已保存"} + + +class EmailTestRequest(BaseModel): + to_email: str + + +@router.post("/email-test") +async def test_email( + req: EmailTestRequest, + admin: User = Depends(require_admin), +): + from app.services.email_service import send_email + html = """ +
+

邮件服务测试

+

您好,

+

这是一封债务管理系统的邮件服务测试邮件。

+

如果您收到此邮件,说明邮件服务器配置正确。

+
+

+

邮件服务正常

+
+

—— 债务管理系统

+
+ """ + ok = await send_email(req.to_email, "【债务管理系统】邮件服务测试", html) + if ok: + return {"message": "发送成功,邮件服务器配置正确。"} + raise HTTPException(status_code=500, detail="邮件发送失败,请检查 SMTP 配置") + + +class SiteUrlUpdate(BaseModel): + site_url: str + + +@router.get("/site-url") +async def get_site_url_config( + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(SystemSetting).where(SystemSetting.key == "site_url")) + setting = result.scalar_one_or_none() + return {"site_url": setting.value if setting else ""} + + +@router.put("/site-url") +async def update_site_url( + req: SiteUrlUpdate, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + url = req.site_url.strip().rstrip("/") + result = await db.execute(select(SystemSetting).where(SystemSetting.key == "site_url")) + setting = result.scalar_one_or_none() + if setting: + setting.value = url + else: + db.add(SystemSetting(key="site_url", value=url)) + await db.commit() + return {"message": "站点地址已保存"} diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..5db95f4 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,337 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel +from datetime import datetime, timedelta +import secrets +from app.core.database import get_db +from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token +from app.models.models import User, Group, SystemSetting +from app.schemas.schemas import ( + RegisterRequest, LoginRequest, TokenResponse, + RefreshRequest, UserProfile, ChangePasswordRequest, + ForgotPasswordRequest, VerifyResetCodeRequest, ResetPasswordRequest, +) +from app.core.deps import get_current_user + +router = APIRouter(prefix="/auth", tags=["认证"]) + + +class RegisterResponse(BaseModel): + message: str + + +@router.post("/register", response_model=RegisterResponse) +async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): + account = req.account.strip().lower() + result = await db.execute(select(User).where(User.email == account)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="账号已注册") + user = User( + email=account, password_hash=hash_password(req.password), + nickname=req.nickname, status="pending", group_id=req.group_id, + ) + db.add(user) + await db.commit() + return RegisterResponse(message="注册成功,等待管理员审批") + + +@router.post("/login", response_model=TokenResponse) +async def login( + req: LoginRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + from app.core.rate_limit import record_login_failure, clear_login_failures, get_account_failure_count, get_unnotified_threshold + ip = request.client.host if request.client else "unknown" + account = req.account.strip().lower() + result = await db.execute(select(User).where(User.email == account)) + user = result.scalar_one_or_none() + if not user or not verify_password(req.password, user.password_hash): + record_login_failure(ip, account) + count = get_account_failure_count(account) + threshold = get_unnotified_threshold(account) + if threshold and user: + from app.services.email_service import send_login_alert_email + from app.services.notification_service import create_notification + from app.models.models import NotificationType + from app.core.database import async_session + + level_map = {3: "注意", 5: "警告", 10: "严重"} + level = level_map.get(threshold, "注意") + title = f"【安全{level}】异常登录 {count} 次" + message = f"您的账号在 5 分钟内出现 {count} 次异常登录尝试,来源 IP: {ip}。若非本人操作,请立即修改密码。" + + async with async_session() as notif_db: + await create_notification( + notif_db, user.id, title, message, + NotificationType.ACCOUNT.value, + ) + await notif_db.commit() + + if user.real_email: + await send_login_alert_email(user, count, threshold, ip) + raise HTTPException(status_code=401, detail="账号或密码错误") + if user.status == "pending": + raise HTTPException(status_code=403, detail="账户正在等待管理员审批") + if user.status == "disabled": + raise HTTPException(status_code=403, detail="账户已被禁用") + clear_login_failures(ip, account) + return TokenResponse( + access_token=create_access_token(user.id), + refresh_token=create_refresh_token(user.id), + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh(req: RefreshRequest, db: AsyncSession = Depends(get_db)): + payload = decode_token(req.refresh_token) + if not payload or payload.get("type") != "refresh": + raise HTTPException(status_code=401, detail="Invalid refresh token") + user_id = int(payload["sub"]) + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=401, detail="User not found") + if user.status == "pending": + raise HTTPException(status_code=403, detail="账户正在等待管理员审批") + if user.status == "disabled": + raise HTTPException(status_code=403, detail="账户已被禁用") + return TokenResponse( + access_token=create_access_token(user.id), + refresh_token=create_refresh_token(user.id), + ) + + +@router.get("/profile", response_model=UserProfile) +async def get_profile(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + group_name = "" + if user.group_id: + result = await db.execute(select(Group.name).where(Group.id == user.group_id)) + row = result.first() + if row: + group_name = row[0] + return UserProfile( + id=user.id, account=user.email, nickname=user.nickname, + real_email=user.real_email or "", + role=user.role, status=user.status, group_id=user.group_id, + group_name=group_name, created_at=user.created_at, + ) + + +@router.get("/groups") +async def list_groups(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Group).where(Group.id.isnot(None))) + return [{"id": g.id, "name": g.name} for g in result.scalars().all()] + + +class ProfileUpdate(BaseModel): + nickname: str | None = None + real_email: str | None = None + + +@router.put("/profile") +async def update_profile( + req: ProfileUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if req.nickname is not None: + user.nickname = req.nickname + if req.real_email is not None: + user.real_email = req.real_email + await db.commit() + return {"message": "资料更新成功"} + + +@router.put("/password") +async def change_password( + req: ChangePasswordRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if not verify_password(req.old_password, user.password_hash): + raise HTTPException(status_code=400, detail="原密码错误") + user.password_hash = hash_password(req.new_password) + await db.commit() + from app.services.email_service import send_password_changed_email + await send_password_changed_email(user, "修改") + return {"message": "密码修改成功"} + + +RESET_CODE_TTL_MINUTES = 10 + + +async def _find_user_by_account_or_email(db: AsyncSession, account: str) -> User | None: + account = account.strip().lower() + result = await db.execute(select(User).where(User.email == account)) + user = result.scalar_one_or_none() + if user: + return user + result = await db.execute(select(User).where(User.real_email == account)) + return result.scalar_one_or_none() + + +def _build_code_key(user_id: int) -> str: + return f"reset_code:{user_id}" + + +def _build_code_html(nickname: str, code: str) -> str: + return f""" +
+

密码重置验证码

+

Hi {nickname},

+

您正在重置密码,验证码如下:

+
+

{code}

+

{RESET_CODE_TTL_MINUTES} 分钟内有效

+
+

如非本人操作,请忽略此邮件。

+

—— 债务管理系统

+
+ """ + + +@router.post("/forgot-password") +async def forgot_password( + req: ForgotPasswordRequest, + db: AsyncSession = Depends(get_db), +): + user = await _find_user_by_account_or_email(db, req.account) + if not user or not user.real_email: + return {"message": "如果该账号已绑定邮箱,验证码已发送"} + code = f"{secrets.randbelow(1000000):06d}" + key = _build_code_key(user.id) + result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) + setting = result.scalar_one_or_none() + value = f"{code}|{(datetime.utcnow() + timedelta(minutes=RESET_CODE_TTL_MINUTES)).isoformat()}" + if setting: + setting.value = value + else: + db.add(SystemSetting(key=key, value=value)) + await db.commit() + from app.services.email_service import send_email + html = _build_code_html(user.nickname or user.email, code) + await send_email(user.real_email, "【债务管理系统】密码重置验证码", html) + return {"message": "如果该账号已绑定邮箱,验证码已发送"} + + +@router.post("/verify-reset-code") +async def verify_reset_code( + req: VerifyResetCodeRequest, + db: AsyncSession = Depends(get_db), +): + user = await _find_user_by_account_or_email(db, req.account) + if not user: + raise HTTPException(status_code=400, detail="账号不存在") + key = _build_code_key(user.id) + result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) + setting = result.scalar_one_or_none() + if not setting: + raise HTTPException(status_code=400, detail="验证码已过期,请重新获取") + parts = setting.value.split("|") + if len(parts) != 2 or parts[0] != req.code: + raise HTTPException(status_code=400, detail="验证码错误") + try: + expires_at = datetime.fromisoformat(parts[1]) + except ValueError: + raise HTTPException(status_code=400, detail="验证码格式错误") + if datetime.utcnow() > expires_at: + raise HTTPException(status_code=400, detail="验证码已过期,请重新获取") + return {"message": "验证码正确"} + + +@router.post("/reset-password") +async def reset_password( + req: ResetPasswordRequest, + db: AsyncSession = Depends(get_db), +): + user = await _find_user_by_account_or_email(db, req.account) + if not user: + raise HTTPException(status_code=400, detail="账号不存在") + key = _build_code_key(user.id) + result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) + setting = result.scalar_one_or_none() + if not setting: + raise HTTPException(status_code=400, detail="验证码已过期,请重新获取") + parts = setting.value.split("|") + if len(parts) != 2 or parts[0] != req.code: + raise HTTPException(status_code=400, detail="验证码错误") + try: + expires_at = datetime.fromisoformat(parts[1]) + except ValueError: + raise HTTPException(status_code=400, detail="验证码格式错误") + if datetime.utcnow() > expires_at: + raise HTTPException(status_code=400, detail="验证码已过期,请重新获取") + user.password_hash = hash_password(req.new_password) + await db.delete(setting) + await db.commit() + from app.services.email_service import send_password_changed_email + await send_password_changed_email(user, "重置") + return {"message": "密码重置成功"} + + +class ResetByTokenRequest(BaseModel): + token: str + uid: int + new_password: str + + +@router.post("/reset-by-token") +async def reset_by_token( + req: ResetByTokenRequest, + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User).where(User.id == req.uid)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=400, detail="链接无效") + key = f"pwd_reset:{req.uid}" + result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) + setting = result.scalar_one_or_none() + if not setting: + raise HTTPException(status_code=400, detail="链接已失效,请联系管理员重新发送") + parts = setting.value.split("|") + if len(parts) != 2 or parts[0] != req.token: + raise HTTPException(status_code=400, detail="链接无效") + try: + expires_at = datetime.fromisoformat(parts[1]) + except ValueError: + raise HTTPException(status_code=400, detail="链接格式错误") + if datetime.utcnow() > expires_at: + await db.delete(setting) + await db.commit() + raise HTTPException(status_code=400, detail="链接已过期,请联系管理员重新发送") + user.password_hash = hash_password(req.new_password) + await db.delete(setting) + await db.commit() + from app.services.email_service import send_password_changed_email + await send_password_changed_email(user, "重置") + return {"message": "密码重置成功"} + + +@router.get("/check-reset-token") +async def check_reset_token( + token: str, + uid: int, + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User).where(User.id == uid)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=400, detail="链接无效") + key = f"pwd_reset:{uid}" + result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) + setting = result.scalar_one_or_none() + if not setting: + raise HTTPException(status_code=400, detail="链接已失效") + parts = setting.value.split("|") + if len(parts) != 2 or parts[0] != token: + raise HTTPException(status_code=400, detail="链接无效") + try: + expires_at = datetime.fromisoformat(parts[1]) + except ValueError: + raise HTTPException(status_code=400, detail="链接格式错误") + if datetime.utcnow() > expires_at: + raise HTTPException(status_code=400, detail="链接已过期") + return {"valid": True, "nickname": user.nickname or user.email} diff --git a/backend/app/api/debts.py b/backend/app/api/debts.py new file mode 100644 index 0000000..f177a5a --- /dev/null +++ b/backend/app/api/debts.py @@ -0,0 +1,172 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from datetime import datetime +from app.core.database import get_db +from app.models.models import User, Debt, RepaymentPlan, RepaymentRecord, DebtStatus, PlanStatus +from app.schemas.schemas import ( + DebtCreate, DebtUpdate, DebtResponse, + RepaymentPlanResponse, RepaymentRecordCreate, RepaymentRecordResponse, +) +from app.core.deps import get_current_user +from app.services.calculator import generate_repayment_plan + +router = APIRouter(prefix="/debts", tags=["债务管理"]) + + +def _naive(dt: datetime) -> datetime: + if dt and dt.tzinfo: + return dt.replace(tzinfo=None) + return dt + + +async def _enrich(db: AsyncSession, d: Debt) -> DebtResponse: + dr = DebtResponse.model_validate(d) + pr = await db.execute(select(func.coalesce(func.sum(RepaymentRecord.amount), 0)).where(RepaymentRecord.debt_id == d.id)) + dr.total_paid = pr.scalar() + ir = await db.execute(select(func.coalesce(func.sum(RepaymentPlan.interest_due), 0)).where(RepaymentPlan.debt_id == d.id)) + dr.total_interest = ir.scalar() + owner = await db.execute(select(User.nickname, User.email).where(User.id == d.user_id)) + row = owner.first() + dr.owner_name = row[0] or row[1] if row else "" + return dr + + +@router.get("", response_model=list[DebtResponse]) +async def list_debts( + status: str | None = None, + creditor: str | None = None, + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + q = select(Debt) + if status: + q = q.where(Debt.status == status) + if creditor: + q = q.where(Debt.creditor_name.ilike(f"%{creditor}%")) + q = q.order_by(Debt.created_at.desc()).offset((page - 1) * size).limit(size) + result = await db.execute(q) + debts = result.scalars().all() + return [await _enrich(db, d) for d in debts] + + +@router.post("", response_model=DebtResponse, status_code=201) +async def create_debt( + req: DebtCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + debt = Debt( + user_id=user.id, creditor_name=req.creditor_name, amount=req.amount, + borrow_date=_naive(req.borrow_date), interest_rate=req.interest_rate, + rate_type=req.rate_type, repayment_method=req.repayment_method, + period_count=req.period_count, period_unit=req.period_unit, notes=req.notes, + ) + db.add(debt) + await db.flush() + + plans = generate_repayment_plan( + req.amount, req.interest_rate, req.rate_type, + req.repayment_method, req.period_count, req.period_unit, + _naive(req.borrow_date), + ) + for p in plans: + db.add(RepaymentPlan(debt_id=debt.id, **p)) + await db.commit() + await db.refresh(debt) + return await _enrich(db, debt) + + +@router.get("/{debt_id}", response_model=DebtResponse) +async def get_debt( + debt_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Debt).where(Debt.id == debt_id)) + debt = result.scalar_one_or_none() + if not debt: + raise HTTPException(status_code=404, detail="债务不存在") + return await _enrich(db, debt) + + +@router.put("/{debt_id}", response_model=DebtResponse) +async def update_debt( + debt_id: int, + req: DebtUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Debt).where(Debt.id == debt_id)) + debt = result.scalar_one_or_none() + if not debt: + raise HTTPException(status_code=404, detail="债务不存在") + if debt.user_id != user.id and user.role != "admin": + raise HTTPException(status_code=403, detail="无权修改此债务") + if debt.status not in ("active",): + raise HTTPException(status_code=400, detail="该债务不可修改") + data = req.model_dump(exclude_unset=True) + for k, v in data.items(): + setattr(debt, k, _naive(v) if k == "borrow_date" else v) + if any(k in data for k in ["amount", "interest_rate", "rate_type", "repayment_method", "period_count", "period_unit", "borrow_date"]): + await db.execute(select(RepaymentPlan).where(RepaymentPlan.debt_id == debt.id).delete()) + plans = generate_repayment_plan( + debt.amount, debt.interest_rate, debt.rate_type, + debt.repayment_method, debt.period_count, debt.period_unit, + _naive(debt.borrow_date), + ) + for p in plans: + db.add(RepaymentPlan(debt_id=debt.id, **p)) + await db.commit() + await db.refresh(debt) + return await _enrich(db, debt) + + +@router.delete("/{debt_id}") +async def delete_debt( + debt_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Debt).where(Debt.id == debt_id)) + debt = result.scalar_one_or_none() + if not debt: + raise HTTPException(status_code=404, detail="债务不存在") + if debt.user_id != user.id and user.role != "admin": + raise HTTPException(status_code=403, detail="无权删除此债务") + has_records = await db.execute(select(func.count(RepaymentRecord.id)).where(RepaymentRecord.debt_id == debt.id)) + if has_records.scalar() > 0: + debt.status = DebtStatus.CLOSED.value + await db.commit() + return {"message": "已有还款记录,债务已归档"} + await db.delete(debt) + await db.commit() + return {"message": "债务已删除"} + + +@router.get("/{debt_id}/plans", response_model=list[RepaymentPlanResponse]) +async def get_plans( + debt_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Debt).where(Debt.id == debt_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="债务不存在") + pr = await db.execute(select(RepaymentPlan).where(RepaymentPlan.debt_id == debt_id).order_by(RepaymentPlan.due_date)) + return pr.scalars().all() + + +@router.get("/{debt_id}/records", response_model=list[RepaymentRecordResponse]) +async def get_records( + debt_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Debt).where(Debt.id == debt_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="债务不存在") + rr = await db.execute(select(RepaymentRecord).where(RepaymentRecord.debt_id == debt_id).order_by(RepaymentRecord.paid_date.desc())) + return rr.scalars().all() diff --git a/backend/app/api/loans.py b/backend/app/api/loans.py new file mode 100644 index 0000000..832c4e2 --- /dev/null +++ b/backend/app/api/loans.py @@ -0,0 +1,152 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from datetime import datetime +from pydantic import BaseModel +from app.core.database import get_db +from app.models.models import User, Debt +from app.schemas.schemas import LoanCreate, LoanResponse +from app.core.deps import get_current_user + +router = APIRouter(prefix="/loans", tags=["借款管理"]) + +METHOD_MAP = {"e": "equal_installment", "p": "interest_first", "i": "lump_sum"} +METHOD_REV = {"equal_installment": "e", "interest_first": "p", "lump_sum": "i"} + + +class PaidUpdate(BaseModel): + paid: list[int] + + +def _debt_to_loan(d: Debt, owner_name: str = "") -> LoanResponse: + return LoanResponse( + id=d.id, + name=d.creditor_name, + date=d.borrow_date.strftime("%Y-%m-%d") if d.borrow_date else "", + amount=d.amount / 100, + rate=d.interest_rate, + periods=d.period_count, + method=METHOD_REV.get(d.repayment_method, "e"), + purpose=d.purpose or "", + schedule=d.schedule_data or [], + paid=d.paid_periods or [], + user_id=d.user_id, + owner_name=owner_name, + ) + + +async def _get_visible_user_ids(user: User, db: AsyncSession) -> list[int]: + if user.role == "admin": + result = await db.execute(select(User.id)) + return [r[0] for r in result.all()] + if user.group_id: + result = await db.execute(select(User.id).where(User.group_id == user.group_id)) + ids = [r[0] for r in result.all()] + if user.id not in ids: + ids.append(user.id) + return ids + return [user.id] + + +@router.get("", response_model=list[LoanResponse]) +async def list_loans( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + visible_ids = await _get_visible_user_ids(user, db) + result = await db.execute(select(Debt).where(Debt.user_id.in_(visible_ids)).order_by(Debt.created_at.desc())) + debts = result.scalars().all() + users_result = await db.execute(select(User.id, User.nickname)) + users_map = {uid: (nick or "") for uid, nick in users_result.all()} + return [_debt_to_loan(d, users_map.get(d.user_id, "")) for d in debts] + + +@router.post("", response_model=LoanResponse, status_code=201) +async def create_loan( + req: LoanCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + debt = Debt( + user_id=user.id, + creditor_name=req.name, + amount=int(req.amount * 100), + borrow_date=datetime.strptime(req.date, "%Y-%m-%d"), + interest_rate=req.rate, + rate_type="annual", + repayment_method=METHOD_MAP.get(req.method, "equal_installment"), + period_count=req.periods, + period_unit="month", + purpose=req.purpose, + schedule_data=req.schedule, + paid_periods=req.paid, + ) + db.add(debt) + await db.commit() + await db.refresh(debt) + return _debt_to_loan(debt, user.nickname or "") + + +@router.put("/{loan_id}", response_model=LoanResponse) +async def update_loan( + loan_id: int, + req: LoanCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Debt).where(Debt.id == loan_id)) + debt = result.scalar_one_or_none() + if not debt: + raise HTTPException(status_code=404, detail="借款不存在") + if debt.user_id != user.id and user.role != "admin": + raise HTTPException(status_code=403, detail="无权修改") + + debt.creditor_name = req.name + debt.amount = int(req.amount * 100) + debt.borrow_date = datetime.strptime(req.date, "%Y-%m-%d") + debt.interest_rate = req.rate + debt.repayment_method = METHOD_MAP.get(req.method, "equal_installment") + debt.period_count = req.periods + debt.purpose = req.purpose + debt.schedule_data = req.schedule + debt.paid_periods = req.paid + + await db.commit() + await db.refresh(debt) + return _debt_to_loan(debt, user.nickname or "") + + +@router.delete("/{loan_id}") +async def delete_loan( + loan_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Debt).where(Debt.id == loan_id)) + debt = result.scalar_one_or_none() + if not debt: + raise HTTPException(status_code=404, detail="借款不存在") + if debt.user_id != user.id and user.role != "admin": + raise HTTPException(status_code=403, detail="无权删除") + await db.delete(debt) + await db.commit() + return {"message": "已删除"} + + +@router.patch("/{loan_id}/toggle-paid") +async def toggle_paid( + loan_id: int, + req: PaidUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Debt).where(Debt.id == loan_id)) + debt = result.scalar_one_or_none() + if not debt: + raise HTTPException(status_code=404, detail="借款不存在") + if debt.user_id != user.id: + raise HTTPException(status_code=403, detail="只能标记自己的还款") + + debt.paid_periods = sorted(req.paid) + await db.commit() + return {"paid": debt.paid_periods} diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py new file mode 100644 index 0000000..2c9438b --- /dev/null +++ b/backend/app/api/notifications.py @@ -0,0 +1,91 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, update +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.models import User, Notification +from app.schemas.schemas import NotificationResponse, NotificationListResponse + +router = APIRouter(prefix="/notifications", tags=["通知管理"]) + + +@router.get("", response_model=NotificationListResponse) +async def list_notifications( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + unread_only: bool = False, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + q = select(Notification).where(Notification.user_id == user.id) + if unread_only: + q = q.where(Notification.is_read == 0) + total_q = select(func.count(Notification.id)).where(Notification.user_id == user.id) + total = (await db.execute(total_q)).scalar() + q = q.order_by(Notification.created_at.desc()).offset((page - 1) * size).limit(size) + items = (await db.execute(q)).scalars().all() + unread = (await db.execute( + select(func.count(Notification.id)).where( + Notification.user_id == user.id, Notification.is_read == 0 + ) + )).scalar() + return NotificationListResponse( + items=[NotificationResponse.model_validate(n) for n in items], + total=total, + unread=unread, + ) + + +@router.put("/{notification_id}/read") +async def mark_read( + notification_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Notification).where( + Notification.id == notification_id, + Notification.user_id == user.id + ) + ) + n = result.scalar_one_or_none() + if not n: + raise HTTPException(status_code=404, detail="通知不存在") + n.is_read = 1 + await db.commit() + return {"message": "已标记已读"} + + +@router.put("/read-all") +async def mark_all_read( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await db.execute( + update(Notification).where( + Notification.user_id == user.id, + Notification.is_read == 0 + ).values(is_read=1) + ) + await db.commit() + return {"message": "全部已读"} + + +@router.delete("/{notification_id}") +async def delete_notification( + notification_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Notification).where( + Notification.id == notification_id, + Notification.user_id == user.id + ) + ) + n = result.scalar_one_or_none() + if not n: + raise HTTPException(status_code=404, detail="通知不存在") + await db.delete(n) + await db.commit() + return {"message": "已删除"} diff --git a/backend/app/api/repayments.py b/backend/app/api/repayments.py new file mode 100644 index 0000000..510473b --- /dev/null +++ b/backend/app/api/repayments.py @@ -0,0 +1,101 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from datetime import datetime, timedelta +from app.core.database import get_db +from app.models.models import User, Debt, RepaymentPlan, RepaymentRecord, PlanStatus, DebtStatus +from app.schemas.schemas import RepaymentRecordCreate, RepaymentRecordResponse, DashboardSummary +from app.core.deps import get_current_user + +router = APIRouter(prefix="/repayments", tags=["还款管理"]) + + +def _naive(dt: datetime) -> datetime: + if dt and dt.tzinfo: + return dt.replace(tzinfo=None) + return dt + + +@router.post("", response_model=RepaymentRecordResponse, status_code=201) +async def create_repayment( + req: RepaymentRecordCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Debt).where(Debt.id == req.debt_id)) + debt = result.scalar_one_or_none() + if not debt: + raise HTTPException(status_code=404, detail="债务不存在") + if debt.user_id != user.id and user.role != "admin": + raise HTTPException(status_code=403, detail="无权为此债务还款") + + record = RepaymentRecord( + debt_id=req.debt_id, plan_id=req.plan_id, + amount=req.amount, paid_date=_naive(req.paid_date), notes=req.notes, + ) + db.add(record) + + if req.plan_id: + plan_result = await db.execute(select(RepaymentPlan).where(RepaymentPlan.id == req.plan_id)) + plan = plan_result.scalar_one_or_none() + if plan: + plan.status = PlanStatus.PAID.value + + total_paid_result = await db.execute( + select(func.coalesce(func.sum(RepaymentRecord.amount), 0)).where(RepaymentRecord.debt_id == req.debt_id) + ) + total_paid = total_paid_result.scalar() + req.amount + if total_paid >= debt.amount: + debt.status = DebtStatus.PAID.value + + await db.commit() + await db.refresh(record) + return record + + +@router.get("/dashboard", response_model=DashboardSummary) +async def get_dashboard( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + debts_result = await db.execute(select(Debt).where(Debt.status == DebtStatus.ACTIVE.value)) + debts = debts_result.scalars().all() + + total_debt = sum(d.amount for d in debts) + total_paid = 0 + for d in debts: + pr = await db.execute(select(func.coalesce(func.sum(RepaymentRecord.amount), 0)).where(RepaymentRecord.debt_id == d.id)) + total_paid += pr.scalar() + + active_count = sum(1 for d in debts if d.status == DebtStatus.ACTIVE.value) + overdue_count = sum(1 for d in debts if d.status == DebtStatus.OVERDUE.value) + paid_all = await db.execute(select(Debt).where(Debt.status == DebtStatus.PAID.value)) + paid_count = len(paid_all.scalars().all()) + + now = datetime.utcnow() + upcoming = [] + for d in debts: + if d.status != DebtStatus.ACTIVE.value: + continue + plans_result = await db.execute( + select(RepaymentPlan).where( + RepaymentPlan.debt_id == d.id, + RepaymentPlan.status == PlanStatus.PENDING.value, + RepaymentPlan.due_date <= now + timedelta(days=7), + ).order_by(RepaymentPlan.due_date) + ) + for p in plans_result.scalars().all(): + upcoming.append({ + "debt_id": d.id, "creditor": d.creditor_name, + "due_date": p.due_date.isoformat(), "amount": p.total_due, + "overdue": p.due_date < now, + }) + + upcoming.sort(key=lambda x: x["due_date"]) + + return DashboardSummary( + total_debt=total_debt, total_paid=total_paid, + total_remaining=total_debt - total_paid, + active_count=active_count, overdue_count=overdue_count, + paid_count=paid_count, upcoming_payments=upcoming[:10], + ) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..927746d --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,32 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + PROJECT_NAME: str = "债务管理系统" + API_V1_PREFIX: str = "/api/v1" + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@db:5432/debt_manager" + REDIS_URL: str = "redis://redis:6379/0" + SECRET_KEY: str = "your-secret-key-change-in-production" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + CORS_ORIGINS: list[str] = ["http://localhost", "http://localhost:80", "http://localhost:806"] + ADMIN_EMAIL: str = "admin@example.com" + ADMIN_PASSWORD: str = "admin123" + + SMTP_HOST: str = "" + SMTP_PORT: int = 465 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + SMTP_FROM: str = "" + SMTP_USE_TLS: bool = True + NOTIFICATION_CHECK_INTERVAL_MINUTES: int = 60 + REMINDER_DAYS_BEFORE: int = 7 + + class Config: + env_file = ".env" + + +@lru_cache() +def get_settings(): + return Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..f8e9927 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,14 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from app.core.config import get_settings + +settings = get_settings() +engine = create_async_engine(settings.DATABASE_URL, echo=False) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db(): + async with async_session() as session: + try: + yield session + finally: + await session.close() diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..5e79043 --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,31 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.core.database import get_db +from app.core.security import decode_token +from app.models.models import User + +security = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), +) -> User: + token = credentials.credentials + payload = decode_token(token) + if not payload or payload.get("type") != "access": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + user_id = int(payload["sub"]) + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user or user.status == "disabled": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or disabled") + return user + + +async def require_admin(user: User = Depends(get_current_user)) -> User: + if user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required") + return user diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py new file mode 100644 index 0000000..79d989e --- /dev/null +++ b/backend/app/core/rate_limit.py @@ -0,0 +1,68 @@ +import time +from collections import defaultdict +from fastapi import Request, HTTPException + +MAX_FAILURES = 10 +WINDOW_SECONDS = 300 +ALERT_THRESHOLDS = [3, 5, 10] + +_login_attempts: dict[str, list[float]] = defaultdict(list) +_account_failures: dict[str, list[float]] = defaultdict(list) +_account_notified: dict[str, set[int]] = defaultdict(set) + + +def record_login_failure(ip: str, account: str = ""): + now = time.time() + _login_attempts[ip] = [t for t in _login_attempts[ip] if now - t < WINDOW_SECONDS] + _login_attempts[ip].append(now) + if account: + _account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS] + _account_failures[account].append(now) + + +def clear_login_failures(ip: str, account: str = ""): + _login_attempts.pop(ip, None) + if account: + _account_failures.pop(account, None) + _account_notified.pop(account, None) + + +def get_remaining_seconds(ip: str) -> int: + now = time.time() + _login_attempts[ip] = [t for t in _login_attempts[ip] if now - t < WINDOW_SECONDS] + if not _login_attempts[ip]: + return 0 + oldest = _login_attempts[ip][0] + remaining = int(WINDOW_SECONDS - (now - oldest)) + return max(remaining, 0) + + +def get_account_failure_count(account: str) -> int: + now = time.time() + _account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS] + return len(_account_failures[account]) + + +def get_unnotified_threshold(account: str) -> int | None: + now = time.time() + _account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS] + count = len(_account_failures[account]) + notified = _account_notified[account] + for threshold in ALERT_THRESHOLDS: + if count >= threshold and threshold not in notified: + notified.add(threshold) + return threshold + return None + + +def is_ip_blocked(ip: str) -> bool: + return get_remaining_seconds(ip) > 0 and len(_login_attempts[ip]) >= MAX_FAILURES + + +async def ip_rate_limit_middleware(request: Request, call_next): + ip = request.client.host if request.client else "unknown" + if is_ip_blocked(ip): + remaining = get_remaining_seconds(ip) + raise HTTPException(status_code=429, detail=f"登录失败次数过多,请 {remaining} 秒后再试") + response = await call_next(request) + return response diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..17b90d0 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,39 @@ +from datetime import datetime, timedelta, timezone +from jose import JWTError, jwt +from passlib.context import CryptContext +from app.core.config import get_settings + +settings = get_settings() +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ALGORITHM = "HS256" + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_token(data: dict, expires_delta: timedelta | None = None) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + + +def create_access_token(user_id: int) -> str: + return create_token({"sub": str(user_id), "type": "access"}, timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) + + +def create_refresh_token(user_id: int) -> str: + return create_token({"sub": str(user_id), "type": "refresh"}, timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)) + + +def decode_token(token: str) -> dict | None: + try: + return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + except JWTError: + return None diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..e7d0a59 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,151 @@ +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from contextlib import asynccontextmanager +from app.core.config import get_settings +from app.core.database import engine, async_session +from app.models.models import Base, User, Debt, RepaymentPlan, RepaymentRecord, Group +from app.core.security import hash_password +from app.api import auth, debts, repayments, admin, loans, notifications +from sqlalchemy import select, text + +settings = get_settings() + +METHOD_REV = {"equal_installment": "e", "interest_first": "p", "lump_sum": "i"} + + +async def seed_admin(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with async_session() as db: + result = await db.execute(select(User)) + existing = result.scalars().all() + if not existing: + admin_user = User( + email="admin", + password_hash=hash_password(settings.ADMIN_PASSWORD), + nickname="管理员", + role="admin", + ) + db.add(admin_user) + test_user = User( + email="test", + password_hash=hash_password("test123"), + nickname="测试用户", + role="user", + ) + db.add(test_user) + await db.commit() + + +async def migrate_account_names(): + async with async_session() as db: + result = await db.execute(select(User)) + users = result.scalars().all() + for u in users: + if "@" in u.email: + new_email = u.email.split("@")[0] + existing = await db.execute(select(User).where(User.email == new_email, User.id != u.id)) + if not existing.scalar_one_or_none(): + u.email = new_email + await db.commit() + + +async def migrate_add_real_email(): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE users ADD COLUMN IF NOT EXISTS real_email VARCHAR(255) DEFAULT ''" + )) + + +async def migrate_debt_data(): + async with async_session() as db: + result = await db.execute(select(Debt).where(Debt.schedule_data == None)) + debts = result.scalars().all() + for debt in debts: + plans_result = await db.execute( + select(RepaymentPlan).where(RepaymentPlan.debt_id == debt.id).order_by(RepaymentPlan.due_date) + ) + plans = plans_result.scalars().all() + + schedule = [] + paid_periods = [] + for p in plans: + item = { + "p": p.principal_due / 100, + "date": p.due_date.strftime("%Y-%m-%d") if p.due_date else "", + "pay": p.total_due / 100, + "pr": p.principal_due / 100, + "it": p.interest_due / 100, + "rp": 0, + } + schedule.append(item) + if p.status == "paid": + paid_periods.append(len(schedule)) + + remaining = debt.amount / 100 + for item in schedule: + item["rp"] = round(remaining, 2) + remaining -= item["pr"] + + debt.schedule_data = schedule + debt.paid_periods = paid_periods + + await db.commit() + + +async def migrate_account_names(): + async with async_session() as db: + result = await db.execute(select(User)) + users = result.scalars().all() + for u in users: + if "@" in u.email: + u.email = u.email.split("@")[0] + await db.commit() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await seed_admin() + await migrate_add_real_email() + await migrate_account_names() + await migrate_debt_data() + from app.services.scheduler import start_scheduler, check_upcoming_repayments + start_scheduler() + await check_upcoming_repayments() + yield + from app.services.scheduler import scheduler + scheduler.shutdown() + + +app = FastAPI(title=settings.PROJECT_NAME, lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.middleware("http") +async def rate_limit_middleware(request: Request, call_next): + from app.core.rate_limit import is_ip_blocked, get_remaining_seconds + ip = request.client.host if request.client else "unknown" + if is_ip_blocked(ip): + remaining = get_remaining_seconds(ip) + return JSONResponse(status_code=429, content={"detail": f"登录失败次数过多,请 {remaining} 秒后再试", "retry_after": remaining}) + return await call_next(request) + +app.include_router(auth.router, prefix=settings.API_V1_PREFIX) +app.include_router(loans.router, prefix=settings.API_V1_PREFIX) +app.include_router(debts.router, prefix=settings.API_V1_PREFIX) +app.include_router(repayments.router, prefix=settings.API_V1_PREFIX) +app.include_router(admin.router, prefix=settings.API_V1_PREFIX) +app.include_router(notifications.router, prefix=settings.API_V1_PREFIX) + + +@app.get("/health") +async def health(): + return {"status": "ok"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/models.py b/backend/app/models/models.py new file mode 100644 index 0000000..0752f30 --- /dev/null +++ b/backend/app/models/models.py @@ -0,0 +1,171 @@ +from datetime import datetime +from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Enum, Text, JSON +from sqlalchemy.orm import relationship, DeclarativeBase +import enum + + +class Base(DeclarativeBase): + pass + + +class UserRole(str, enum.Enum): + USER = "user" + ADMIN = "admin" + + +class UserStatus(str, enum.Enum): + ACTIVE = "active" + PENDING = "pending" + DISABLED = "disabled" + + +class DebtStatus(str, enum.Enum): + ACTIVE = "active" + PAID = "paid" + OVERDUE = "overdue" + CLOSED = "closed" + + +class RateType(str, enum.Enum): + ANNUAL = "annual" + MONTHLY = "monthly" + + +class RepaymentMethod(str, enum.Enum): + EQUAL_INSTALLMENT = "equal_installment" + INTEREST_FIRST = "interest_first" + LUMP_SUM = "lump_sum" + + +class PlanStatus(str, enum.Enum): + PENDING = "pending" + PAID = "paid" + OVERDUE = "overdue" + + +class NotificationType(str, enum.Enum): + REPAYMENT_DUE = "repayment_due" + REPAYMENT_OVERDUE = "repayment_overdue" + SYSTEM = "system" + ACCOUNT = "account" + + +class Group(Base): + __tablename__ = "groups" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + description = Column(Text, default="") + created_at = Column(DateTime, default=lambda: datetime.utcnow()) + + members = relationship("User", back_populates="group") + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String(255), unique=True, index=True, nullable=False) + real_email = Column(String(255), default="") + password_hash = Column(String(255), nullable=False) + nickname = Column(String(100), default="") + role = Column(String(20), default=UserRole.USER.value) + status = Column(String(20), default=UserStatus.ACTIVE.value) + group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) + created_at = Column(DateTime, default=lambda: datetime.utcnow()) + + group = relationship("Group", back_populates="members") + debts = relationship("Debt", back_populates="user") + audit_logs = relationship("AuditLog", back_populates="user") + + +class Debt(Base): + __tablename__ = "debts" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + creditor_name = Column(String(255), nullable=False) + amount = Column(Integer, nullable=False) + borrow_date = Column(DateTime, nullable=False) + interest_rate = Column(Float, nullable=False) + rate_type = Column(String(20), default=RateType.ANNUAL.value) + repayment_method = Column(String(30), nullable=False) + period_count = Column(Integer, nullable=False) + period_unit = Column(String(10), default="month") + status = Column(String(20), default=DebtStatus.ACTIVE.value) + purpose = Column(String(255), default="") + notes = Column(Text, default="") + created_at = Column(DateTime, default=lambda: datetime.utcnow()) + schedule_data = Column(JSON, default=list) + paid_periods = Column(JSON, default=list) + + user = relationship("User", back_populates="debts") + plans = relationship("RepaymentPlan", back_populates="debt", cascade="all, delete-orphan") + records = relationship("RepaymentRecord", back_populates="debt", cascade="all, delete-orphan") + + +class RepaymentPlan(Base): + __tablename__ = "repayment_plans" + + id = Column(Integer, primary_key=True, index=True) + debt_id = Column(Integer, ForeignKey("debts.id"), nullable=False) + due_date = Column(DateTime, nullable=False) + principal_due = Column(Integer, nullable=False) + interest_due = Column(Integer, nullable=False) + total_due = Column(Integer, nullable=False) + status = Column(String(20), default=PlanStatus.PENDING.value) + + debt = relationship("Debt", back_populates="plans") + records = relationship("RepaymentRecord", back_populates="plan") + + +class RepaymentRecord(Base): + __tablename__ = "repayment_records" + + id = Column(Integer, primary_key=True, index=True) + debt_id = Column(Integer, ForeignKey("debts.id"), nullable=False) + plan_id = Column(Integer, ForeignKey("repayment_plans.id"), nullable=True) + amount = Column(Integer, nullable=False) + paid_date = Column(DateTime, nullable=False) + notes = Column(Text, default="") + created_at = Column(DateTime, default=lambda: datetime.utcnow()) + + debt = relationship("Debt", back_populates="records") + plan = relationship("RepaymentPlan", back_populates="records") + + +class SystemSetting(Base): + __tablename__ = "system_settings" + + key = Column(String(100), primary_key=True) + value = Column(Text, nullable=False) + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + action = Column(String(50), nullable=False) + target_type = Column(String(50), default="") + target_id = Column(Integer, nullable=True) + details = Column(JSON, default=dict) + created_at = Column(DateTime, default=lambda: datetime.utcnow()) + + user = relationship("User", back_populates="audit_logs") + + +class Notification(Base): + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + title = Column(String(255), nullable=False) + message = Column(Text, nullable=False) + type = Column(String(30), default=NotificationType.SYSTEM.value) + is_read = Column(Integer, default=0) + related_debt_id = Column(Integer, nullable=True) + related_plan_id = Column(Integer, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.utcnow()) + + user = relationship("User", backref="notifications") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py new file mode 100644 index 0000000..e611787 --- /dev/null +++ b/backend/app/schemas/schemas.py @@ -0,0 +1,256 @@ +from pydantic import BaseModel +from datetime import datetime + + +class RegisterRequest(BaseModel): + account: str + password: str + nickname: str = "" + group_id: int | None = None + + +class LoginRequest(BaseModel): + account: str + password: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class UserProfile(BaseModel): + id: int + account: str + nickname: str + real_email: str = "" + role: str + status: str + group_id: int | None = None + group_name: str = "" + created_at: datetime + + class Config: + from_attributes = True + + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + + +class GroupCreate(BaseModel): + name: str + description: str = "" + + +class GroupResponse(BaseModel): + id: int + name: str + description: str + member_count: int = 0 + created_at: datetime + + class Config: + from_attributes = True + + +class GroupMemberAdd(BaseModel): + user_id: int + + +class LoanCreate(BaseModel): + name: str + date: str + amount: float + rate: float + periods: int + method: str + purpose: str = "" + schedule: list[dict] = [] + paid: list[int] = [] + + +class LoanResponse(BaseModel): + id: int + name: str + date: str + amount: float + rate: float + periods: int + method: str + purpose: str + schedule: list[dict] + paid: list[int] + user_id: int + owner_name: str = "" + + +class DebtCreate(BaseModel): + creditor_name: str + amount: int + borrow_date: datetime + interest_rate: float + rate_type: str = "annual" + repayment_method: str + period_count: int + period_unit: str = "month" + notes: str = "" + + +class DebtUpdate(BaseModel): + creditor_name: str | None = None + amount: int | None = None + borrow_date: datetime | None = None + interest_rate: float | None = None + rate_type: str | None = None + repayment_method: str | None = None + period_count: int | None = None + period_unit: str | None = None + notes: str | None = None + + +class DebtResponse(BaseModel): + id: int + user_id: int + creditor_name: str + amount: int + borrow_date: datetime + interest_rate: float + rate_type: str + repayment_method: str + period_count: int + period_unit: str + status: str + notes: str + created_at: datetime + total_paid: int = 0 + total_interest: int = 0 + owner_name: str = "" + + class Config: + from_attributes = True + + +class RepaymentPlanResponse(BaseModel): + id: int + debt_id: int + due_date: datetime + principal_due: int + interest_due: int + total_due: int + status: str + + class Config: + from_attributes = True + + +class RepaymentRecordCreate(BaseModel): + debt_id: int + plan_id: int | None = None + amount: int + paid_date: datetime + notes: str = "" + + +class RepaymentRecordResponse(BaseModel): + id: int + debt_id: int + plan_id: int | None + amount: int + paid_date: datetime + notes: str + created_at: datetime + + class Config: + from_attributes = True + + +class DashboardSummary(BaseModel): + total_debt: int + total_paid: int + total_remaining: int + active_count: int + overdue_count: int + paid_count: int + upcoming_payments: list[dict] + + +class AdminUserResponse(BaseModel): + id: int + account: str + nickname: str + real_email: str = "" + role: str + status: str + group_id: int | None = None + group_name: str = "" + created_at: datetime + debt_count: int = 0 + total_amount: int = 0 + + class Config: + from_attributes = True + + +class AdminDashboard(BaseModel): + total_users: int + active_debts: int + total_amount: int + overdue_count: int + rate_distribution: dict + status_distribution: dict + + +class SystemSettingUpdate(BaseModel): + value: str + + +class NotificationResponse(BaseModel): + id: int + user_id: int + title: str + message: str + type: str + is_read: int + related_debt_id: int | None = None + related_plan_id: int | None = None + created_at: datetime + + class Config: + from_attributes = True + + +class NotificationListResponse(BaseModel): + items: list[NotificationResponse] + total: int + unread: int + + +class EmailServiceConfig(BaseModel): + smtp_host: str = "" + smtp_port: int = 465 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "" + smtp_use_tls: bool = True + + +class ForgotPasswordRequest(BaseModel): + account: str + + +class VerifyResetCodeRequest(BaseModel): + account: str + code: str + + +class ResetPasswordRequest(BaseModel): + account: str + code: str + new_password: str diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/calculator.py b/backend/app/services/calculator.py new file mode 100644 index 0000000..9af0222 --- /dev/null +++ b/backend/app/services/calculator.py @@ -0,0 +1,79 @@ +from datetime import datetime, timedelta +from dateutil.relativedelta import relativedelta + + +def calculate_monthly_rate(interest_rate: float, rate_type: str) -> float: + if rate_type == "annual": + return interest_rate / 100 / 12 + return interest_rate / 100 + + +def generate_equal_installment_plan( + amount: int, interest_rate: float, rate_type: str, + period_count: int, start_date: datetime +) -> list[dict]: + mr = calculate_monthly_rate(interest_rate, rate_type) + if mr == 0: + principal_each = amount // period_count + plans = [] + for i in range(period_count): + due = start_date + relativedelta(months=i + 1) + p = principal_each if i < period_count - 1 else amount - principal_each * (period_count - 1) + plans.append({"due_date": due, "principal_due": p, "interest_due": 0, "total_due": p}) + return plans + + mp = amount * mr * (1 + mr) ** period_count / ((1 + mr) ** period_count - 1) + plans = [] + remaining = amount + for i in range(period_count): + due = start_date + relativedelta(months=i + 1) + interest = int(remaining * mr) + principal = int(mp) - interest + if i == period_count - 1: + principal = remaining + interest = int(remaining * mr) + remaining -= principal + total = principal + interest + plans.append({"due_date": due, "principal_due": principal, "interest_due": interest, "total_due": total}) + return plans + + +def generate_interest_first_plan( + amount: int, interest_rate: float, rate_type: str, + period_count: int, start_date: datetime +) -> list[dict]: + mr = calculate_monthly_rate(interest_rate, rate_type) + plans = [] + for i in range(period_count): + due = start_date + relativedelta(months=i + 1) + interest = int(amount * mr) + principal = amount if i == period_count - 1 else 0 + total = principal + interest + plans.append({"due_date": due, "principal_due": principal, "interest_due": interest, "total_due": total}) + return plans + + +def generate_lump_sum_plan( + amount: int, interest_rate: float, rate_type: str, + period_count: int, period_unit: str, start_date: datetime +) -> list[dict]: + if period_unit == "year": + total_interest = int(amount * interest_rate / 100 * period_count) + else: + total_interest = int(amount * interest_rate / 100 / 12 * period_count) + due = start_date + relativedelta(months=period_count) if period_unit == "month" else start_date + relativedelta(years=period_count) + return [{"due_date": due, "principal_due": amount, "interest_due": total_interest, "total_due": amount + total_interest}] + + +def generate_repayment_plan( + amount: int, interest_rate: float, rate_type: str, + repayment_method: str, period_count: int, period_unit: str, + start_date: datetime +) -> list[dict]: + if repayment_method == "equal_installment": + return generate_equal_installment_plan(amount, interest_rate, rate_type, period_count, start_date) + elif repayment_method == "interest_first": + return generate_interest_first_plan(amount, interest_rate, rate_type, period_count, start_date) + elif repayment_method == "lump_sum": + return generate_lump_sum_plan(amount, interest_rate, rate_type, period_count, period_unit, start_date) + return [] diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..b9c3a9e --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,233 @@ +import aiosmtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from sqlalchemy import select +from app.core.database import async_session +from app.core.config import get_settings +from app.models.models import SystemSetting + +settings = get_settings() + + +async def get_site_url() -> str: + async with async_session() as db: + result = await db.execute(select(SystemSetting).where(SystemSetting.key == "site_url")) + setting = result.scalar_one_or_none() + if setting and setting.value.strip(): + return setting.value.strip().rstrip("/") + return "http://localhost:806" + + +async def get_smtp_config() -> dict: + async with async_session() as db: + result = await db.execute( + select(SystemSetting).where( + SystemSetting.key.in_([ + "smtp_host", "smtp_port", "smtp_user", + "smtp_password", "smtp_from", "smtp_use_tls", + ]) + ) + ) + rows = {r.key: r.value for r in result.scalars().all()} + + if rows.get("smtp_host") and rows.get("smtp_user"): + return { + "host": rows.get("smtp_host", ""), + "port": int(rows.get("smtp_port", "465")), + "user": rows.get("smtp_user", ""), + "password": rows.get("smtp_password", ""), + "from_addr": rows.get("smtp_from") or rows.get("smtp_user", ""), + "use_tls": rows.get("smtp_use_tls", "true") == "true", + } + return { + "host": settings.SMTP_HOST, + "port": settings.SMTP_PORT, + "user": settings.SMTP_USER, + "password": settings.SMTP_PASSWORD, + "from_addr": settings.SMTP_FROM or settings.SMTP_USER, + "use_tls": settings.SMTP_USE_TLS, + } + + +async def send_email(to_email: str, subject: str, body: str) -> bool: + smtp = await get_smtp_config() + if not smtp["host"] or not smtp["user"]: + return False + + msg = MIMEMultipart("alternative") + msg["From"] = smtp["from_addr"] + msg["To"] = to_email + msg["Subject"] = subject + msg.attach(MIMEText(body, "html", "utf-8")) + + try: + await aiosmtplib.send( + msg, + hostname=smtp["host"], + port=smtp["port"], + username=smtp["user"], + password=smtp["password"], + use_tls=smtp["use_tls"], + ) + return True + except Exception: + return False + + +def build_repayment_reminder_html( + nickname: str, creditor: str, amount_yuan: float, + due_date: str, days_left: int, is_overdue: bool = False +) -> str: + status_text = f"已逾期 {abs(days_left)} 天" if is_overdue else f"还有 {days_left} 天到期" + color = "#ff3b30" if is_overdue else "#0071e3" + title = "逾期提醒" if is_overdue else "还款提醒" + return f""" +
+

{title}

+

Hi {nickname},

+

您有一笔借款即将到期:

+
+

债权人:{creditor}

+

金额:¥{amount_yuan:,.2f}

+

到期日:{due_date}

+

{status_text}

+
+

—— 债务管理系统

+
+ """ + + +_LOGIN_ALERT_CONFIG = { + 3: { + "level": "注意", + "color": "#ff9500", + "bg": "rgba(255,149,0,.06)", + "border": "rgba(255,149,0,.15)", + "icon": "⚠️", + "desc": "您的账号出现了异常登录尝试。", + }, + 5: { + "level": "警告", + "color": "#ff6b35", + "bg": "rgba(255,107,53,.06)", + "border": "rgba(255,107,53,.15)", + "icon": "🔶", + "desc": "您的账号正遭受多次暴力破解尝试,若非本人操作请立即修改密码。", + }, + 10: { + "level": "严重", + "color": "#ff3b30", + "bg": "rgba(255,59,48,.08)", + "border": "rgba(255,59,48,.18)", + "icon": "🔴", + "desc": "您的账号已被暴力破解锁定 5 分钟。若非本人操作,请立即修改密码并检查账户安全。", + }, +} + + +def _build_login_alert_html(nickname: str, count: int, threshold: int, ip: str, site_url: str, token: str, uid: int, ttl_minutes: int = 5) -> str: + cfg = _LOGIN_ALERT_CONFIG[threshold] + change_pwd_url = f"{site_url}/reset-password.html?token={token}&uid={uid}" + return f""" +
+

{cfg['icon']} 登录安全{cfg['level']}

+

Hi {nickname},

+

{cfg['desc']}

+
+

异常登录次数:{count} 次

+

触发来源 IP:{ip}

+

时间:{__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

+
+
+ 修改密码 +
+

此链接 {ttl_minutes} 分钟内有效,仅可使用一次。

+

如非本人操作,请忽略此邮件并尽快检查账户安全。

+

—— 债务管理系统

+
+ """ + + +async def send_login_alert_email(user, count: int, threshold: int, ip: str): + if not user.real_email: + return + import secrets + from datetime import datetime, timedelta + + token = secrets.token_urlsafe(32) + ttl_minutes = 5 + expires_at = (datetime.utcnow() + timedelta(minutes=ttl_minutes)).isoformat() + key = f"pwd_reset:{user.id}" + value = f"{token}|{expires_at}" + + async with async_session() as db: + result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) + setting = result.scalar_one_or_none() + if setting: + setting.value = value + else: + db.add(SystemSetting(key=key, value=value)) + await db.commit() + + cfg = _LOGIN_ALERT_CONFIG[threshold] + site_url = await get_site_url() + html = _build_login_alert_html(user.nickname or user.email, count, threshold, ip, site_url, token, user.id, ttl_minutes) + subject = f"【{cfg['level']}】账号安全提醒 - 异常登录 {count} 次" + await send_email(user.real_email, subject, html) + + +def _build_password_changed_html(nickname: str, action: str, operator: str = "") -> str: + from datetime import datetime + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return f""" +
+

🔐 密码{action}通知

+

Hi {nickname},

+

您的账户密码已被{action}。

+
+

操作类型:密码{action}

+

操作时间:{now}

+ {"

操作人:" + operator + "

" if operator else ""} +
+

如非本人操作,请立即联系管理员并修改密码。

+

—— 债务管理系统

+
+ """ + + +async def send_password_changed_email(user, action: str, operator: str = ""): + if not user.real_email: + return + html = _build_password_changed_html(user.nickname or user.email, action, operator) + subject = f"【安全通知】您的密码已被{action}" + await send_email(user.real_email, subject, html) + + +def _build_password_reset_link_html(nickname: str, reset_url: str, ttl_hours: int, operator: str) -> str: + from datetime import datetime + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return f""" +
+

🔐 密码重置链接

+

Hi {nickname},

+

管理员已为您发起密码重置,请点击下方按钮设置新密码。

+
+

操作人:{operator}

+

发送时间:{now}

+

有效期:{ttl_hours} 小时内有效

+
+
+ 重置密码 +
+

如非本人操作,请忽略此邮件。

+

—— 债务管理系统

+
+ """ + + +async def send_password_reset_link_email(user, reset_url: str, ttl_hours: int, operator: str = ""): + if not user.real_email: + return + html = _build_password_reset_link_html(user.nickname or user.email, reset_url, ttl_hours, operator) + subject = "【密码重置】管理员为您重置了密码" + await send_email(user.real_email, subject, html) diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..9506660 --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,41 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.models import Notification, NotificationType, User + + +async def create_notification( + db: AsyncSession, + user_id: int, + title: str, + message: str, + notification_type: str = NotificationType.SYSTEM.value, + related_debt_id: int | None = None, + related_plan_id: int | None = None, +) -> Notification: + n = Notification( + user_id=user_id, + title=title, + message=message, + type=notification_type, + related_debt_id=related_debt_id, + related_plan_id=related_plan_id, + ) + db.add(n) + return n + + +async def create_system_broadcast( + db: AsyncSession, + title: str, + message: str, +) -> int: + users = await db.execute(select(User).where(User.status == "active")) + count = 0 + for user in users.scalars().all(): + db.add(Notification( + user_id=user.id, title=title, message=message, + type=NotificationType.SYSTEM.value, + )) + count += 1 + await db.commit() + return count diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py new file mode 100644 index 0000000..e94065c --- /dev/null +++ b/backend/app/services/scheduler.py @@ -0,0 +1,105 @@ +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from sqlalchemy import select, and_ +from datetime import datetime, timedelta +from app.core.database import async_session +from app.core.config import get_settings +from app.models.models import ( + Debt, Notification, NotificationType, User, DebtStatus +) +from app.services.notification_service import create_notification +from app.services.email_service import send_email, build_repayment_reminder_html + +settings = get_settings() +scheduler = AsyncIOScheduler() + + +async def check_upcoming_repayments(): + async with async_session() as db: + now = datetime.utcnow() + reminder_days = settings.REMINDER_DAYS_BEFORE + check_window = now + timedelta(days=reminder_days) + + result = await db.execute( + select(Debt).where( + and_( + Debt.status == DebtStatus.ACTIVE.value, + Debt.schedule_data.isnot(None), + ) + ) + ) + debts = result.scalars().all() + + for debt in debts: + schedule = debt.schedule_data or [] + paid_periods = debt.paid_periods or [] + user_result = await db.execute(select(User).where(User.id == debt.user_id)) + user = user_result.scalar_one_or_none() + if not user: + continue + + for item in schedule: + p = item.get("p") + if p in paid_periods: + continue + + try: + due_date = datetime.strptime(item["date"], "%Y-%m-%d") + except (ValueError, KeyError): + continue + + if due_date > check_window: + continue + + is_overdue = due_date < now + amount_yuan = item.get("pay", 0) + days_left = (due_date - now).days + + if is_overdue: + ntype = NotificationType.REPAYMENT_OVERDUE.value + title = f"逾期提醒:{debt.creditor_name} 第{p}期还款已逾期" + msg = f"您在 {debt.creditor_name} 的第{p}期 ¥{amount_yuan:,.2f} 还款已于 {item['date']} 逾期,请尽快处理。" + else: + ntype = NotificationType.REPAYMENT_DUE.value + title = f"还款提醒:{debt.creditor_name} 第{p}期将在 {days_left} 天后到期" + msg = f"您在 {debt.creditor_name} 的第{p}期 ¥{amount_yuan:,.2f} 将于 {item['date']} 到期,请按时还款。" + + existing = await db.execute( + select(Notification).where( + and_( + Notification.user_id == user.id, + Notification.related_debt_id == debt.id, + Notification.title == title, + ) + ) + ) + if existing.scalar_one_or_none(): + continue + + await create_notification( + db, user.id, title, msg, ntype, + related_debt_id=debt.id, + ) + + if user.email and "@" in user.email: + html = build_repayment_reminder_html( + user.nickname or user.email, + debt.creditor_name, + amount_yuan, + item["date"], + days_left, + is_overdue, + ) + await send_email(user.email, title, html) + + await db.commit() + + +def start_scheduler(): + scheduler.add_job( + check_upcoming_repayments, + "interval", + minutes=settings.NOTIFICATION_CHECK_INTERVAL_MINUTES, + id="repayment_check", + replace_existing=True, + ) + scheduler.start() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9e0284a --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,16 @@ +fastapi==0.109.2 +uvicorn[standard]==0.27.1 +sqlalchemy==2.0.27 +asyncpg==0.29.0 +alembic==1.13.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.1.3 +python-multipart==0.0.9 +pydantic==2.6.1 +pydantic-settings==2.1.0 +redis==5.0.1 +httpx==0.26.0 +python-dateutil==2.8.2 +apscheduler==3.10.4 +aiosmtplib==3.0.1 diff --git a/data.sql b/data.sql new file mode 100644 index 0000000..da9ed82 --- /dev/null +++ b/data.sql @@ -0,0 +1,603 @@ +-- +-- PostgreSQL database dump +-- + +\restrict RdcQlBPEqYuP88EyiWHtXP8VF7CTE1R3DOjX7UMme8jFrcKXvNbaZeFWWNkzt7J + +-- Dumped from database version 15.18 +-- Dumped by pg_dump version 15.18 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: audit_logs; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.audit_logs ( + id integer NOT NULL, + user_id integer, + action character varying(50) NOT NULL, + target_type character varying(50), + target_id integer, + details json, + created_at timestamp without time zone +); + + +ALTER TABLE public.audit_logs OWNER TO postgres; + +-- +-- Name: audit_logs_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres +-- + +CREATE SEQUENCE public.audit_logs_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.audit_logs_id_seq OWNER TO postgres; + +-- +-- Name: audit_logs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres +-- + +ALTER SEQUENCE public.audit_logs_id_seq OWNED BY public.audit_logs.id; + + +-- +-- Name: debts; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.debts ( + id integer NOT NULL, + user_id integer NOT NULL, + creditor_name character varying(255) NOT NULL, + amount integer NOT NULL, + borrow_date timestamp without time zone NOT NULL, + interest_rate double precision NOT NULL, + rate_type character varying(20), + repayment_method character varying(30) NOT NULL, + period_count integer NOT NULL, + period_unit character varying(10), + status character varying(20), + purpose character varying(255), + notes text, + created_at timestamp without time zone, + schedule_data json, + paid_periods json +); + + +ALTER TABLE public.debts OWNER TO postgres; + +-- +-- Name: debts_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres +-- + +CREATE SEQUENCE public.debts_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.debts_id_seq OWNER TO postgres; + +-- +-- Name: debts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres +-- + +ALTER SEQUENCE public.debts_id_seq OWNED BY public.debts.id; + + +-- +-- Name: groups; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.groups ( + id integer NOT NULL, + name character varying(100) NOT NULL, + description text, + created_at timestamp without time zone +); + + +ALTER TABLE public.groups OWNER TO postgres; + +-- +-- Name: groups_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres +-- + +CREATE SEQUENCE public.groups_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.groups_id_seq OWNER TO postgres; + +-- +-- Name: groups_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres +-- + +ALTER SEQUENCE public.groups_id_seq OWNED BY public.groups.id; + + +-- +-- Name: repayment_plans; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.repayment_plans ( + id integer NOT NULL, + debt_id integer NOT NULL, + due_date timestamp without time zone NOT NULL, + principal_due integer NOT NULL, + interest_due integer NOT NULL, + total_due integer NOT NULL, + status character varying(20) +); + + +ALTER TABLE public.repayment_plans OWNER TO postgres; + +-- +-- Name: repayment_plans_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres +-- + +CREATE SEQUENCE public.repayment_plans_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.repayment_plans_id_seq OWNER TO postgres; + +-- +-- Name: repayment_plans_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres +-- + +ALTER SEQUENCE public.repayment_plans_id_seq OWNED BY public.repayment_plans.id; + + +-- +-- Name: repayment_records; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.repayment_records ( + id integer NOT NULL, + debt_id integer NOT NULL, + plan_id integer, + amount integer NOT NULL, + paid_date timestamp without time zone NOT NULL, + notes text, + created_at timestamp without time zone +); + + +ALTER TABLE public.repayment_records OWNER TO postgres; + +-- +-- Name: repayment_records_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres +-- + +CREATE SEQUENCE public.repayment_records_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.repayment_records_id_seq OWNER TO postgres; + +-- +-- Name: repayment_records_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres +-- + +ALTER SEQUENCE public.repayment_records_id_seq OWNED BY public.repayment_records.id; + + +-- +-- Name: system_settings; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.system_settings ( + key character varying(100) NOT NULL, + value text NOT NULL +); + + +ALTER TABLE public.system_settings OWNER TO postgres; + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.users ( + id integer NOT NULL, + email character varying(255) NOT NULL, + password_hash character varying(255) NOT NULL, + nickname character varying(100), + role character varying(20), + status character varying(20), + group_id integer, + created_at timestamp without time zone +); + + +ALTER TABLE public.users OWNER TO postgres; + +-- +-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres +-- + +CREATE SEQUENCE public.users_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.users_id_seq OWNER TO postgres; + +-- +-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres +-- + +ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id; + + +-- +-- Name: audit_logs id; Type: DEFAULT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.audit_logs ALTER COLUMN id SET DEFAULT nextval('public.audit_logs_id_seq'::regclass); + + +-- +-- Name: debts id; Type: DEFAULT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.debts ALTER COLUMN id SET DEFAULT nextval('public.debts_id_seq'::regclass); + + +-- +-- Name: groups id; Type: DEFAULT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.groups ALTER COLUMN id SET DEFAULT nextval('public.groups_id_seq'::regclass); + + +-- +-- Name: repayment_plans id; Type: DEFAULT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.repayment_plans ALTER COLUMN id SET DEFAULT nextval('public.repayment_plans_id_seq'::regclass); + + +-- +-- Name: repayment_records id; Type: DEFAULT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.repayment_records ALTER COLUMN id SET DEFAULT nextval('public.repayment_records_id_seq'::regclass); + + +-- +-- Name: users id; Type: DEFAULT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass); + + +-- +-- Data for Name: audit_logs; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.audit_logs (id, user_id, action, target_type, target_id, details, created_at) FROM stdin; +\. + + +-- +-- Data for Name: debts; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.debts (id, user_id, creditor_name, amount, borrow_date, interest_rate, rate_type, repayment_method, period_count, period_unit, status, purpose, notes, created_at, schedule_data, paid_periods) FROM stdin; +61 1 管理员的单 5000000 2026-01-01 00:00:00 8 annual equal_installment 6 month active 2026-06-25 15:11:21.412465 [] [] +54 5 借呗 1000000 2026-04-14 00:00:00 14.4 annual equal_installment 24 month active 2026-06-24 14:59:49.57332 [{"p": 1, "date": "2026-05-14", "pay": 482.02, "pr": 362.02, "it": 120, "rp": 9637.98}, {"p": 2, "date": "2026-06-14", "pay": 482.02, "pr": 366.36, "it": 115.66, "rp": 9271.61}, {"p": 3, "date": "2026-07-14", "pay": 482.02, "pr": 370.76, "it": 111.26, "rp": 8900.85}, {"p": 4, "date": "2026-08-14", "pay": 482.02, "pr": 375.21, "it": 106.81, "rp": 8525.64}, {"p": 5, "date": "2026-09-14", "pay": 482.02, "pr": 379.71, "it": 102.31, "rp": 8145.93}, {"p": 6, "date": "2026-10-14", "pay": 482.02, "pr": 384.27, "it": 97.75, "rp": 7761.66}, {"p": 7, "date": "2026-11-14", "pay": 482.02, "pr": 388.88, "it": 93.14, "rp": 7372.78}, {"p": 8, "date": "2026-12-14", "pay": 482.02, "pr": 393.55, "it": 88.47, "rp": 6979.23}, {"p": 9, "date": "2027-01-14", "pay": 482.02, "pr": 398.27, "it": 83.75, "rp": 6580.96}, {"p": 10, "date": "2027-02-14", "pay": 482.02, "pr": 403.05, "it": 78.97, "rp": 6177.91}, {"p": 11, "date": "2027-03-14", "pay": 482.02, "pr": 407.89, "it": 74.13, "rp": 5770.03}, {"p": 12, "date": "2027-04-14", "pay": 482.02, "pr": 412.78, "it": 69.24, "rp": 5357.25}, {"p": 13, "date": "2027-05-14", "pay": 482.02, "pr": 417.73, "it": 64.29, "rp": 4939.51}, {"p": 14, "date": "2027-06-14", "pay": 482.02, "pr": 422.75, "it": 59.27, "rp": 4516.77}, {"p": 15, "date": "2027-07-14", "pay": 482.02, "pr": 427.82, "it": 54.2, "rp": 4088.95}, {"p": 16, "date": "2027-08-14", "pay": 482.02, "pr": 432.95, "it": 49.07, "rp": 3655.99}, {"p": 17, "date": "2027-09-14", "pay": 482.02, "pr": 438.15, "it": 43.87, "rp": 3217.85}, {"p": 18, "date": "2027-10-14", "pay": 482.02, "pr": 443.41, "it": 38.61, "rp": 2774.44}, {"p": 19, "date": "2027-11-14", "pay": 482.02, "pr": 448.73, "it": 33.29, "rp": 2325.71}, {"p": 20, "date": "2027-12-14", "pay": 482.02, "pr": 454.11, "it": 27.91, "rp": 1871.6}, {"p": 21, "date": "2028-01-14", "pay": 482.02, "pr": 459.56, "it": 22.46, "rp": 1412.04}, {"p": 22, "date": "2028-02-14", "pay": 482.02, "pr": 465.08, "it": 16.94, "rp": 946.96}, {"p": 23, "date": "2028-03-14", "pay": 482.02, "pr": 470.66, "it": 11.36, "rp": 476.3}, {"p": 24, "date": "2028-04-14", "pay": 482.02, "pr": 476.3, "it": 5.72, "rp": 0}] [1, 2, 7] +36 4 国庆 8000000 2025-12-22 00:00:00 14.45 annual equal_installment 12 month active 2026-06-24 13:50:38.299868 [{"p": 1, "date": "2026-01-22", "pay": 7199.92, "pr": 6236.59, "it": 963.33, "rp": 73763.41}, {"p": 2, "date": "2026-02-22", "pay": 7199.92, "pr": 6311.68, "it": 888.23, "rp": 67451.73}, {"p": 3, "date": "2026-03-22", "pay": 7199.92, "pr": 6387.69, "it": 812.23, "rp": 61064.04}, {"p": 4, "date": "2026-04-22", "pay": 7199.92, "pr": 6464.61, "it": 735.31, "rp": 54599.44}, {"p": 5, "date": "2026-05-22", "pay": 7199.92, "pr": 6542.45, "it": 657.47, "rp": 48056.98}, {"p": 6, "date": "2026-06-22", "pay": 7199.92, "pr": 6621.23, "it": 578.69, "rp": 41435.75}, {"p": 7, "date": "2026-07-22", "pay": 7199.92, "pr": 6700.96, "it": 498.96, "rp": 34734.79}, {"p": 8, "date": "2026-08-22", "pay": 7199.92, "pr": 6781.65, "it": 418.26, "rp": 27953.13}, {"p": 9, "date": "2026-09-22", "pay": 7199.92, "pr": 6863.32, "it": 336.6, "rp": 21089.82}, {"p": 10, "date": "2026-10-22", "pay": 7199.92, "pr": 6945.96, "it": 253.96, "rp": 14143.86}, {"p": 11, "date": "2026-11-22", "pay": 7199.92, "pr": 7029.6, "it": 170.32, "rp": 7114.25}, {"p": 12, "date": "2026-12-22", "pay": 7199.92, "pr": 7114.25, "it": 85.67, "rp": 0}] [1, 2, 3, 4, 5] +39 4 国庆 3000000 2026-02-14 00:00:00 4 annual lump_sum 12 month active 2026-06-24 13:50:38.336453 [{"p": 1, "date": "2026-03-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 2, "date": "2026-04-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 3, "date": "2026-05-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 4, "date": "2026-06-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 5, "date": "2026-07-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 6, "date": "2026-08-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 7, "date": "2026-09-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 8, "date": "2026-10-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 9, "date": "2026-11-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 10, "date": "2026-12-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 11, "date": "2027-01-14", "pay": 100, "pr": 0, "it": 100, "rp": 30000}, {"p": 12, "date": "2027-02-14", "pay": 30100, "pr": 30000, "it": 100, "rp": 0}] [1, 2, 3] +33 4 广发信用卡 3600000 2025-10-19 00:00:00 9.2 annual equal_installment 24 month active 2026-06-24 13:50:38.210885 [{"p": 1, "date": "2025-11-19", "pay": 1647.96, "pr": 1371.96, "it": 276, "rp": 34628.04}, {"p": 2, "date": "2025-12-19", "pay": 1647.96, "pr": 1382.47, "it": 265.48, "rp": 33245.57}, {"p": 3, "date": "2026-01-19", "pay": 1647.96, "pr": 1393.07, "it": 254.88, "rp": 31852.5}, {"p": 4, "date": "2026-02-19", "pay": 1647.96, "pr": 1403.75, "it": 244.2, "rp": 30448.74}, {"p": 5, "date": "2026-03-19", "pay": 1647.96, "pr": 1414.52, "it": 233.44, "rp": 29034.23}, {"p": 6, "date": "2026-04-19", "pay": 1647.96, "pr": 1425.36, "it": 222.6, "rp": 27608.87}, {"p": 7, "date": "2026-05-19", "pay": 1647.96, "pr": 1436.29, "it": 211.67, "rp": 26172.58}, {"p": 8, "date": "2026-06-19", "pay": 1647.96, "pr": 1447.3, "it": 200.66, "rp": 24725.28}, {"p": 9, "date": "2026-07-19", "pay": 1647.96, "pr": 1458.4, "it": 189.56, "rp": 23266.88}, {"p": 10, "date": "2026-08-19", "pay": 1647.96, "pr": 1469.58, "it": 178.38, "rp": 21797.31}, {"p": 11, "date": "2026-09-19", "pay": 1647.96, "pr": 1480.84, "it": 167.11, "rp": 20316.46}, {"p": 12, "date": "2026-10-19", "pay": 1647.96, "pr": 1492.2, "it": 155.76, "rp": 18824.27}, {"p": 13, "date": "2026-11-19", "pay": 1647.96, "pr": 1503.64, "it": 144.32, "rp": 17320.63}, {"p": 14, "date": "2026-12-19", "pay": 1647.96, "pr": 1515.16, "it": 132.79, "rp": 15805.46}, {"p": 15, "date": "2027-01-19", "pay": 1647.96, "pr": 1526.78, "it": 121.18, "rp": 14278.68}, {"p": 16, "date": "2027-02-19", "pay": 1647.96, "pr": 1538.49, "it": 109.47, "rp": 12740.2}, {"p": 17, "date": "2027-03-19", "pay": 1647.96, "pr": 1550.28, "it": 97.67, "rp": 11189.92}, {"p": 18, "date": "2027-04-19", "pay": 1647.96, "pr": 1562.17, "it": 85.79, "rp": 9627.75}, {"p": 19, "date": "2027-05-19", "pay": 1647.96, "pr": 1574.14, "it": 73.81, "rp": 8053.6}, {"p": 20, "date": "2027-06-19", "pay": 1647.96, "pr": 1586.21, "it": 61.74, "rp": 6467.39}, {"p": 21, "date": "2027-07-19", "pay": 1647.96, "pr": 1598.37, "it": 49.58, "rp": 4869.02}, {"p": 22, "date": "2027-08-19", "pay": 1647.96, "pr": 1610.63, "it": 37.33, "rp": 3258.39}, {"p": 23, "date": "2027-09-19", "pay": 1647.96, "pr": 1622.98, "it": 24.98, "rp": 1635.42}, {"p": 24, "date": "2027-10-19", "pay": 1647.96, "pr": 1635.42, "it": 12.54, "rp": 0}] [1, 2, 3, 4, 5, 6, 7] +40 4 GK 2400000 2026-03-15 00:00:00 0 annual lump_sum 1 month active 2026-06-24 13:50:38.350761 [{"p": 1, "date": "2026-04-15", "pay": 24000, "pr": 24000, "it": 0, "rp": 0}] [1] +31 4 GK 8600000 2025-06-14 00:00:00 5.7 annual equal_installment 24 month active 2026-06-24 13:50:38.130577 [{"p": 1, "date": "2025-07-14", "pay": 3799.96, "pr": 3391.46, "it": 408.5, "rp": 82608.54}, {"p": 2, "date": "2025-08-14", "pay": 3799.96, "pr": 3407.57, "it": 392.39, "rp": 79200.98}, {"p": 3, "date": "2025-09-14", "pay": 3799.96, "pr": 3423.75, "it": 376.2, "rp": 75777.22}, {"p": 4, "date": "2025-10-14", "pay": 3799.96, "pr": 3440.02, "it": 359.94, "rp": 72337.21}, {"p": 5, "date": "2025-11-14", "pay": 3799.96, "pr": 3456.36, "it": 343.6, "rp": 68880.85}, {"p": 6, "date": "2025-12-14", "pay": 3799.96, "pr": 3472.77, "it": 327.18, "rp": 65408.08}, {"p": 7, "date": "2026-01-14", "pay": 3799.96, "pr": 3489.27, "it": 310.69, "rp": 61918.81}, {"p": 8, "date": "2026-02-14", "pay": 3799.96, "pr": 3505.84, "it": 294.11, "rp": 58412.96}, {"p": 9, "date": "2026-03-14", "pay": 3799.96, "pr": 3522.5, "it": 277.46, "rp": 54890.47}, {"p": 10, "date": "2026-04-14", "pay": 3799.96, "pr": 3539.23, "it": 260.73, "rp": 51351.24}, {"p": 11, "date": "2026-05-14", "pay": 3799.96, "pr": 3556.04, "it": 243.92, "rp": 47795.2}, {"p": 12, "date": "2026-06-14", "pay": 3799.96, "pr": 3572.93, "it": 227.03, "rp": 44222.27}, {"p": 13, "date": "2026-07-14", "pay": 3799.96, "pr": 3589.9, "it": 210.06, "rp": 40632.37}, {"p": 14, "date": "2026-08-14", "pay": 3799.96, "pr": 3606.95, "it": 193, "rp": 37025.41}, {"p": 15, "date": "2026-09-14", "pay": 3799.96, "pr": 3624.09, "it": 175.87, "rp": 33401.33}, {"p": 16, "date": "2026-10-14", "pay": 3799.96, "pr": 3641.3, "it": 158.66, "rp": 29760.02}, {"p": 17, "date": "2026-11-14", "pay": 3799.96, "pr": 3658.6, "it": 141.36, "rp": 26101.43}, {"p": 18, "date": "2026-12-14", "pay": 3799.96, "pr": 3675.98, "it": 123.98, "rp": 22425.45}, {"p": 19, "date": "2027-01-14", "pay": 3799.96, "pr": 3693.44, "it": 106.52, "rp": 18732.01}, {"p": 20, "date": "2027-02-14", "pay": 3799.96, "pr": 3710.98, "it": 88.98, "rp": 15021.03}, {"p": 21, "date": "2027-03-14", "pay": 3799.96, "pr": 3728.61, "it": 71.35, "rp": 11292.43}, {"p": 22, "date": "2027-04-14", "pay": 3799.96, "pr": 3746.32, "it": 53.64, "rp": 7546.11}, {"p": 23, "date": "2027-05-14", "pay": 3799.96, "pr": 3764.11, "it": 35.84, "rp": 3781.99}, {"p": 24, "date": "2027-06-14", "pay": 3799.96, "pr": 3781.99, "it": 17.96, "rp": 0}] [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] +34 4 广发信用卡 3500000 2025-11-03 00:00:00 16.43 annual equal_installment 24 month active 2026-06-24 13:50:38.248821 [{"p": 1, "date": "2025-12-03", "pay": 1720.91, "pr": 1241.7, "it": 479.21, "rp": 33758.3}, {"p": 2, "date": "2026-01-03", "pay": 1720.91, "pr": 1258.7, "it": 462.21, "rp": 32499.6}, {"p": 3, "date": "2026-02-03", "pay": 1720.91, "pr": 1275.93, "it": 444.97, "rp": 31223.66}, {"p": 4, "date": "2026-03-03", "pay": 1720.91, "pr": 1293.4, "it": 427.5, "rp": 29930.26}, {"p": 5, "date": "2026-04-03", "pay": 1720.91, "pr": 1311.11, "it": 409.8, "rp": 28619.15}, {"p": 6, "date": "2026-05-03", "pay": 1720.91, "pr": 1329.06, "it": 391.84, "rp": 27290.08}, {"p": 7, "date": "2026-06-03", "pay": 1720.91, "pr": 1347.26, "it": 373.65, "rp": 25942.82}, {"p": 8, "date": "2026-07-03", "pay": 1720.91, "pr": 1365.71, "it": 355.2, "rp": 24577.11}, {"p": 9, "date": "2026-08-03", "pay": 1720.91, "pr": 1384.41, "it": 336.5, "rp": 23192.7}, {"p": 10, "date": "2026-09-03", "pay": 1720.91, "pr": 1403.36, "it": 317.55, "rp": 21789.34}, {"p": 11, "date": "2026-10-03", "pay": 1720.91, "pr": 1422.58, "it": 298.33, "rp": 20366.77}, {"p": 12, "date": "2026-11-03", "pay": 1720.91, "pr": 1442.05, "it": 278.85, "rp": 18924.71}, {"p": 13, "date": "2026-12-03", "pay": 1720.91, "pr": 1461.8, "it": 259.11, "rp": 17462.91}, {"p": 14, "date": "2027-01-03", "pay": 1720.91, "pr": 1481.81, "it": 239.1, "rp": 15981.1}, {"p": 15, "date": "2027-02-03", "pay": 1720.91, "pr": 1502.1, "it": 218.81, "rp": 14479}, {"p": 16, "date": "2027-03-03", "pay": 1720.91, "pr": 1522.67, "it": 198.24, "rp": 12956.33}, {"p": 17, "date": "2027-04-03", "pay": 1720.91, "pr": 1543.51, "it": 177.39, "rp": 11412.82}, {"p": 18, "date": "2027-05-03", "pay": 1720.91, "pr": 1564.65, "it": 156.26, "rp": 9848.17}, {"p": 19, "date": "2027-06-03", "pay": 1720.91, "pr": 1586.07, "it": 134.84, "rp": 8262.1}, {"p": 20, "date": "2027-07-03", "pay": 1720.91, "pr": 1607.79, "it": 113.12, "rp": 6654.31}, {"p": 21, "date": "2027-08-03", "pay": 1720.91, "pr": 1629.8, "it": 91.11, "rp": 5024.51}, {"p": 22, "date": "2027-09-03", "pay": 1720.91, "pr": 1652.11, "it": 68.79, "rp": 3372.4}, {"p": 23, "date": "2027-10-03", "pay": 1720.91, "pr": 1674.73, "it": 46.17, "rp": 1697.66}, {"p": 24, "date": "2027-11-03", "pay": 1720.91, "pr": 1697.66, "it": 23.24, "rp": 0}] [1, 2, 3, 4, 5, 6] +37 4 国庆 3000000 2025-11-02 00:00:00 28.33 annual equal_installment 12 month active 2026-06-24 13:50:38.312429 [{"p": 1, "date": "2025-12-02", "pay": 2900.03, "pr": 2191.78, "it": 708.25, "rp": 27808.22}, {"p": 2, "date": "2026-01-02", "pay": 2900.03, "pr": 2243.52, "it": 656.51, "rp": 25564.71}, {"p": 3, "date": "2026-02-02", "pay": 2900.03, "pr": 2296.49, "it": 603.54, "rp": 23268.22}, {"p": 4, "date": "2026-03-02", "pay": 2900.03, "pr": 2350.7, "it": 549.32, "rp": 20917.52}, {"p": 5, "date": "2026-04-02", "pay": 2900.03, "pr": 2406.2, "it": 493.83, "rp": 18511.32}, {"p": 6, "date": "2026-05-02", "pay": 2900.03, "pr": 2463, "it": 437.02, "rp": 16048.32}, {"p": 7, "date": "2026-06-02", "pay": 2900.03, "pr": 2521.15, "it": 378.87, "rp": 13527.17}, {"p": 8, "date": "2026-07-02", "pay": 2900.03, "pr": 2580.67, "it": 319.35, "rp": 10946.49}, {"p": 9, "date": "2026-08-02", "pay": 2900.03, "pr": 2641.6, "it": 258.43, "rp": 8304.9}, {"p": 10, "date": "2026-09-02", "pay": 2900.03, "pr": 2703.96, "it": 196.06, "rp": 5600.94}, {"p": 11, "date": "2026-10-02", "pay": 2900.03, "pr": 2767.8, "it": 132.23, "rp": 2833.14}, {"p": 12, "date": "2026-11-02", "pay": 2900.03, "pr": 2833.14, "it": 66.89, "rp": 0}] [1, 2, 3, 4, 5, 6] +32 4 信用卡 6000000 2025-09-25 00:00:00 17.45 annual equal_installment 6 month active 2026-06-24 13:50:38.182766 [{"p": 1, "date": "2025-10-25", "pay": 10515.08, "pr": 9642.58, "it": 872.5, "rp": 50357.42}, {"p": 2, "date": "2025-11-25", "pay": 10515.08, "pr": 9782.8, "it": 732.28, "rp": 40574.62}, {"p": 3, "date": "2025-12-25", "pay": 10515.08, "pr": 9925.06, "it": 590.02, "rp": 30649.56}, {"p": 4, "date": "2026-01-25", "pay": 10515.08, "pr": 10069.39, "it": 445.7, "rp": 20580.18}, {"p": 5, "date": "2026-02-25", "pay": 10515.08, "pr": 10215.81, "it": 299.27, "rp": 10364.37}, {"p": 6, "date": "2026-03-25", "pay": 10515.08, "pr": 10364.37, "it": 150.72, "rp": 0}] [1, 2, 3, 4, 5, 6] +53 5 借呗 2000000 2026-05-14 00:00:00 12.24 annual equal_installment 24 month active 2026-06-24 14:59:49.562775 [{"p": 1, "date": "2026-06-14", "pay": 943.71, "pr": 739.71, "it": 204, "rp": 19260.29}, {"p": 2, "date": "2026-07-14", "pay": 943.71, "pr": 747.26, "it": 196.45, "rp": 18513.03}, {"p": 3, "date": "2026-08-14", "pay": 943.71, "pr": 754.88, "it": 188.83, "rp": 17758.15}, {"p": 4, "date": "2026-09-14", "pay": 943.71, "pr": 762.58, "it": 181.13, "rp": 16995.57}, {"p": 5, "date": "2026-10-14", "pay": 943.71, "pr": 770.36, "it": 173.35, "rp": 16225.21}, {"p": 6, "date": "2026-11-14", "pay": 943.71, "pr": 778.22, "it": 165.5, "rp": 15447}, {"p": 7, "date": "2026-12-14", "pay": 943.71, "pr": 786.15, "it": 157.56, "rp": 14660.84}, {"p": 8, "date": "2027-01-14", "pay": 943.71, "pr": 794.17, "it": 149.54, "rp": 13866.67}, {"p": 9, "date": "2027-02-14", "pay": 943.71, "pr": 802.27, "it": 141.44, "rp": 13064.4}, {"p": 10, "date": "2027-03-14", "pay": 943.71, "pr": 810.46, "it": 133.26, "rp": 12253.94}, {"p": 11, "date": "2027-04-14", "pay": 943.71, "pr": 818.72, "it": 124.99, "rp": 11435.22}, {"p": 12, "date": "2027-05-14", "pay": 943.71, "pr": 827.07, "it": 116.64, "rp": 10608.15}, {"p": 13, "date": "2027-06-14", "pay": 943.71, "pr": 835.51, "it": 108.2, "rp": 9772.64}, {"p": 14, "date": "2027-07-14", "pay": 943.71, "pr": 844.03, "it": 99.68, "rp": 8928.61}, {"p": 15, "date": "2027-08-14", "pay": 943.71, "pr": 852.64, "it": 91.07, "rp": 8075.97}, {"p": 16, "date": "2027-09-14", "pay": 943.71, "pr": 861.34, "it": 82.37, "rp": 7214.63}, {"p": 17, "date": "2027-10-14", "pay": 943.71, "pr": 870.12, "it": 73.59, "rp": 6344.51}, {"p": 18, "date": "2027-11-14", "pay": 943.71, "pr": 879, "it": 64.71, "rp": 5465.51}, {"p": 19, "date": "2027-12-14", "pay": 943.71, "pr": 887.96, "it": 55.75, "rp": 4577.54}, {"p": 20, "date": "2028-01-14", "pay": 943.71, "pr": 897.02, "it": 46.69, "rp": 3680.52}, {"p": 21, "date": "2028-02-14", "pay": 943.71, "pr": 906.17, "it": 37.54, "rp": 2774.35}, {"p": 22, "date": "2028-03-14", "pay": 943.71, "pr": 915.41, "it": 28.3, "rp": 1858.94}, {"p": 23, "date": "2028-04-14", "pay": 943.71, "pr": 924.75, "it": 18.96, "rp": 934.18}, {"p": 24, "date": "2028-05-14", "pay": 943.71, "pr": 934.18, "it": 9.53, "rp": 0}] [1, 6] +38 4 中信信用卡 3700000 2026-02-05 00:00:00 16.64 annual equal_installment 12 month active 2026-06-24 13:50:38.325601 [{"p": 1, "date": "2026-03-05", "pay": 3368.26, "pr": 2855.19, "it": 513.07, "rp": 34144.81}, {"p": 2, "date": "2026-04-05", "pay": 3368.26, "pr": 2894.78, "it": 473.47, "rp": 31250.03}, {"p": 3, "date": "2026-05-05", "pay": 3368.26, "pr": 2934.92, "it": 433.33, "rp": 28315.1}, {"p": 4, "date": "2026-06-05", "pay": 3368.26, "pr": 2975.62, "it": 392.64, "rp": 25339.48}, {"p": 5, "date": "2026-07-05", "pay": 3368.26, "pr": 3016.88, "it": 351.37, "rp": 22322.6}, {"p": 6, "date": "2026-08-05", "pay": 3368.26, "pr": 3058.72, "it": 309.54, "rp": 19263.88}, {"p": 7, "date": "2026-09-05", "pay": 3368.26, "pr": 3101.13, "it": 267.13, "rp": 16162.75}, {"p": 8, "date": "2026-10-05", "pay": 3368.26, "pr": 3144.13, "it": 224.12, "rp": 13018.61}, {"p": 9, "date": "2026-11-05", "pay": 3368.26, "pr": 3187.73, "it": 180.52, "rp": 9830.88}, {"p": 10, "date": "2026-12-05", "pay": 3368.26, "pr": 3231.94, "it": 136.32, "rp": 6598.94}, {"p": 11, "date": "2027-01-05", "pay": 3368.26, "pr": 3276.75, "it": 91.51, "rp": 3322.19}, {"p": 12, "date": "2027-02-05", "pay": 3368.26, "pr": 3322.19, "it": 46.07, "rp": 0}] [1, 2, 3] +35 4 mm 3500000 2026-01-15 00:00:00 17.1429 annual equal_installment 1 month active 2026-06-24 13:50:38.273985 [{"p": 1, "date": "2026-02-15", "pay": 35500, "pr": 35000, "it": 500, "rp": 0}] [] +47 5 招商 1000000 2026-03-22 00:00:00 9.81 annual equal_installment 24 month active 2026-06-24 14:59:49.469152 [{"p": 1, "date": "2026-04-22", "pay": 460.57, "pr": 378.82, "it": 81.75, "rp": 9621.18}, {"p": 2, "date": "2026-05-22", "pay": 460.57, "pr": 381.92, "it": 78.65, "rp": 9239.26}, {"p": 3, "date": "2026-06-22", "pay": 460.57, "pr": 385.04, "it": 75.53, "rp": 8854.22}, {"p": 4, "date": "2026-07-22", "pay": 460.57, "pr": 388.19, "it": 72.38, "rp": 8466.03}, {"p": 5, "date": "2026-08-22", "pay": 460.57, "pr": 391.36, "it": 69.21, "rp": 8074.66}, {"p": 6, "date": "2026-09-22", "pay": 460.57, "pr": 394.56, "it": 66.01, "rp": 7680.1}, {"p": 7, "date": "2026-10-22", "pay": 460.57, "pr": 397.79, "it": 62.78, "rp": 7282.31}, {"p": 8, "date": "2026-11-22", "pay": 460.57, "pr": 401.04, "it": 59.53, "rp": 6881.27}, {"p": 9, "date": "2026-12-22", "pay": 460.57, "pr": 404.32, "it": 56.25, "rp": 6476.95}, {"p": 10, "date": "2027-01-22", "pay": 460.57, "pr": 407.62, "it": 52.95, "rp": 6069.33}, {"p": 11, "date": "2027-02-22", "pay": 460.57, "pr": 410.96, "it": 49.62, "rp": 5658.37}, {"p": 12, "date": "2027-03-22", "pay": 460.57, "pr": 414.32, "it": 46.26, "rp": 5244.06}, {"p": 13, "date": "2027-04-22", "pay": 460.57, "pr": 417.7, "it": 42.87, "rp": 4826.36}, {"p": 14, "date": "2027-05-22", "pay": 460.57, "pr": 421.12, "it": 39.46, "rp": 4405.24}, {"p": 15, "date": "2027-06-22", "pay": 460.57, "pr": 424.56, "it": 36.01, "rp": 3980.68}, {"p": 16, "date": "2027-07-22", "pay": 460.57, "pr": 428.03, "it": 32.54, "rp": 3552.65}, {"p": 17, "date": "2027-08-22", "pay": 460.57, "pr": 431.53, "it": 29.04, "rp": 3121.12}, {"p": 18, "date": "2027-09-22", "pay": 460.57, "pr": 435.06, "it": 25.52, "rp": 2686.06}, {"p": 19, "date": "2027-10-22", "pay": 460.57, "pr": 438.61, "it": 21.96, "rp": 2247.45}, {"p": 20, "date": "2027-11-22", "pay": 460.57, "pr": 442.2, "it": 18.37, "rp": 1805.25}, {"p": 21, "date": "2027-12-22", "pay": 460.57, "pr": 445.81, "it": 14.76, "rp": 1359.43}, {"p": 22, "date": "2028-01-22", "pay": 460.57, "pr": 449.46, "it": 11.11, "rp": 909.97}, {"p": 23, "date": "2028-02-22", "pay": 460.57, "pr": 453.13, "it": 7.44, "rp": 456.84}, {"p": 24, "date": "2028-03-22", "pay": 460.57, "pr": 456.84, "it": 3.73, "rp": 0}] [1, 2, 3, 8] +41 4 招行信用卡 2800000 2026-06-20 00:00:00 9.7 annual equal_installment 12 month active 周转 2026-06-24 13:50:38.363134 [{"p": 1, "date": "2026-07-20", "pay": 2457.74, "pr": 2231.41, "it": 226.33, "rp": 25768.59}, {"p": 2, "date": "2026-08-20", "pay": 2457.74, "pr": 2249.44, "it": 208.3, "rp": 23519.15}, {"p": 3, "date": "2026-09-20", "pay": 2457.74, "pr": 2267.63, "it": 190.11, "rp": 21251.52}, {"p": 4, "date": "2026-10-20", "pay": 2457.74, "pr": 2285.96, "it": 171.78, "rp": 18965.57}, {"p": 5, "date": "2026-11-20", "pay": 2457.74, "pr": 2304.43, "it": 153.3, "rp": 16661.13}, {"p": 6, "date": "2026-12-20", "pay": 2457.74, "pr": 2323.06, "it": 134.68, "rp": 14338.07}, {"p": 7, "date": "2027-01-20", "pay": 2457.74, "pr": 2341.84, "it": 115.9, "rp": 11996.23}, {"p": 8, "date": "2027-02-20", "pay": 2457.74, "pr": 2360.77, "it": 96.97, "rp": 9635.46}, {"p": 9, "date": "2027-03-20", "pay": 2457.74, "pr": 2379.85, "it": 77.89, "rp": 7255.61}, {"p": 10, "date": "2027-04-20", "pay": 2457.74, "pr": 2399.09, "it": 58.65, "rp": 4856.52}, {"p": 11, "date": "2027-05-20", "pay": 2457.74, "pr": 2418.48, "it": 39.26, "rp": 2438.03}, {"p": 12, "date": "2027-06-20", "pay": 2457.74, "pr": 2438.03, "it": 19.71, "rp": 0}] [] +49 5 抖音 2000000 2025-10-09 00:00:00 4.63 annual equal_installment 12 month active 2026-06-24 14:59:49.499084 [{"p": 1, "date": "2025-11-09", "pay": 1708.76, "pr": 1631.59, "it": 77.17, "rp": 18368.41}, {"p": 2, "date": "2025-12-09", "pay": 1708.76, "pr": 1637.89, "it": 70.87, "rp": 16730.52}, {"p": 3, "date": "2026-01-09", "pay": 1708.76, "pr": 1644.21, "it": 64.55, "rp": 15086.31}, {"p": 4, "date": "2026-02-09", "pay": 1708.76, "pr": 1650.55, "it": 58.21, "rp": 13435.76}, {"p": 5, "date": "2026-03-09", "pay": 1708.76, "pr": 1656.92, "it": 51.84, "rp": 11778.84}, {"p": 6, "date": "2026-04-09", "pay": 1708.76, "pr": 1663.31, "it": 45.45, "rp": 10115.52}, {"p": 7, "date": "2026-05-09", "pay": 1708.76, "pr": 1669.73, "it": 39.03, "rp": 8445.79}, {"p": 8, "date": "2026-06-09", "pay": 1708.76, "pr": 1676.17, "it": 32.59, "rp": 6769.62}, {"p": 9, "date": "2026-07-09", "pay": 1708.76, "pr": 1682.64, "it": 26.12, "rp": 5086.98}, {"p": 10, "date": "2026-08-09", "pay": 1708.76, "pr": 1689.13, "it": 19.63, "rp": 3397.84}, {"p": 11, "date": "2026-09-09", "pay": 1708.76, "pr": 1695.65, "it": 13.11, "rp": 1702.19}, {"p": 12, "date": "2026-10-09", "pay": 1708.76, "pr": 1702.19, "it": 6.57, "rp": 0}] [1, 2, 3, 4, 5, 6, 7, 8] +46 5 招商 859900 2025-10-20 00:00:00 0 annual equal_installment 12 month active 手机 2026-06-24 14:59:49.45153 [{"p": 1, "date": "2025-11-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 7882.42}, {"p": 2, "date": "2025-12-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 7165.83}, {"p": 3, "date": "2026-01-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 6449.25}, {"p": 4, "date": "2026-02-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 5732.67}, {"p": 5, "date": "2026-03-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 5016.08}, {"p": 6, "date": "2026-04-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 4299.5}, {"p": 7, "date": "2026-05-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 3582.92}, {"p": 8, "date": "2026-06-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 2866.33}, {"p": 9, "date": "2026-07-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 2149.75}, {"p": 10, "date": "2026-08-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 1433.17}, {"p": 11, "date": "2026-09-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 716.58}, {"p": 12, "date": "2026-10-20", "pay": 716.58, "pr": 716.58, "it": 0, "rp": 0}] [1, 2, 3, 4, 5, 6, 7, 8] +48 5 平安 2900000 2025-11-19 00:00:00 3.65 annual equal_installment 24 month active 2026-06-24 14:59:49.483587 [{"p": 1, "date": "2025-12-19", "pay": 1254.81, "pr": 1166.6, "it": 88.21, "rp": 27833.4}, {"p": 2, "date": "2026-01-19", "pay": 1254.81, "pr": 1170.15, "it": 84.66, "rp": 26663.25}, {"p": 3, "date": "2026-02-19", "pay": 1254.81, "pr": 1173.71, "it": 81.1, "rp": 25489.54}, {"p": 4, "date": "2026-03-19", "pay": 1254.81, "pr": 1177.28, "it": 77.53, "rp": 24312.26}, {"p": 5, "date": "2026-04-19", "pay": 1254.81, "pr": 1180.86, "it": 73.95, "rp": 23131.4}, {"p": 6, "date": "2026-05-19", "pay": 1254.81, "pr": 1184.45, "it": 70.36, "rp": 21946.95}, {"p": 7, "date": "2026-06-19", "pay": 1254.81, "pr": 1188.05, "it": 66.76, "rp": 20758.89}, {"p": 8, "date": "2026-07-19", "pay": 1254.81, "pr": 1191.67, "it": 63.14, "rp": 19567.22}, {"p": 9, "date": "2026-08-19", "pay": 1254.81, "pr": 1195.29, "it": 59.52, "rp": 18371.93}, {"p": 10, "date": "2026-09-19", "pay": 1254.81, "pr": 1198.93, "it": 55.88, "rp": 17173}, {"p": 11, "date": "2026-10-19", "pay": 1254.81, "pr": 1202.58, "it": 52.23, "rp": 15970.43}, {"p": 12, "date": "2026-11-19", "pay": 1254.81, "pr": 1206.23, "it": 48.58, "rp": 14764.19}, {"p": 13, "date": "2026-12-19", "pay": 1254.81, "pr": 1209.9, "it": 44.91, "rp": 13554.29}, {"p": 14, "date": "2027-01-19", "pay": 1254.81, "pr": 1213.58, "it": 41.23, "rp": 12340.71}, {"p": 15, "date": "2027-02-19", "pay": 1254.81, "pr": 1217.27, "it": 37.54, "rp": 11123.44}, {"p": 16, "date": "2027-03-19", "pay": 1254.81, "pr": 1220.98, "it": 33.83, "rp": 9902.46}, {"p": 17, "date": "2027-04-19", "pay": 1254.81, "pr": 1224.69, "it": 30.12, "rp": 8677.77}, {"p": 18, "date": "2027-05-19", "pay": 1254.81, "pr": 1228.42, "it": 26.39, "rp": 7449.35}, {"p": 19, "date": "2027-06-19", "pay": 1254.81, "pr": 1232.15, "it": 22.66, "rp": 6217.2}, {"p": 20, "date": "2027-07-19", "pay": 1254.81, "pr": 1235.9, "it": 18.91, "rp": 4981.3}, {"p": 21, "date": "2027-08-19", "pay": 1254.81, "pr": 1239.66, "it": 15.15, "rp": 3741.65}, {"p": 22, "date": "2027-09-19", "pay": 1254.81, "pr": 1243.43, "it": 11.38, "rp": 2498.22}, {"p": 23, "date": "2027-10-19", "pay": 1254.81, "pr": 1247.21, "it": 7.6, "rp": 1251}, {"p": 24, "date": "2027-11-19", "pay": 1254.81, "pr": 1251, "it": 3.81, "rp": 0}] [1, 2, 3, 4, 5, 6, 7, 12] +51 5 借呗 1000000 2026-01-10 00:00:00 10.4 annual equal_installment 24 month active 2026-06-24 14:59:49.53963 [{"p": 1, "date": "2026-02-10", "pay": 463.3, "pr": 376.63, "it": 86.67, "rp": 9623.37}, {"p": 2, "date": "2026-03-10", "pay": 463.3, "pr": 379.9, "it": 83.4, "rp": 9243.47}, {"p": 3, "date": "2026-04-10", "pay": 463.3, "pr": 383.19, "it": 80.11, "rp": 8860.29}, {"p": 4, "date": "2026-05-10", "pay": 463.3, "pr": 386.51, "it": 76.79, "rp": 8473.78}, {"p": 5, "date": "2026-06-10", "pay": 463.3, "pr": 389.86, "it": 73.44, "rp": 8083.92}, {"p": 6, "date": "2026-07-10", "pay": 463.3, "pr": 393.24, "it": 70.06, "rp": 7690.68}, {"p": 7, "date": "2026-08-10", "pay": 463.3, "pr": 396.65, "it": 66.65, "rp": 7294.04}, {"p": 8, "date": "2026-09-10", "pay": 463.3, "pr": 400.08, "it": 63.21, "rp": 6893.95}, {"p": 9, "date": "2026-10-10", "pay": 463.3, "pr": 403.55, "it": 59.75, "rp": 6490.4}, {"p": 10, "date": "2026-11-10", "pay": 463.3, "pr": 407.05, "it": 56.25, "rp": 6083.36}, {"p": 11, "date": "2026-12-10", "pay": 463.3, "pr": 410.58, "it": 52.72, "rp": 5672.78}, {"p": 12, "date": "2027-01-10", "pay": 463.3, "pr": 414.13, "it": 49.16, "rp": 5258.65}, {"p": 13, "date": "2027-02-10", "pay": 463.3, "pr": 417.72, "it": 45.57, "rp": 4840.93}, {"p": 14, "date": "2027-03-10", "pay": 463.3, "pr": 421.34, "it": 41.95, "rp": 4419.58}, {"p": 15, "date": "2027-04-10", "pay": 463.3, "pr": 424.99, "it": 38.3, "rp": 3994.59}, {"p": 16, "date": "2027-05-10", "pay": 463.3, "pr": 428.68, "it": 34.62, "rp": 3565.91}, {"p": 17, "date": "2027-06-10", "pay": 463.3, "pr": 432.39, "it": 30.9, "rp": 3133.52}, {"p": 18, "date": "2027-07-10", "pay": 463.3, "pr": 436.14, "it": 27.16, "rp": 2697.38}, {"p": 19, "date": "2027-08-10", "pay": 463.3, "pr": 439.92, "it": 23.38, "rp": 2257.46}, {"p": 20, "date": "2027-09-10", "pay": 463.3, "pr": 443.73, "it": 19.56, "rp": 1813.72}, {"p": 21, "date": "2027-10-10", "pay": 463.3, "pr": 447.58, "it": 15.72, "rp": 1366.14}, {"p": 22, "date": "2027-11-10", "pay": 463.3, "pr": 451.46, "it": 11.84, "rp": 914.69}, {"p": 23, "date": "2027-12-10", "pay": 463.3, "pr": 455.37, "it": 7.93, "rp": 459.32}, {"p": 24, "date": "2028-01-10", "pay": 463.3, "pr": 459.32, "it": 3.98, "rp": 0}] [1, 2, 3, 4, 5, 10] +52 5 借呗 1000000 2026-02-18 00:00:00 10.8 annual lump_sum 12 month active 2026-06-24 14:59:49.549909 [{"p": 1, "date": "2026-03-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 2, "date": "2026-04-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 3, "date": "2026-05-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 4, "date": "2026-06-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 5, "date": "2026-07-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 6, "date": "2026-08-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 7, "date": "2026-09-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 8, "date": "2026-10-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 9, "date": "2026-11-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 10, "date": "2026-12-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 11, "date": "2027-01-18", "pay": 90, "pr": 0, "it": 90, "rp": 10000}, {"p": 12, "date": "2027-02-18", "pay": 10090, "pr": 10000, "it": 90, "rp": 0}] [1, 2, 3, 4, 9] +45 5 招商 2100000 2026-06-23 00:00:00 6.57 annual equal_installment 24 month active 2026-06-24 14:59:49.41078 [{"p": 1, "date": "2026-07-23", "pay": 936.14, "pr": 821.16, "it": 114.98, "rp": 20178.84}, {"p": 2, "date": "2026-08-23", "pay": 936.14, "pr": 825.66, "it": 110.48, "rp": 19353.18}, {"p": 3, "date": "2026-09-23", "pay": 936.14, "pr": 830.18, "it": 105.96, "rp": 18523.01}, {"p": 4, "date": "2026-10-23", "pay": 936.14, "pr": 834.72, "it": 101.41, "rp": 17688.28}, {"p": 5, "date": "2026-11-23", "pay": 936.14, "pr": 839.29, "it": 96.84, "rp": 16848.99}, {"p": 6, "date": "2026-12-23", "pay": 936.14, "pr": 843.89, "it": 92.25, "rp": 16005.1}, {"p": 7, "date": "2027-01-23", "pay": 936.14, "pr": 848.51, "it": 87.63, "rp": 15156.6}, {"p": 8, "date": "2027-02-23", "pay": 936.14, "pr": 853.15, "it": 82.98, "rp": 14303.44}, {"p": 9, "date": "2027-03-23", "pay": 936.14, "pr": 857.82, "it": 78.31, "rp": 13445.62}, {"p": 10, "date": "2027-04-23", "pay": 936.14, "pr": 862.52, "it": 73.61, "rp": 12583.1}, {"p": 11, "date": "2027-05-23", "pay": 936.14, "pr": 867.24, "it": 68.89, "rp": 11715.85}, {"p": 12, "date": "2027-06-23", "pay": 936.14, "pr": 871.99, "it": 64.14, "rp": 10843.86}, {"p": 13, "date": "2027-07-23", "pay": 936.14, "pr": 876.77, "it": 59.37, "rp": 9967.1}, {"p": 14, "date": "2027-08-23", "pay": 936.14, "pr": 881.57, "it": 54.57, "rp": 9085.53}, {"p": 15, "date": "2027-09-23", "pay": 936.14, "pr": 886.39, "it": 49.74, "rp": 8199.14}, {"p": 16, "date": "2027-10-23", "pay": 936.14, "pr": 891.25, "it": 44.89, "rp": 7307.89}, {"p": 17, "date": "2027-11-23", "pay": 936.14, "pr": 896.13, "it": 40.01, "rp": 6411.77}, {"p": 18, "date": "2027-12-23", "pay": 936.14, "pr": 901.03, "it": 35.1, "rp": 5510.73}, {"p": 19, "date": "2028-01-23", "pay": 936.14, "pr": 905.96, "it": 30.17, "rp": 4604.77}, {"p": 20, "date": "2028-02-23", "pay": 936.14, "pr": 910.92, "it": 25.21, "rp": 3693.85}, {"p": 21, "date": "2028-03-23", "pay": 936.14, "pr": 915.91, "it": 20.22, "rp": 2777.93}, {"p": 22, "date": "2028-04-23", "pay": 936.14, "pr": 920.93, "it": 15.21, "rp": 1857.01}, {"p": 23, "date": "2028-05-23", "pay": 936.14, "pr": 925.97, "it": 10.17, "rp": 931.04}, {"p": 24, "date": "2028-06-23", "pay": 936.14, "pr": 931.04, "it": 5.1, "rp": 0}] [] +\. + + +-- +-- Data for Name: groups; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.groups (id, name, description, created_at) FROM stdin; +1 A组 测试 2026-06-24 11:03:50.691376 +2 nnwqkj 2026-06-24 11:10:49.680329 +\. + + +-- +-- Data for Name: repayment_plans; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.repayment_plans (id, debt_id, due_date, principal_due, interest_due, total_due, status) FROM stdin; +\. + + +-- +-- Data for Name: repayment_records; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.repayment_records (id, debt_id, plan_id, amount, paid_date, notes, created_at) FROM stdin; +\. + + +-- +-- Data for Name: system_settings; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.system_settings (key, value) FROM stdin; +\. + + +-- +-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.users (id, email, password_hash, nickname, role, status, group_id, created_at) FROM stdin; +5 mo $2b$12$xGBqDkS0UXRgCwl37Pf/ius6JFZAZ2uqJ/3CcYuI9HS2cIBIPCa9O 莫 user active 2 2026-06-24 14:58:20.107655 +4 w $2b$12$08TQRMaHOZxatKL4wxPTG./Hpdr6i9Kr63we0tq0eJJvBzV5Mfczq 国玮 user active 2 2026-06-24 13:07:27.778884 +2 test $2b$12$RHko0eF2q8kGB.aM.I82VuBh5IJ3ZafHyigFIHxR73r/FIixp3Gwi 测试用户新昵称 user active \N 2026-06-24 11:03:36.561415 +1 admin $2b$12$j/.oSg76c3GNiALEgOAWqehT/vh1dektBmQ7gPMFlEOtdQF7sCrC2 admin admin active \N 2026-06-24 11:03:36.561411 +\. + + +-- +-- Name: audit_logs_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres +-- + +SELECT pg_catalog.setval('public.audit_logs_id_seq', 1, false); + + +-- +-- Name: debts_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres +-- + +SELECT pg_catalog.setval('public.debts_id_seq', 61, true); + + +-- +-- Name: groups_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres +-- + +SELECT pg_catalog.setval('public.groups_id_seq', 2, true); + + +-- +-- Name: repayment_plans_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres +-- + +SELECT pg_catalog.setval('public.repayment_plans_id_seq', 1, false); + + +-- +-- Name: repayment_records_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres +-- + +SELECT pg_catalog.setval('public.repayment_records_id_seq', 1, false); + + +-- +-- Name: users_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres +-- + +SELECT pg_catalog.setval('public.users_id_seq', 6, true); + + +-- +-- Name: audit_logs audit_logs_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.audit_logs + ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); + + +-- +-- Name: debts debts_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.debts + ADD CONSTRAINT debts_pkey PRIMARY KEY (id); + + +-- +-- Name: groups groups_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.groups + ADD CONSTRAINT groups_pkey PRIMARY KEY (id); + + +-- +-- Name: repayment_plans repayment_plans_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.repayment_plans + ADD CONSTRAINT repayment_plans_pkey PRIMARY KEY (id); + + +-- +-- Name: repayment_records repayment_records_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.repayment_records + ADD CONSTRAINT repayment_records_pkey PRIMARY KEY (id); + + +-- +-- Name: system_settings system_settings_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.system_settings + ADD CONSTRAINT system_settings_pkey PRIMARY KEY (key); + + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + +-- +-- Name: ix_audit_logs_id; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX ix_audit_logs_id ON public.audit_logs USING btree (id); + + +-- +-- Name: ix_debts_id; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX ix_debts_id ON public.debts USING btree (id); + + +-- +-- Name: ix_groups_id; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX ix_groups_id ON public.groups USING btree (id); + + +-- +-- Name: ix_repayment_plans_id; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX ix_repayment_plans_id ON public.repayment_plans USING btree (id); + + +-- +-- Name: ix_repayment_records_id; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX ix_repayment_records_id ON public.repayment_records USING btree (id); + + +-- +-- Name: ix_users_email; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE UNIQUE INDEX ix_users_email ON public.users USING btree (email); + + +-- +-- Name: ix_users_id; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX ix_users_id ON public.users USING btree (id); + + +-- +-- Name: audit_logs audit_logs_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.audit_logs + ADD CONSTRAINT audit_logs_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + + +-- +-- Name: debts debts_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.debts + ADD CONSTRAINT debts_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + + +-- +-- Name: repayment_plans repayment_plans_debt_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.repayment_plans + ADD CONSTRAINT repayment_plans_debt_id_fkey FOREIGN KEY (debt_id) REFERENCES public.debts(id); + + +-- +-- Name: repayment_records repayment_records_debt_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.repayment_records + ADD CONSTRAINT repayment_records_debt_id_fkey FOREIGN KEY (debt_id) REFERENCES public.debts(id); + + +-- +-- Name: repayment_records repayment_records_plan_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.repayment_records + ADD CONSTRAINT repayment_records_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES public.repayment_plans(id); + + +-- +-- Name: users users_group_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_group_id_fkey FOREIGN KEY (group_id) REFERENCES public.groups(id); + + +-- +-- PostgreSQL database dump complete +-- + +\unrestrict RdcQlBPEqYuP88EyiWHtXP8VF7CTE1R3DOjX7UMme8jFrcKXvNbaZeFWWNkzt7J + diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..1374523 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,40 @@ +services: + db: + image: postgres:15-alpine + environment: + POSTGRES_DB: debt_manager + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "54326:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + backend: + image: debt-manager-backend:latest + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/debt_manager + SECRET_KEY: change-this-in-production-please + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + frontend: + image: debt-manager-frontend:latest + ports: + - "806:80" + depends_on: + - backend + restart: unless-stopped + +volumes: + pgdata: diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..5419d34 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -e + +echo "=========================================" +echo " 债务管理系统 - 一键安装" +echo "=========================================" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "" +echo "[1/3] 加载 Docker 镜像..." +docker load -i "$SCRIPT_DIR/debt-manager-backend.tar.gz" +docker load -i "$SCRIPT_DIR/debt-manager-frontend.tar.gz" +docker load -i "$SCRIPT_DIR/postgres-15-alpine.tar.gz" + +echo "" +echo "[2/3] 启动服务..." +cd "$SCRIPT_DIR" +docker compose up -d + +echo "" +echo "[3/3] 等待服务就绪..." +sleep 5 + +echo "" +echo "=========================================" +echo " 安装完成!" +echo "=========================================" +echo "" +echo " 前台地址: http://localhost:806" +echo " 后台地址: http://localhost:806/admin.html" +echo " API 文档: http://localhost:8000/docs" +echo "" +echo " 默认账号: admin / admin123" +echo " 默认账号: test / test123" +echo "" +echo " 停止服务: docker compose -f $SCRIPT_DIR/docker-compose.yml down" +echo " 查看日志: docker compose -f $SCRIPT_DIR/docker-compose.yml logs -f" +echo "=========================================" diff --git a/deploy/uninstall.sh b/deploy/uninstall.sh new file mode 100755 index 0000000..1ac024c --- /dev/null +++ b/deploy/uninstall.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -e + +echo "=========================================" +echo " 债务管理系统 - 卸载" +echo "=========================================" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "" +echo "[1/2] 停止并删除容器..." +cd "$SCRIPT_DIR" +docker compose down -v + +echo "" +echo "[2/2] 删除 Docker 镜像..." +docker rmi debt-manager-backend:latest debt-manager-frontend:latest postgres:15-alpine 2>/dev/null || true + +echo "" +echo "=========================================" +echo " 卸载完成" +echo "=========================================" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b31deeb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +services: + db: + image: postgres:15-alpine + environment: + POSTGRES_DB: debt_manager + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "54326:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: ./backend + image: debt-manager-backend + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/debt_manager + SECRET_KEY: change-this-in-production-please + depends_on: + db: + condition: service_healthy + + frontend: + build: ./frontend + image: debt-manager-frontend + ports: + - "806:80" + depends_on: + - backend + +volumes: + pgdata: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..fc70c79 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,3 @@ +FROM nginx:alpine +COPY index.html admin.html reset-password.html /usr/share/nginx/html/ +COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..3777ff4 --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,520 @@ + + + + + + +后台管理 + + + + +
+
+

🔧 后台管理

+
+
+
+ + +
+
+
+

通知

+ +
+
+
暂无通知
+
+
+
+ 返回前台 + 退出 +
+
+
+ +
+
+ +
+
+

用户组管理

+ +
+
+ + + +
组名描述成员数操作
+
+
+ +
+
+

用户管理

+ +
+
+ + + +
账号昵称邮箱角色债务状态操作
+
+
+ +
+
+

站点配置

+ +
+
+
+
+
+ +
+
+ +
+
+

邮件服务配置

+
+ + +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b67942a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,668 @@ + + + + + + +债务统计 + + + +
+ + +
+
+

债务管理系统

+
+
+
+
+ +
+
+ + + + + + + +
+
+

注册账户

+ +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+

债务统计

+
+
+
+ + +
+
+
+

通知

+ +
+
+
暂无通知
+
+
+
+ + +
+ + +
+
+ + +
+ 退出 +
+
+
+ +
+
+
+
📝 新增借款编辑中
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
📋 借款列表 🙈 显示已还清🙈 显示他人
+
暂无记录
+
+
+ +
+
+ + + + + + + + + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..40afcf2 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ =404; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/reset-password.html b/frontend/reset-password.html new file mode 100644 index 0000000..b7fe326 --- /dev/null +++ b/frontend/reset-password.html @@ -0,0 +1,113 @@ + + + + + +重置密码 + + + +
+

重置密码

+
验证链接中...
+ + +
+ + + diff --git a/restore.sh b/restore.sh new file mode 100755 index 0000000..2a105f1 --- /dev/null +++ b/restore.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# 债务管理系统 - 一键部署脚本 +# 用法: bash restore.sh + +set -e +cd "$(dirname "$0")" + +echo "==============================" +echo " 债务管理系统 - 离线部署" +echo "==============================" +echo "" + +echo "[1/4] 加载 Docker 镜像..." +for img in postgres-15-alpine.tar debt-manager-backend.tar debt-manager-frontend.tar; do + if [ -f "$img" ]; then + echo " 加载 $img ..." + docker load -i "$img" + else + echo " 跳过 $img (文件不存在)" + fi +done + +echo "[2/4] 启动数据库..." +docker compose up -d db +echo " 等待数据库就绪..." +sleep 15 + +echo "[3/4] 恢复数据..." +if [ -f "data.sql" ]; then + docker compose exec -T db psql -U postgres debt_manager < data.sql + echo " 数据恢复完成" +else + echo " 跳过数据恢复 (无 data.sql)" +fi + +echo "[4/4] 启动所有服务..." +docker compose up -d + +echo "" +echo "==============================" +echo " 部署完成!" +echo "==============================" +echo "" +echo " 前台: http://localhost:806" +echo " 后台: http://localhost:806/admin.html" +echo " 管理员: admin / admin123" +echo ""