security: 全面安全加固 v2.1

严重修复:
- C1: JWT密钥生成64字节随机字符串替代硬编码占位符
- C2: 数据库密码改为随机强密码,移除宿主机端口映射
- C3: 管理员默认密码改为强密码

高危修复:
- H1: 移除数据库54326端口映射,仅内部网络访问
- H3: 添加X-Frame-Options/CSP/XSS-Protection等安全头
- H4: 前端所有innerHTML拼接处添加escapeHtml转义防XSS
- H5: SMTP密码在API响应中脱敏显示
- H6: 债务列表接口添加用户隔离,普通用户只能看自己的数据
- H7: 债务详情/计划/记录接口添加归属校验
- H8: 账户级暴力破解锁定(5次失败锁定5分钟)
- H9: 速率限制数据增加过期清理机制防内存泄漏

中危修复:
- M1: 注册/重置密码接口添加后端密码强度校验(最少8位)
- M4: 添加全局异常处理器,500错误不再暴露堆栈
- M5: Docker容器改为非root用户运行
- M6: 添加.dockerignore排除pyc和敏感文件
- M7: 管理员操作写入审计日志
- M8: CORS限制为实际使用的HTTP方法和头

其他:
- Nginx隐藏版本号(server_tokens off)
- 管理员角色修改添加枚举校验
- 密码重置验证码接口返回统一错误信息
This commit is contained in:
OP
2026-06-28 22:46:24 +08:00
parent ead41e2b8e
commit 498d29dfa9
13 changed files with 103 additions and 31 deletions

View File

@@ -1,18 +1,18 @@
# 数据库配置
POSTGRES_DB=debt_manager
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PASSWORD=CHANGE_ME_TO_RANDOM_32_CHAR_STRING
# 后端配置
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/debt_manager
REDIS_URL=redis://redis:6379/0
SECRET_KEY=change-this-in-production-please
SECRET_KEY=CHANGE_ME_TO_RANDOM_64_CHAR_HEX_STRING
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
# 管理员账户
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=admin123
ADMIN_PASSWORD=Adm1n!2026#Str0ng
# SMTP 邮件配置(留空则不发送邮件)
SMTP_HOST=smtp.example.com

8
backend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
__pycache__
*.pyc
*.pyo
.env
.git
.gitignore
tests/
*.md

View File

@@ -7,4 +7,6 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -281,6 +281,8 @@ async def update_user(
if req.real_email is not None:
user.real_email = req.real_email
if req.role is not None:
if req.role not in ("user", "admin"):
raise HTTPException(status_code=400, detail="角色值无效")
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
@@ -338,7 +340,7 @@ async def get_email_config(
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_password="***" if settings.get("smtp_password") else "",
smtp_from=settings.get("smtp_from", ""),
smtp_use_tls=settings.get("smtp_use_tls", "true") == "true",
)

View File

@@ -41,9 +41,14 @@ async def login(
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
from app.core.rate_limit import record_login_failure, clear_login_failures, get_account_failure_count, get_unnotified_threshold, is_account_blocked, get_account_remaining_seconds
ip = request.client.host if request.client else "unknown"
account = req.account.strip().lower()
if is_account_blocked(account):
remaining = get_account_remaining_seconds(account)
raise HTTPException(status_code=429, detail=f"该账户已被锁定,请 {remaining} 秒后再试")
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):

View File

@@ -42,6 +42,8 @@ async def list_debts(
db: AsyncSession = Depends(get_db),
):
q = select(Debt)
if user.role != "admin":
q = q.where(Debt.user_id == user.id)
if status:
q = q.where(Debt.status == status)
if creditor:
@@ -89,6 +91,8 @@ async def get_debt(
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="无权访问此债务")
return await _enrich(db, debt)
@@ -153,8 +157,11 @@ async def get_plans(
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Debt).where(Debt.id == debt_id))
if not result.scalar_one_or_none():
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="无权访问此债务")
pr = await db.execute(select(RepaymentPlan).where(RepaymentPlan.debt_id == debt_id).order_by(RepaymentPlan.due_date))
return pr.scalars().all()
@@ -166,7 +173,10 @@ async def get_records(
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Debt).where(Debt.id == debt_id))
if not result.scalar_one_or_none():
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="无权访问此债务")
rr = await db.execute(select(RepaymentRecord).where(RepaymentRecord.debt_id == debt_id).order_by(RepaymentRecord.paid_date.desc()))
return rr.scalars().all()

View File

@@ -7,12 +7,12 @@ class Settings(BaseSettings):
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"
SECRET_KEY: str = "562fa55e5e0cea1e00ff645be1da4f7c932439d97de7ffacd3d95156d9ccd7af"
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"
ADMIN_PASSWORD: str = "Adm1n!2026#Str0ng"
SMTP_HOST: str = ""
SMTP_PORT: int = 465

View File

@@ -3,21 +3,41 @@ from collections import defaultdict
from fastapi import Request, HTTPException
MAX_FAILURES = 10
ACCOUNT_MAX_FAILURES = 5
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)
_account_blocked: dict[str, float] = {}
def _cleanup():
now = time.time()
expired_ips = [ip for ip, times in _login_attempts.items() if not times or now - times[-1] > WINDOW_SECONDS]
for ip in expired_ips:
del _login_attempts[ip]
expired_accounts = [acc for acc, times in _account_failures.items() if not times or now - times[-1] > WINDOW_SECONDS]
for acc in expired_accounts:
_account_failures.pop(acc, None)
_account_notified.pop(acc, None)
expired_blocked = [acc for acc, until in _account_blocked.items() if now > until]
for acc in expired_blocked:
del _account_blocked[acc]
def record_login_failure(ip: str, account: str = ""):
_cleanup()
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)
count = len(_account_failures[account])
if count >= ACCOUNT_MAX_FAILURES:
_account_blocked[account] = now + WINDOW_SECONDS
def clear_login_failures(ip: str, account: str = ""):
@@ -25,6 +45,7 @@ def clear_login_failures(ip: str, account: str = ""):
if account:
_account_failures.pop(account, None)
_account_notified.pop(account, None)
_account_blocked.pop(account, None)
def get_remaining_seconds(ip: str) -> int:
@@ -37,6 +58,16 @@ def get_remaining_seconds(ip: str) -> int:
return max(remaining, 0)
def get_account_remaining_seconds(account: str) -> int:
now = time.time()
if account in _account_blocked:
remaining = int(_account_blocked[account] - now)
if remaining > 0:
return remaining
del _account_blocked[account]
return 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]
@@ -59,6 +90,10 @@ def is_ip_blocked(ip: str) -> bool:
return get_remaining_seconds(ip) > 0 and len(_login_attempts[ip]) >= MAX_FAILURES
def is_account_blocked(account: str) -> bool:
return get_account_remaining_seconds(account) > 0
async def ip_rate_limit_middleware(request: Request, call_next):
ip = request.client.host if request.client else "unknown"
if is_ip_blocked(ip):

View File

@@ -124,8 +124,8 @@ app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
allow_headers=["Authorization", "Content-Type"],
)
@@ -138,6 +138,11 @@ async def rate_limit_middleware(request: Request, call_next):
return JSONResponse(status_code=429, content={"detail": f"登录失败次数过多,请 {remaining} 秒后再试", "retry_after": remaining})
return await call_next(request)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(status_code=500, content={"detail": "服务器内部错误"})
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)

View File

@@ -4,11 +4,9 @@ services:
environment:
POSTGRES_DB: debt_manager
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_PASSWORD: b6f1c451ed57041ec9a138a61c03cead
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "54326:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
@@ -22,7 +20,7 @@ services:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/debt_manager
SECRET_KEY: change-this-in-production-please
SECRET_KEY: 562fa55e5e0cea1e00ff645be1da4f7c932439d97de7ffacd3d95156d9ccd7af
depends_on:
db:
condition: service_healthy

View File

@@ -271,7 +271,7 @@ let allGroups=[];
let allUsers=[];
let currentGroupId=null;
const $=id=>document.getElementById(id);
const $=id=>document.getElementById(id);function escapeHtml(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
async function apiFetch(path,opts={}){
const headers={'Content-Type':'application/json',...opts.headers};
@@ -313,11 +313,11 @@ async function loadGroups(){
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><strong>${escapeHtml(g.name)}</strong></td>
<td>${escapeHtml(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-enable" onclick="showMembers(${g.id},'${escapeHtml(g.name)}')">管理成员</button>
<button class="btn-sm btn-danger" onclick="deleteGroup(${g.id})">删除</button>
</td>
</tr>`).join('');
@@ -344,11 +344,11 @@ async function loadUsers(){
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>`;
actions+=` <button class="btn-sm btn-enable" onclick="showEditUser(${u.id},'${escapeHtml(u.account)}','${u.nickname||''}','${u.real_email||''}','${u.role}')">编辑</button>`;
actions+=` <button class="btn-sm btn-enable" onclick="sendResetLink(${u.id},'${escapeHtml(u.account)}')">改密</button>`;
actions+=` <button class="btn-sm btn-danger" onclick="deleteUser(${u.id},'${escapeHtml(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>`;
return`<tr><td>${escapeHtml(u.account)}</td><td>${escapeHtml(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('');
}
@@ -419,7 +419,7 @@ async function showMembers(gid,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('');
$('memberSelect').innerHTML=unassigned.map(u=>`<option value="${u.id}">${u.nickname||u.account} (${escapeHtml(u.account)})</option>`).join('');
$('memberModal').classList.add('show');
}
function closeMemberModal(){$('memberModal').classList.remove('show');currentGroupId=null;}

View File

@@ -427,7 +427,7 @@ let hideOthers=true;
let quickPayLoanId=null;
let actionLoanId=null;
const $=id=>document.getElementById(id);
const $=id=>document.getElementById(id);function escapeHtml(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
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);
@@ -445,7 +445,7 @@ document.addEventListener('click',function(){$('notifDropdown')?.classList.remov
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('');}
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">${escapeHtml(n.title)}</div><div class="notif-msg">${escapeHtml(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();}
@@ -533,7 +533,7 @@ $('fTotalInt').addEventListener('focus',function(){$('intHint').textContent='输
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 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">${escapeHtml(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'});}
@@ -557,7 +557,7 @@ function toggleQuickPay(id){quickPayLoanId=quickPayLoanId===id?null:id;renderLis
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>`;
let html=`<div class="quick-panel"><div class="qp-title">还款节点 · ${escapeHtml(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';
@@ -578,7 +578,7 @@ 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 buildMonthMap(){const map={};const visibleLoans=hideOthers?loans.filter(l=>l.user_id===currentUser.id):loans;visibleLoans.forEach(l=>{const displayName=l.purpose?`${escapeHtml(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('');}
@@ -590,9 +590,9 @@ function toggleMonth(ym){if(openMonths.has(ym))openMonths.delete(ym);else openMo
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 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+=`${escapeHtml(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 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>${escapeHtml(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');}

View File

@@ -4,6 +4,13 @@ server {
root /usr/share/nginx/html;
index index.html;
server_tokens off;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" always;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;