diff --git a/.env.example b/.env.example index 42c34e6..0beafcf 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..a50f79c --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +__pycache__ +*.pyc +*.pyo +.env +.git +.gitignore +tests/ +*.md diff --git a/backend/Dockerfile b/backend/Dockerfile index 534cbc6..cc17817 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 2ec646b..a68acd9 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -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", ) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index d94e46a..124f007 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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): diff --git a/backend/app/api/debts.py b/backend/app/api/debts.py index f177a5a..c6984f1 100644 --- a/backend/app/api/debts.py +++ b/backend/app/api/debts.py @@ -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() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 927746d..86e33fa 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py index 79d989e..3e83171 100644 --- a/backend/app/core/rate_limit.py +++ b/backend/app/core/rate_limit.py @@ -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): diff --git a/backend/app/main.py b/backend/app/main.py index e7d0a59..bdbc99d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index b31deeb..cbd9b25 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/admin.html b/frontend/admin.html index 3777ff4..d71fb01 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -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='暂无组';return;} tb.innerHTML=allGroups.map(g=>` - ${g.name} - ${g.description||'-'} + ${escapeHtml(g.name)} + ${escapeHtml(g.description||'-')} ${g.member_count}人 - + `).join(''); @@ -344,11 +344,11 @@ async function loadUsers(){ if(u.status==='pending')actions=``; if(u.status==='active')actions=``; if(u.status==='disabled')actions+=` `; - actions+=` `; - actions+=` `; - actions+=` `; + actions+=` `; + actions+=` `; + actions+=` `; } - return`${u.account}${u.nickname}${emailDisplay}${roleBadge}${groupBadge}${u.debt_count}笔${statusBadge}${actions}`; + return`${escapeHtml(u.account)}${escapeHtml(u.nickname)}${emailDisplay}${roleBadge}${groupBadge}${u.debt_count}笔${statusBadge}${actions}`; }).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=>`${u.nickname||u.account} ×`).join(''):'暂无成员'; const unassigned=allUsers.filter(u=>!u.group_id&&u.role!=='admin'); - $('memberSelect').innerHTML=unassigned.map(u=>``).join(''); + $('memberSelect').innerHTML=unassigned.map(u=>``).join(''); $('memberModal').classList.add('show'); } function closeMemberModal(){$('memberModal').classList.remove('show');currentGroupId=null;} diff --git a/frontend/index.html b/frontend/index.html index b67942a..1089f33 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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='
暂无通知
';return;}el.innerHTML=items.map(n=>`
${notifTypeIcon(n.type)}
${n.title}
${n.message}
${timeAgo(n.created_at)}
`).join('');} +function renderNotifList(items){const el=$('notifList');if(!items.length){el.innerHTML='
暂无通知
';return;}el.innerHTML=items.map(n=>`
${notifTypeIcon(n.type)}
${escapeHtml(n.title)}
${escapeHtml(n.message)}
${timeAgo(n.created_at)}
`).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暂无记录';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.datel.date{items.sort((a,b)=>b.date.localeCompare(a.date));let visibleItems=items;if(hidePaidItems)visibleItems=items.filter(l=>l.paid.lengths+l.amount,0);const doneCount=items.filter(l=>l.paid.length>=l.periods).length;html+=`
${name}${visibleItems.length}笔 · ¥${fmt(totalAmt)}${doneCount===items.length?' · 全部还清':''}
`;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+=`
${l.purpose||''}${allDone?'已还清':''} ${mt}${noInt?' 无息':''}${l.owner_name?` ${l.owner_name}`:''}
${l.paid.length}/${l.periods}期 · ${l.rate}% · ${l.date}
¥${fmt(l.amount)}
${hasPopup?`
${isOwner?``:''}
`:''}${quickPayLoanId===l.id?renderQuickPay(l):''}`;});html+=`
`;});el.innerHTML=html;} +function renderList(){const el=$('loanList');if(!loans.length){el.innerHTML='
暂无记录
';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.datel.date{items.sort((a,b)=>b.date.localeCompare(a.date));let visibleItems=items;if(hidePaidItems)visibleItems=items.filter(l=>l.paid.lengths+l.amount,0);const doneCount=items.filter(l=>l.paid.length>=l.periods).length;html+=`
${name}${visibleItems.length}笔 · ¥${fmt(totalAmt)}${doneCount===items.length?' · 全部还清':''}
`;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+=`
${escapeHtml(l.purpose||'')}${allDone?'已还清':''} ${mt}${noInt?' 无息':''}${l.owner_name?` ${l.owner_name}`:''}
${l.paid.length}/${l.periods}期 · ${l.rate}% · ${l.date}
¥${fmt(l.amount)}
${hasPopup?`
${isOwner?``:''}
`:''}${quickPayLoanId===l.id?renderQuickPay(l):''}`;});html+=`
`;});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=`
还款节点 · ${l.name} ${l.purpose?'· '+l.purpose:''}
`; + let html=`
还款节点 · ${escapeHtml(l.name)} ${l.purpose?'· '+l.purpose:''}
`; 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+=`
${c.lbl}
${c.val}
`;if(isOpen){html+=`
`;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+=`
${owner}${vals[c.key]}
`;});html+=`
`;}html+=`
`;});$('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='
暂无还款计划
';return;}if(hidePaidMonths)months=months.filter(ym=>{const items=map[ym];return items.some(r=>!r.paid);});if(!months.length){$('monthList').innerHTML='
所有月份已还清
';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一键还款`;return`
${ym.replace('-','年')}月${isNow?' (本月)':''}${isPast&&!allPaid?' ⚠️':''}${paidCount}/${items.length}笔${payAllBtn}${allPaid?'✓ ':''}¥${fmt(monthTotal)}
${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`
${r.loanName}${isChecked?' ':''}${ml} · 第${r.p}期 · ${ds}
¥${r.pay.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g,',')}
${r.date}
`;}).join('')}
`;}).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=`债务统计

债务统计报表 - ${today()}

`;loans.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const st=l.paid.length>=l.periods;h+=`

${l.name} - ${l.date} - ¥${fmt(l.amount)}${st?' 已还清':''}

年利率 ${l.rate}% · ${methodLabel(l.method)} · ${l.periods}期 · 总利息 ¥${ti.toFixed(2)}

`;l.schedule.forEach(r=>{h+=``;});h+=`
期数日期还款额本金利息剩余本金状态
${r.p}${r.date}¥${fmt(r.pay)}¥${fmt(r.pr)}¥${fmt(r.it)}¥${fmt(r.rp)}${l.paid.includes(r.p)?'✓ 已还':'待还'}
`;});h+=``;download('债务统计_'+today()+'.html',h,'text/html;charset=utf-8');} +function exportHTML(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');let h=`债务统计

债务统计报表 - ${today()}

`;loans.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const st=l.paid.length>=l.periods;h+=`

${escapeHtml(l.name)} - ${l.date} - ¥${fmt(l.amount)}${st?' 已还清':''}

年利率 ${l.rate}% · ${methodLabel(l.method)} · ${l.periods}期 · 总利息 ¥${ti.toFixed(2)}

`;l.schedule.forEach(r=>{h+=``;});h+=`
期数日期还款额本金利息剩余本金状态
${r.p}${r.date}¥${fmt(r.pay)}¥${fmt(r.pr)}¥${fmt(r.it)}¥${fmt(r.rp)}${l.paid.includes(r.p)?'✓ 已还':'待还'}
`;});h+=``;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');} diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 40afcf2..18bf22d 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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;