Sync: add project management and xr notes

This commit is contained in:
2026-04-25 06:25:02 +08:00
parent a26d62bb6d
commit 20f686ea5f
52 changed files with 3251 additions and 9 deletions

View File

@@ -0,0 +1,334 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-04-25T00:00:00.000Z" agent="OpenCode" version="21.0.0">
<!-- ============================================================
5.4.1 找回用户名流程
============================================================ -->
<diagram id="recover-username" name="5.4.1 找回用户名流程">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1"
connect="1" arrows="1" fold="1" page="1" pageScale="1"
pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<!-- Title -->
<mxCell id="t1" value="5.4.1 找回用户名流程" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="175" y="20" width="500" height="40" as="geometry" />
</mxCell>
<!-- Start -->
<mxCell id="u1" value="用户点击「忘记用户名」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontStyle=1;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="275" y="80" width="300" height="50" as="geometry" />
</mxCell>
<!-- Decision: Is email format valid? -->
<mxCell id="u2" value="展示「找回用户名」页面&#xa;(邮箱输入框 + 发送按钮)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="250" y="170" width="350" height="50" as="geometry" />
</mxCell>
<!-- User inputs email -->
<mxCell id="u3" value="用户输入邮箱并点击「发送」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="250" y="260" width="350" height="50" as="geometry" />
</mxCell>
<!-- Decision: Email format valid? -->
<mxCell id="u4" value="邮箱格式校验通过?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="275" y="350" width="300" height="70" as="geometry" />
</mxCell>
<!-- Format invalid -->
<mxCell id="u5" value="提示「请输入有效的邮箱地址」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="620" y="360" width="220" height="50" as="geometry" />
</mxCell>
<!-- Backend query -->
<mxCell id="u6" value="服务端查询邮箱是否绑定账号&#xa;(不向前端返回查询结果)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="250" y="470" width="350" height="60" as="geometry" />
</mxCell>
<!-- Unified response to frontend -->
<mxCell id="u7" value="统一响应前端:&#xa;「如该邮箱已绑定账号,您将收到邮件」&#xa;发送按钮进入 60 秒倒计时" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="250" y="580" width="350" height="70" as="geometry" />
</mxCell>
<!-- Decision: Email exists? -->
<mxCell id="u8" value="邮箱已绑定 Tenant Admin 账号?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="630" y="475" width="240" height="70" as="geometry" />
</mxCell>
<!-- Email found: send email -->
<mxCell id="u9" value="后台:异步发送邮件&#xa;(包含用户名、发送时间)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="640" y="580" width="220" height="60" as="geometry" />
</mxCell>
<!-- Email not found: silent -->
<mxCell id="u10" value="后台:静默处理&#xa;(不发送邮件,不报错)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="640" y="660" width="220" height="50" as="geometry" />
</mxCell>
<!-- Rate limit check -->
<mxCell id="u11" value="同一邮箱 1 小时内已发送 ≥ 3 次?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="630" y="700" width="240" height="70" as="geometry" />
</mxCell>
<!-- Rate limit exceeded -->
<mxCell id="u12" value="拒绝发送&#xa;(达到频率上限)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="900" y="715" width="160" height="50" as="geometry" />
</mxCell>
<!-- User receives email -->
<mxCell id="u13" value="用户查收邮件,获取用户名" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="640" y="810" width="220" height="50" as="geometry" />
</mxCell>
<!-- Return to login -->
<mxCell id="u14" value="点击「返回登录」&#xa;回到登录界面" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="275" y="700" width="300" height="50" as="geometry" />
</mxCell>
<!-- Edges -->
<mxCell id="e1" edge="1" source="u1" target="u2" parent="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e2" edge="1" source="u2" target="u3" parent="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e3" edge="1" source="u3" target="u4" parent="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e4" value="否" edge="1" source="u4" target="u5" parent="1">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="620" y="385" /></Array></mxGeometry>
</mxCell>
<mxCell id="e5" value="" edge="1" source="u5" target="u3" parent="1">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="730" y="285" /></Array></mxGeometry>
</mxCell>
<mxCell id="e6" value="是" edge="1" source="u4" target="u6" parent="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e7" edge="1" source="u6" target="u7" parent="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e8" edge="1" source="u6" target="u8" parent="1">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="600" y="500" /><mxPoint x="630" y="510" /></Array></mxGeometry>
</mxCell>
<mxCell id="e9" value="是" edge="1" source="u8" target="u11" parent="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e10" value="否(普通员工邮箱或不存在)" edge="1" source="u8" target="u10" parent="1">
<mxGeometry relative="1" as="geometry"><mxPoint x="760" y="660" as="targetPoint" /></mxGeometry>
</mxCell>
<mxCell id="e11" value="否(未超限)" edge="1" source="u11" target="u9" parent="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e12" value="是(超过 3 次)" edge="1" source="u11" target="u12" parent="1">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="735" /></Array></mxGeometry>
</mxCell>
<mxCell id="e13" edge="1" source="u9" target="u13" parent="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e14" edge="1" source="u13" target="u14" parent="1">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="750" y="835" /><mxPoint x="425" y="835" /><mxPoint x="425" y="750" /></Array></mxGeometry>
</mxCell>
<mxCell id="e15" edge="1" source="u7" target="u14" parent="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
<!-- ============================================================
5.4.2 找回密码流程
============================================================ -->
<diagram id="recover-password" name="5.4.2 找回密码流程">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1"
connect="1" arrows="1" fold="1" page="1" pageScale="1"
pageWidth="850" pageHeight="1300" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<!-- Title -->
<mxCell id="title" value="5.4.2 找回密码流程" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="175" y="20" width="500" height="40" as="geometry" />
</mxCell>
<!-- ===== STEP 1 Header ===== -->
<mxCell id="step1hdr" value="步骤一:身份验证" style="text;html=1;strokeColor=none;fillColor=#dae8fc;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="60" y="65" width="730" height="30" as="geometry" />
</mxCell>
<!-- Start -->
<mxCell id="p1" value="用户点击「忘记密码」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontStyle=1;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="275" y="115" width="300" height="50" as="geometry" />
</mxCell>
<!-- Show form -->
<mxCell id="p2" value="展示「找回密码」页面Stepper&#xa;步骤一:用户名 + 绑定邮箱输入框" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="225" y="200" width="400" height="60" as="geometry" />
</mxCell>
<!-- User submits -->
<mxCell id="p3" value="用户输入用户名 + 邮箱,点击「下一步」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="225" y="300" width="400" height="50" as="geometry" />
</mxCell>
<!-- Backend verification -->
<mxCell id="p4" value="服务端校验用户名与邮箱是否匹配" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="225" y="390" width="400" height="50" as="geometry" />
</mxCell>
<!-- Unified response -->
<mxCell id="p5" value="统一响应前端:&#xa;「如信息匹配,重置链接将发送至您的邮箱」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="225" y="480" width="400" height="60" as="geometry" />
</mxCell>
<!-- Decision: Match? -->
<mxCell id="p6" value="用户名与邮箱匹配?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="650" y="395" width="220" height="70" as="geometry" />
</mxCell>
<!-- Rate limit check -->
<mxCell id="p7" value="同一账号 1 小时内已发 ≥ 3 次?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="650" y="490" width="220" height="70" as="geometry" />
</mxCell>
<!-- Matched: generate token -->
<mxCell id="p8" value="生成加密 Token&#xa;secrets.token_urlsafe(32),有效期 30 分钟)&#xa;异步发送重置邮件" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="650" y="590" width="240" height="70" as="geometry" />
</mxCell>
<!-- No match: silent -->
<mxCell id="p9" value="不匹配:静默处理&#xa;(不发邮件,不报错)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="900" y="405" width="180" height="60" as="geometry" />
</mxCell>
<!-- Rate limit exceeded -->
<mxCell id="p10" value="已超频率上限&#xa;静默处理" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="900" y="505" width="160" height="50" as="geometry" />
</mxCell>
<!-- ===== STEP 2 Header ===== -->
<mxCell id="step2hdr" value="步骤二:用户点击邮件重置链接" style="text;html=1;strokeColor=none;fillColor=#d5e8d4;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="60" y="690" width="730" height="30" as="geometry" />
</mxCell>
<!-- User clicks link -->
<mxCell id="p11" value="用户点击邮件中的重置链接" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="275" y="740" width="300" height="50" as="geometry" />
</mxCell>
<!-- Validate token -->
<mxCell id="p12" value="服务端校验 Token 有效性&#xa;is_used=False AND expires_at 未过期)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="250" y="830" width="350" height="60" as="geometry" />
</mxCell>
<!-- Token decision -->
<mxCell id="p13" value="Token 有效?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="300" y="930" width="250" height="70" as="geometry" />
</mxCell>
<!-- Invalid token -->
<mxCell id="p14" value="提示「链接已过期或已使用,请重新申请」&#xa;提供「重新申请」按钮(跳回步骤一)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="620" y="940" width="260" height="60" as="geometry" />
</mxCell>
<!-- ===== STEP 3 Header ===== -->
<mxCell id="step3hdr" value="步骤三:输入并提交新密码" style="text;html=1;strokeColor=none;fillColor=#fff2cc;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="60" y="1030" width="730" height="30" as="geometry" />
</mxCell>
<!-- Show reset form -->
<mxCell id="p15" value="展示「重置密码」表单&#xa;(新密码 + 确认新密码 + 密码强度指示)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="250" y="1080" width="350" height="60" as="geometry" />
</mxCell>
<!-- User submits new password -->
<mxCell id="p16" value="用户输入新密码并提交" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="275" y="1180" width="300" height="50" as="geometry" />
</mxCell>
<!-- Complexity check -->
<mxCell id="p17" value="密码复杂度校验&#xa;≥8位含字母+数字,两次一致)" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="275" y="1265" width="300" height="80" as="geometry" />
</mxCell>
<!-- Complexity fail -->
<mxCell id="p18" value="实时提示不满足的规则&#xa;(逐条红色 ✗ / 绿色 ✓ 视觉指引)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="640" y="1275" width="240" height="60" as="geometry" />
</mxCell>
<!-- History check -->
<mxCell id="p19" value="历史密码校验&#xa;(不得与最近 3 次历史密码相同)" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="275" y="1380" width="300" height="80" as="geometry" />
</mxCell>
<!-- History fail -->
<mxCell id="p20" value="提示「不得与最近 3 次密码相同」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="640" y="1395" width="240" height="50" as="geometry" />
</mxCell>
<!-- Success: update password -->
<mxCell id="p21" value="✅ 校验通过:&#xa;① 更新密码PBKDF2+SHA256 哈希存储)&#xa;② is_initial_password = False&#xa;③ 清除该账号所有有效 Session&#xa;④ 标记 Token 为 is_used = True" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="225" y="1500" width="400" height="100" as="geometry" />
</mxCell>
<!-- End -->
<mxCell id="p22" value="跳转登录界面&#xa;提示「密码已重置,请使用新密码登录」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;" vertex="1" parent="1">
<mxGeometry x="275" y="1630" width="300" height="60" as="geometry" />
</mxCell>
<!-- Edges Step 1 -->
<mxCell id="ep1" edge="1" source="p1" target="p2" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="ep2" edge="1" source="p2" target="p3" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="ep3" edge="1" source="p3" target="p4" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="ep4" edge="1" source="p4" target="p5" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="ep5" edge="1" source="p4" target="p6" parent="1">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="625" y="415" /><mxPoint x="650" y="430" /></Array></mxGeometry>
</mxCell>
<mxCell id="ep6" value="否" edge="1" source="p6" target="p9" parent="1">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="430" /></Array></mxGeometry>
</mxCell>
<mxCell id="ep7" value="是" edge="1" source="p6" target="p7" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="ep8" value="是(超限)" edge="1" source="p7" target="p10" parent="1">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="525" /></Array></mxGeometry>
</mxCell>
<mxCell id="ep9" value="否(未超限)" edge="1" source="p7" target="p8" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
<!-- Step 2 edges -->
<mxCell id="ep10" edge="1" source="p5" target="p11" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="ep11" edge="1" source="p11" target="p12" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="ep12" edge="1" source="p12" target="p13" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="ep13" value="否(无效/过期)" edge="1" source="p13" target="p14" parent="1">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="620" y="965" /></Array></mxGeometry>
</mxCell>
<mxCell id="ep14" value="重新申请" edge="1" source="p14" target="p2" parent="1">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="960" y="970" /><mxPoint x="960" y="230" /><mxPoint x="625" y="230" /></Array></mxGeometry>
</mxCell>
<mxCell id="ep15" value="是(有效)" edge="1" source="p13" target="p15" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
<!-- Step 3 edges -->
<mxCell id="ep16" edge="1" source="p15" target="p16" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="ep17" edge="1" source="p16" target="p17" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="ep18" value="不通过" edge="1" source="p17" target="p18" parent="1">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="640" y="1305" /></Array></mxGeometry>
</mxCell>
<mxCell id="ep19" value="" edge="1" source="p18" target="p16" parent="1">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="760" y="1205" /></Array></mxGeometry>
</mxCell>
<mxCell id="ep20" value="通过" edge="1" source="p17" target="p19" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="ep21" value="不通过" edge="1" source="p19" target="p20" parent="1">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="640" y="1420" /></Array></mxGeometry>
</mxCell>
<mxCell id="ep22" value="" edge="1" source="p20" target="p16" parent="1">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="760" y="1205" /></Array></mxGeometry>
</mxCell>
<mxCell id="ep23" value="通过" edge="1" source="p19" target="p21" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="ep24" edge="1" source="p21" target="p22" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

@@ -0,0 +1,303 @@
# Fonrey 登录管理系统技术方案
**版本**: 1.0 | **项目**: Fonrey 房产经纪管理系统 | **技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL + Redis + Celery
**关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v1.3)
> **For AI assistants**: Read this entire file before writing any code.
> All decisions here are final. Do not suggest alternatives unless asked.
---
## 一、模块定位与架构边界
登录管理模块(`accounts` App负责多租户环境下的身份识别、认证、账号安全及凭据找回。其架构边界如下
| 层级 | 位置 | 说明 |
|------|------|------|
| Tenant ID 验证 | `shared_apps`(公共 Schema | 属于平台基础服务,在 `public` schema 下运行,无需租户切换 |
| 账号认证、找回密码等 | 租户 SchemaTenant Schema | 通过请求域名 `{tenant_slug}.fonrey.com` 自动切换,`django-tenants` 中间件处理 |
| Electron 客户端 | 前端 | 负责 Tenant ID 本地缓存、Session Token 管理、页面加载 |
---
## 二、依赖与技术选型
| 依赖项 | 版本/方案 | 用途 | 说明 |
|--------|-----------|------|------|
| `django.contrib.auth` | Django 内置 | 用户认证基础框架 | 扩展 `AbstractBaseUser`**不直接使用** `User` 模型username 唯一性约束在租户 Schema 维度生效,而非全局 |
| `django-tenants` | 已有 | 多租户隔离 | `UserAccount` 在租户 SchemaTenant 验证接口在 `shared_apps` |
| `PostgreSQL` | 已有 | 数据持久化 | Schema 级别隔离租户数据 |
| `Redis` | 必须 | 多用途缓存 | 滑块验证 TokenTTL 3min、登录失败计数TTL 30min、密码重置 Token 缓存 |
| `Celery` | 必须 | 异步任务队列 | 邮件发送异步处理,防止登录/找回接口超时 |
| `Pillow` | 必须(若自研验证码) | 图片处理 | 生成拼图背景图(抠出缺口)+ 拼图碎片,输出 Base64 |
| `django-ratelimit` 或自定义中间件 | 必须 | 接口限流 | Tenant 验证、登录、找回密码接口均需限流 |
| `electron-store` 或 AES 加密文件 | Electron 侧 | 本地持久化 | 加密存储 Tenant ID不存明文路径为 `app.getPath('userData')` |
| `secrets` (Python 标准库) | Python 内置 | Token 生成 | 使用 `secrets.token_urlsafe(32)` 生成密码重置 Token |
### 滑块验证码方案选型(待确认,见开放问题)
| 方案 | 优点 | 缺点 |
|------|------|------|
| 自研Pillow + 前端拖拽组件) | 完全可控,无外部依赖,数据合规性好 | 需维护图库,需自己实现轨迹检测算法 |
| 第三方服务(极验 GeeTest / 网易易盾) | 开箱即用,安全性更高 | 引入外部依赖,有数据合规风险,需评估 |
**当前方案**:暂按自研设计,后端负责人需在开发启动前确认最终选型。
---
## 三、目录结构
```
fonrey/apps/
└── accounts/ # 账号认证管理(租户级 App
├── models.py # UserAccount, LoginAttempt, PasswordResetToken
├── views.py # 登录/登出/找回账号/找回密码视图
├── urls.py
├── serializers.py # API 序列化JSON 接口)
└── services/
├── auth.py # 认证逻辑(验证码校验、账号锁定判断)
├── recovery.py # 找回密码/用户名逻辑(含邮件发送 Celery 任务)
└── tenant.py # Tenant 验证逻辑(属于 shared_apps公共 Schema
```
---
## 四、数据模型
### 4.1 `UserAccount`(核心账号表,位于租户 Schema
```python
class UserAccount(AbstractBaseUser):
id = BigAutoField(primary_key=True)
username = CharField(max_length=30) # 同租户内唯一普通员工为手机号Tenant Admin 为自定义字符串
email = EmailField(null=True, blank=True) # 同租户唯一,为空则无法自助找回密码
phone = CharField(max_length=11, null=True) # 加密存储core.encryption普通员工必填
staff = OneToOneField('org.Staff', null=True, on_delete=SET_NULL) # 实名绑定;普通员工必须
is_tenant_admin = BooleanField(default=False)
status = CharField(max_length=10) # active / disabled / locked
is_initial_password = BooleanField(default=True) # True → 登录后强制跳转修改密码
last_login = DateTimeField(null=True)
created_at = DateTimeField(auto_now_add=True)
created_by = ForeignKey('self', null=True, on_delete=SET_NULL)
USERNAME_FIELD = 'username'
class Meta:
unique_together = [('username',)] # Schema 内唯一,跨租户不冲突
```
**关键约束**
- `username` 唯一性约束仅在当前租户 Schema 内生效(`django-tenants` 隔离机制),不同租户可以有相同 username
- 密码存储使用 Django 默认 `PBKDF2+SHA256``make_password`**后端不得明文存储或传输**
- `phone` 字段使用 `core.encryption` 加密存储
### 4.2 `LoginAttempt`(登录审计,位于租户 Schema
```python
class LoginAttempt(Model):
username = CharField(max_length=30)
ip_address = GenericIPAddressField()
success = BooleanField()
failure_reason = CharField(max_length=30, null=True)
# 可选值wrong_password / wrong_captcha / account_locked / account_disabled
attempted_at = DateTimeField(auto_now_add=True)
```
**保留策略**:合规审计数据,**最少保留 90 天**,不得提前清理。
### 4.3 `PasswordResetToken`(密码重置令牌,位于租户 Schema
```python
class PasswordResetToken(Model):
user = ForeignKey(UserAccount, on_delete=CASCADE)
token = CharField(max_length=64) # secrets.token_urlsafe(32) 生成
expires_at = DateTimeField() # created_at + 30 分钟
is_used = BooleanField(default=False)
created_at = DateTimeField(auto_now_add=True)
```
**安全约束**
- Token 单次有效(使用后立即设 `is_used=True`
- 有效期 30 分钟,过期后拒绝使用
- 同一账号 1 小时内最多生成 3 个有效 Token服务端计数
---
## 五、Redis Key 规范
| 用途 | Key 格式 | TTL | 说明 |
|------|----------|-----|------|
| 滑块验证会话 Token | `captcha_token:{uuid}` | 3 分钟 | 前端拖动完成后服务端生成一次性通过凭证 |
| 登录失败计数 | `login_fail:{tenant_id}:{username}` | 30 分钟 | 计数 ≥ 5 时锁定账号TTL 30 分钟自动解锁 |
| 找回邮件发送频率 | `recover_email:{email}` | 1 小时 | 记录已发送次数,上限 3 次/小时 |
| Tenant ID 限流 | `tenant_verify_ip:{ip}` | 1 分钟 | 计数 ≥ 10 时拒绝请求 |
---
## 六、接口清单
| 接口 | 方法 | Schema 位置 | 是否需要鉴权 | 限流规则 | 说明 |
|------|------|------------|------------|---------|------|
| `/api/auth/tenant/verify/` | POST | Publicshared | 否 | 每 IP 每分钟 ≤ 10 次 | Tenant ID 验证 |
| `/api/auth/captcha/` | GET | Tenant | 否 | — | 获取滑块拼图验证码(背景图 Base64 + 碎片图 Base64 + 验证 Token |
| `/api/auth/captcha/verify/` | POST | Tenant | 否 | — | 提交滑动轨迹 + 位置,返回一次性通过凭证 |
| `/api/auth/login/` | POST | Tenant | 否 | 每 IP 每分钟 ≤ 20 次 | 账号密码登录 |
| `/api/auth/logout/` | POST | Tenant | 是 | — | 登出,使服务端 Session 失效 |
| `/api/auth/recover/username/` | POST | Tenant | 否 | 每邮箱每小时 ≤ 3 次 | 发起找回用户名(发送邮件) |
| `/api/auth/recover/password/request/` | POST | Tenant | 否 | 每账号每小时 ≤ 3 次 | 发起找回密码(发送重置链接邮件) |
| `/api/auth/recover/password/reset/` | POST | Tenant | 否Token 鉴权) | — | 提交新密码,使用 PasswordResetToken 校验 |
| `/api/auth/login/phone/` | POST | Tenant | 否 | — | **预留**v2 实现,手机验证码登录 |
| `/api/auth/wechat/qrcode/` | GET | Tenant | 否 | — | **预留**v2 实现,获取微信二维码 |
| `/api/auth/wechat/callback/` | POST | Tenant | 否 | — | **预留**v2 实现,微信扫码回调 |
### Tenant 验证接口 Request/Response 规范
```
POST /api/auth/tenant/verify/
Request Body:
{
"tenant_id": "202500010001" // 固定 12 位纯数字
}
Response 200 (成功):
{
"valid": true,
"tenant_name": "XX房产经纪有限公司",
"tenant_logo_url": "https://cdn.fonrey.com/tenants/xxx/logo.png",
"login_url": "https://xxx.fonrey.com/auth/login/"
}
Response 200 (失败):
{
"valid": false,
"error_code": "TENANT_NOT_FOUND",
"message": "识别码无效"
}
```
> **注意**:失败响应统一返回 HTTP 200不区分"未找到"与"已禁用",防止枚举攻击。
### 登录接口核心逻辑
```
POST /api/auth/login/
Request Body:
{
"username": "string",
"password": "string",
"captcha_token": "string", // 滑块验证通过后的一次性凭证
"captcha_pass_token": "string"
}
Response 200 (成功):
{
"token": "...",
"user": {
"id": 1,
"username": "...",
"display_name": "...",
"is_initial_password": false
}
}
```
---
## 七、安全机制设计
### 7.1 滑块拼图验证码
- **图片生成**`Pillow` 从预置图库随机抽取背景图,服务端随机生成缺口位置,抠出缺口并生成拼图碎片,两者分别以 Base64 返回前端
- **轨迹校验**:前端记录滑动过程的坐标序列 + 时间戳,提交至 `/api/auth/captcha/verify/`;服务端综合校验:
- **位置偏差**:碎片最终位置与缺口中心偏差 ≤ ±5px
- **轨迹特征**:存在加速→减速的非线性运动曲线;拒绝匀速/程序化轨迹
- **独立性**:验证码失败**不计入**账号密码错误次数,两者独立计数
- **有效期**:通过凭证(`captcha_pass_token`TTL 3 分钟,单次有效
### 7.2 账号锁定机制
- 同一账号(`login_fail:{tenant_id}:{username}`)连续密码错误 ≥ 5 次:
- 账号状态置为 `locked`,持续 30 分钟
- Redis TTL 30 分钟到期后自动恢复,同时 `status` 更新为 `active`
- Tenant Admin 可在管理界面手动解锁(提前恢复)
### 7.3 密码安全
| 规则 | 说明 |
|------|------|
| 存储哈希 | Django `PBKDF2+SHA256``make_password` |
| 传输安全 | 强制 HTTPS前端**不加密**密码HTTPS 层保证) |
| 复杂度 | 长度 8~32 位,必须包含字母(区分大小写)+ 数字;建议特殊符号(非强制) |
| 历史密码 | 不得与最近 3 次历史密码相同(含系统固定初始密码 `Fonrey@2025` |
| Session 有效期 | 默认 8 小时;可由 Tenant Admin 在「系统设置」中调整 |
### 7.4 密码重置流程安全要点
- Token 由 `secrets.token_urlsafe(32)` 生成64 字符,全局唯一
- 单次有效:使用后立即标记 `is_used=True`
- 有效期 30 分钟(`expires_at = created_at + timedelta(minutes=30)`
- 重置成功后:清除该账号所有有效 Session强制重新登录
- 重置成功后:`is_initial_password = False`
---
## 八、Electron 客户端约定
| 约定项 | 规格 |
|--------|------|
| Tenant ID 存储 | `electron-store``app.getPath('userData')` + AES 加密文件,**不存明文** |
| Session Token 存储 | 内存(`global` 变量)+ Chromium `session` Cookie**不写入磁盘明文文件** |
| 登录页加载方式 | 主进程根据 Tenant ID 构建 `https://{tenant_slug}.fonrey.com/auth/login/`,通过 `BrowserWindow.loadURL()` 加载 |
| 多标签页 | 同一 `BrowserWindow` 内所有页面共享同一 Session Cookie |
| 客户端登出 | 调用 `POST /api/auth/logout/` 使服务端 Session 失效 + 清除 Chromium Session Cookie |
| 窗口关闭 | Session 保留(不自动登出),下次打开若 Session 未过期则直接进入系统 |
| 强制更新 | 客户端版本低于服务端 `min_required_version` 时,阻断登录流程,展示更新提示 |
---
## 九、多租户隔离要点
- `UserAccount``LoginAttempt``PasswordResetToken` 均位于**租户 Schema 内**,数据完全隔离
- `username` 唯一性约束在 Schema 维度生效,不同租户可以存在相同 username
- Tenant 验证接口(`/api/auth/tenant/verify/`)位于 **Public Schema**`shared_apps`),查询 `TenantModel`
- 登录等接口通过请求域名(`{tenant_slug}.fonrey.com`)自动切换 Schema`django-tenants` 中间件处理,**无需手动切换**
---
## 十、已知风险与缓解措施
| 风险 | 可能性 | 影响 | 缓解措施 |
|------|--------|------|---------|
| 滑块验证被机器模拟轨迹绕过 | 低 | 高 | 服务端同时校验位置偏差 + 轨迹曲线特征,拒绝匀速/程序化轨迹;后续可引入设备指纹 |
| Tenant ID 枚举攻击 | 低 | 中 | 限流(每 IP 每分钟 ≤ 10 次);响应不区分"未找到"与"已禁用" |
| 密码重置 Token 泄露 | 低 | 高 | 单次有效 + 30 分钟过期 + HTTPS 传输 |
| 邮件发送失败 | 中 | 中 | 异步任务失败写入告警日志;管理员可通过后台查看 Token 手动告知用户 |
| 多端并发登录 | 高(正常场景) | 低 | 本期允许v2 可在 Token 引入版本号实现踢出策略 |
---
## 十一、开放问题(开发启动前必须确认)
| 问题 | 负责人 | 截止 |
|------|--------|------|
| 邮件服务商选型SendGrid / 阿里云邮件推送 / SMTP 自建? | 后端负责人 + 运维 | 开发启动前 |
| 滑块验证码方案自研Pillow还是第三方极验 / 网易易盾)? | 后端负责人 + 安全 | 开发启动前 |
| Session 有效期默认值 8 小时,是否允许 Tenant Admin 自行配置? | 产品经理 | 开发启动前 |
| 账号锁定后是否自动发邮件通知用户和/或管理员? | 产品经理 | 开发启动前 |
| 历史密码校验范围:最近 3 次是否足够?是否增加"不得与用户名相同"规则? | 产品经理 | 开发启动前 |
---
## 十二、明确禁止
- ❌ 不得使用 Django 原生 `User` 模型,必须扩展 `AbstractBaseUser`
- ❌ 不得在全局 Schema 创建 `UserAccount` 表(必须在租户 Schema 内)
- ❌ 不得明文存储或传输密码
- ❌ 不得在 `LoginAttempt` 记录中存储密码明文(含错误密码)
- ❌ 不得在前端做密码哈希HTTPS 层保证传输安全)
- ❌ 不得将 Session Token 写入 Electron 磁盘明文文件
- ❌ 不得在找回账号/密码响应中区分"邮箱存在"与"邮箱不存在"(防止枚举)
-`PasswordResetToken` 不得重复使用(`is_used=True` 后立即失效)
- ❌ 登录失败响应不得区分"用户名错误"与"密码错误"