v2.1: 新增通知提醒功能和安全增强

功能:
- 通知提醒系统: 还款日提醒、逾期提醒、系统消息横幅、登录时通知
- 通知中心UI: 铃铛图标 + 下拉列表 + 未读徽章
- 定时任务: APScheduler 每小时检查即将到期的还款
- 邮件通知: SMTP 发送还款提醒和安全警告
- 异常登录通知: 3/5/10次失败触发网页和邮件通知

安全:
- 登录安全告警: 异常登录次数达到阈值时发送通知
- 密码重置链接: 一次性使用, 5分钟有效期
- 登录频率限制: IP级和账户级限制

技术:
- 后端: FastAPI + SQLAlchemy + PostgreSQL
- 前端: 纯 HTML/CSS/JS
- 部署: Docker Compose (新增 build 配置)
This commit is contained in:
OP
2026-06-28 18:06:56 +08:00
commit ffca168dbe
41 changed files with 4834 additions and 0 deletions

27
.env.example Normal file
View File

@@ -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

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.DS_Store
*.tar
*.tar.gz
.env
__pycache__/
*.pyc
node_modules/
pgdata/

76
DEPLOY.md Normal file
View File

@@ -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: 登录前台 → 右上角"修改密码"。

78
README.md Normal file
View File

@@ -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 # 部署指南
```

10
backend/Dockerfile Normal file
View File

@@ -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"]

0
backend/app/__init__.py Normal file
View File

View File

429
backend/app/api/admin.py Normal file
View File

@@ -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 = """
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
<h2 style="color:#16a34a;margin-bottom:16px">邮件服务测试</h2>
<p>您好,</p>
<p>这是一封债务管理系统的邮件服务测试邮件。</p>
<p>如果您收到此邮件,说明邮件服务器配置正确。</p>
<div style="background:#f5f5f7;border-radius:12px;padding:20px;margin:16px 0;text-align:center">
<p style="font-size:24px;margin:0">✅</p>
<p style="color:#16a34a;font-weight:600;margin:8px 0 0 0">邮件服务正常</p>
</div>
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
</div>
"""
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": "站点地址已保存"}

337
backend/app/api/auth.py Normal file
View File

@@ -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"""
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
<h2 style="color:#0071e3;margin-bottom:16px">密码重置验证码</h2>
<p>Hi {nickname}</p>
<p>您正在重置密码,验证码如下:</p>
<div style="background:#f5f5f7;border-radius:12px;padding:24px;margin:16px 0;text-align:center">
<p style="font-size:36px;font-weight:700;letter-spacing:8px;color:#1d1d1f;margin:0">{code}</p>
<p style="color:#6e6e73;font-size:12px;margin:8px 0 0 0">{RESET_CODE_TTL_MINUTES} 分钟内有效</p>
</div>
<p style="color:#6e6e73;font-size:13px">如非本人操作,请忽略此邮件。</p>
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
</div>
"""
@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}

172
backend/app/api/debts.py Normal file
View File

@@ -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()

152
backend/app/api/loans.py Normal file
View File

@@ -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}

View File

@@ -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": "已删除"}

View File

@@ -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],
)

View File

View File

@@ -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()

View File

@@ -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()

31
backend/app/core/deps.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

151
backend/app/main.py Normal file
View File

@@ -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"}

View File

View File

@@ -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")

View File

View File

@@ -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

View File

View File

@@ -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 []

View File

@@ -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"""
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
<h2 style="color:{color};margin-bottom:16px">{title}</h2>
<p>Hi {nickname}</p>
<p>您有一笔借款即将到期:</p>
<div style="background:#f5f5f7;border-radius:12px;padding:20px;margin:16px 0">
<p><strong>债权人:</strong>{creditor}</p>
<p><strong>金额:</strong>¥{amount_yuan:,.2f}</p>
<p><strong>到期日:</strong>{due_date}</p>
<p style="color:{color};font-weight:600">{status_text}</p>
</div>
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
</div>
"""
_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"""
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
<h2 style="color:{cfg['color']};margin-bottom:16px">{cfg['icon']} 登录安全{cfg['level']}</h2>
<p>Hi {nickname}</p>
<p>{cfg['desc']}</p>
<div style="background:{cfg['bg']};border:1px solid {cfg['border']};border-radius:12px;padding:20px;margin:16px 0">
<p><strong>异常登录次数:</strong><span style="color:{cfg['color']};font-weight:700">{count} 次</span></p>
<p><strong>触发来源 IP</strong>{ip}</p>
<p><strong>时间:</strong>{__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
<div style="text-align:center;margin:24px 0">
<a href="{change_pwd_url}" style="display:inline-block;padding:12px 32px;background:#0071e3;color:#fff;border-radius:8px;text-decoration:none;font-weight:600">修改密码</a>
</div>
<p style="color:#6e6e73;font-size:12px">此链接 {ttl_minutes} 分钟内有效,仅可使用一次。</p>
<p style="color:#6e6e73;font-size:13px">如非本人操作,请忽略此邮件并尽快检查账户安全。</p>
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
</div>
"""
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"""
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
<h2 style="color:#0071e3;margin-bottom:16px">🔐 密码{action}通知</h2>
<p>Hi {nickname}</p>
<p>您的账户密码已被{action}。</p>
<div style="background:#f5f5f7;border-radius:12px;padding:20px;margin:16px 0">
<p><strong>操作类型:</strong>密码{action}</p>
<p><strong>操作时间:</strong>{now}</p>
{"<p><strong>操作人:</strong>" + operator + "</p>" if operator else ""}
</div>
<p style="color:#6e6e73;font-size:13px">如非本人操作,请立即联系管理员并修改密码。</p>
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
</div>
"""
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"""
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
<h2 style="color:#0071e3;margin-bottom:16px">🔐 密码重置链接</h2>
<p>Hi {nickname}</p>
<p>管理员已为您发起密码重置,请点击下方按钮设置新密码。</p>
<div style="background:#f5f5f7;border-radius:12px;padding:20px;margin:16px 0">
<p><strong>操作人:</strong>{operator}</p>
<p><strong>发送时间:</strong>{now}</p>
<p><strong>有效期:</strong>{ttl_hours} 小时内有效</p>
</div>
<div style="text-align:center;margin:24px 0">
<a href="{reset_url}" style="display:inline-block;padding:14px 36px;background:#0071e3;color:#fff;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px">重置密码</a>
</div>
<p style="color:#6e6e73;font-size:12px">如非本人操作,请忽略此邮件。</p>
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
</div>
"""
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)

View File

@@ -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

View File

@@ -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()

16
backend/requirements.txt Normal file
View File

@@ -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

603
data.sql Normal file
View File

@@ -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

40
deploy/docker-compose.yml Normal file
View File

@@ -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:

39
deploy/install.sh Executable file
View File

@@ -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 "========================================="

22
deploy/uninstall.sh Executable file
View File

@@ -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 "========================================="

39
docker-compose.yml Normal file
View File

@@ -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:

3
frontend/Dockerfile Normal file
View File

@@ -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

520
frontend/admin.html Normal file
View File

@@ -0,0 +1,520 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<title>后台管理</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;background:#eef2f7;color:#1a2332;min-height:100vh;font-size:14px}
.header{background:linear-gradient(135deg,#1e3a5f 0%,#2563eb 100%);padding:14px 0;color:#fff}
.header-inner{max-width:1200px;margin:0 auto;padding:0 24px;display:flex;align-items:center;justify-content:space-between}
.header h1{font-size:18px;font-weight:600}
.header-right{display:flex;align-items:center;gap:12px}
.header-right a{color:rgba(255,255,255,.8);text-decoration:none;font-size:12px;padding:4px 10px;border:1px solid rgba(255,255,255,.3);border-radius:4px}
.header-right a:hover{background:rgba(255,255,255,.15);color:#fff}
.container{max-width:1200px;margin:0 auto;padding:20px 24px}
.stats-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px}
.stat-card{background:#fff;border-radius:6px;padding:16px;text-align:center;border:1px solid #e2e8f0}
.stat-card .lbl{font-size:12px;color:#64748b;margin-bottom:4px}
.stat-card .val{font-size:22px;font-weight:700}
.stat-card .val.blue{color:#2563eb}.stat-card .val.green{color:#16a34a}.stat-card .val.orange{color:#ea580c}.stat-card .val.red{color:#dc2626}
.card{background:#fff;border-radius:6px;border:1px solid #d0d7e2;overflow:hidden;margin-bottom:16px}
.card-header{padding:12px 20px;border-bottom:1px solid #e5eaf0;display:flex;justify-content:space-between;align-items:center}
.card-header h3{font-size:15px;color:#1e3a5f}
.card-body{padding:0}
.search-bar{display:flex;gap:8px;align-items:center}
.search-bar input,.search-bar select{padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none}
.search-bar input:focus{border-color:#2563eb}
table{width:100%;border-collapse:collapse;font-size:13px}
th{background:#f8fafc;padding:10px 16px;text-align:left;font-weight:600;color:#64748b;border-bottom:1px solid #e5eaf0}
td{padding:10px 16px;border-bottom:1px solid #f0f3f7}
tr:hover{background:#f8fafc}
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600}
.badge-pending{background:#fef3c7;color:#b45309}
.badge-active{background:#dcfce7;color:#16a34a}
.badge-disabled{background:#fee2e2;color:#dc2626}
.badge-admin{background:#ede9fe;color:#7c3aed}
.badge-group{background:#dbeafe;color:#1d4ed8}
.btn-sm{padding:4px 10px;border:none;border-radius:4px;font-size:12px;cursor:pointer;font-weight:500}
.btn-approve{background:#dcfce7;color:#16a34a}.btn-approve:hover{background:#bbf7d0}
.btn-enable{background:#dbeafe;color:#2563eb}.btn-enable:hover{background:#bfdbfe}
.btn-disable{background:#fee2e2;color:#dc2626}.btn-disable:hover{background:#fecaca}
.btn-restore{background:#f1f5f9;color:#64748b}.btn-restore:hover{background:#e2e8f0}
.btn-primary{background:#2563eb;color:#fff}.btn-primary:hover{background:#1d4ed8}
.btn-danger{background:#fee2e2;color:#dc2626}.btn-danger:hover{background:#fecaca}
.empty{text-align:center;padding:40px;color:#94a3b8}
.form-inline{display:flex;gap:8px;align-items:center}
.form-inline input{padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none}
.modal-mask{display:none;position:fixed;inset:0;background:rgba(15,23,42,.4);z-index:100;align-items:center;justify-content:center}
.modal-mask.show{display:flex}
.modal{background:#fff;border-radius:6px;padding:20px;max-width:400px;width:90%}
.modal h3{margin-bottom:12px;font-size:15px;color:#1e3a5f}
.modal .form-row{margin-bottom:10px}
.modal .form-row label{display:block;font-size:12px;color:#64748b;margin-bottom:3px}
.modal .form-row input,.modal .form-row select{width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none}
.modal .btn-row{display:flex;gap:8px;margin-top:14px;justify-content:flex-end}
@media(max-width:768px){.stats-row{grid-template-columns:repeat(2,1fr)}}
/* NOTIFICATION CENTER */
.notif-bell{position:relative;cursor:pointer;padding:6px 10px;border-radius:4px;transition:all .2s}
.notif-bell:hover{background:rgba(255,255,255,.15)}
.notif-bell svg{width:18px;height:18px;fill:rgba(255,255,255,.8);transition:fill .2s}
.notif-bell:hover svg{fill:#fff}
.notif-badge{position:absolute;top:4px;right:4px;min-width:16px;height:16px;background:#dc2626;color:#fff;border-radius:8px;font-size:10px;font-weight:600;display:flex;align-items:center;justify-content:center;padding:0 4px;border:2px solid #2563eb;animation:badgePop .3s ease-out}
@keyframes badgePop{from{transform:scale(0)}to{transform:scale(1)}}
.notif-dropdown{display:none;position:absolute;right:0;top:calc(100% + 8px);background:#fff;border:1px solid #e2e8f0;border-radius:6px;box-shadow:0 12px 48px rgba(0,0,0,.15);width:360px;max-height:480px;overflow:hidden;z-index:60}
.notif-dropdown.show{display:block}
.notif-header{padding:14px 16px;border-bottom:1px solid #e5eaf0;display:flex;justify-content:space-between;align-items:center}
.notif-header h4{font-size:14px;font-weight:600;color:#1e3a5f}
.notif-header .mark-all{font-size:12px;color:#2563eb;cursor:pointer;background:none;border:none;font-family:inherit}
.notif-header .mark-all:hover{text-decoration:underline}
.notif-list{overflow-y:auto;max-height:380px}
.notif-item{padding:12px 16px;border-bottom:1px solid #f0f3f7;cursor:pointer;transition:background .15s;display:flex;gap:12px;align-items:flex-start}
.notif-item:hover{background:#f8fafc}
.notif-item.unread{background:rgba(37,99,235,.03)}
.notif-item .notif-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
.notif-icon.repayment_due{background:rgba(37,99,235,.08)}
.notif-icon.repayment_overdue{background:rgba(220,38,38,.08)}
.notif-icon.system{background:rgba(124,58,237,.08)}
.notif-icon.account{background:rgba(22,163,74,.08)}
.notif-item .notif-content{flex:1;min-width:0}
.notif-item .notif-title{font-size:13px;font-weight:500;color:#1a2332;margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.notif-item .notif-msg{font-size:12px;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.notif-item .notif-time{font-size:11px;color:#94a3b8;white-space:nowrap}
.notif-empty{text-align:center;padding:32px;color:#94a3b8;font-size:13px}
</style>
</head>
<body>
<div class="header">
<div class="header-inner">
<h1>🔧 后台管理</h1>
<div class="header-right">
<div style="position:relative">
<div class="notif-bell" onclick="toggleNotifDropdown(event)">
<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>
<span class="notif-badge" id="notifBadge" style="display:none">0</span>
</div>
<div class="notif-dropdown" id="notifDropdown">
<div class="notif-header">
<h4>通知</h4>
<button class="mark-all" onclick="markAllNotifRead()">全部已读</button>
</div>
<div class="notif-list" id="notifList">
<div class="notif-empty">暂无通知</div>
</div>
</div>
</div>
<a href="/">返回前台</a>
<a href="#" onclick="adminLogout()">退出</a>
</div>
</div>
</div>
<div class="container">
<div class="stats-row" id="statsRow"></div>
<div class="card">
<div class="card-header">
<h3>用户组管理</h3>
<button class="btn-sm btn-primary" onclick="showCreateGroup()">新增组</button>
</div>
<div class="card-body">
<table>
<thead><tr><th>组名</th><th>描述</th><th>成员数</th><th>操作</th></tr></thead>
<tbody id="groupTable"></tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>用户管理</h3>
<div class="search-bar">
<button class="btn-sm btn-primary" onclick="showCreateUser()">新增用户</button>
<input type="text" id="searchInput" placeholder="搜索账号/昵称" onkeyup="if(event.key==='Enter')loadUsers()">
<select id="statusFilter" onchange="loadUsers()">
<option value="">全部状态</option>
<option value="pending">待审批</option>
<option value="active">已激活</option>
<option value="disabled">已禁用</option>
</select>
<button class="btn-sm btn-enable" onclick="loadUsers()">查询</button>
</div>
</div>
<div class="card-body">
<table>
<thead><tr><th>账号</th><th>昵称</th><th>邮箱</th><th>角色</th><th></th><th>债务</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="userTable"></tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>站点配置</h3>
<button class="btn-sm btn-primary" onclick="saveSiteUrl()">保存</button>
</div>
<div class="card-body" style="padding:16px 20px">
<div style="display:grid;grid-template-columns:1fr;gap:12px">
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">站点地址(用于邮件中的链接)</label><input type="text" id="siteUrl" placeholder="http://your-domain:806" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
</div>
<div id="siteUrlMsg" style="margin-top:10px;font-size:12px;display:none"></div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>邮件服务配置</h3>
<div style="display:flex;gap:8px">
<button class="btn-sm btn-enable" onclick="showEmailTestModal()">邮件测试</button>
<button class="btn-sm btn-primary" onclick="saveEmailConfig()">保存配置</button>
</div>
</div>
<div class="card-body" style="padding:16px 20px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">SMTP 服务器</label><input type="text" id="smtpHost" placeholder="smtp.example.com" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">端口</label><input type="number" id="smtpPort" value="465" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">用户名</label><input type="text" id="smtpUser" placeholder="发件账号" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">密码/授权码</label><input type="password" id="smtpPassword" placeholder="SMTP 密码" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">发件人地址</label><input type="text" id="smtpFrom" placeholder="留空则使用用户名" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
<div style="display:flex;align-items:end"><label style="font-size:13px;cursor:pointer"><input type="checkbox" id="smtpTls" checked style="margin-right:6px">启用 TLS/SSL</label></div>
</div>
<div id="emailConfigMsg" style="margin-top:10px;font-size:12px;display:none"></div>
</div>
</div>
</div>
<div class="modal-mask" id="groupModal">
<div class="modal">
<h3>新增组</h3>
<div class="form-row"><label>组名</label><input type="text" id="groupName" placeholder="输入组名"></div>
<div class="form-row"><label>描述</label><input type="text" id="groupDesc" placeholder="可选"></div>
<div class="btn-row">
<button class="btn-sm btn-restore" onclick="closeGroupModal()">取消</button>
<button class="btn-sm btn-primary" onclick="createGroup()">创建</button>
</div>
</div>
</div>
<div class="modal-mask" id="memberModal">
<div class="modal">
<h3>管理组成员 - <span id="memberGroupName"></span></h3>
<div class="form-row">
<label>添加成员</label>
<select id="memberSelect"></select>
</div>
<div class="btn-row">
<button class="btn-sm btn-primary" onclick="addMember()">添加</button>
</div>
<div style="margin-top:12px">
<label style="font-size:12px;color:#64748b">当前成员:</label>
<div id="memberList" style="margin-top:6px"></div>
</div>
<div class="btn-row" style="margin-top:14px">
<button class="btn-sm btn-restore" onclick="closeMemberModal()">关闭</button>
</div>
</div>
</div>
<div class="modal-mask" id="createUserModal">
<div class="modal">
<h3>新增用户</h3>
<div class="form-row"><label>账号</label><input type="text" id="cuAccount" placeholder="登录账号"></div>
<div class="form-row"><label>密码</label><input type="password" id="cuPassword" placeholder="登录密码"></div>
<div class="form-row"><label>昵称</label><input type="text" id="cuNickname" placeholder="显示名称"></div>
<div class="form-row"><label>邮箱(可选)</label><input type="email" id="cuEmail" placeholder="用于接收通知"></div>
<div class="form-row"><label>角色</label><select id="cuRole"><option value="user">用户</option><option value="admin">管理员</option></select></div>
<div id="cuError" style="color:#dc2626;font-size:12px;margin-top:8px;display:none"></div>
<div class="btn-row">
<button class="btn-sm btn-restore" onclick="closeCreateUser()">取消</button>
<button class="btn-sm btn-primary" onclick="doCreateUser()">创建</button>
</div>
</div>
</div>
<div class="modal-mask" id="editUserModal">
<div class="modal">
<h3>编辑用户</h3>
<div class="form-row"><label>账号</label><input type="text" id="euAccount"></div>
<div class="form-row"><label>昵称</label><input type="text" id="euNickname"></div>
<div class="form-row"><label>邮箱</label><input type="email" id="euEmail"></div>
<div class="form-row"><label>角色</label><select id="euRole"><option value="user">用户</option><option value="admin">管理员</option></select></div>
<div id="euError" style="color:#dc2626;font-size:12px;margin-top:8px;display:none"></div>
<div class="btn-row">
<button class="btn-sm btn-restore" onclick="closeEditUser()">取消</button>
<button class="btn-sm btn-primary" onclick="doEditUser()">保存</button>
</div>
</div>
</div>
<div class="modal-mask" id="emailTestModal">
<div class="modal">
<h3>邮件测试</h3>
<div class="form-row"><label>接收邮箱</label><input type="email" id="emailTestTo" placeholder="输入测试接收邮箱"></div>
<div id="emailTestMsg" style="font-size:12px;margin-top:8px;display:none"></div>
<div class="btn-row">
<button class="btn-sm btn-restore" onclick="closeEmailTestModal()">取消</button>
<button class="btn-sm btn-primary" id="emailTestBtn" onclick="doEmailTest()">发送测试</button>
</div>
</div>
</div>
<script>
const API='/api/v1';
let token=localStorage.getItem('access_token')||'';
let refreshToken=localStorage.getItem('refresh_token')||'';
let allGroups=[];
let allUsers=[];
let currentGroupId=null;
const $=id=>document.getElementById(id);
async function apiFetch(path,opts={}){
const headers={'Content-Type':'application/json',...opts.headers};
if(token)headers['Authorization']='Bearer '+token;
let res=await fetch(API+path,{...opts,headers});
if(res.status===401&&refreshToken){
const r=await fetch(API+'/auth/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({refresh_token:refreshToken})});
if(r.ok){const d=await r.json();token=d.access_token;refreshToken=d.refresh_token;localStorage.setItem('access_token',token);localStorage.setItem('refresh_token',refreshToken);headers['Authorization']='Bearer '+token;res=await fetch(API+path,{...opts,headers});}
else{adminLogout();return null;}
}
return res;
}
async function init(){
if(!token){window.location.href='/';return;}
const res=await apiFetch('/auth/profile');
if(!res||!res.ok){window.location.href='/';return;}
const user=await res.json();
if(user.role!=='admin'){window.location.href='/';return;}
loadStats();loadGroups();loadUsers();loadEmailConfig();loadSiteUrl();
}
async function loadStats(){
const res=await apiFetch('/admin/dashboard');
if(!res||!res.ok)return;
const d=await res.json();
const sd=d.status_distribution||{};
$('statsRow').innerHTML=`
<div class="stat-card"><div class="lbl">总用户</div><div class="val blue">${d.total_users}</div></div>
<div class="stat-card"><div class="lbl">待审批</div><div class="val orange">${sd.pending||0}</div></div>
<div class="stat-card"><div class="lbl">已激活</div><div class="val green">${sd.active||0}</div></div>
<div class="stat-card"><div class="lbl">已禁用</div><div class="val red">${sd.disabled||0}</div></div>`;
}
async function loadGroups(){
const res=await apiFetch('/admin/groups');
if(!res||!res.ok)return;
allGroups=await res.json();
const tb=$('groupTable');
if(!allGroups.length){tb.innerHTML='<tr><td colspan="4" class="empty">暂无组</td></tr>';return;}
tb.innerHTML=allGroups.map(g=>`<tr>
<td><strong>${g.name}</strong></td>
<td>${g.description||'-'}</td>
<td>${g.member_count}人</td>
<td>
<button class="btn-sm btn-enable" onclick="showMembers(${g.id},'${g.name}')">管理成员</button>
<button class="btn-sm btn-danger" onclick="deleteGroup(${g.id})">删除</button>
</td>
</tr>`).join('');
}
async function loadUsers(){
const search=$('searchInput').value;
const status=$('statusFilter').value;
const params=new URLSearchParams();
if(search)params.set('search',search);
if(status)params.set('status_filter',status);
const res=await apiFetch('/admin/users?'+params);
if(!res||!res.ok)return;
allUsers=await res.json();
const tb=$('userTable');
if(!allUsers.length){tb.innerHTML='<tr><td colspan="7" class="empty">暂无用户</td></tr>';return;}
tb.innerHTML=allUsers.map(u=>{
const roleBadge=u.role==='admin'?'<span class="badge badge-admin">管理员</span>':'用户';
const statusBadge=u.status==='pending'?'<span class="badge badge-pending">待审批</span>':u.status==='active'?'<span class="badge badge-active">已激活</span>':'<span class="badge badge-disabled">已禁用</span>';
const groupBadge=u.group_name?`<span class="badge badge-group">${u.group_name}</span>`:'<span style="color:#94a3b8">未分组</span>';
const emailDisplay=u.real_email||'<span style="color:#94a3b8">未绑定</span>';
let actions='';
if(u.role!=='admin'){
if(u.status==='pending')actions=`<button class="btn-sm btn-approve" onclick="approveUser(${u.id})">通过</button>`;
if(u.status==='active')actions=`<button class="btn-sm btn-disable" onclick="setStatus(${u.id},'disabled')">禁用</button>`;
if(u.status==='disabled')actions+=` <button class="btn-sm btn-enable" onclick="setStatus(${u.id},'active')">启用</button> <button class="btn-sm btn-restore" onclick="setStatus(${u.id},'pending')">恢复待审</button>`;
actions+=` <button class="btn-sm btn-enable" onclick="showEditUser(${u.id},'${u.account}','${u.nickname||''}','${u.real_email||''}','${u.role}')">编辑</button>`;
actions+=` <button class="btn-sm btn-enable" onclick="sendResetLink(${u.id},'${u.account}')">改密</button>`;
actions+=` <button class="btn-sm btn-danger" onclick="deleteUser(${u.id},'${u.account}')">删除</button>`;
}
return`<tr><td>${u.account}</td><td>${u.nickname}</td><td>${emailDisplay}</td><td>${roleBadge}</td><td>${groupBadge}</td><td>${u.debt_count}笔</td><td>${statusBadge}</td><td>${actions}</td></tr>`;
}).join('');
}
async function approveUser(id){
await apiFetch('/admin/users/'+id+'/status?status=active',{method:'PUT'});
loadStats();loadUsers();
}
async function setStatus(id,status){
await apiFetch('/admin/users/'+id+'/status?status='+status,{method:'PUT'});
loadStats();loadUsers();
}
function showCreateUser(){$('cuAccount').value='';$('cuPassword').value='';$('cuNickname').value='';$('cuEmail').value='';$('cuRole').value='user';$('cuError').style.display='none';$('createUserModal').classList.add('show');}
function closeCreateUser(){$('createUserModal').classList.remove('show');}
async function doCreateUser(){
const account=$('cuAccount').value.trim(),pwd=$('cuPassword').value,nick=$('cuNickname').value.trim(),realEmail=$('cuEmail').value.trim(),role=$('cuRole').value;
if(!account||!pwd){$('cuError').textContent='账号和密码必填';$('cuError').style.display='';return;}
$('cuError').style.display='none';
const res=await apiFetch('/admin/users',{method:'POST',body:JSON.stringify({account,password:pwd,nickname:nick,real_email:realEmail,role})});
if(res&&res.ok){closeCreateUser();loadStats();loadUsers();}
else{const d=await res.json();$('cuError').textContent=d.detail||'创建失败';$('cuError').style.display='';}
}
let editUserId=null;
function showEditUser(id,account,nickname,realEmail,role){editUserId=id;$('euAccount').value=account;$('euNickname').value=nickname;$('euEmail').value=realEmail;$('euRole').value=role;$('euError').style.display='none';$('editUserModal').classList.add('show');}
function closeEditUser(){$('editUserModal').classList.remove('show');editUserId=null;}
async function doEditUser(){
if(!editUserId)return;
const account=$('euAccount').value.trim(),nick=$('euNickname').value.trim(),realEmail=$('euEmail').value.trim(),role=$('euRole').value;
if(!account){$('euError').textContent='账号必填';$('euError').style.display='';return;}
$('euError').style.display='none';
const res=await apiFetch('/admin/users/'+editUserId,{method:'PUT',body:JSON.stringify({account,nickname:nick,real_email:realEmail,role})});
if(res&&res.ok){closeEditUser();loadStats();loadUsers();}
else{const d=await res.json();$('euError').textContent=d.detail||'保存失败';$('euError').style.display='';}
}
async function deleteUser(id,email){
if(!confirm(`确定删除用户 ${email}?此操作不可恢复!`))return;
await apiFetch('/admin/users/'+id,{method:'DELETE'});
loadStats();loadGroups();loadUsers();
}
let pwdUserId=null;
async function sendResetLink(uid,uname){
if(!confirm(`确定向 ${uname} 发送密码重置链接?`))return;
const res=await apiFetch('/admin/users/'+uid+'/password',{method:'PUT'});
if(res&&res.ok){const d=await res.json();alert(d.message);}
else{const d=await res.json().catch(()=>({}));alert(d.detail||'发送失败,请确认用户已绑定邮箱');}
}
function showCreateGroup(){$('groupModal').classList.add('show');$('groupName').value='';$('groupDesc').value='';}
function closeGroupModal(){$('groupModal').classList.remove('show');}
async function createGroup(){
const name=$('groupName').value.trim();if(!name)return;
await apiFetch('/admin/groups',{method:'POST',body:JSON.stringify({name,description:$('groupDesc').value.trim()})});
closeGroupModal();loadGroups();
}
async function deleteGroup(id){
if(!confirm('确定删除该组?组内成员将被移出。'))return;
await apiFetch('/admin/groups/'+id,{method:'DELETE'});
loadGroups();loadUsers();
}
async function showMembers(gid,gname){
currentGroupId=gid;
$('memberGroupName').textContent=gname;
const members=allUsers.filter(u=>u.group_id===gid);
$('memberList').innerHTML=members.length?members.map(u=>`<span style="display:inline-block;padding:3px 8px;margin:2px;background:#eef2f7;border-radius:4px;font-size:12px">${u.nickname||u.account} <a href="#" onclick="removeMember(${u.id});return false" style="color:#dc2626;margin-left:4px">×</a></span>`).join(''):'<span style="font-size:12px;color:#94a3b8">暂无成员</span>';
const unassigned=allUsers.filter(u=>!u.group_id&&u.role!=='admin');
$('memberSelect').innerHTML=unassigned.map(u=>`<option value="${u.id}">${u.nickname||u.account} (${u.account})</option>`).join('');
$('memberModal').classList.add('show');
}
function closeMemberModal(){$('memberModal').classList.remove('show');currentGroupId=null;}
async function addMember(){
const uid=+$('memberSelect').value;if(!uid||!currentGroupId)return;
await apiFetch('/admin/groups/'+currentGroupId+'/members',{method:'POST',body:JSON.stringify({user_id:uid})});
loadGroups();loadUsers();showMembers(currentGroupId,$('memberGroupName').textContent);
}
async function removeMember(uid){
if(!currentGroupId)return;
await apiFetch('/admin/groups/'+currentGroupId+'/members/'+uid,{method:'DELETE'});
loadGroups();loadUsers();showMembers(currentGroupId,$('memberGroupName').textContent);
}
function adminLogout(){
localStorage.removeItem('access_token');localStorage.removeItem('refresh_token');
window.location.href='/';
}
function showEmailTestModal(){$('emailTestTo').value='';$('emailTestMsg').style.display='none';$('emailTestModal').classList.add('show');}
function closeEmailTestModal(){$('emailTestModal').classList.remove('show');}
async function doEmailTest(){
const to=$('emailTestTo').value.trim();
if(!to||!to.includes('@'))return;
const msg=$('emailTestMsg');
const btn=$('emailTestBtn');
btn.disabled=true;btn.textContent='发送中...';msg.style.display='none';
const res=await apiFetch('/admin/email-test',{method:'POST',body:JSON.stringify({to_email:to})});
if(res&&res.ok){
const d=await res.json();
msg.style.display='';msg.style.color='#16a34a';msg.textContent=d.message;
}else{
const d=await res.json().catch(()=>({}));
msg.style.display='';msg.style.color='#dc2626';msg.textContent=d.detail||'发送失败,请检查 SMTP 配置';
}
btn.disabled=false;btn.textContent='发送测试';
}
async function loadEmailConfig(){
const res=await apiFetch('/admin/email-config');
if(!res||!res.ok)return;
const d=await res.json();
$('smtpHost').value=d.smtp_host||'';
$('smtpPort').value=d.smtp_port||465;
$('smtpUser').value=d.smtp_user||'';
$('smtpPassword').value=d.smtp_password||'';
$('smtpFrom').value=d.smtp_from||'';
$('smtpTls').checked=d.smtp_use_tls!==false;
}
async function saveEmailConfig(){
const data={
smtp_host:$('smtpHost').value.trim(),
smtp_port:parseInt($('smtpPort').value)||465,
smtp_user:$('smtpUser').value.trim(),
smtp_password:$('smtpPassword').value,
smtp_from:$('smtpFrom').value.trim(),
smtp_use_tls:$('smtpTls').checked,
};
const res=await apiFetch('/admin/email-config',{method:'PUT',body:JSON.stringify(data)});
const msg=$('emailConfigMsg');
if(res&&res.ok){msg.style.display='';msg.style.color='#16a34a';msg.textContent='配置已保存';}
else{msg.style.display='';msg.style.color='#dc2626';msg.textContent='保存失败';}
setTimeout(()=>{msg.style.display='none';},3000);
}
async function loadSiteUrl(){
const res=await apiFetch('/admin/site-url');
if(!res||!res.ok)return;
const d=await res.json();
$('siteUrl').value=d.site_url||'';
}
async function saveSiteUrl(){
const url=$('siteUrl').value.trim();
const res=await apiFetch('/admin/site-url',{method:'PUT',body:JSON.stringify({site_url:url})});
const msg=$('siteUrlMsg');
if(res&&res.ok){msg.style.display='';msg.style.color='#16a34a';msg.textContent='站点地址已保存';}
else{msg.style.display='';msg.style.color='#dc2626';msg.textContent='保存失败';}
setTimeout(()=>{msg.style.display='none';},3000);
}
let notifPage=1;
function notifTypeIcon(type){const map={repayment_due:"📅",repayment_overdue:"⚠️",system:"📢",account:"👤"};return map[type]||"📩";}
function timeAgo(dt){const diff=(Date.now()-new Date(dt).getTime())/1000;if(diff<60)return "刚刚";if(diff<3600)return Math.floor(diff/60)+"分钟前";if(diff<86400)return Math.floor(diff/3600)+"小时前";return Math.floor(diff/86400)+"天前";}
function toggleNotifDropdown(e){e.stopPropagation();const dd=$("notifDropdown");dd.classList.toggle("show");if(dd.classList.contains("show"))loadNotifications();}
document.addEventListener("click",function(){$("notifDropdown")?.classList.remove("show");});
async function loadNotifications(){const res=await apiFetch("/notifications?size=20");if(!res||!res.ok)return;const data=await res.json();renderNotifBadge(data.unread);renderNotifList(data.items);}
function renderNotifBadge(unread){const badge=$("notifBadge");if(unread>0){badge.style.display="";badge.textContent=unread>99?"99+":unread;}else{badge.style.display="none";}}
function renderNotifList(items){const el=$("notifList");if(!items.length){el.innerHTML="<div class="notif-empty">暂无通知</div>";return;}el.innerHTML=items.map(n=>`<div class="notif-item${n.is_read===0?" unread":""}" onclick="readNotif(${n.id})"><div class="notif-icon ${n.type}">${notifTypeIcon(n.type)}</div><div class="notif-content"><div class="notif-title">${n.title}</div><div class="notif-msg">${n.message}</div></div><div class="notif-time">${timeAgo(n.created_at)}</div></div>`).join("");}
async function readNotif(id){await apiFetch("/notifications/"+id+"/read",{method:"PUT"});loadNotifications();}
async function markAllNotifRead(){await apiFetch("/notifications/read-all",{method:"PUT"});loadNotifications();}
init();
</script>
</body>
</html>

668
frontend/index.html Normal file
View File

@@ -0,0 +1,668 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache">
<title>债务统计</title>
<style>
:root {
--bg: #f5f5f7;
--card: rgba(255,255,255,.8);
--glass: rgba(255,255,255,.72);
--border: rgba(0,0,0,.06);
--text: #1d1d1f;
--text2: #6e6e73;
--text3: #aeaeb2;
--blue: #0071e3;
--green: #34c759;
--red: #ff3b30;
--orange: #ff9500;
--purple: #af52de;
--radius: 16px;
--radius-sm: 10px;
--radius-xs: 8px;
}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text","PingFang SC",sans-serif;background:var(--bg);color:var(--text);min-height:100vh;font-size:14px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
/* HEADER */
.header{background:rgba(255,255,255,.72);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:40;padding:0}
.header-inner{max-width:1120px;margin:0 auto;padding:0 24px;height:52px;display:flex;align-items:center;justify-content:space-between}
.header h1{font-size:17px;font-weight:600;color:var(--text);letter-spacing:-.2px}
.header-right{display:flex;align-items:center;gap:4px}
.header-right a,.hdr-btn{color:var(--text2);text-decoration:none;font-size:12px;padding:6px 12px;border-radius:var(--radius-xs);transition:all .2s;background:transparent;border:none;cursor:pointer;font-family:inherit}
.header-right a:hover,.hdr-btn:hover{background:rgba(0,0,0,.04);color:var(--text)}
.header-right .admin-link{color:var(--blue)}
.header-right .logout-link{color:var(--red)}
.user-info{color:var(--text);font-size:13px;font-weight:500;cursor:pointer;padding:6px 12px;border-radius:var(--radius-xs);transition:all .2s}
.user-info:hover{background:rgba(0,0,0,.04)}
/* MAIN */
.main{max-width:1120px;margin:0 auto;padding:24px;display:grid;grid-template-columns:320px 1fr;gap:20px}
@media(max-width:900px){.main{grid-template-columns:1fr}}
/* PANEL */
.panel{background:var(--card);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);border-radius:var(--radius);border:1px solid var(--border);overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.panel-title{padding:16px 20px;border-bottom:1px solid var(--border);font-size:14px;font-weight:600;color:var(--text);display:flex;align-items:center;justify-content:space-between;background:linear-gradient(180deg,rgba(245,245,247,.8) 0%,rgba(255,255,255,0) 100%);position:relative}
.panel-title::before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:18px;background:var(--blue);border-radius:2px}
.panel-title .title-text{display:flex;align-items:center;gap:8px;padding-left:8px}
.panel-title .title-badge{font-size:10px;padding:2px 8px;border-radius:4px;background:rgba(0,113,227,.08);color:var(--blue);font-weight:500}
.panel-body{padding:16px 20px}
.panel+.panel{margin-top:16px}
/* FORM */
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px 16px;padding:4px 0}
.form-grid .form-row{margin-bottom:0;min-width:0}
.form-row label{display:block;font-size:11px;color:var(--text2);margin-bottom:4px;font-weight:500}
.form-row input,.form-row select{width:100%;min-width:0;padding:8px 12px;border:1px solid #d2d2d7;border-radius:var(--radius-xs);font-size:13px;outline:none;transition:all .2s;background:#fff;color:var(--text);-webkit-appearance:none}
.form-row input:focus,.form-row select:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,113,227,.12)}
.calc-hint{font-size:11px;color:var(--blue);margin-top:3px;min-height:16px}
/* BUTTONS */
.btn-row{display:flex;gap:10px;margin-top:16px}
.btn{flex:1;padding:10px 0;border:none;border-radius:var(--radius-xs);font-size:14px;font-weight:500;cursor:pointer;transition:all .2s;font-family:inherit}
.btn-main{background:var(--blue);color:#fff}
.btn-main:hover{background:#0077ed}
.btn-ghost{background:#fff;color:var(--text2);border:1px solid #d2d2d7}
.btn-ghost:hover{background:#f5f5f7;border-color:#bbb}
/* LOAN LIST */
.loan-list{overflow-y:auto}
.loan-group{border-bottom:1px solid var(--border)}
.loan-group:last-child{border-bottom:none}
.loan-group-header{padding:12px 20px;display:flex;justify-content:space-between;align-items:center;cursor:pointer;user-select:none;transition:background .2s}
.loan-group-header:hover{background:rgba(0,0,0,.02)}
.loan-group-header .arrow{font-size:10px;color:var(--text3);transition:transform .3s cubic-bezier(.4,0,.2,1);margin-right:8px;display:inline-block}
.loan-group-header .arrow.open{transform:rotate(90deg)}
.loan-group-header .gname{font-size:15px;font-weight:600;color:var(--text)}
.loan-group-header .gsummary{font-size:12px;color:var(--text3)}
.loan-group-body{display:none}
.loan-group-body.open{display:block}
.loan-item{padding:10px 20px 10px 36px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .15s;display:flex;justify-content:space-between;align-items:center}
.loan-item:last-child{border-bottom:none}
.loan-item:hover{background:rgba(0,0,0,.02)}
.loan-item.active{background:rgba(0,113,227,.04);border-left:3px solid var(--blue)}
.loan-item .iname{font-size:13px;font-weight:500;color:var(--text)}
.loan-item .isub{font-size:11px;color:var(--text3);margin-top:2px}
.loan-item .iamt{font-size:13px;font-weight:600;color:var(--text)}
.loan-item.done-item{background:rgba(52,199,89,.04)}
.loan-item.done-item .iname,.loan-item.done-item .isub{text-decoration:line-through;color:var(--text3)}
.loan-item.done-item .iamt{text-decoration:line-through;color:var(--text3)}
.loan-actions-popup{display:flex;gap:6px;padding:8px 20px;border-top:1px solid var(--border);background:rgba(245,245,247,.5)}
.loan-actions-popup .act-btn{padding:5px 14px;border-radius:8px;font-size:12px;font-weight:500;cursor:pointer;border:none;transition:all .2s;font-family:inherit}
.act-btn-edit{background:rgba(0,113,227,.08);color:var(--blue)}
.act-btn-edit:hover{background:rgba(0,113,227,.15)}
.act-btn-pay{background:rgba(52,199,89,.08);color:var(--green)}
.act-btn-pay:hover{background:rgba(52,199,89,.15)}
.act-btn-del{background:rgba(255,59,48,.08);color:var(--red)}
.act-btn-del:hover{background:rgba(255,59,48,.15)}
.quick-panel{padding:8px 20px 12px;border-top:1px solid var(--border);background:rgba(245,245,247,.5)}
.quick-panel .qp-title{font-size:11px;color:var(--text3);margin-bottom:6px;font-weight:500}
.quick-panel .qp-row{display:flex;justify-content:space-between;align-items:center;padding:4px 0;font-size:12px}
.quick-panel .qp-row .qp-info{color:var(--text2)}
.quick-panel .qp-row .qp-amt{color:var(--text);font-weight:500;font-variant-numeric:tabular-nums}
.quick-panel .qp-row .qp-btn{padding:3px 10px;border:none;border-radius:6px;font-size:11px;cursor:pointer;font-weight:500;transition:all .2s;font-family:inherit}
.qp-btn-done{background:#34c759!important;color:#fff!important}
.qp-btn-pay{background:var(--blue);color:#fff}
.qp-btn-pay:hover{background:#0077ed}
.badge-done{display:inline-block;font-size:9px;padding:2px 6px;border-radius:4px;background:var(--green);color:#fff;font-weight:600;margin-left:6px}
.empty-tip{padding:32px;text-align:center;color:var(--text3);font-size:13px}
/* STATS */
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.stat-block{margin-bottom:0}
.stat-card{background:var(--card);border-radius:var(--radius-sm);padding:16px;text-align:center;border:1px solid var(--border);transition:all .2s;position:relative}
.stat-card .lbl{font-size:11px;color:var(--text3);margin-bottom:4px;font-weight:500;letter-spacing:.3px}
.stat-card .val{font-size:18px;font-weight:700;color:var(--text)}
.stat-card .val.blue{color:var(--blue)}
.stat-card .val.red{color:var(--red)}
.stat-card .val.green{color:var(--green)}
.stat-card.clickable{cursor:pointer}
.stat-card.clickable:hover{box-shadow:0 2px 12px rgba(0,0,0,.06);transform:translateY(-1px)}
.stat-arrow{position:absolute;top:8px;right:10px;font-size:8px;color:var(--text3);transition:transform .3s cubic-bezier(.4,0,.2,1)}
.stat-arrow.open{transform:rotate(90deg)}
.stats-detail-wrap{background:rgba(245,245,247,.5);border:1px solid var(--border);border-top:none;border-radius:0 0 var(--radius-sm) var(--radius-sm);padding:10px 16px}
.stat-sub{display:flex;justify-content:space-between;font-size:12px;padding:5px 0;border-bottom:1px solid var(--border)}
.stat-sub:last-child{border-bottom:none}
.stat-sub span:first-child{color:var(--text2)}
.stat-sub .stat-sub-val{font-weight:600;color:var(--text)}
@media(max-width:600px){.stats{grid-template-columns:repeat(2,1fr)}}
/* TAGS */
.tag{display:inline-block;padding:2px 7px;border-radius:6px;font-size:10px;font-weight:600}
.tag-e{background:rgba(0,113,227,.08);color:var(--blue)}
.tag-p{background:rgba(255,149,0,.08);color:var(--orange)}
.tag-i{background:rgba(175,82,222,.08);color:var(--purple)}
.tag-0{background:rgba(0,0,0,.04);color:var(--text3)}
/* RATE */
.rate-low{color:var(--green);font-weight:600}
.rate-mid{color:var(--orange);font-weight:600}
.rate-high{color:var(--red);font-weight:700}
/* MONTH PLAN */
.month-group{border-bottom:1px solid var(--border)}
.month-group:last-child{border-bottom:none}
.month-header{display:flex;justify-content:space-between;align-items:center;padding:12px 20px;cursor:pointer;user-select:none;transition:background .2s}
.month-header:hover{background:rgba(0,0,0,.02)}
.month-header .ym{font-size:14px;font-weight:600;color:var(--text)}
.month-header .total{font-size:15px;font-weight:700;color:var(--blue)}
.month-header .arrow{font-size:10px;color:var(--text3);transition:transform .3s cubic-bezier(.4,0,.2,1);display:inline-block}
.month-header .arrow.open{transform:rotate(90deg)}
.month-detail{display:none;padding:0 20px 12px}
.month-detail.show{display:block}
.loan-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border);cursor:pointer;font-size:13px;transition:all .15s}
.loan-row:last-child{border-bottom:none}
.loan-row:hover{background:rgba(0,0,0,.02);margin:0 -12px;padding:8px 12px;border-radius:var(--radius-xs)}
.loan-row .left{flex:1;min-width:0;display:flex;flex-direction:column}
.loan-row .borrower{font-size:13px;font-weight:500;color:var(--text)}
.loan-row .detail{font-size:11px;color:var(--text3);margin-top:2px}
.loan-row .right{flex-shrink:0;width:110px;text-align:right}
.loan-row .pay-amt{font-size:14px;font-weight:600;color:var(--text);font-variant-numeric:tabular-nums}
.loan-row .pay-date{font-size:11px;color:var(--text3);font-variant-numeric:tabular-nums}
.loan-row.paid{opacity:.45}
.loan-row.paid .borrower{text-decoration:line-through}
.paid-badge{display:inline-block;font-size:9px;padding:2px 6px;border-radius:4px;background:var(--green);color:#fff;margin-left:4px}
.loan-row .pay-check{width:22px;height:22px;border:2px solid #d2d2d7;border-radius:6px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:all .2s;margin-left:12px}
.loan-row .pay-check:hover{border-color:var(--blue);background:rgba(0,113,227,.06)}
.loan-row .pay-check.checked{background:var(--blue);border-color:var(--blue)}
.loan-row .pay-check.checked::after{content:"✓";color:#fff;font-size:12px;font-weight:700}
.btn-pay-all{padding:6px 16px;border:1px solid var(--blue);background:transparent;color:var(--blue);border-radius:var(--radius-xs);font-size:12px;cursor:pointer;transition:all .2s;white-space:nowrap;font-weight:500;font-family:inherit}
.btn-pay-all:hover{background:var(--blue);color:#fff}
/* MODALS */
.modal-mask{display:none;position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:100;align-items:center;justify-content:center;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
.modal-mask.show{display:flex}
.modal{background:#fff;border-radius:var(--radius);padding:28px;max-width:420px;width:90%;box-shadow:0 24px 80px rgba(0,0,0,.15);animation:fadeIn .25s ease-out}
@keyframes fadeIn{from{opacity:0;transform:scale(.96) translateY(8px)}to{opacity:1;transform:scale(1) translateY(0)}}
.modal h3{margin-bottom:18px;font-size:17px;color:var(--text);font-weight:600}
.modal .btn-row{margin-top:18px}
/* AUTH */
.auth-page{display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg)}
.auth-card{background:rgba(255,255,255,.72);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);border-radius:20px;padding:48px 44px;width:400px;border:1px solid var(--border);box-shadow:0 16px 48px rgba(0,0,0,.08)}
.auth-card h2{text-align:center;margin-bottom:32px;color:var(--text);font-size:22px;font-weight:700;letter-spacing:-.3px}
.auth-card .form-row{margin-bottom:18px}
.auth-card .form-row label{font-size:13px;font-weight:500}
.auth-card .form-row input{padding:12px 16px;font-size:15px}
.auth-card .links{text-align:center;margin-top:18px}
.auth-card .links a{color:var(--blue);text-decoration:none;font-size:13px;font-weight:500}
.auth-card .links a:hover{text-decoration:underline}
.auth-card .error-msg{display:none;font-size:13px;margin-top:0;text-align:center;padding:12px 18px;border-radius:var(--radius-sm);background:rgba(255,59,48,.08);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);color:var(--red);border:1px solid rgba(255,59,48,.15);line-height:1.5;align-items:center;justify-content:center;gap:8px;box-shadow:0 8px 32px rgba(255,59,48,.1);animation:fadeSlideIn .3s ease-out}
.auth-card .error-msg.show{display:flex}
@keyframes fadeSlideIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
.auth-card .success-msg{color:var(--green);font-size:13px;margin-top:12px;text-align:center;background:rgba(52,199,89,.06);padding:12px;border-radius:var(--radius-xs)}
/* DROPDOWN */
.export-wrap{position:relative;display:inline-block}
.export-menu{display:none;position:absolute;right:0;top:calc(100% + 4px);background:rgba(255,255,255,.95);backdrop-filter:blur(20px);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:0 8px 32px rgba(0,0,0,.12);min-width:180px;z-index:50;overflow:hidden}
.export-menu.show{display:block}
.export-menu a{display:block;padding:10px 18px;font-size:13px;color:var(--text);text-decoration:none;transition:background .15s}
.export-menu a:hover{background:rgba(0,0,0,.04)}
#loginPage,#registerPage{display:none}
/* TOAST */
.toast-container{position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none}
.toast{padding:12px 24px;border-radius:var(--radius-sm);font-size:13px;font-weight:500;box-shadow:0 8px 24px rgba(0,0,0,.12);animation:toastIn .3s ease-out;pointer-events:auto;max-width:400px;text-align:center}
.toast.info{background:#1d1d1f;color:#fff}
.toast.success{background:#fff;color:var(--green);border:1px solid rgba(52,199,89,.2)}
.toast.warning{background:#fff;color:var(--orange);border:1px solid rgba(255,149,0,.2)}
.toast.error{background:#fff;color:var(--red);border:1px solid rgba(255,59,48,.2)}
@keyframes toastIn{from{opacity:0;transform:translateY(-12px)}to{opacity:1;transform:translateY(0)}}
/* NOTIFICATION BANNER */
.notif-banner{background:linear-gradient(135deg,#ff9500,#ff3b30);color:#fff;padding:10px 24px;text-align:center;font-size:13px;font-weight:500;cursor:pointer;animation:slideDown .3s ease-out}
.notif-banner:hover{filter:brightness(1.05)}
@keyframes slideDown{from{max-height:0;opacity:0}to{max-height:60px;opacity:1}}
/* NOTIFICATION CENTER */
.notif-bell{position:relative;cursor:pointer;padding:6px 10px;border-radius:var(--radius-xs);transition:all .2s}
.notif-bell:hover{background:rgba(0,0,0,.04)}
.notif-bell svg{width:18px;height:18px;fill:var(--text2);transition:fill .2s}
.notif-bell:hover svg{fill:var(--text)}
.notif-badge{position:absolute;top:4px;right:4px;min-width:16px;height:16px;background:var(--red);color:#fff;border-radius:8px;font-size:10px;font-weight:600;display:flex;align-items:center;justify-content:center;padding:0 4px;border:2px solid #fff;animation:badgePop .3s ease-out}
@keyframes badgePop{from{transform:scale(0)}to{transform:scale(1)}}
.notif-dropdown{display:none;position:absolute;right:0;top:calc(100% + 8px);background:rgba(255,255,255,.96);backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 12px 48px rgba(0,0,0,.15);width:360px;max-height:480px;overflow:hidden;z-index:60}
.notif-dropdown.show{display:block}
.notif-header{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.notif-header h4{font-size:14px;font-weight:600;color:var(--text)}
.notif-header .mark-all{font-size:12px;color:var(--blue);cursor:pointer;background:none;border:none;font-family:inherit}
.notif-header .mark-all:hover{text-decoration:underline}
.notif-list{overflow-y:auto;max-height:380px}
.notif-item{padding:12px 16px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .15s;display:flex;gap:12px;align-items:flex-start}
.notif-item:hover{background:rgba(0,0,0,.02)}
.notif-item.unread{background:rgba(0,113,227,.03)}
.notif-item .notif-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
.notif-icon.repayment_due{background:rgba(0,113,227,.08)}
.notif-icon.repayment_overdue{background:rgba(255,59,48,.08)}
.notif-icon.system{background:rgba(175,82,222,.08)}
.notif-icon.account{background:rgba(52,199,89,.08)}
.notif-item .notif-content{flex:1;min-width:0}
.notif-item .notif-title{font-size:13px;font-weight:500;color:var(--text);margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.notif-item .notif-msg{font-size:12px;color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.notif-item .notif-time{font-size:11px;color:var(--text3);white-space:nowrap}
.notif-empty{text-align:center;padding:32px;color:var(--text3);font-size:13px}
</style>
</head>
<body>
<div class="toast-container" id="toastContainer"></div>
<div id="notifBanner" style="display:none"></div>
<div id="loginPage" class="auth-page">
<div class="auth-card">
<h2>债务管理系统</h2>
<div class="form-row"><label>账号</label><input type="text" id="loginEmail" placeholder="请输入账号"></div>
<div class="form-row"><label>密码</label><input type="password" id="loginPwd" placeholder="请输入密码" onkeydown="if(event.key==='Enter')doLogin()"></div>
<div id="loginError" class="error-msg"><span></span><span id="loginErrorText"></span></div>
<div class="btn-row"><button class="btn btn-main" onclick="doLogin()">登录</button></div>
<div class="links"><a href="#" onclick="showRegister()">注册新账户</a> <span style="color:var(--text3);margin:0 6px">|</span> <a href="#" onclick="showForgotStep1()">找回密码</a></div>
</div>
</div>
<div class="modal-mask" id="forgotStep1">
<div class="modal">
<h3>找回密码</h3>
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">请输入账号或绑定的邮箱,我们将发送验证码。</p>
<div class="form-row"><label>账号 / 邮箱</label><input type="text" id="fpAccount" placeholder="请输入账号或邮箱"></div>
<div id="fpStep1Error" class="error-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeForgotModals()">取消</button>
<button class="btn btn-main" onclick="doForgotStep1()">发送验证码</button>
</div>
</div>
</div>
<div class="modal-mask" id="forgotStep2">
<div class="modal">
<h3>输入验证码</h3>
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">验证码已发送至绑定邮箱,请输入 6 位验证码。</p>
<div class="form-row"><label>验证码</label><input type="text" id="fpCode" placeholder="6 位数字验证码" maxlength="6" style="letter-spacing:4px;text-align:center;font-size:18px"></div>
<div id="fpStep2Error" class="error-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeForgotModals()">取消</button>
<button class="btn btn-main" onclick="doForgotStep2()">验证</button>
</div>
</div>
</div>
<div class="modal-mask" id="forgotStep3">
<div class="modal">
<h3>重置密码</h3>
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">验证码通过,请设置新密码。</p>
<div class="form-row"><label>新密码</label><input type="password" id="fpNewPwd" placeholder="请输入新密码"></div>
<div class="form-row"><label>确认新密码</label><input type="password" id="fpNewPwd2" placeholder="请再次输入新密码"></div>
<div id="fpStep3Error" class="error-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeForgotModals()">取消</button>
<button class="btn btn-main" onclick="doForgotStep3()">确认重置</button>
</div>
</div>
</div>
<div id="registerPage" class="auth-page">
<div class="auth-card">
<h2>注册账户</h2>
<div id="regSuccess" class="success-msg" style="display:none"></div>
<div id="regForm">
<div class="form-row"><label>账号</label><input type="text" id="regEmail" placeholder="请输入账号"></div>
<div class="form-row"><label>昵称</label><input type="text" id="regNick" placeholder="请输入昵称"></div>
<div class="form-row"><label>密码</label><input type="password" id="regPwd" placeholder="请输入密码"></div>
<div class="form-row"><label>确认密码</label><input type="password" id="regPwd2" placeholder="请再次输入密码"></div>
<div id="regError" class="error-msg"></div>
<div class="btn-row"><button class="btn btn-main" onclick="doRegister()">注册</button></div>
</div>
<div class="links"><a href="#" onclick="showLogin()">已有账户?去登录</a></div>
</div>
</div>
<div id="appPage">
<div class="header">
<div class="header-inner">
<h1>债务统计</h1>
<div class="header-right">
<div class="export-wrap" style="position:relative">
<div class="notif-bell" onclick="toggleNotifDropdown(event)">
<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>
<span class="notif-badge" id="notifBadge" style="display:none">0</span>
</div>
<div class="notif-dropdown" id="notifDropdown">
<div class="notif-header">
<h4>通知</h4>
<button class="mark-all" onclick="markAllNotifRead()">全部已读</button>
</div>
<div class="notif-list" id="notifList">
<div class="notif-empty">暂无通知</div>
</div>
</div>
</div>
<div class="export-wrap">
<span class="user-info" id="userInfo" onclick="toggleUserMenu(event)"></span>
<div class="export-menu" id="userMenu">
<a href="#" onclick="showChangeNick(event)">修改昵称</a>
<a href="#" onclick="showChangePwd(event)">修改密码</a>
</div>
</div>
<a href="/admin.html" class="admin-link" id="adminLink" style="display:none">管理</a>
<div class="export-wrap">
<button class="hdr-btn" onclick="toggleExport(event)">导出</button>
<div class="export-menu" id="exportMenu">
<a href="#" onclick="exportCSV(event)">导出 CSV</a>
<a href="#" onclick="exportJSON(event)">导出 JSON</a>
<a href="#" onclick="exportHTML(event)">导出 HTML</a>
</div>
</div>
<div class="export-wrap">
<button class="hdr-btn" onclick="toggleImport(event)">导入</button>
<div class="export-menu" id="importMenu">
<a href="#" onclick="downloadTemplate(event)">下载模板</a>
<a href="#" onclick="showImportModal(event)">导入 CSV</a>
</div>
</div>
<a href="#" class="logout-link" onclick="doLogout()">退出</a>
</div>
</div>
</div>
<div class="main">
<div style="display:flex;flex-direction:column;gap:16px">
<div class="panel">
<div class="panel-title"><span class="title-text">📝 新增借款</span><span class="title-badge" id="formTitle">编辑中</span></div>
<div class="panel-body">
<div class="form-grid">
<div class="form-row"><label>借款日期</label><input type="date" id="fDate"></div>
<div class="form-row"><label>债权人</label><input type="text" id="fName" placeholder="债权人名称"></div>
<div class="form-row"><label>借款金额(元)</label><input type="number" id="fAmt" placeholder="0.00" step="0.01" min="0"></div>
<div class="form-row"><label>用途</label><input type="text" id="fPurpose" placeholder="如:装修、教育"></div>
<div class="form-row"><label>还款方式</label><select id="fMethod"><option value="e">等额本息</option><option value="p">等额本金</option><option value="i">先息后本</option></select></div>
<div class="form-row"><label>分期数(月)</label><input type="number" id="fN" placeholder="12" min="1" max="360"></div>
<div class="form-row"><label>年化率(%</label><input type="number" id="fRate" placeholder="利率" step="0.01" min="0"><div class="calc-hint" id="rateHint"></div></div>
<div class="form-row"><label>总利息(元)</label><input type="number" id="fTotalInt" placeholder="总利息" step="0.01" min="0"><div class="calc-hint" id="intHint"></div></div>
</div>
<div class="btn-row">
<button class="btn btn-main" id="btnSubmit" onclick="submitLoan()">添加</button>
<button class="btn btn-ghost" onclick="clearForm()">清空</button>
</div>
</div>
</div>
<div class="panel">
<div class="panel-title"><span class="title-text">📋 借款列表</span> <span style="font-size:11px;color:var(--text3);font-weight:400;cursor:pointer;display:flex;gap:12px"><span onclick="toggleHidePaid()"><span id="hidePaidIcon">🙈</span> 显示已还清</span><span onclick="toggleHideOthers()"><span id="hideOthersIcon">🙈</span> 显示他人</span></span></div>
<div class="loan-list" id="loanList"><div class="empty-tip">暂无记录</div></div>
</div>
</div>
<div id="rightPanel" style="display:none">
<div class="panel" style="margin-bottom:16px">
<div class="panel-title"><span class="title-text">📊 汇总概览</span></div>
<div class="panel-body"><div class="stats" id="statsGrid"></div></div>
</div>
<div class="panel">
<div class="panel-title"><span class="title-text">📅 月度还款计划</span> <span style="font-size:11px;color:var(--text3);font-weight:400;margin-left:auto;display:flex;gap:14px;align-items:center"><span style="cursor:pointer" onclick="toggleHidePaidMonths()"><span id="hidePaidMonthsIcon">🙈</span> 显示已还完</span><span style="cursor:pointer" onclick="toggleHideOthers()"><span id="hideOthersIcon2">🙈</span> 显示他人</span><span>点击标记已还</span></span></div>
<div id="monthList"></div>
</div>
</div>
</div>
</div>
<div class="modal-mask" id="delModal"><div class="modal"><h3>确认删除</h3><p style="font-size:13px;color:var(--text2);line-height:1.6">此操作不可撤销,删除后该借款记录将永久移除。</p><div class="btn-row"><button class="btn btn-ghost" onclick="closeDel()">取消</button><button class="btn btn-main" style="background:var(--red)" onclick="confirmDel()">确认删除</button></div></div></div>
<div class="modal-mask" id="importModal"><div class="modal" style="max-width:500px"><h3>导入 CSV 数据</h3><p style="font-size:12px;color:var(--text2);margin-bottom:14px">选择 CSV 文件导入借款数据,可先下载模板再修改。</p><div class="form-row"><input type="file" id="importFile" accept=".csv" style="font-size:13px"></div><div id="importError" class="error-msg" style="display:none"></div><div id="importSuccess" class="success-msg" style="display:none"></div><div class="btn-row"><button class="btn btn-ghost" onclick="closeImportModal()">取消</button><button class="btn btn-main" onclick="doImport()">导入</button></div></div></div>
<div class="modal-mask" id="changeNickModal"><div class="modal"><h3>修改昵称</h3><div class="form-row"><label>新昵称</label><input type="text" id="newNick" placeholder="请输入新昵称"></div><div id="nickError" class="error-msg" style="display:none"></div><div id="nickSuccess" class="success-msg" style="display:none"></div><div class="btn-row"><button class="btn btn-ghost" onclick="closeChangeNick()">取消</button><button class="btn btn-main" onclick="doChangeNick()">确认修改</button></div></div></div>
<div class="modal-mask" id="changePwdModal"><div class="modal"><h3>修改密码</h3><div class="form-row"><label>原密码</label><input type="password" id="oldPwd" placeholder="请输入原密码"></div><div class="form-row"><label>新密码</label><input type="password" id="newPwd" placeholder="请输入新密码"></div><div class="form-row"><label>确认新密码</label><input type="password" id="newPwd2" placeholder="请再次输入新密码"></div><div id="pwdError" class="error-msg" style="display:none"></div><div id="pwdSuccess" class="success-msg" style="display:none"></div><div class="btn-row"><button class="btn btn-ghost" onclick="closeChangePwd()">取消</button><button class="btn btn-main" onclick="doChangePwd()">确认修改</button></div></div></div>
<div class="modal-mask" id="emailModal"><div class="modal"><h3>绑定邮箱</h3><p style="font-size:13px;color:var(--text2);line-height:1.6;margin-bottom:14px">首次使用请绑定邮箱,用于接收还款提醒和密码找回。</p><div class="form-row"><label>邮箱地址</label><input type="email" id="bindEmail" placeholder="请输入有效邮箱"></div><div id="bindEmailError" class="error-msg" style="display:none"></div><div class="btn-row"><button class="btn btn-main" onclick="doBindEmail()">确认绑定</button></div></div></div>
<script>
const API='/api/v1';
let token=localStorage.getItem('access_token')||'';
let refreshToken=localStorage.getItem('refresh_token')||'';
let currentUser=null;
let loans=[];
let delTarget=null;
let openMonths=new Set();
let openGroups=new Set();
let editId=null;
let hidePaidItems=true;
let hidePaidMonths=true;
let hideOthers=true;
let quickPayLoanId=null;
let actionLoanId=null;
const $=id=>document.getElementById(id);
const fmt=n=>n.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2});
const today=()=>new Date().toISOString().slice(0,10);
const ymOf=d=>d.slice(0,7);
const methodLabel=m=>m==='e'?'等额本息':m==='p'?'等额本金':'先息后本';
const methodTag=m=>m==='e'?'tag-e':m==='p'?'tag-p':'tag-i';
function showToast(msg,type='info',dur=3000){const c=$('toastContainer');const t=document.createElement('div');t.className='toast '+type;t.textContent=msg;c.appendChild(t);setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),300);},dur);}
let notifPage=1;
function notifTypeIcon(type){const map={repayment_due:'📅',repayment_overdue:'⚠️',system:'📢',account:'👤'};return map[type]||'📩';}
function notifTypeLabel(type){const map={repayment_due:'还款提醒',repayment_overdue:'逾期提醒',system:'系统消息',account:'账户通知'};return map[type]||'通知';}
function timeAgo(dt){const diff=(Date.now()-new Date(dt).getTime())/1000;if(diff<60)return '刚刚';if(diff<3600)return Math.floor(diff/60)+'分钟前';if(diff<86400)return Math.floor(diff/3600)+'小时前';return Math.floor(diff/86400)+'天前';}
function toggleNotifDropdown(e){e.stopPropagation();const dd=$('notifDropdown');dd.classList.toggle('show');if(dd.classList.contains('show'))loadNotifications();}
document.addEventListener('click',function(){$('notifDropdown')?.classList.remove('show');});
async function loadNotifications(){const res=await apiFetch('/notifications?size=20');if(!res||!res.ok)return;const data=await res.json();renderNotifBadge(data.unread);renderNotifList(data.items);}
function renderNotifBadge(unread){const badge=$('notifBadge');if(unread>0){badge.style.display='';badge.textContent=unread>99?'99+':unread;}else{badge.style.display='none';}}
function renderNotifList(items){const el=$('notifList');if(!items.length){el.innerHTML='<div class="notif-empty">暂无通知</div>';return;}el.innerHTML=items.map(n=>`<div class="notif-item${n.is_read===0?' unread':''}" onclick="readNotif(${n.id},'${n.type}',${n.related_debt_id||0})"><div class="notif-icon ${n.type}">${notifTypeIcon(n.type)}</div><div class="notif-content"><div class="notif-title">${n.title}</div><div class="notif-msg">${n.message}</div></div><div class="notif-time">${timeAgo(n.created_at)}</div></div>`).join('');}
async function readNotif(id,type,debtId){await apiFetch('/notifications/'+id+'/read',{method:'PUT'});loadNotifications();if(debtId&&(type==='repayment_due'||type==='repayment_overdue')){$('notifDropdown').classList.remove('show');}}
async function markAllNotifRead(){await apiFetch('/notifications/read-all',{method:'PUT'});loadNotifications();}
async function loadNotifBanner(){const res=await apiFetch('/notifications?unread_only=true&size=5');if(!res||!res.ok)return;const data=await res.json();const overdue=data.items.find(n=>n.type==='repayment_overdue');const due=data.items.find(n=>n.type==='repayment_due');const banner=overdue||due;if(banner){$('notifBanner').style.display='';$('notifBanner').className='notif-banner';$('notifBanner').textContent=banner.title;$('notifBanner').onclick=()=>{$('notifBanner').style.display='none';toggleNotifDropdown(new Event('click'));};}}
async function apiFetch(path,opts={}){
const headers={'Content-Type':'application/json',...opts.headers};
if(token)headers['Authorization']='Bearer '+token;
let res=await fetch(API+path,{...opts,headers});
if(res.status===401&&refreshToken){
const r=await fetch(API+'/auth/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({refresh_token:refreshToken})});
if(r.ok){const d=await r.json();token=d.access_token;refreshToken=d.refresh_token;localStorage.setItem('access_token',token);localStorage.setItem('refresh_token',refreshToken);headers['Authorization']='Bearer '+token;res=await fetch(API+path,{...opts,headers});}
else{doLogout();return null;}
}
return res;
}
async function checkAuth(){
if(!token){showLogin();return false;}
const res=await apiFetch('/auth/profile');
if(!res||!res.ok){showLogin();return false;}
currentUser=await res.json();
$('userInfo').textContent=currentUser.nickname||currentUser.account;
if(currentUser.role==='admin')$('adminLink').style.display='';
loadNotifBanner();
showApp();
if(!currentUser.real_email)$('emailModal').classList.add('show');
return true;
}
function showLogin(){$('loginPage').style.display='flex';$('registerPage').style.display='none';$('appPage').style.display='none';}
function showRegister(){$('loginPage').style.display='none';$('registerPage').style.display='flex';$('appPage').style.display='none';}
function showApp(){$('loginPage').style.display='none';$('registerPage').style.display='none';$('appPage').style.display='';}
function showLoginError(msg){$('loginErrorText').textContent=msg;$('loginError').classList.add('show');}
function hideLoginError(){$('loginError').classList.remove('show');$('loginErrorText').textContent='';}
let loginCountdown=null;
function startLoginCountdown(seconds){
if(loginCountdown)clearInterval(loginCountdown);
let remaining=seconds;
const btn=document.querySelector('#loginPage .btn-main');
btn.disabled=true;btn.style.opacity='.5';
function tick(){
const m=Math.floor(remaining/60),s=remaining%60;
showLoginError(`登录失败次数过多,请 ${m}:${String(s).padStart(2,'0')} 后再试`);
if(remaining<=0){clearInterval(loginCountdown);loginCountdown=null;btn.disabled=false;btn.style.opacity='';hideLoginError();}
remaining--;
}
tick();
loginCountdown=setInterval(tick,1000);
}
async function doLogin(){
const account=$('loginEmail').value.trim(),pwd=$('loginPwd').value;
if(!account||!pwd)return showLoginError('请输入账号和密码');
hideLoginError();
try{const res=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account,password:pwd})});const data=await res.json();if(!res.ok){if(res.status===429&&data.retry_after){startLoginCountdown(data.retry_after);}else{showLoginError(data.detail||'登录失败');}return;}token=data.access_token;refreshToken=data.refresh_token;localStorage.setItem('access_token',token);localStorage.setItem('refresh_token',refreshToken);await checkAuth();await loadLoans();}catch(e){showLoginError('网络连接异常,请稍后重试');}
}
async function doRegister(){
const account=$('regEmail').value.trim(),nick=$('regNick').value.trim(),pwd=$('regPwd').value,pwd2=$('regPwd2').value;
if(!account||!pwd)return $('regError').textContent='请填写账号和密码';
if(pwd!==pwd2)return $('regError').textContent='两次输入的密码不一致';
$('regError').textContent='';
try{const res=await fetch(API+'/auth/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account,password:pwd,nickname:nick})});const data=await res.json();if(!res.ok){$('regError').textContent=data.detail||'注册失败';return;}$('regForm').style.display='none';$('regSuccess').style.display='';$('regSuccess').textContent=data.message||'注册成功,等待管理员审批';}catch(e){$('regError').textContent='网络连接异常,请稍后重试';}
}
function doLogout(){token='';refreshToken='';currentUser=null;localStorage.removeItem('access_token');localStorage.removeItem('refresh_token');showLogin();}
async function loadLoans(){const res=await apiFetch('/loans');if(!res||!res.ok)return;loans=await res.json();renderAll();}
async function saveLoanToServer(loan){if(loan.id){const res=await apiFetch('/loans/'+loan.id,{method:'PUT',body:JSON.stringify(loan)});if(res&&res.ok)return await res.json();}else{const res=await apiFetch('/loans',{method:'POST',body:JSON.stringify(loan)});if(res&&res.ok)return await res.json();}return null;}
async function deleteLoanFromServer(id){const res=await apiFetch('/loans/'+id,{method:'DELETE'});return res&&res.ok;}
async function togglePaidOnServer(id,paid){const res=await apiFetch('/loans/'+id+'/toggle-paid',{method:'PATCH',body:JSON.stringify({paid})});if(res&&res.ok){const data=await res.json();return data.paid;}return null;}
$('fDate').value=today();
let syncingFields=false;
function calcTotalIntFromRate(a,r,n,m){if(!a||!r||!n)return 0;const mr=r/100/12;if(m==='e'){const mp=a*mr*Math.pow(1+mr,n)/(Math.pow(1+mr,n)-1);return mp*n-a;}if(m==='p')return a*mr*(n+1)/2;if(m==='i')return a*mr*n;return 0;}
function calcRateFromTotalInt(a,ti,n,m){if(!a||!ti||!n)return 0;if(m==='i')return ti*1200/(a*n);if(m==='p')return ti*2400/(a*(n+1));let lo=0,hi=1;for(let i=0;i<50;i++){if(calcTotalIntFromRate(a,hi,n,'e')>=ti)break;hi*=2;if(hi>100)return 0;}for(let i=0;i<100;i++){const mid=(lo+hi)/2;if(calcTotalIntFromRate(a,mid,n,'e')<ti)lo=mid;else hi=mid;}return(lo+hi)/2;}
function syncFromRate(){if(syncingFields)return;syncingFields=true;const a=+$('fAmt').value,r=+$('fRate').value,n=+$('fN').value,m=$('fMethod').value;if(a&&!isNaN(r)&&$('fRate').value!==''&&n){$('fTotalInt').value=calcTotalIntFromRate(a,r,n,m).toFixed(2);$('intHint').textContent='← 自动计算';$('rateHint').textContent='';}else{$('fTotalInt').value='';$('intHint').textContent='';}syncingFields=false;}
function syncFromTotalInt(){if(syncingFields)return;syncingFields=true;const a=+$('fAmt').value,ti=+$('fTotalInt').value,n=+$('fN').value,m=$('fMethod').value;if(a&&!isNaN(ti)&&$('fTotalInt').value!==''&&n){if(ti===0){$('fRate').value='0';$('rateHint').textContent='← 无息';$('intHint').textContent='';}else{const r=calcRateFromTotalInt(a,ti,n,m);$('fRate').value=r.toFixed(4);$('rateHint').textContent='← 自动计算';$('intHint').textContent='';}}else{$('fRate').value='';$('rateHint').textContent='';}syncingFields=false;}
let rateDebounce=null,intDebounce=null;
$('fRate').addEventListener('input',function(){clearTimeout(rateDebounce);clearTimeout(intDebounce);rateDebounce=setTimeout(syncFromRate,300);});
$('fRate').addEventListener('focus',function(){$('rateHint').textContent='输入后自动计算总利息';$('intHint').textContent='';});
$('fTotalInt').addEventListener('input',function(){clearTimeout(rateDebounce);clearTimeout(intDebounce);intDebounce=setTimeout(syncFromTotalInt,300);});
$('fTotalInt').addEventListener('focus',function(){$('intHint').textContent='输入后自动计算年利率';$('rateHint').textContent='';});
['fAmt','fN','fMethod'].forEach(id=>{$(id).addEventListener('change',function(){clearTimeout(rateDebounce);clearTimeout(intDebounce);const a=+$('fAmt').value,r=+$('fRate').value,ti=+$('fTotalInt').value,n=+$('fN').value,m=$('fMethod').value;if(a&&n&&$('fTotalInt').value!==''&&!isNaN(ti))syncFromTotalInt();else if(a&&n&&$('fRate').value!==''&&!isNaN(r))syncFromRate();});});
function calcSchedule(a,r,n,m,start){const mr=r/100/12,sd=new Date(start),list=[];if(m==='e'){const mp=mr===0?a/n:a*mr*Math.pow(1+mr,n)/(Math.pow(1+mr,n)-1);let rp=a;for(let i=0;i<n;i++){const it=rp*mr,pr=mp-it;rp-=pr;const dd=new Date(sd);dd.setMonth(dd.getMonth()+i+1);list.push({p:i+1,date:dd.toISOString().slice(0,10),pay:Math.round(mp*100)/100,pr:Math.round(pr*100)/100,it:Math.round(it*100)/100,rp:Math.round(Math.max(0,rp)*100)/100});}}else if(m==='p'){const pp=a/n;let rp=a;for(let i=0;i<n;i++){const it=rp*mr,pay=pp+it;rp-=pp;const dd=new Date(sd);dd.setMonth(dd.getMonth()+i+1);list.push({p:i+1,date:dd.toISOString().slice(0,10),pay:Math.round(pay*100)/100,pr:Math.round(pp*100)/100,it:Math.round(it*100)/100,rp:Math.round(Math.max(0,rp)*100)/100});}}else{const mi=a*mr;let rp=a;for(let i=0;i<n;i++){const isLast=i===n-1;const pr=isLast?a:0;const pay=mi+pr;const dd=new Date(sd);dd.setMonth(dd.getMonth()+i+1);list.push({p:i+1,date:dd.toISOString().slice(0,10),pay:Math.round(pay*100)/100,pr:Math.round(pr*100)/100,it:Math.round(mi*100)/100,rp:Math.round(Math.max(0,rp-pr)*100)/100});if(isLast)rp=0;}}return list;}
function renderAll(){renderList();renderRight();}
function renderList(){const el=$('loanList');if(!loans.length){el.innerHTML='<div class="empty-tip">暂无记录</div>';return;}let visibleLoans=loans;if(hideOthers)visibleLoans=loans.filter(l=>l.user_id===currentUser.id);const groups=new Map();visibleLoans.forEach(l=>{if(!groups.has(l.name))groups.set(l.name,[]);groups.get(l.name).push(l);});const sortedGroups=[...groups.entries()].sort(([,a],[,b])=>{const ea=a.reduce((m,l)=>l.date<m?l.date:m,'9999-99-99');const eb=b.reduce((m,l)=>l.date<m?l.date:m,'9999-99-99');return eb.localeCompare(ea);});let html='';sortedGroups.forEach(([name,items])=>{items.sort((a,b)=>b.date.localeCompare(a.date));let visibleItems=items;if(hidePaidItems)visibleItems=items.filter(l=>l.paid.length<l.periods);if(hidePaidItems&&!visibleItems.length)return;const isOpen=openGroups.has(name);const totalAmt=visibleItems.reduce((s,l)=>s+l.amount,0);const doneCount=items.filter(l=>l.paid.length>=l.periods).length;html+=`<div class="loan-group"><div class="loan-group-header" onclick="toggleGroup('${name}')"><span><span class="arrow${isOpen?' open':''}">▶</span><span class="gname">${name}</span></span><span class="gsummary">${visibleItems.length}笔 · ¥${fmt(totalAmt)}${doneCount===items.length?' · <span style="color:var(--green)">全部还清</span>':''}</span></div><div class="loan-group-body${isOpen?' open':''}">`;visibleItems.forEach(l=>{const mt=methodLabel(l.method),tc=methodTag(l.method);const allDone=l.paid.length>=l.periods;const noInt=l.rate===0;const rc=l.rate===0?'':l.rate<12?'rate-low':l.rate<=24?'rate-mid':'rate-high';const hasPopup=actionLoanId===l.id;const isOwner=l.user_id===currentUser.id;html+=`<div class="loan-item${l.id===editId?' active':''}${allDone?' done-item':''}" onclick="toggleLoanAction(${l.id})"><div style="flex:1"><div class="iname">${l.purpose||''}${allDone?'<span class="badge-done">已还清</span>':''} <span class="tag ${tc}">${mt}</span>${noInt?' <span class="tag tag-0">无息</span>':''}${l.owner_name?` <span class="tag" style="background:rgba(0,0,0,.04);color:var(--text2)">${l.owner_name}</span>`:''}</div><div class="isub">${l.paid.length}/${l.periods}期 · <span class="${rc}">${l.rate}%</span> · ${l.date}</div></div><div style="text-align:right"><div class="iamt">¥${fmt(l.amount)}</div></div></div>${hasPopup?`<div class="loan-actions-popup"><button class="act-btn act-btn-edit" onclick="event.stopPropagation();doEditLoan(${l.id})">✏️ 修改</button><button class="act-btn act-btn-pay" onclick="event.stopPropagation();toggleQuickPay(${l.id})">💰 快速还款</button>${isOwner?`<button class="act-btn act-btn-del" onclick="event.stopPropagation();showDel(${l.id})">🗑 删除</button>`:''}</div>`:''}${quickPayLoanId===l.id?renderQuickPay(l):''}`;});html+=`</div></div>`;});el.innerHTML=html;}
function toggleGroup(name){if(openGroups.has(name))openGroups.delete(name);else openGroups.add(name);renderList();}
function showLoanDetail(id){const l=loans.find(x=>x.id===id);if(!l)return;editId=id;$('fDate').value=l.date;$('fName').value=l.name;$('fPurpose').value=l.purpose||'';$('fAmt').value=l.amount;$('fRate').value=l.rate;$('fTotalInt').value='';$('fN').value=l.periods;$('fMethod').value=l.method;$('rateHint').textContent='';$('intHint').textContent='';$('formTitle').textContent='编辑中';$('btnSubmit').textContent='修改';document.querySelector('.main').scrollIntoView({behavior:'smooth',block:'start'});}
async function submitLoan(){clearTimeout(rateDebounce);clearTimeout(intDebounce);const a=+$('fAmt').value,ti=+$('fTotalInt').value,n=+$('fN').value,m=$('fMethod').value;let r=+$('fRate').value;if(a&&n&&!r&&ti){r=calcRateFromTotalInt(a,ti,n,m);$('fRate').value=r.toFixed(4);}const d=$('fDate').value,nm=$('fName').value.trim(),p=+$('fN').value;r=+$('fRate').value;if(!d||!nm||!a||isNaN(r)||r===''||!p)return showToast('请完善借款信息,年化率和总利息至少填写一项','warning');const schedule=calcSchedule(a,r,p,m,d);const loan={name:nm,date:d,amount:a,rate:Math.round(r*10000)/10000,periods:p,method:m,purpose:($('fPurpose').value||'').trim(),schedule,paid:[]};if(editId){const existing=loans.find(x=>x.id===editId);if(existing)loan.paid=existing.paid.filter(x=>x<=p);loan.id=editId;}const saved=await saveLoanToServer(loan);if(saved){if(editId){const idx=loans.findIndex(x=>x.id===editId);if(idx>=0)loans[idx]=saved;}else loans.push(saved);renderAll();clearForm();}}
let toggleLock=false;
async function togglePaid(id,p){if(toggleLock)return;const l=loans.find(x=>x.id===id);if(!l)return;if(l.user_id!==currentUser.id){showToast('仅支持本人操作还款','warning');return;}toggleLock=true;const wasPaid=l.paid.includes(p);if(wasPaid){l.paid=l.paid.filter(x=>x!==p);}else{l.paid=[...l.paid,p];}renderMonthPlan();renderStats();renderList();try{const serverPaid=await togglePaidOnServer(id,l.paid);if(serverPaid!==null){l.paid=serverPaid;}}catch(e){}toggleLock=false;renderMonthPlan();renderStats();renderList();}
async function payAllMonth(ym){if(toggleLock)return;toggleLock=true;const map=buildMonthMap();const items=map[ym];if(!items){toggleLock=false;return;}const unpaidItems=items.filter(r=>{const l=loans.find(x=>x.id===r.loanId);return l&&l.user_id===currentUser.id&&!l.paid.includes(r.p);});if(!unpaidItems.length){toggleLock=false;return;}for(const r of unpaidItems){const l=loans.find(x=>x.id===r.loanId);if(l&&!l.paid.includes(r.p)){l.paid=[...l.paid,r.p];}}renderMonthPlan();renderStats();renderList();for(const r of unpaidItems){const l=loans.find(x=>x.id===r.loanId);if(l){await togglePaidOnServer(r.loanId,l.paid);}}toggleLock=false;renderMonthPlan();renderStats();renderList();}
function clearForm(){$('fName').value='';$('fPurpose').value='';$('fAmt').value='';$('fRate').value='';$('fTotalInt').value='';$('fN').value='';$('fDate').value=today();$('rateHint').textContent='';$('intHint').textContent='';editId=null;$('formTitle').textContent='新增';$('btnSubmit').textContent='添加';}
function toggleLoanAction(id){if(actionLoanId===id){actionLoanId=null;quickPayLoanId=null;}else{actionLoanId=id;quickPayLoanId=null;}renderList();}
function doEditLoan(id){
const l=loans.find(x=>x.id===id);
if(!l)return;
if(l.user_id!==currentUser.id){showToast('仅支持修改本人的借款记录','warning');return;}
actionLoanId=null;quickPayLoanId=null;showLoanDetail(id);
}
function toggleQuickPay(id){quickPayLoanId=quickPayLoanId===id?null:id;renderList();}
function renderQuickPay(l){
const isOwner=l.user_id===currentUser.id;
let html=`<div class="quick-panel"><div class="qp-title">还款节点 · ${l.name} ${l.purpose?'· '+l.purpose:''}</div>`;
l.schedule.forEach(r=>{
const isPaid=l.paid.includes(r.p);
const btnClass=isPaid?'qp-btn-done':'qp-btn-pay';
const btnText=isPaid?'✓ 已还':'还款';
const disabled=!isOwner?(isPaid?'':`disabled title="只允许借款人还款"`):'';
const onclick=isOwner?`onclick="event.stopPropagation();togglePaid(${l.id},${r.p})"`:(isPaid?`onclick="event.stopPropagation();togglePaid(${l.id},${r.p})"`:'');
html+=`<div class="qp-row"><span class="qp-info">第${r.p}期 · ${r.date}</span><span class="qp-amt">¥${r.pay.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g,',')}</span><button class="qp-btn ${btnClass}" ${onclick}>${btnText}</button></div>`;
});
html+=`</div>`;
return html;
}
function showDel(id){const l=loans.find(x=>x.id===id);if(l&&l.user_id!==currentUser.id){showToast('仅支持删除本人的借款记录','warning');return;}delTarget=id;$('delModal').classList.add('show');}
function closeDel(){$('delModal').classList.remove('show');delTarget=null;}
async function confirmDel(){if(!delTarget)return;const ok=await deleteLoanFromServer(delTarget);if(ok){loans=loans.filter(l=>l.id!==delTarget);renderAll();}closeDel();}
function renderRight(){if(!loans.length){$('rightPanel').style.display='none';return;}$('rightPanel').style.display='';renderStats();renderMonthPlan();}
const statsExpanded=new Set();
function renderStats(){let totalAmt=0,totalInt=0,totalPaid=0,paidN=0,totalN=0;loans.forEach(l=>{totalAmt+=l.amount;totalN+=l.periods;const s=l.schedule;totalInt+=s.reduce((a,r)=>a+r.it,0);paidN+=l.paid.length;totalPaid+=s.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);});const totalPay=totalAmt+totalInt;const memberMap=new Map();loans.forEach(l=>{const owner=l.owner_name||'未知';if(!memberMap.has(owner))memberMap.set(owner,{amt:0,int:0,paid:0,paidN:0,totalN:0});const m=memberMap.get(owner);m.amt+=l.amount;m.totalN+=l.periods;const s=l.schedule;m.int+=s.reduce((a,r)=>a+r.it,0);m.paidN+=l.paid.length;m.paid+=s.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);});const cards=[{lbl:'借款总额',val:`¥${fmt(totalAmt)}`,cls:'blue',key:'amt'},{lbl:'总利息',val:`¥${fmt(totalInt)}`,cls:'red',key:'int'},{lbl:'总还款',val:`¥${fmt(totalPay)}`,cls:'',key:'pay'},{lbl:'已还',val:`¥${fmt(totalPaid)}`,cls:'green',key:'paid'},{lbl:'待还',val:`¥${fmt(totalPay-totalPaid)}`,cls:'red',key:'remain'},{lbl:'已还期数',val:`${paidN}/${totalN}`,cls:'green',key:'periods'}];let html='';cards.forEach(c=>{const isOpen=statsExpanded.has(c.key);html+=`<div class="stat-block"><div class="stat-card clickable" onclick="toggleStat('${c.key}')"><div class="lbl">${c.lbl}</div><div class="val ${c.cls}">${c.val}</div><div class="stat-arrow${isOpen?' open':''}">▶</div></div>`;if(isOpen){html+=`<div class="stats-detail-wrap">`;memberMap.forEach((m,owner)=>{const vals={amt:fmt(m.amt),int:fmt(m.int),pay:fmt(m.amt+m.int),paid:fmt(m.paid),remain:fmt(m.amt+m.int-m.paid),periods:`${m.paidN}/${m.totalN}`};html+=`<div class="stat-sub"><span>${owner}</span><span class="stat-sub-val">${vals[c.key]}</span></div>`;});html+=`</div>`;}html+=`</div>`;});$('statsGrid').innerHTML=html;}
function toggleStat(key){if(statsExpanded.has(key))statsExpanded.delete(key);else statsExpanded.add(key);renderStats();}
function buildMonthMap(){const map={};const visibleLoans=hideOthers?loans.filter(l=>l.user_id===currentUser.id):loans;visibleLoans.forEach(l=>{const displayName=l.purpose?`${l.name} · ${l.purpose}`:l.name;const ownerTag=l.owner_name?` [${l.owner_name}]`:'';l.schedule.forEach(r=>{const ym=ymOf(r.date);if(!map[ym])map[ym]=[];map[ym].push({loanId:l.id,loanName:displayName+ownerTag,method:l.method,...r,paid:l.paid.includes(r.p)});});});return map;}
function renderMonthPlan(){const map=buildMonthMap();let months=Object.keys(map).sort();if(!months.length){$('monthList').innerHTML='<div class="empty-tip">暂无还款计划</div>';return;}if(hidePaidMonths)months=months.filter(ym=>{const items=map[ym];return items.some(r=>!r.paid);});if(!months.length){$('monthList').innerHTML='<div class="empty-tip">所有月份已还清</div>';return;}const now=today(),nowYm=now.slice(0,7);$('monthList').innerHTML=months.map(ym=>{const items=map[ym];const monthTotal=items.reduce((a,r)=>a+r.pay,0);const paidCount=items.filter(r=>r.paid).length;const allPaid=paidCount===items.length;const isPast=ym<nowYm;const isNow=ym===nowYm;const isOpen=openMonths.has(ym);const payAllBtn=allPaid?'':`<button class="btn-pay-all" onclick="event.stopPropagation();payAllMonth('${ym}')">一键还款</button>`;return`<div class="month-group"><div class="month-header" onclick="toggleMonth('${ym}')"><span class="ym">${ym.replace('-','年')}${isNow?' (本月)':''}${isPast&&!allPaid?' ⚠️':''}</span><span style="display:flex;align-items:center;gap:12px"><span style="font-size:11px;color:var(--text3)">${paidCount}/${items.length}笔</span>${payAllBtn}<span class="total"${allPaid?' style="color:var(--green)"':''}>${allPaid?'✓ ':''}¥${fmt(monthTotal)}</span><span class="arrow${isOpen?' open':''}">▶</span></span></div><div class="month-detail${isOpen?' show':''}">${items.map(r=>{const ml=methodLabel(r.method);const ds=r.method==='i'?`利息 ¥${fmt(r.it)}${r.pr>0?' + 本金 ¥'+fmt(r.pr):''}`:`本金 ¥${fmt(r.pr)} + 利息 ¥${fmt(r.it)}`;const isChecked=r.paid;const cls='pay-check'+(isChecked?' checked':'')+(toggleLock?' disabled':'');return`<div class="loan-row${isChecked?' paid':''}" data-loan="${r.loanId}" data-period="${r.p}"><div class="left"><span class="borrower">${r.loanName}${isChecked?' <span class="paid-badge">✓ 已还</span>':''}</span><span class="detail">${ml} · 第${r.p}期 · ${ds}</span></div><div class="right"><div class="pay-amt">¥${r.pay.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g,',')}</div><div class="pay-date">${r.date}</div></div><div class="${cls}" onclick="event.stopPropagation();togglePaid(${r.loanId},${r.p})"></div></div>`;}).join('')}</div></div>`;}).join('');}
function toggleHidePaid(){hidePaidItems=!hidePaidItems;$('hidePaidIcon').textContent=hidePaidItems?'🙈':'👁';$('hidePaidIcon').nextSibling.textContent=hidePaidItems?' 显示已还清':' 隐藏已还清';renderList();}
function toggleHidePaidMonths(){hidePaidMonths=!hidePaidMonths;$('hidePaidMonthsIcon').textContent=hidePaidMonths?'🙈':'👁';$('hidePaidMonthsIcon').nextSibling.textContent=hidePaidMonths?' 显示已还完':' 隐藏已还完';renderMonthPlan();}
function toggleHideOthers(){hideOthers=!hideOthers;$('hideOthersIcon').textContent=hideOthers?'🙈':'👁';$('hideOthersIcon').nextSibling.textContent=hideOthers?' 显示他人':' 隐藏他人';$('hideOthersIcon2').textContent=hideOthers?'🙈':'👁';$('hideOthersIcon2').nextSibling.textContent=hideOthers?' 显示他人':' 隐藏他人';renderList();renderMonthPlan();}
function toggleMonth(ym){if(openMonths.has(ym))openMonths.delete(ym);else openMonths.add(ym);renderMonthPlan();}
function toggleExport(e){e.stopPropagation();$('exportMenu').classList.toggle('show');}
document.addEventListener('click',function(){$('exportMenu').classList.remove('show');});
function download(filename,content,mime){const blob=new Blob(['\uFEFF'+content],{type:mime});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=filename;a.click();}
function exportCSV(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');let csv='借款人,借款日期,借款金额,年利率(%),总利息,分期数,还款方式,已还期数,状态\n';loans.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const paid=l.paid.length+'/'+l.periods;const status=l.paid.length>=l.periods?'已还清':'还款中';csv+=`${l.name},${l.date},${l.amount},${l.rate},${ti.toFixed(2)},${l.periods},${methodLabel(l.method)},${paid},${status}\n`;});download('债务统计_'+today()+'.csv',csv,'text/csv;charset=utf-8');}
function exportJSON(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');const data=loans.map(l=>({借款人:l.name,借款日期:l.date,借款金额:l.amount,年利率:l.rate,分期数:l.periods,还款方式:methodLabel(l.method),已还期数:l.paid.length,状态:l.paid.length>=l.periods?'已还清':'还款中',还款计划:l.schedule.map(r=>({期数:r.p,日期:r.date,还款额:r.pay,本金:r.pr,利息:r.it}))}));download('债务统计_'+today()+'.json',JSON.stringify(data,null,2),'application/json;charset=utf-8');}
function exportHTML(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');let h=`<html><head><meta charset="utf-8"><title>债务统计</title><style>body{font-family:"Microsoft YaHei",sans-serif;padding:20px}h2{color:#1d1d1f}table{border-collapse:collapse;width:100%;font-size:13px;margin-bottom:20px}th{background:#0071e3;color:#fff;padding:8px 12px;text-align:left}td{padding:6px 12px;border-bottom:1px solid #d2d2d7}tr:nth-child(even){background:#f5f5f7}.done{color:#34c759;font-weight:600}</style></head><body><h2>债务统计报表 - ${today()}</h2>`;loans.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const st=l.paid.length>=l.periods;h+=`<h3>${l.name} - ${l.date} - ¥${fmt(l.amount)}${st?' <span class="done">已还清</span>':''}</h3><p>年利率 ${l.rate}% · ${methodLabel(l.method)} · ${l.periods}期 · 总利息 ¥${ti.toFixed(2)}</p><table><tr><th>期数</th><th>日期</th><th>还款额</th><th>本金</th><th>利息</th><th>剩余本金</th><th>状态</th></tr>`;l.schedule.forEach(r=>{h+=`<tr><td>${r.p}</td><td>${r.date}</td><td>¥${fmt(r.pay)}</td><td>¥${fmt(r.pr)}</td><td>¥${fmt(r.it)}</td><td>¥${fmt(r.rp)}</td><td>${l.paid.includes(r.p)?'✓ 已还':'待还'}</td></tr>`;});h+=`</table>`;});h+=`</body></html>`;download('债务统计_'+today()+'.html',h,'text/html;charset=utf-8');}
function toggleImport(e){e.stopPropagation();$('importMenu').classList.toggle('show');}
document.addEventListener('click',function(){$('importMenu').classList.remove('show');});
function showImportModal(e){e.preventDefault();$('importMenu').classList.remove('show');$('importFile').value='';$('importError').style.display='none';$('importSuccess').style.display='none';$('importModal').classList.add('show');}
function closeImportModal(){$('importModal').classList.remove('show');}
function downloadTemplate(e){e.preventDefault();$('importMenu').classList.remove('show');let csv='借款人,借款日期,借款金额,年利率(%),总利息,分期数,还款方式,已还期数,状态\n';csv+='张三,2026-01-01,100000,5.7,5198.99,24,等额本息,0/24,还款中\n';csv+='李四,2026-03-15,50000,8,1200.00,12,先息后本,0/12,还款中\n';csv+='王五,2026-06-01,30000,0,0.00,6,等额本息,0/6,还款中\n';download('导入模板.csv',csv,'text/csv;charset=utf-8');}
async function doImport(){const file=$('importFile').files[0];if(!file){$('importError').textContent='请选择文件';$('importError').style.display='';return;}$('importError').style.display='none';$('importSuccess').style.display='none';const text=await file.text();const lines=text.trim().split('\n');if(lines.length<2){$('importError').textContent='文件为空或格式错误';$('importError').style.display='';return;}const header=lines[0].split(',').map(s=>s.trim());const nameIdx=header.indexOf('借款人'),dateIdx=header.indexOf('借款日期'),amtIdx=header.indexOf('借款金额'),rateIdx=header.indexOf('年利率(%)'),periodsIdx=header.indexOf('分期数'),methodIdx=header.indexOf('还款方式'),paidIdx=header.indexOf('已还期数');if(nameIdx===-1||dateIdx===-1||amtIdx===-1||rateIdx===-1||periodsIdx===-1||methodIdx===-1){$('importError').textContent='缺少必需列:借款人,借款日期,借款金额,年利率(%),分期数,还款方式';$('importError').style.display='';return;}const methodMap={'等额本息':'e','等额本金':'p','先息后本':'i','e':'e','p':'p','i':'i'};let imported=0,failed=0;for(let i=1;i<lines.length;i++){const line=lines[i].trim();if(!line)continue;const cols=line.split(',').map(s=>s.trim());if(cols.length<6)continue;const name=cols[nameIdx],date=cols[dateIdx],amount=parseFloat(cols[amtIdx]),rate=parseFloat(cols[rateIdx]),periods=parseInt(cols[periodsIdx]),method=methodMap[cols[methodIdx]];if(!name||!date||isNaN(amount)||isNaN(rate)||isNaN(periods)||!method){failed++;continue;}const schedule=calcSchedule(amount,rate,periods,method,date);let paid=[];if(paidIdx>=0&&cols[paidIdx]){const paidStr=cols[paidIdx].trim();const m=paidStr.match(/^(\d+)\//);if(m){const paidCount=parseInt(m[1]);for(let p=1;p<=Math.min(paidCount,periods);p++)paid.push(p);}}const loan={name,date,amount,rate,periods,method,purpose:'',schedule,paid};const saved=await saveLoanToServer(loan);if(saved)loans.push(saved);else failed++;imported++;}if(imported>0){$('importSuccess').style.display='';$('importSuccess').textContent=`成功导入 ${imported}${failed?`,失败 ${failed}`:''}`;renderAll();}else{$('importError').textContent='导入失败,请检查文件格式';$('importError').style.display='';}}
function toggleUserMenu(e){e.stopPropagation();$('userMenu').classList.toggle('show');}
document.addEventListener('click',function(){$('userMenu').classList.remove('show');});
function showChangeNick(e){if(e)e.preventDefault();$('userMenu').classList.remove('show');$('newNick').value='';$('nickError').style.display='none';$('nickSuccess').style.display='none';$('changeNickModal').classList.add('show');}
function closeChangeNick(){$('changeNickModal').classList.remove('show');}
async function doChangeNick(){const nick=$('newNick').value.trim();if(!nick)return $('nickError').textContent='请输入昵称';$('nickError').style.display='';$('nickError').style.display='none';const res=await apiFetch('/auth/profile',{method:'PUT',body:JSON.stringify({nickname:nick})});if(res&&res.ok){$('nickSuccess').style.display='';$('nickSuccess').textContent='昵称修改成功';currentUser.nickname=nick;$('userInfo').textContent=nick||currentUser.account;}else{const d=await res.json();$('nickError').textContent=d.detail||'修改失败';$('nickError').style.display='';}}
function showChangePwd(e){if(e)e.preventDefault();$('userMenu').classList.remove('show');$('oldPwd').value='';$('newPwd').value='';$('newPwd2').value='';$('pwdError').style.display='none';$('pwdSuccess').style.display='none';$('changePwdModal').classList.add('show');}
function closeChangePwd(){$('changePwdModal').classList.remove('show');}
async function doChangePwd(){const old=$('oldPwd').value,nw=$('newPwd').value,nw2=$('newPwd2').value;if(!old||!nw)return $('pwdError').textContent='请填写所有字段';$('pwdError').style.display='';if(nw!==nw2){$('pwdError').textContent='两次新密码不一致';$('pwdError').style.display='';return;}$('pwdError').style.display='none';const res=await apiFetch('/auth/password',{method:'PUT',body:JSON.stringify({old_password:old,new_password:nw})});if(res&&res.ok){$('pwdSuccess').style.display='';$('pwdSuccess').textContent='密码修改成功';$('oldPwd').value='';$('newPwd').value='';$('newPwd2').value='';}else{const d=await res.json();$('pwdError').textContent=d.detail||'修改失败';$('pwdError').style.display='';}}
async function doBindEmail(){
const email=$('bindEmail').value.trim();
if(!email||!email.includes('@'))return $('bindEmailError').textContent='请输入有效邮箱地址';$('bindEmailError').style.display='';
$('bindEmailError').style.display='none';
const res=await apiFetch('/auth/profile',{method:'PUT',body:JSON.stringify({real_email:email})});
if(res&&res.ok){currentUser.real_email=email;$('emailModal').classList.remove('show');showToast('邮箱绑定成功','success');}
else{const d=await res.json();$('bindEmailError').textContent=d.detail||'绑定失败';$('bindEmailError').style.display='';}
}
let fpAccount='';
function showForgotStep1(){$('fpAccount').value='';$('fpStep1Error').style.display='none';$('forgotStep1').classList.add('show');}
function closeForgotModals(){$('forgotStep1').classList.remove('show');$('forgotStep2').classList.remove('show');$('forgotStep3').classList.remove('show');}
async function doForgotStep1(){
const account=$('fpAccount').value.trim();
if(!account){$('fpStep1Error').textContent='请输入账号或邮箱';$('fpStep1Error').style.display='';return;}
$('fpStep1Error').style.display='none';
const res=await fetch(API+'/auth/forgot-password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account})});
const data=await res.json();
fpAccount=account;
$('forgotStep1').classList.remove('show');
$('fpCode').value='';$('fpStep2Error').style.display='none';
$('forgotStep2').classList.add('show');
showToast(data.message||'验证码已发送','success');
}
async function doForgotStep2(){
const code=$('fpCode').value.trim();
if(!code||code.length!==6){$('fpStep2Error').textContent='请输入 6 位验证码';$('fpStep2Error').style.display='';return;}
$('fpStep2Error').style.display='none';
const res=await fetch(API+'/auth/verify-reset-code',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account:fpAccount,code})});
if(res.ok){
$('forgotStep2').classList.remove('show');
$('fpNewPwd').value='';$('fpNewPwd2').value='';$('fpStep3Error').style.display='none';
$('forgotStep3').classList.add('show');
}else{
const d=await res.json();
$('fpStep2Error').textContent=d.detail||'验证码错误';$('fpStep2Error').style.display='';
}
}
async function doForgotStep3(){
const nw=$('fpNewPwd').value,nw2=$('fpNewPwd2').value;
if(!nw){$('fpStep3Error').textContent='请输入新密码';$('fpStep3Error').style.display='';return;}
if(nw!==nw2){$('fpStep3Error').textContent='两次密码不一致';$('fpStep3Error').style.display='';return;}
$('fpStep3Error').style.display='none';
const code=$('fpCode').value.trim();
const res=await fetch(API+'/auth/reset-password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account:fpAccount,code,new_password:nw})});
if(res.ok){
closeForgotModals();
showToast('密码重置成功,请重新登录','success');
}else{
const d=await res.json();
$('fpStep3Error').textContent=d.detail||'重置失败';$('fpStep3Error').style.display='';
}
}
checkAuth().then(ok=>{if(ok)loadLoans();});
</script>
</body>
</html>

23
frontend/nginx.conf Normal file
View File

@@ -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";
}
}

View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>重置密码</title>
<style>
:root{--bg:#f5f5f7;--card:rgba(255,255,255,.72);--border:rgba(0,0,0,.06);--text:#1d1d1f;--text2:#6e6e73;--blue:#0071e3;--green:#34c759;--red:#ff3b30;--radius:20px;--radius-sm:10px;--radius-xs:8px}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC",sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;-webkit-font-smoothing:antialiased}
.auth-card{background:var(--card);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);border-radius:var(--radius);padding:48px 44px;width:400px;border:1px solid var(--border);box-shadow:0 16px 48px rgba(0,0,0,.08);animation:fadeIn .3s ease-out}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
.auth-card h2{text-align:center;margin-bottom:32px;color:var(--text);font-size:22px;font-weight:700;letter-spacing:-.3px}
.auth-card .form-row{margin-bottom:18px}
.auth-card .form-row label{display:block;font-size:13px;font-weight:500;margin-bottom:4px}
.auth-card .form-row input{width:100%;padding:12px 16px;border:1px solid #d2d2d7;border-radius:var(--radius-xs);font-size:15px;outline:none;transition:all .2s;background:#fff;color:var(--text)}
.auth-card .form-row input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,113,227,.12)}
.btn{width:100%;padding:12px 0;border:none;border-radius:var(--radius-xs);font-size:15px;font-weight:600;cursor:pointer;transition:all .2s;font-family:inherit;background:var(--blue);color:#fff}
.btn:hover{background:#0077ed}
.btn:disabled{opacity:.5;cursor:not-allowed}
.error-msg{display:none;font-size:12px;margin-top:12px;text-align:center;padding:10px 14px;border-radius:var(--radius-xs);background:rgba(255,59,48,.06);backdrop-filter:blur(20px);color:var(--red);border:1px solid rgba(255,59,48,.12);line-height:1.5}
.error-msg.show{display:flex;align-items:center;justify-content:center;gap:6px}
.success-msg{display:none;font-size:13px;margin-top:12px;text-align:center;padding:12px;border-radius:var(--radius-xs);background:rgba(52,199,89,.06);color:var(--green)}
.success-msg.show{display:block}
.loading{display:none;text-align:center;padding:40px;color:var(--text2);font-size:14px}
.loading.show{display:block}
.invalid{display:none;text-align:center;padding:40px;color:var(--text2);font-size:14px}
.invalid.show{display:block}
.links{text-align:center;margin-top:18px}
.links a{color:var(--blue);text-decoration:none;font-size:13px;font-weight:500}
.links a:hover{text-decoration:underline}
</style>
</head>
<body>
<div class="auth-card" id="card">
<h2>重置密码</h2>
<div class="loading" id="loading">验证链接中...</div>
<div class="invalid" id="invalid" style="display:none">
<p style="font-size:24px;margin-bottom:12px"></p>
<p id="invalidMsg">链接无效或已过期</p>
<div class="links" style="margin-top:20px"><a href="/">返回登录</a></div>
</div>
<div id="resetForm" style="display:none">
<p style="font-size:13px;color:var(--text2);margin-bottom:18px;text-align:center">Hi <strong id="userName"></strong>,请设置新密码。</p>
<div class="form-row"><label>新密码</label><input type="password" id="newPwd" placeholder="请输入新密码"></div>
<div class="form-row"><label>确认新密码</label><input type="password" id="newPwd2" placeholder="请再次输入新密码" onkeydown="if(event.key==='Enter')doReset()"></div>
<div id="resetError" class="error-msg"><span></span><span id="resetErrorText"></span></div>
<div id="resetSuccess" class="success-msg"></div>
<button class="btn" id="resetBtn" onclick="doReset()">确认重置</button>
<div class="links"><a href="/">返回登录</a></div>
</div>
</div>
<script>
const API='/api/v1';
const $=id=>document.getElementById(id);
const params=new URLSearchParams(window.location.search);
const token=params.get('token');
const uid=params.get('uid');
function showError(msg){$('resetErrorText').textContent=msg;$('resetError').classList.add('show');}
function hideError(){$('resetError').classList.remove('show');$('resetErrorText').textContent='';}
async function init(){
if(!token||!uid){$('loading').style.display='none';$('invalid').style.display='';$('invalidMsg').textContent='链接参数缺失';return;}
try{
const res=await fetch(API+'/auth/check-reset-token?token='+encodeURIComponent(token)+'&uid='+uid);
if(res.ok){
const data=await res.json();
$('userName').textContent=data.nickname;
$('loading').style.display='none';
$('resetForm').style.display='';
}else{
const data=await res.json().catch(()=>({}));
$('loading').style.display='none';
$('invalid').style.display='';
$('invalidMsg').textContent=data.detail||'链接无效';
}
}catch(e){
$('loading').style.display='none';
$('invalid').style.display='';
$('invalidMsg').textContent='网络异常,请稍后重试';
}
}
async function doReset(){
const nw=$('newPwd').value,nw2=$('newPwd2').value;
if(!nw){showError('请输入新密码');return;}
if(nw.length<6){showError('密码至少6位');return;}
if(nw!==nw2){showError('两次密码不一致');return;}
hideError();
$('resetBtn').disabled=true;$('resetBtn').textContent='重置中...';
try{
const res=await fetch(API+'/auth/reset-by-token',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,uid:+uid,new_password:nw})});
if(res.ok){
$('resetForm').style.display='none';
$('resetSuccess').style.display='';
$('resetSuccess').textContent='密码重置成功!';
setTimeout(()=>{window.location.href='/';},2000);
}else{
const data=await res.json().catch(()=>({}));
showError(data.detail||'重置失败');
$('resetBtn').disabled=false;$('resetBtn').textContent='确认重置';
}
}catch(e){
showError('网络异常,请稍后重试');
$('resetBtn').disabled=false;$('resetBtn').textContent='确认重置';
}
}
init();
</script>
</body>
</html>

47
restore.sh Executable file
View File

@@ -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 ""