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=>`${u.nickname||u.account} (${u.account}) `).join('');
+ $('memberSelect').innerHTML=unassigned.map(u=>`${u.nickname||u.account} (${escapeHtml(u.account)}) `).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)}
${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+=``;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}
${hasPopup?``:''}${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+=``;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}
${hasPopup?``:''}${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+=`
`;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`${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+=`${r.p} ${r.date} ¥${fmt(r.pay)} ¥${fmt(r.pr)} ¥${fmt(r.it)} ¥${fmt(r.rp)} ${l.paid.includes(r.p)?'✓ 已还':'待还'} `;});h+=`
`;});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+=`${r.p} ${r.date} ¥${fmt(r.pay)} ¥${fmt(r.pr)} ¥${fmt(r.it)} ¥${fmt(r.rp)} ${l.paid.includes(r.p)?'✓ 已还':'待还'} `;});h+=`
`;});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;