> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked. # Fonrey 登录管理系统技术方案 **版本**: 2.0 | **项目**: Fonrey 房产经纪管理系统 | **技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL + Redis + Celery **关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v1.3) **关联数据模型**: `Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md` **最后更新**: 2026-04-25(v2.0 补充服务层设计、HTMX 交互模式、Celery 任务、错误处理规范) > **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`(Public Schema) | 属于平台基础服务,在 `public` schema 下运行,无需租户切换 | | 账号认证、找回密码等 | 租户 Schema(Tenant Schema) | 通过请求域名 `{tenant_slug}.fonrey.com` 自动切换,`django-tenants` 中间件处理 | | Electron 客户端 | 前端 | 负责 Tenant ID 本地缓存、Session 管理、页面加载 | ### 模块依赖关系 ``` accounts ├── 依赖 → org (Staff 实名绑定,单向依赖) ├── 依赖 → core.encryption (手机号加密) ├── 依赖 → core.cache (Redis 工具封装) ├── 依赖 → shared.tenants (Tenant ID 验证,Public Schema) └── 被依赖 ← org (离职联动,通过 Service 层调用) ``` --- ## 二、依赖与技术选型 | 依赖项 | 版本/方案 | 用途 | 说明 | |--------|-----------|------|------| | `django.contrib.auth` | Django 内置 | 用户认证基础框架 | 扩展 `AbstractBaseUser`,**不直接使用** `User` 模型;username 唯一性约束在租户 Schema 维度生效 | | `django-tenants` | 已有 | 多租户隔离 | `UserAccount` 在租户 Schema;Tenant 验证接口在 `shared_apps` | | `PostgreSQL` | 已有 | 数据持久化 | Schema 级别隔离租户数据 | | `Redis` | 必须 | 多用途缓存 | 滑块验证 Token(TTL 3min)、登录失败计数(TTL 30min)、密码重置频率限制 | | `Celery` | 必须 | 异步任务队列 | 邮件发送异步处理,防止登录/找回接口超时(邮件发送可能耗时 > 500ms) | | `Pillow` | 必须(若自研验证码) | 图片处理 | 生成拼图背景图(抠出缺口)+ 拼图碎片,输出 Base64 | | `django-ratelimit` 或自定义中间件 | 必须 | 接口限流 | Tenant 验证、登录、找回密码接口均需限流 | | `electron-store` 或 AES 加密文件 | Electron 侧 | 本地持久化 | 加密存储 Tenant ID,不存明文;路径为 `app.getPath('userData')` | | `secrets` (Python 标准库) | Python 内置 | Token 生成 | 使用 `secrets.token_urlsafe(64)` 生成密码重置 Token(86 字符) | ### 滑块验证码方案选型(待确认,见开放问题) | 方案 | 优点 | 缺点 | |------|------|------| | 自研(Pillow + 前端拖拽组件) | 完全可控,无外部依赖,数据合规性好 | 需维护图库,需自己实现轨迹检测算法 | | 第三方服务(极验 GeeTest / 网易易盾) | 开箱即用,安全性更高 | 引入外部依赖,有数据合规风险,需评估 | **当前方案**:暂按自研设计,后端负责人需在开发启动前确认最终选型。 --- ## 三、目录结构 ``` fonrey/apps/ └── accounts/ # 账号认证管理(租户级 App) ├── models.py # UserAccount, LoginAttempt, PasswordResetToken, PasswordHistory ├── views/ │ ├── auth.py # 登录/登出视图(HTMX 响应) │ ├── captcha.py # 滑块验证码视图 │ └── recovery.py # 找回用户名/密码视图 ├── urls.py ├── serializers.py # API 序列化(JSON 接口,供 Electron 前端使用) ├── forms.py # 登录表单、找回密码表单 ├── templates/ │ └── accounts/ │ ├── login.html # 登录页(含滑块验证码区域) │ ├── tenant_verify.html # Tenant 识别页(首次启动) │ ├── change_password.html # 强制修改初始密码页 │ ├── recover_username.html # 找回用户名页 │ ├── recover_password.html # 找回密码(步骤 1:身份验证) │ └── reset_password.html # 重置密码(步骤 2:设置新密码) └── services/ ├── auth.py # 认证逻辑:滑块验证、账号锁定、登录流程 ├── captcha.py # 验证码生成与校验(Pillow 或第三方) ├── recovery.py # 找回用户名/密码逻辑(含 Celery 任务触发) ├── password.py # 密码复杂度校验、历史密码比对 └── tenant.py # Tenant 验证逻辑(属于 shared_apps,Public Schema) fonrey/shared/ # Public Schema App(django-tenants shared_apps) └── tenants/ ├── models.py # TenantModel, Domain └── views.py # tenant/verify/ 接口(在公共 Schema 下) ``` --- ## 四、数据模型 > **数据模型完整定义已迁移至** `Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md`,本节仅保留技术实现视角的关键说明。 ### 4.1 表归属汇总 | 表名 | Schema | 说明 | |------|--------|------| | `user_accounts` | 租户 Schema | 账号主表,`username` 唯一性在 Schema 维度生效 | | `login_attempts` | 租户 Schema | 登录审计,保留 ≥ 90 天 | | `password_reset_tokens` | 租户 Schema | 一次性重置令牌,30 分钟过期 | | `password_histories` | 租户 Schema | 最近 3 次密码哈希,防重用 | ### 4.2 关键约束汇总 - `username` 唯一性约束仅在当前租户 Schema 内生效(`django-tenants` 隔离机制),**不同租户可以有相同 username** - 密码存储使用 Django 默认 `PBKDF2+SHA256`(`make_password`),**后端不得明文存储或传输** - `phone_enc` 字段使用 `core.encryption` AES-256-GCM 加密存储;`phone_hash` SHA-256 哈希用于唯一性校验 - `locked_until` 字段持久化锁定到期时间,防止 Redis 故障导致锁定状态丢失 --- ## 五、服务层设计(Service Layer) ### 5.1 `services/auth.py` — 核心认证服务 ```python # apps/accounts/services/auth.py class AuthService: LOGIN_FAIL_LIMIT = 5 # 连续失败次数触发锁定 LOCK_DURATION_MINUTES = 30 # 锁定时长(分钟) @classmethod def authenticate(cls, username: str, password: str, captcha_pass_token: str, tenant_id: str, ip_address: str, user_agent: str) -> dict: """ 完整登录流程: 1. 校验 captcha_pass_token(一次性凭证,Redis 查询后立即删除) 2. 查询账号(不存在则记录审计日志,返回通用错误) 3. 检查账号状态(locked / disabled) 4. 校验密码 5. 登录成功后:更新 last_login,清零失败计数,返回账号信息 6. 失败时:递增失败计数,超限触发锁定 Returns: {'success': True, 'user': UserAccount, 'is_initial_password': bool} {'success': False, 'error_code': str, 'error_message': str} """ ... @classmethod def _check_lock_status(cls, user: 'UserAccount') -> bool: """检查账号锁定状态,自动解锁已到期的锁定""" ... @classmethod def _increment_fail_count(cls, tenant_id: str, username: str) -> int: """递增失败计数,返回当前计数;超限时触发账号锁定""" ... @classmethod def _trigger_lock(cls, user: 'UserAccount') -> None: """触发账号锁定:status=locked, locked_until=now+30min""" ... @classmethod def unlock_account(cls, user: 'UserAccount') -> None: """管理员手动解锁账号""" ... ``` ### 5.2 `services/captcha.py` — 验证码服务 ```python # apps/accounts/services/captcha.py class CaptchaService: CAPTCHA_TTL_SECONDS = 180 # 验证会话有效期(3分钟) PASS_TOKEN_TTL_SECONDS = 180 # 通过凭证有效期(3分钟) @classmethod def generate(cls) -> dict: """ 生成滑块拼图验证码。 Returns: { 'session_token': str, # Redis Key uuid,供前端提交时携带 'background_b64': str, # 背景图(含缺口)Base64 'puzzle_b64': str, # 拼图碎片 Base64 'gap_y': int, # 缺口 Y 坐标(前端定位碎片初始位置) } 注意:缺口 X 坐标(gap_x)不返回给前端,服务端保存在 Redis。 """ ... @classmethod def verify(cls, session_token: str, slide_x: int, trajectory: list) -> dict: """ 校验滑动结果。 Args: session_token: generate() 返回的会话标识 slide_x: 用户最终滑动距离(px) trajectory: 滑动轨迹,格式 [{'x': int, 'y': int, 't': int}, ...] Returns: {'pass': True, 'pass_token': str} # 通过,pass_token 用于登录接口 {'pass': False, 'message': str} # 失败,前端自动刷新拼图 校验规则: 1. 位置偏差:abs(slide_x - gap_x) <= 5px 2. 轨迹特征:存在加速→减速曲线,拒绝匀速/程序化轨迹 """ ... ``` ### 5.3 `services/recovery.py` — 找回账号服务 ```python # apps/accounts/services/recovery.py class RecoveryService: RESET_LINK_EXPIRE_MINUTES = 30 MAX_EMAILS_PER_HOUR = 3 @classmethod def request_username_recovery(cls, email: str) -> None: """ 发起找回用户名。 - 无论邮箱是否存在,统一返回「如该邮箱已绑定账号,您将收到邮件」 - 邮箱存在时:触发 Celery 任务异步发送邮件 - 限频:同一邮箱 1 小时内最多 3 次(Redis 计数) """ ... @classmethod def request_password_reset(cls, username: str, email: str) -> None: """ 发起找回密码(步骤 1)。 - 无论匹配结果,统一返回「如信息匹配,重置链接将发送至邮箱」(防枚举) - 匹配成功时:生成 PasswordResetToken,触发 Celery 异步发送邮件 - 限频:同一账号 1 小时内最多 3 次(Redis 计数) """ ... @classmethod def reset_password(cls, token_str: str, new_password: str) -> dict: """ 重置密码(步骤 2)。 Returns: {'success': True} {'success': False, 'error_code': 'TOKEN_INVALID' | 'TOKEN_EXPIRED' | 'PASSWORD_REUSED'} 操作顺序: 1. 查询并校验 token(is_used=False, expires_at > now) 2. 校验密码复杂度 3. 校验历史密码(最近 3 次) 4. 更新密码哈希,is_initial_password=False 5. 标记 token is_used=True 6. 清除该账号所有有效 Session(强制重新登录) 7. 写入 PasswordHistory """ ... ``` ### 5.4 `services/password.py` — 密码规则服务 ```python # apps/accounts/services/password.py class PasswordService: MIN_LENGTH = 8 MAX_LENGTH = 32 HISTORY_COUNT = 3 # 保留最近 N 条历史密码 @classmethod def validate_complexity(cls, password: str) -> list[str]: """ 校验密码复杂度。 Returns: 错误列表(空列表表示通过) 规则: - 长度 8~32 位 - 必须包含字母(区分大小写) - 必须包含数字 """ ... @classmethod def check_history(cls, user: 'UserAccount', new_password: str) -> bool: """ 检查新密码是否与最近 3 次历史密码重复。 Returns: True(允许使用)/ False(与历史重复) """ ... @classmethod def save_history(cls, user: 'UserAccount', password_hash: str) -> None: """ 保存新密码哈希至历史记录,超出 HISTORY_COUNT 时删除最旧记录。 """ ... ``` --- ## 六、Celery 异步任务 ```python # apps/accounts/tasks.py from celery import shared_task @shared_task( name='accounts.send_username_recovery_email', max_retries=3, default_retry_delay=60, # 失败后 60 秒重试 ) def send_username_recovery_email(email: str, username: str, company_name: str) -> None: """ 发送找回用户名邮件。 失败时自动重试最多 3 次;3 次均失败则写入告警日志(Sentry)。 邮件内容:用户名 + 发送时间 + 联系管理员说明。 """ ... @shared_task( name='accounts.send_password_reset_email', max_retries=3, default_retry_delay=60, ) def send_password_reset_email(email: str, reset_link: str, company_name: str, expires_at: str) -> None: """ 发送密码重置链接邮件。 失败时自动重试最多 3 次;3 次均失败则写入告警日志(Sentry)。 邮件内容:重置链接(30分钟有效)+ 安全说明。 """ ... ``` > **重试策略**:邮件发送失败时不向前端返回错误(用户已看到「邮件已发送」提示),在后台静默重试;3 次重试均失败后通过 Sentry 上报告警,管理员可在后台查看 Token 手动告知用户。 --- ## 七、接口清单 | 接口 | 方法 | Schema 位置 | 是否需要鉴权 | 限流规则 | 响应格式 | 说明 | |------|------|------------|------------|---------|---------|------| | `/api/auth/tenant/verify/` | POST | Public(shared) | 否 | 每 IP 每分钟 ≤ 10 次 | JSON | Tenant ID 验证 | | `/api/auth/captcha/` | GET | Tenant | 否 | — | JSON | 获取滑块拼图验证码 | | `/api/auth/captcha/verify/` | POST | Tenant | 否 | — | JSON | 提交滑动轨迹,返回一次性通过凭证 | | `/api/auth/login/` | POST | Tenant | 否 | 每 IP 每分钟 ≤ 20 次 | JSON | 账号密码登录 | | `/api/auth/logout/` | POST | Tenant | 是 | — | JSON | 登出,使服务端 Session 失效 | | `/api/auth/password/change/` | POST | Tenant | 是 | — | JSON / HTMX | 强制修改初始密码(登录后跳转) | | `/api/auth/recover/username/` | POST | Tenant | 否 | 每邮箱每小时 ≤ 3 次 | JSON / HTMX | 发起找回用户名(发送邮件) | | `/api/auth/recover/password/request/` | POST | Tenant | 否 | 每账号每小时 ≤ 3 次 | JSON / HTMX | 发起找回密码(发送重置链接邮件) | | `/api/auth/recover/password/reset/` | POST | Tenant | 否(Token 鉴权) | — | JSON / HTMX | 提交新密码,使用 PasswordResetToken 校验 | | `/api/auth/login/phone/` | POST | Tenant | 否 | — | JSON | **预留**,v2 实现,手机验证码登录 | | `/api/auth/wechat/qrcode/` | GET | Tenant | 否 | — | JSON | **预留**,v2 实现,获取微信二维码 | | `/api/auth/wechat/callback/` | POST | Tenant | 否 | — | JSON | **预留**,v2 实现,微信扫码回调 | ### 7.1 Tenant 验证接口规范 ``` 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,不区分「未找到」与「已禁用」,防止枚举攻击。 ### 7.2 登录接口规范 ``` POST /api/auth/login/ Request Body: { "username": "string", "password": "string", "captcha_pass_token": "string" // 滑块验证通过后的一次性凭证(UUID) } Response 200 (登录成功): { "success": true, "token": "...", "user": { "id": 1, "username": "...", "display_name": "...", "is_initial_password": false } } Response 200 (登录失败): { "success": false, "error_code": "WRONG_CREDENTIALS" | "ACCOUNT_LOCKED" | "ACCOUNT_DISABLED" | "CAPTCHA_INVALID", "message": "...", "lock_remaining_seconds": 1800 // 仅 ACCOUNT_LOCKED 时返回 } ``` > **注意**:`WRONG_CREDENTIALS` 不区分「用户名错误」与「密码错误」,防止枚举攻击。 ### 7.3 验证码接口规范 ``` GET /api/auth/captcha/ Response 200: { "session_token": "uuid-string", // 提交验证时携带 "background_b64": "data:image/png;base64,...", // 带缺口的背景图 "puzzle_b64": "data:image/png;base64,...", // 拼图碎片 "gap_y": 120, // 缺口 Y 坐标(用于定位碎片初始位置) "width": 320, // 背景图宽度(px) "height": 160 // 背景图高度(px) } POST /api/auth/captcha/verify/ Request Body: { "session_token": "uuid-string", "slide_x": 185, // 最终滑动距离(px) "trajectory": [ {"x": 0, "y": 0, "t": 0}, {"x": 20, "y": 1, "t": 80}, {"x": 185, "y": 2, "t": 1200} ] } Response 200 (验证通过): { "pass": true, "pass_token": "uuid-string" // 一次性凭证,TTL 3分钟,登录时携带 } Response 200 (验证失败): { "pass": false, "message": "验证失败,请重新拖动" } ``` --- ## 八、前端交互模式(HTMX + Alpine.js) ### 8.1 页面结构说明 登录相关页面均为**全页面渲染**(Server-Side Rendered),Electron 客户端通过 `BrowserWindow.loadURL()` 加载完整 HTML。登录流程中的局部交互(如验证码刷新、错误提示)通过 HTMX 局部刷新实现。 ### 8.2 登录页核心交互 ```html