登录模块审核

This commit is contained in:
Shen Wei
2026-04-30 18:40:55 +08:00
parent 4030a91100
commit 57600598ac
34 changed files with 2544 additions and 2431 deletions

View File

@@ -4,7 +4,7 @@
> **版本**: v1.0
> **日期**: 2026-04-24
> **关联模块**: `apps/accounts/` — 账号认证、登录安全、密码管理
> **关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v1.3)
> **关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v2.0)
> **关联技术方案**: `Project/fonrey/TECH_STACK/登录管理技术方案.md`
---
@@ -13,17 +13,18 @@
### 核心概念
- **UserAccount用户账号**:系统登录主体,必须与员工档案(`org.Staff`1:1 绑定。分为 Tenant Admin超级管理账号每租户唯一和普通员工账号username 固定为手机号)。
- **UserAccount用户账号**:系统登录主体,必须与员工档案(`org.Staff`1:1 绑定。分为 Tenant Admin超级管理账号每租户唯一username 固定为联系人手机号和普通员工账号username 固定为员工手机号)。
- **LoginAttempt登录尝试记录**:记录每次登录行为(成功/失败),用于安全审计和账号锁定判断,保留 ≥ 90 天。
- **PasswordResetToken密码重置令牌**:通过邮件找回密码时生成的一次性令牌,有效期 30 分钟,使用后立即失效
- **PasswordResetToken密码重置令牌**~~通过邮件找回密码时生成的一次性令牌~~ 已废弃,详见 `SmsOtpRecord`
- **SmsOtpRecord短信验证码记录**:找回密码和手机验证码登录时向用户手机号发送的 6 位一次性验证码。用 `scene` 字段区分场景(`password_reset` / `login`),有效期按场景不同(找回密码 10 分钟,验证码登录 5 分钟);找回密码验证通过后颁发 `sms_reset_token`(有效期 15 分钟),验证码登录验证通过后直接颁发 Session Token使用后立即作废。
- **PasswordHistory历史密码记录**:保存最近 3 次密码哈希,用于防止重复使用历史密码。
### 关键业务规则
1. **账号与员工强绑定**:每个登录账号 **必须**`org.Staff` 中的员工档案 1:1 绑定Tenant Admin 例外,可不绑定)。
2. **用户名规则差异化**
- Tenant Admin由平台运营自定义字母开头6~30 位,含字母/数字/下划线)
- 普通员工:**固定为员工手机号**11 位数字),创建后不可变更
2. **用户名规则统一为手机号**
- Tenant Admin**固定为该租户联系人的手机号**11 位数字),来源于 `public.tenants.contact_phone`,全局唯一,创建后不可更改
- 普通员工:**固定为员工手机号**11 位数字),同租户内唯一,创建后不可变更
3. **初始密码强制修改**:新账号及密码重置后,`is_initial_password = True`,首次登录必须修改密码,不可跳过。
4. **账号锁定机制**:同一账号连续密码错误 ≥ 5 次,状态置为 `locked`30 分钟后自动恢复Tenant Admin 可提前手动解锁。
5. **员工离职联动**:员工离职时,对应账号的 `status` 自动置为 `disabled`,不可登录,历史操作记录保留。
@@ -38,7 +39,7 @@ UserAccount
├── 1:1 ── org.Staff (实名绑定,普通员工必须)
├── 1:N ── LoginAttempt (登录审计记录)
├── 1:N ── PasswordResetToken (密码重置令牌)
├── 1:N ── SmsOtpRecord (短信验证码记录,找回密码用)
├── 1:N ── PasswordHistory (历史密码记录)
└── M:1 ── UserAccount.created_by (创建人自引用)
```
@@ -49,10 +50,10 @@ UserAccount
|----|------------|------|
| `user_accounts` | 租户 Schema | 账号数据按租户隔离username 唯一性在 Schema 维度生效 |
| `login_attempts` | 租户 Schema | 审计记录属于租户,跨租户不可见 |
| `password_reset_tokens` | 租户 Schema | 令牌与租户账号绑定 |
| `sms_otp_records` | 租户 Schema | 短信验证码记录,找回密码用 |
| `password_histories` | 租户 Schema | 历史密码与账号绑定 |
> **注意**Tenant ID 验证相关逻辑在 **Public Schema**`shared_apps`),使用 `django-tenants` 的 `TenantModel`,不在本文档范围内,详见 `DATA_MODEL.md` §四(公共 Schema
> **注意**Tenant Code 验证相关逻辑在 **Public Schema**`shared_apps`),使用 `django-tenants` 的 `TenantModel`,不在本文档范围内,详见 `DATA_MODEL.md` §四(公共 Schema
---
@@ -69,7 +70,7 @@ UserAccount
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键(审计场景下 BigInt 更直观;跨环境引用使用 UUID 扩展字段见下) |
| `username` | `VARCHAR(30)` | `NOT NULL` | — | 登录名普通员工为手机号11 位数字Tenant Admin 为自定义字符串;创建后不可更改 |
| `password` | `VARCHAR(128)` | `NOT NULL` | — | PBKDF2+SHA256 哈希存储,使用 Django `make_password` |
| `email` | `VARCHAR(254)` | `NULL` | `NULL` | 绑定邮箱;用于找回密码/用户名;为空则无法自助找回;同租户唯一 |
| `email` | `VARCHAR(254)` | `NULL` | `NULL` | 绑定邮箱;完全可选,在本系统无任何必须业务用途;若填写则在同租户唯一 |
| `phone_enc` | `TEXT` | `NULL` | `NULL` | 手机号 AES-256-GCM 加密密文(`core.encryption`);普通员工必填 |
| `phone_hash` | `VARCHAR(64)` | `NULL` | `NULL` | 手机号 SHA-256 哈希;用于唯一性校验和查询;不可反推原文 |
| `staff_id` | `BIGINT` | `FK → org_staff.id`, `NULL`, `UNIQUE` | `NULL` | 员工档案绑定1:1普通员工必须有值Tenant Admin 可为空 |
@@ -122,8 +123,8 @@ class UserAccountManager(BaseUserManager):
class UserAccount(AbstractBaseUser):
"""
租户级用户账号。
- 普通员工username 固定为手机号11 位数字)
- Tenant Adminusername 由平台运营自定义字母开头6~30 位
- 普通员工username 固定为员工手机号11 位数字)
- Tenant Adminusername 固定为该租户联系人手机号(来源于 public.tenants.contact_phone
注意:此表位于租户 Schemausername 唯一性约束在 Schema 维度生效。
"""
username = models.CharField(max_length=30)
@@ -212,7 +213,8 @@ CREATE TABLE login_attempts (
failure_reason VARCHAR(30)
CHECK (failure_reason IS NULL OR failure_reason IN (
'wrong_password','wrong_captcha','account_locked',
'account_disabled','tenant_not_found'
'account_disabled','tenant_not_found',
'wrong_otp','otp_expired'
)),
PRIMARY KEY (id, attempted_at) -- 分区表主键必须包含分区键
) PARTITION BY RANGE (attempted_at);
@@ -233,6 +235,8 @@ CREATE TABLE login_attempts_default PARTITION OF login_attempts DEFAULT;
| `account_locked` | 账号已锁定 |
| `account_disabled` | 账号已停用 |
| `tenant_not_found` | 租户不存在(理论上不应出现,防御性记录) |
| `wrong_otp` | 短信验证码错误 |
| `otp_expired` | 短信验证码已过期 |
#### 索引
@@ -259,6 +263,8 @@ class LoginAttempt(models.Model):
('account_locked', '账号已锁定'),
('account_disabled', '账号已停用'),
('tenant_not_found', '租户不存在'),
('wrong_otp', '短信验证码错误'),
('otp_expired', '短信验证码已过期'),
]
username = models.CharField(max_length=30)
@@ -284,9 +290,9 @@ class LoginAttempt(models.Model):
---
### 3.3 `password_reset_tokens` — 密码重置令牌表(租户 Schema
### 3.3 `sms_otp_records` — 短信验证码记录表(租户 Schema
**表说明**用于通过邮件找回密码的一次性令牌。单次有效30 分钟过期
**表说明**记录找回密码和手机验证码登录两个场景中,向用户手机号发送的短信验证码及其状态。用 `scene` 字段区分场景(`password_reset` / `login`),有效期和发送频率上限按场景独立控制。原 `password_reset_tokens`(邮件令牌)已废弃,由本表替代
#### 字段定义
@@ -294,45 +300,61 @@ class LoginAttempt(models.Model):
|--------|------|------|--------|------|
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
| `user_id` | `BIGINT` | `FK → user_accounts.id`, `NOT NULL` | — | 关联账号 |
| `token` | `VARCHAR(86)` | `NOT NULL`, `UNIQUE` | — | `secrets.token_urlsafe(64)` 生成86 字符),全局唯一 |
| `expires_at` | `TIMESTAMPTZ` | `NOT NULL` | — | 过期时间(`created_at + 30 分钟` |
| `is_used` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否已使用;使用后立即置 True防止重放攻击 |
| `phone_hash` | `VARCHAR(64)` | `NOT NULL` | — | 收码手机号 SHA-256 哈希(与账号的 `phone_hash` 一致,冗余存储便于限频查询) |
| `scene` | `VARCHAR(20)` | `NOT NULL` | — | 使用场景:`password_reset`(找回密码)或 `login`(验证码登录 |
| `otp_hash` | `VARCHAR(128)` | `NOT NULL` | — | 验证码 PBKDF2 哈希(禁止明文存储,服务端校验时重新哈希比对) |
| `expires_at` | `TIMESTAMPTZ` | `NOT NULL` | — | 过期时间(`password_reset``created_at + 10 分钟``login``created_at + 5 分钟` |
| `is_used` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否已验证通过;通过后立即置 True防止重放攻击 |
| `verify_attempts` | `SMALLINT` | `NOT NULL` | `0` | 已尝试验证次数;≥ 5 次后本条记录强制作废(`is_used = TRUE` |
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 创建时间 |
#### 索引
```sql
CREATE UNIQUE INDEX uq_password_reset_tokens_token ON password_reset_tokens (token);
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens (user_id);
CREATE INDEX idx_password_reset_tokens_expiry ON password_reset_tokens (expires_at) WHERE is_used = FALSE;
CREATE INDEX idx_sms_otp_user ON sms_otp_records (user_id, created_at DESC);
CREATE INDEX idx_sms_otp_phone ON sms_otp_records (phone_hash, created_at DESC);
CREATE INDEX idx_sms_otp_active ON sms_otp_records (user_id, expires_at) WHERE is_used = FALSE;
```
#### Django Model 定义
```python
class PasswordResetToken(models.Model):
class SmsOtpRecord(models.Model):
"""
密码重置令牌
短信验证码记录(找回密码 + 验证码登录共用)
安全约束:
- Token 单次有效is_used=True 后立即失效)
- 有效期 30 分钟
- 同一号 1 小时内最多生成 3 个服务层限频Redis 计数
- OTP 明文仅在发送短信时生成,不持久化;仅存 PBKDF2 哈希
- 单条记录验证次数 ≥ 5 次后强制作废is_used=True
- 同一手机号 1 小时内按 scene 独立限频password_reset ≤ 5 次login ≤ 10 次
"""
user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='reset_tokens')
token = models.CharField(max_length=86, unique=True) # secrets.token_urlsafe(64)
expires_at = models.DateTimeField()
is_used = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
SCENE_CHOICES = [
('password_reset', '找回密码'),
('login', '验证码登录'),
]
user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='sms_otp_records')
phone_hash = models.CharField(max_length=64) # SHA-256 哈希,不暴露手机号原文
scene = models.CharField(max_length=20, choices=SCENE_CHOICES) # 区分场景
otp_hash = models.CharField(max_length=128) # PBKDF2 哈希,禁止明文存储
expires_at = models.DateTimeField() # password_reset: +10min; login: +5min
is_used = models.BooleanField(default=False)
verify_attempts = models.SmallIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'password_reset_tokens'
db_table = 'sms_otp_records'
indexes = [
models.Index(fields=['user_id']),
models.Index(fields=['user', '-created_at']),
models.Index(fields=['phone_hash', '-created_at']),
]
def is_valid(self) -> bool:
from django.utils import timezone
return not self.is_used and timezone.now() < self.expires_at
return not self.is_used and self.verify_attempts < 5 and timezone.now() < self.expires_at
def mark_used(self):
self.is_used = True
self.save(update_fields=['is_used'])
```
---
@@ -387,8 +409,9 @@ class PasswordHistory(models.Model):
| `captcha_token:{uuid}` | STRING | 3 分钟 | 滑块验证会话 Token验证通过后生成 `captcha_pass_token` |
| `captcha_pass:{uuid}` | STRING | 3 分钟 | 一次性通过凭证;登录提交时校验后立即删除 |
| `login_fail:{tenant_id}:{username}` | STRING计数 | 30 分钟 | 连续密码错误次数;≥ 5 触发锁定TTL 30 分钟自动清零 |
| `recover_email:{email}` | STRING计数 | 1 小时 | 找回邮件发送次数;上限 3 次/小时 |
| `recover_reset:{account_id}` | STRING计数 | 1 小时 | 同一账号密码重置 Token 生成次数;上限 3 次/小时 |
| `sms_limit:password_reset:{phone}` | STRING计数 | 1 小时 | 找回密码场景:同一手机号短信发送次数;上限 **5 次**/小时 |
| `sms_limit:login:{phone}` | STRING计数 | 1 小时 | 验证码登录场景:同一手机号短信发送次数;上限 **10 次**/小时 |
| `sms_reset_token:{token}` | STRINGuser_id | 15 分钟 | 验证码通过后颁发的一次性重置凭证;用于步骤三提交新密码;使用后立即删除 |
| `tenant_verify_ip:{ip}` | STRING计数 | 1 分钟 | Tenant 验证接口 IP 限流;≥ 10 次拒绝请求 |
> **一致性说明**:账号锁定状态由 `user_accounts.status` 持久化Redis 仅做计数触发器。当 Redis 数据丢失(如 Redis 重启),应用层通过 `locked_until` 字段恢复锁定状态判断。
@@ -435,8 +458,8 @@ class PasswordHistory(models.Model):
### 5.2 账号创建触发时机
| 账号类型 | 触发时机 | 创建者 | username 规则 | 初始密码 |
|----------|----------|--------|--------------|---------|
| Tenant Admin | 平台运营在系统后台开通租户时 | 平台运营 | 自定义字母开头6~30 位) | 平台运营自定义 |
|----------|----------|--------|--------------|---------|
| Tenant Admin | 平台运营在系统后台开通租户时 | 平台运营 | **固定为该租户联系人手机号**11 位数字,来源于 `public.tenants.contact_phone` | **系统统一固定初始密码**(如 `Fonrey@2025`,由平台部署配置设定) |
| 普通员工 | Tenant Admin 在「新增员工」时系统自动生成 | 系统Tenant Admin 触发) | 固定为员工手机号11 位) | 系统统一初始密码(部署配置) |
---
@@ -472,7 +495,7 @@ org ──► accounts (反向触发,通过 Service 层调用,不通过 FK
```
0001_initial_user_accounts.py # UserAccount 表(依赖 org.Staff 表已存在)
0002_login_attempts.py # LoginAttempt 表
0003_password_reset_tokens.py # PasswordResetToken
0003_sms_otp_records.py # SmsOtpRecord 表(替代原 password_reset_tokens
0004_password_histories.py # PasswordHistory 表
```
@@ -492,5 +515,6 @@ org ──► accounts (反向触发,通过 Service 层调用,不通过 FK
| `phone` 字段拆分为 `phone_enc` + `phone_hash` | 是 | 与 `org.Staff` 保持一致;`phone_enc` 保存原文用于展示,`phone_hash` 用于唯一性校验和快速查询,避免加密字段全表扫描 |
| 不扩展 Django `User` | 使用 `AbstractBaseUser` | 避免 `django.contrib.auth.User` 的全局唯一性限制(多租户下同一 username 在不同租户是允许的) |
| `LoginAttempt` 不设外键到 `UserAccount` | 是(冗余存储 username | 即使账号被删除(停用),审计记录仍需保留;使用 username 字符串字段保证审计完整性 |
| 找回密码机制 | 短信验证码(`SmsOtpRecord` | 经纪人普遍无邮箱手机号是本系统唯一已知联系方式短信核验更直接废弃邮件找回路径减少外部依赖SMTP |
| 历史密码单独建表 | `PasswordHistory` 独立表 | 而非在 `UserAccount` 中存 JSON 数组,便于查询和维护,支持未来扩展保留次数 |
| 锁定到期时间持久化 | `locked_until` 字段 | Redis 可能重启丢失数据,持久化 `locked_until` 保证 Redis 故障时锁定状态不丢失 |

View File

@@ -877,25 +877,25 @@ _本文档版本 v1.1 | 作者: Backend Architect | 更新时间 2026-04-24_
```sql
-- permission_defs
CREATE TABLE permission_defs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(150) NOT NULL UNIQUE,
module VARCHAR(50) NOT NULL,
sub_module VARCHAR(50) NOT NULL DEFAULT '',
group_name VARCHAR(100) NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT NOT NULL DEFAULT '',
value_type VARCHAR(20) NOT NULL CHECK (value_type IN ('BOOLEAN','SCOPE','INTEGER')),
scope_choices JSONB NOT NULL DEFAULT '[]'::jsonb,
integer_min INTEGER,
integer_max INTEGER,
default_value JSONB NOT NULL DEFAULT '{"v":false}'::jsonb,
max_allowed_categories VARCHAR(50)[] NOT NULL DEFAULT ARRAY[]::VARCHAR[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_deprecated BOOLEAN NOT NULL DEFAULT FALSE,
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
code VARCHAR(150) NOT NULL UNIQUE, -- 权限码,格式 {module}.{sub_module}.{action}[.{qualifier}],全局唯一,创建后不可修改
module VARCHAR(50) NOT NULL, -- 一级模块标识,如 property / client / org
sub_module VARCHAR(50) NOT NULL DEFAULT '', -- 二级子模块标识;无子模块时为空字符串
group_name VARCHAR(100) NOT NULL, -- 权限分组显示名称(管理界面分组展示用)
name VARCHAR(200) NOT NULL, -- 权限项中文名称(管理界面展示)
description TEXT NOT NULL DEFAULT '', -- 权限项说明(管理界面 tooltip 文案)
value_type VARCHAR(20) NOT NULL CHECK (value_type IN ('BOOLEAN','SCOPE','INTEGER')), -- 权限值类型BOOLEAN=开关 / SCOPE=数据范围 / INTEGER=数量上限
scope_choices JSONB NOT NULL DEFAULT '[]'::jsonb, -- SCOPE 类型可选范围列表JSON 数组);非 SCOPE 类型为空数组
integer_min INTEGER, -- INTEGER 类型最小允许值;其他类型为 NULL
integer_max INTEGER, -- INTEGER 类型最大允许值;其他类型为 NULL
default_value JSONB NOT NULL DEFAULT '{"v":false}'::jsonb, -- 权限默认值,格式 {"v": false/scope_str/int}
max_allowed_categories VARCHAR(50)[] NOT NULL DEFAULT ARRAY[]::VARCHAR[], -- 可配置此权限的角色分类白名单;空数组=无限制
sort_order INTEGER NOT NULL DEFAULT 0, -- 同分组内排序权重(数值越小越靠前)
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用FALSE=已下线,前端配置页隐藏
is_deprecated BOOLEAN NOT NULL DEFAULT FALSE, -- 是否已废弃;废弃后不可被新角色引用
version INTEGER NOT NULL DEFAULT 1, -- 乐观锁版本号(每次更新+1
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
CONSTRAINT chk_code_format CHECK (code ~ '^[a-z_]+\.[a-z_]+(\.[a-z_]+){1,2}$')
);
CREATE INDEX idx_permission_defs_module ON permission_defs(module, sub_module, sort_order) WHERE is_active = TRUE;
@@ -903,18 +903,18 @@ CREATE INDEX idx_permission_defs_active ON permission_defs(is_active) WHERE is_a
-- roles
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
category VARCHAR(30) NOT NULL CHECK (category IN ('agent','store_manager','director','operator','custom')),
description TEXT NOT NULL DEFAULT '',
template_role_id UUID REFERENCES roles(id) ON DELETE SET NULL,
is_system_builtin BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by UUID REFERENCES staff(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
name VARCHAR(100) NOT NULL, -- 角色显示名称(同租户内唯一,软删除后不参与唯一性校验)
category VARCHAR(30) NOT NULL CHECK (category IN ('agent','store_manager','director','operator','custom')), -- 角色分类agent=经纪人 / store_manager=门店管理 / director=区域管理 / operator=运营职能 / custom=自定义
description TEXT NOT NULL DEFAULT '', -- 角色说明文案
template_role_id UUID REFERENCES roles(id) ON DELETE SET NULL, -- 模板来源自引用从某内置角色克隆时记录NULL=无模板
is_system_builtin BOOLEAN NOT NULL DEFAULT FALSE, -- 是否系统内置角色TRUE=不可删除、不可改名
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用FALSE=角色已停用,员工不可再分配此角色
created_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 创建人(关联 staff 表);系统内置角色为 NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 最后修改人(关联 staff 表)
deleted_at TIMESTAMPTZ -- 软删除时间戳NULL=未删除非NULL=已软删除
);
CREATE UNIQUE INDEX idx_roles_name_active ON roles(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_roles_category ON roles(category) WHERE deleted_at IS NULL;
@@ -922,13 +922,13 @@ CREATE INDEX idx_roles_template ON roles(template_role_id);
-- role_permissions
CREATE TABLE role_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_def_id UUID NOT NULL REFERENCES permission_defs(id) ON DELETE RESTRICT,
value JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, -- 关联角色(角色删除则权限配置同步级联清除)
permission_def_id UUID NOT NULL REFERENCES permission_defs(id) ON DELETE RESTRICT, -- 关联权限定义(有角色引用时权限项不可删除)
value JSONB NOT NULL, -- 权限配置值,格式 {"v": false/scope_str/int},与 permission_defs.value_type 对应
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL -- 最后修改人(关联 staff 表)
);
CREATE UNIQUE INDEX idx_role_permissions_uniq ON role_permissions(role_id, permission_def_id);
CREATE INDEX idx_role_permissions_role ON role_permissions(role_id);
@@ -936,14 +936,14 @@ CREATE INDEX idx_role_permissions_def ON role_permissions(permission_def_id);
-- staff_roles
CREATE TABLE staff_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES staff(id) ON DELETE SET NULL,
valid_from DATE,
valid_until DATE
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, -- 员工 ID员工删除则角色分配同步级联删除
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT, -- 角色 ID有员工使用的角色不可删除
is_primary BOOLEAN NOT NULL DEFAULT FALSE, -- 是否主角色;每员工同时仅可有 1 个主角色(唯一索引保障)
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 角色分配时间(系统自动)
assigned_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 分配操作人(关联 staff 表NULL=系统自动分配
valid_from DATE, -- 角色有效期开始日期NULL=立即生效
valid_until DATE -- 角色有效期结束日期NULL=永久有效
);
CREATE UNIQUE INDEX idx_staff_roles_uniq ON staff_roles(staff_id, role_id);
CREATE UNIQUE INDEX idx_staff_roles_primary ON staff_roles(staff_id) WHERE is_primary = TRUE;
@@ -951,32 +951,32 @@ CREATE INDEX idx_staff_roles_role ON staff_roles(role_id);
-- staff_permission_overrides
CREATE TABLE staff_permission_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE,
permission_def_id UUID NOT NULL REFERENCES permission_defs(id) ON DELETE RESTRICT,
value JSONB NOT NULL,
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, -- 员工 ID员工删除则个人覆盖配置同步级联删除
permission_def_id UUID NOT NULL REFERENCES permission_defs(id) ON DELETE RESTRICT, -- 关联权限定义
value JSONB NOT NULL, -- 覆盖配置值,格式与 role_permissions.value 一致
override_mode VARCHAR(10) NOT NULL DEFAULT 'REPLACE'
CHECK (override_mode IN ('REPLACE','RESTRICT','GRANT')),
reason TEXT NOT NULL DEFAULT '',
modified_by UUID REFERENCES staff(id) ON DELETE SET NULL,
modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
CHECK (override_mode IN ('REPLACE','RESTRICT','GRANT')), -- 覆盖模式REPLACE=完全替换角色权限 / RESTRICT=向下收紧 / GRANT=向上提升
reason TEXT NOT NULL DEFAULT '', -- 覆盖理由(操作审计留存)
modified_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 修改操作人(关联 staff 表)
modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- 修改时间(系统自动)
);
CREATE UNIQUE INDEX idx_staff_overrides_uniq ON staff_permission_overrides(staff_id, permission_def_id);
CREATE INDEX idx_staff_overrides_staff ON staff_permission_overrides(staff_id);
-- staff_data_scopes
CREATE TABLE staff_data_scopes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE,
scope_type VARCHAR(20) NOT NULL
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, -- 员工 ID
scope_type VARCHAR(20) NOT NULL -- 数据范围类型self=本人 / group=小组 / store=门店 / area=大区 / region=区域 / company=全公司 / custom_unit=自定义单元
CHECK (scope_type IN ('self','group','store','area','region','company','custom_unit')),
org_unit_id UUID REFERENCES org_units(id) ON DELETE RESTRICT,
is_readable BOOLEAN NOT NULL DEFAULT TRUE,
is_writable BOOLEAN NOT NULL DEFAULT FALSE,
granted_by UUID REFERENCES staff(id) ON DELETE SET NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
reason TEXT NOT NULL DEFAULT '',
org_unit_id UUID REFERENCES org_units(id) ON DELETE RESTRICT, -- 自定义组织单元scope_type=custom_unit 时必填,其他为 NULL
is_readable BOOLEAN NOT NULL DEFAULT TRUE, -- 是否有读权限
is_writable BOOLEAN NOT NULL DEFAULT FALSE, -- 是否有写权限
granted_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 授权操作人(关联 staff 表)
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 授权时间(系统自动)
expires_at TIMESTAMPTZ, -- 到期时间NULL=永久有效
reason TEXT NOT NULL DEFAULT '', -- 数据范围授权理由(操作审计留存)
CONSTRAINT chk_custom_unit_has_org CHECK (
(scope_type = 'custom_unit' AND org_unit_id IS NOT NULL) OR
(scope_type <> 'custom_unit')
@@ -988,22 +988,22 @@ CREATE INDEX idx_data_scopes_expires ON staff_data_scopes(expires_at) WHERE expi
-- permission_change_logs (append-only, no deleted_at)
CREATE TABLE permission_change_logs (
id UUID NOT NULL DEFAULT gen_random_uuid(),
id UUID NOT NULL DEFAULT gen_random_uuid(), -- 主键(与 operated_at 组成复合主键,分区表要求)
operated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键(原 operated_at 前置)
target_type VARCHAR(30) NOT NULL
target_type VARCHAR(30) NOT NULL -- 操作对象类型role / role_permission / staff_role / staff_override / staff_scope
CHECK (target_type IN ('role','role_permission','staff_role','staff_override','staff_scope')),
target_id UUID NOT NULL,
staff_id UUID REFERENCES staff(id) ON DELETE SET NULL,
role_id UUID REFERENCES roles(id) ON DELETE SET NULL,
permission_code VARCHAR(150),
action VARCHAR(20) NOT NULL
target_id UUID NOT NULL, -- 操作对象 ID
staff_id UUID REFERENCES staff(id) ON DELETE SET NULL, -- 被操作员工 ID如分配/撤销角色时的目标员工)
role_id UUID REFERENCES roles(id) ON DELETE SET NULL, -- 被操作角色 ID
permission_code VARCHAR(150), -- 操作涉及的权限码(冗余存储,避免关联查询)
action VARCHAR(20) NOT NULL -- 操作类型create=新建 / update=修改 / delete=删除 / assign=分配 / revoke=撤销
CHECK (action IN ('create','update','delete','assign','revoke')),
old_value JSONB,
new_value JSONB,
operator_id UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT,
operator_ip INET,
user_agent TEXT,
reason TEXT NOT NULL DEFAULT '',
old_value JSONB, -- 变更前值create 时为 NULL
new_value JSONB, -- 变更后值delete 时为 NULL
operator_id UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT, -- 操作人员工 IDRESTRICT操作记录保留操作人不可删除
operator_ip INET, -- 操作人来源 IP
user_agent TEXT, -- 操作人客户端 UA
reason TEXT NOT NULL DEFAULT '', -- 操作理由(可选,审计留存)
PRIMARY KEY (id, operated_at) -- 分区表主键必须包含分区键
) PARTITION BY RANGE (operated_at);

View File

@@ -2,8 +2,8 @@
# Fonrey — Public Schema 数据模型
> **作者**: Backend Architect
> **版本**: v1.2
> **日期**: 2026-04-26
> **版本**: v1.3
> **日期**: 2026-04-30
> **权威源**: 本文件是 `public` schema 所有表的唯一权威定义
> **设计依据**: 系统管理模块 PRD`PRD/系统管理/系统管理模块PRD.md`);客户端发布管理模块 PRD`PRD/发布管理/客户端发布管理模块PRD.md`
> **索引文档**: [`DATA_MODEL.md §三`](./DATA_MODEL.md)(仅保留摘要索引,开发以本文件为准)
@@ -87,11 +87,13 @@ PostgreSQL Instance
-- 租户主表(每家房产经纪公司一条记录)
CREATE TABLE public.tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
schema_name VARCHAR(63) UNIQUE NOT NULL, -- PG schema 名,最长 63 字符,创建后不可修改
schema_name VARCHAR(63) UNIQUE NOT NULL, -- PG schema 名,最长 63 字符,创建后不可修改命名规则t_{uuid前8位},如 t_3f2a1b4c
tenant_code CHAR(12) UNIQUE NOT NULL, -- 对外暴露的 12 位纯数字识别码,如 202500010001用户登录时输入由平台运营在注册时生成创建后不可修改
name VARCHAR(255) NOT NULL, -- 公司名称
short_name VARCHAR(100), -- 简称/品牌名
contact_name VARCHAR(100) NOT NULL, -- 主联系人姓名
contact_email VARCHAR(254) NOT NULL, -- 联系邮箱(接收通知/欢迎邮件)
contact_phone CHAR(11) NOT NULL, -- 联系人手机号11位纯数字系统开通租户时自动以此手机号创建 Tenant Admin 登录账号;必填
contact_email VARCHAR(254), -- 联系邮箱(接收通知/欢迎邮件;选填,为空时 Tenant Admin 无法自助找回密码)
region VARCHAR(100), -- 所在地区(省市,如「上海市」)
plan VARCHAR(20) NOT NULL DEFAULT 'basic'
CHECK (plan IN ('basic','professional','enterprise')),
@@ -857,6 +859,7 @@ ORDER BY created_at DESC;
| `UPDATE public.tenant_status_logs` | append-only 审计表 |
| `DELETE FROM public.platform_audit_logs` | append-only 审计表 |
| `UPDATE public.tenants SET schema_name = ...` | schema 名绑定 PG 物理 schema |
| `UPDATE public.tenants SET tenant_code = ...` | 12 位识别码一旦发放给用户不可更改,避免经纪人无法登录 |
| `UPDATE public.domains SET domain = ...` | 域名路由不可变 |
| `UPDATE public.client_releases SET version = ...` | 版本号创建后不可修改,变更须新建记录 |
| 在租户 schema 中创建 `client_releases` 副本 | 本表属于 public schema多租户共享禁止下沉到租户层 |

View File

@@ -50,14 +50,14 @@ apps/setting/
-- 研发预置,租户不可修改分组本身
-- ============================================================
CREATE TABLE lookup_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
module VARCHAR(50) NOT NULL, -- 'client' | 'property'
key VARCHAR(100) NOT NULL, -- 'source' | 'follow_purpose'
label_zh VARCHAR(50) NOT NULL, -- 界面显示名称,如「客源来源」
description TEXT, -- 说明文案(前端 tooltip 使用)
sort_order SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sort_order SMALLINT NOT NULL DEFAULT 0, -- 排序权重(数值越小越靠前)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
UNIQUE (module, key)
);
```
@@ -80,16 +80,16 @@ CREATE TABLE lookup_groups (
-- 租户管理员可增删排序is_system=True 的预制项不可物理删除
-- ============================================================
CREATE TABLE lookup_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES lookup_groups(id) ON DELETE CASCADE,
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
group_id UUID NOT NULL REFERENCES lookup_groups(id) ON DELETE CASCADE, -- 所属枚举分组(关联 lookup_groups
value VARCHAR(100) NOT NULL, -- 存储值,英文 snake_case如 'door_to_door'
label_zh VARCHAR(50) NOT NULL, -- 显示文本(如「上门」)
is_system BOOLEAN NOT NULL DEFAULT FALSE, -- True=系统预制,不可删除,仅可停用
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order SMALLINT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用FALSE 后前端下拉不展示,历史数据保留并追加「(已停用)」后缀
sort_order SMALLINT NOT NULL DEFAULT 0, -- 排序权重(数值越小越靠前)
created_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 系统预制时为 NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
UNIQUE (group_id, value)
);
@@ -153,14 +153,14 @@ CREATE INDEX idx_lookup_items_group_active
-- 租户标量配置键值对Tenant Schema
-- ============================================================
CREATE TABLE tenant_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
category VARCHAR(50) NOT NULL, -- 配置分类:'client' | 'property' | 'showroom'
key VARCHAR(100) NOT NULL, -- 配置 key如 'duplicate_check_scope'
value JSONB NOT NULL, -- 存储任意类型bool/int/str如 {"v": "self"}
value_type VARCHAR(20) NOT NULL -- 'bool' | 'int' | 'string' | 'enum'(用于前端渲染控件)
CHECK (value_type IN ('bool', 'int', 'string', 'enum')),
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 最后修改人(关联 staff 表)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
UNIQUE (category, key)
);
@@ -193,7 +193,7 @@ CREATE INDEX idx_tenant_settings_category ON tenant_settings(category);
-- MVP 仅支持 module='property'
-- ============================================================
CREATE TABLE field_requirement_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
module VARCHAR(20) NOT NULL, -- 'property' | 'client'MVP 只用 'property'
entity_type VARCHAR(50) NOT NULL, -- 与 property.property_type CHECK 约束值对齐
-- 'residential'|'villa'|'commercial_residential'|'shop'|'office'|'other'
@@ -202,8 +202,8 @@ CREATE TABLE field_requirement_rules (
field_key VARCHAR(50) NOT NULL, -- 字段 key如 'orientation'|'decoration'|'floor'
requirement VARCHAR(10) NOT NULL -- 规则值
CHECK (requirement IN ('required', 'optional', 'hidden')),
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 最后修改人(关联 staff 表)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
UNIQUE (module, entity_type, trade_status, field_key)
);

View File

@@ -0,0 +1,111 @@
# Schema 变更记录
> **用途**:记录在初始 Model 创建之后新增/修改的字段,供架构师逐一更新 Migration 脚本。
> **格式**:每条变更含目标表、变更类型、字段定义、变更原因、来源 PRD 依据。
> **状态流转**`待迁移` → `已迁移`(架构师完成 migration 后更新状态)
---
## 变更清单
| # | 状态 | 目标表 | 变更类型 | 字段名 | 提出日期 |
|---|------|--------|----------|--------|----------|
| 1 | 待迁移 | `public.tenants` | 新增字段 | `tenant_code` | 2026-04-30 |
| 2 | 待迁移 | `public.tenants` | 新增字段 | `contact_phone` | 2026-04-30 |
| 3 | 待迁移 | `public.tenants` | 字段修改 | `contact_email`NOT NULL → 可 NULL | 2026-04-30 |
---
## 变更详情
### #1 — `public.tenants` 新增 `tenant_code`
**状态**:待迁移
**提出日期**2026-04-30
**目标表**`public.tenants`Public Schemadjango-tenants 主租户表)
**字段定义**
```sql
tenant_code CHAR(12) UNIQUE NOT NULL
```
**注释**:对外暴露的 12 位纯数字识别码,如 `202500010001`;用户首次登录客户端时输入;由平台运营在注册租户时生成;创建后不可修改。
**推荐加在**`schema_name` 字段之后,`name` 字段之前。
**约束要求**
- `CHAR(12)`:固定 12 位
- `UNIQUE`:全局唯一
- `NOT NULL`:注册时必填
- 禁止 `UPDATE`(应用层 + DB 层双重约束,与 `schema_name` 同等级别)
**建议索引**
```sql
CREATE UNIQUE INDEX idx_tenants_tenant_code ON public.tenants(tenant_code);
```
**变更原因**
登录 PRD §5.1.3 明确用户输入 12 位 Tenant Code 完成租户识别,服务端需通过该码查找对应 `schema_name`,原始 `public.tenants` 表中缺少此字段。
**PRD 依据**
`PRD/登录管理/用户登录管理模块PRD.md` §5.1.3 — Tenant Code 格式规范:
> 格式:固定 12 位纯数字,如 `202500010001`
**登录查询示例**
```sql
SELECT schema_name, name
FROM public.tenants
WHERE tenant_code = '202500010001'
AND status = 'active';
```
---
### #2 — `public.tenants` 新增 `contact_phone`
**状态**:待迁移
**提出日期**2026-04-30
**目标表**`public.tenants`Public Schema
**字段定义**
```sql
contact_phone CHAR(11) NOT NULL
```
**注释**联系人手机号11位纯数字系统开通租户时自动以此手机号创建 Tenant Admin 登录账号;必填字段,平台运营在创建租户时录入。
**推荐加在**`contact_name` 字段之后,`contact_email` 字段之前。
**约束要求**
- `CHAR(11)`:固定 11 位手机号
- `NOT NULL`:开通租户时必填
**变更原因**
登录 PRD v1.5 决策Tenant Admin 账号统一以联系人手机号作为用户名,与普通员工账号规则对齐。需在 tenants 表增加手机号字段作为 Tenant Admin 账号创建的数据来源。
**PRD 依据**
`PRD/登录管理/用户登录管理模块PRD.md` §5.3.2 Tenant Admin 账号规格表
---
### #3 — `public.tenants.contact_email` 改为可 NULL
**状态**:待迁移
**提出日期**2026-04-30
**目标表**`public.tenants`Public Schema
**变更 SQL**
```sql
ALTER TABLE public.tenants
ALTER COLUMN contact_email DROP NOT NULL;
```
**变更原因**
`contact_email` 原为 `NOT NULL`。由于 Tenant Admin 账号创建数据来源改为 `contact_phone`(手机号,必填),邮箱降级为选填,仅用于找回密码功能。为空时 Tenant Admin 无法自助找回密码,需联系平台运营处理。
**PRD 依据**
`PRD/登录管理/用户登录管理模块PRD.md` §5.3.2
---
*后续如有新增字段,按同样格式追加到本文件。*

View File

@@ -0,0 +1,177 @@
# Fonrey 系统角色 Persona 定义
**版本**v1.0
**状态**:已定稿
**作者**:产品团队
**最后更新**2026-04-30
> 本文档是 Fonrey 所有 PRD、DATA_MODEL、PERMISSION_SEED 等文档的角色命名**唯一权威来源**。
> 所有其他文档中的角色称谓**必须**以本文档为准,禁止自造名称或混用。
---
## 一、角色层级概览
```
┌─────────────────────────────────────────────────────────┐
│ Fonrey SaaS 平台 │
│ │
│ ① Platform Admin平台超级管理员
│ └── 管理所有租户:开通、暂停、配置、版本升级 │
│ │
│ ─ ─ ─ ─ ─ ─ ─ 租 户 边 界 ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ │
│ ② Tenant Admin租户管理员
│ └── 管理本租户:组织架构、账号、权限、系统配置 │
│ │
│ ③ Agent经纪人
│ └── 日常业务操作:录入房源、跟进客源、查看数据 │
│ │
└─────────────────────────────────────────────────────────┘
```
---
## 二、Persona 详细定义
### P1 — Platform Admin平台超级管理员
| 属性 | 值 |
| ---------------- | --------------------- |
| **Persona 名称** | Platform Admin |
| **中文称谓** | 平台超级管理员 |
| **Persona Code** | `PLATFORM_ADMIN` |
| **所属层** | 平台层Platform |
| **账号归属** | 平台运营团队Fonrey 公司内部人员) |
| **账号数量** | 极少,手动创建,不通过租户系统管理 |
| **认证入口** | 独立管理后台(非租户客户端) |
| **Schema 归属** | `public`(跨所有租户) |
**职责范围:**
- 创建、暂停、注销租户Tenant
- 为租户初始化 Tenant Admin 账号
- 管理平台版本、发布客户端安装包
- 监控系统健康状态、查看平台级日志
- 配置平台级参数短信网关、OSS、第三方集成等
**不涉及:**
- 不进入任何租户的业务数据
- 不参与租户内部的权限分配、组织管理
**关键约束:**
- Platform Admin 走**独立认证体系**,不纳入租户 RBAC 权限模型
- 无需在 `permission_definitions` 中注册权限项
---
### P2 — Tenant Admin租户管理员
| 属性 | 值 |
| ---------------- | ---------------------------------- |
| **Persona 名称** | Tenant Admin |
| **中文称谓** | 租户管理员 |
| **Persona Code** | `TENANT_ADMIN` |
| **所属层** | 租户层Tenant |
| **账号归属** | 各租户(房产经纪公司)的系统管理员 |
| **账号数量** | 每个租户 13 个,由 Platform Admin 创建初始账号 |
| **认证入口** | 与经纪人相同的租户客户端Electron App |
| **登录账号** | 平台运营分配的自定义字符串(不限于手机号格式) |
| **初始密码** | 平台统一固定初始密码,首次登录强制修改 |
| **Schema 归属** | 租户 Schema`tenant_{id}` |
| | |
**职责范围:**
- 维护组织架构(部门/门店树)
- 办理员工入职、离职、调动
- 创建和管理员工系统账号
- 配置角色与权限(角色创建、权限分配、个人权限调整)
- 配置系统枚举值Lookup Items、房源录入规则、客源规则
- 查看全租户范围的业务数据(受数据权限规则约束)
**不涉及:**
- 不能跨租户操作
- 不能修改平台级配置(版本、短信网关等)
**与「经纪人」的区别:**
- Tenant Admin 是**管理身份**,不从事日常房源/客源业务操作
- 在系统内显示为「管理员」身份,拥有全模块管理权限
- 可以同时持有某个业务角色(如总经),但账号性质以 Tenant Admin 为主
**在 PRD 中的出现场景:**
- 组织人事管理模块的主操作者
- 权限管理模块的主操作者
- 系统配置模块的主操作者
- 在楼盘管理、发布管理中执行管理操作的用户
---
### P3 — Agent经纪人
| 属性 | 值 |
| ---------------- | ----------------------------- |
| **Persona 名称** | Agent |
| **中文称谓** | 经纪人 |
| **Persona Code** | `AGENT` |
| **所属层** | 租户层Tenant |
| **账号归属** | 各租户的在职员工 |
| **账号数量** | 每个租户 N 个,由 Tenant Admin 创建 |
| **认证入口** | 租户客户端Electron App |
| **登录账号** | 手机号(由 Tenant Admin 录入员工时自动创建) |
| **初始密码** | 系统统一固定初始密码,首次登录强制修改 |
| **Schema 归属** | 租户 Schema`tenant_{id}` |
**内部岗位子类型Agent Sub-roles**
Agent 是统称,内部通过「角色」区分岗位权限层级。系统内置以下角色(不可删除):
| 角色名称 | Role Code | 典型岗位 | 数据权限范围 |
| ---- | ---------------- | -------- | --------- |
| 置业顾问 | `ROLE_AGENT` | 一线经纪人 | 仅自己的数据 |
| 店管 | `ROLE_STORE_MGR` | 门店店长 | 本门店数据 |
| 区管 | `ROLE_AREA_MGR` | 区域经理 | 本区域数据 |
| 区总 | `ROLE_AREA_DIR` | 区域总监 | 本区域+下属区数据 |
| 副总 | `ROLE_VP` | 副总经理 | 全公司数据(部分) |
| 总经 | `ROLE_GM` | 总经理 | 全公司数据 |
| 其他职能 | `ROLE_OTHER` | 行政/财务/HR | 按需配置 |
**在 PRD 中的出现场景:**
- 房源管理模块的主操作者
- 客源管理模块的主操作者
- 楼盘管理模块的查看用户
- 登录模块的主要使用者
---
## 三、命名规范与替换对照表
以下为历史文档中出现的混乱称谓,与标准称谓的对照关系:
| 历史称谓(禁止继续使用) | 应替换为 | 说明 |
| ------------ | ----------------------- | ---------------- |
| 超级管理员 | Platform Admin平台超级管理员 | 仅指平台层 |
| 平台管理员 | Platform Admin平台超级管理员 | 同上 |
| 系统管理员 | Tenant Admin租户管理员 | 租户层管理员 |
| 管理员(泛称) | Tenant Admin租户管理员 | 明确指向租户层 |
| HR 管理员 | Tenant Admin租户管理员 | 同一人,不区分子角色 |
| HR 行政 | Tenant Admin租户管理员 | 同上 |
| 一线经纪人 | Agent经纪人 | 统称,角色层面才区分岗位 |
| 置业顾问(作为用户称谓) | Agent经纪人 | 置业顾问仅作为「内置角色名」使用 |
**规则说明:**
1. PRD User Story 的 **As a** 部分:使用中文称谓(`Tenant Admin租户管理员``Agent经纪人`
2. 权限矩阵、DATA_MODEL 注释:使用 Persona Code`TENANT_ADMIN``AGENT`
3. 角色名称(置业顾问、店管、总经等)**仅在角色管理相关语境中**出现,不作为用户身份称谓
4. `Platform Admin` 在各 PRD 中尽量少出现,其功能属于「系统管理模块」,不在各子模块 PRD 内展开
---
## 四、快速索引
| 场景 | 使用 |
|------|------|
| PRD User Story 主语 | `Tenant Admin租户管理员` / `Agent经纪人` |
| 错误提示文案(面向用户) | 「请联系您的租户管理员」 |
| DATA_MODEL 注释 | `created_by: 创建该记录的 Agent经纪人用户 ID` |
| 权限矩阵行标题 | 使用角色名(置业顾问 / 店管 / 总经 ... |
| 代码注释 / 枚举值 | `PLATFORM_ADMIN` / `TENANT_ADMIN` / `AGENT` |
| 日志、审计记录 | `operator_type: PLATFORM_ADMIN / TENANT_ADMIN / AGENT` |

View File

@@ -180,7 +180,7 @@
## 4. 用户故事MVP 核心路径)
### Story 1 — 经纪人录入房源
> As a **一线经纪人**,
> As a **Agent经纪人**,
> I want to **快速录入一套二手住宅并上传图片和业主联系方式**,
> So that **这套房源的信息能被团队所有成员找到和跟进**.
@@ -192,7 +192,7 @@
---
### Story 2 — 经纪人跟进房源
> As a **一线经纪人**,
> As a **Agent经纪人**,
> I want to **对我负责的房源记录每次跟进(面访/电话/钥匙/实勘)**,
> So that **我的跟进历史有据可查,团队不会重复联系同一业主**.
@@ -204,7 +204,7 @@
---
### Story 3 — 经纪人录入客源
> As a **一线经纪人**,
> As a **Agent经纪人**,
> I want to **录入意向购房/租房客户并跟进其需求变化**,
> So that **我能在合适时机将客户与合适房源匹配**.
@@ -216,7 +216,7 @@
---
### Story 4 — 转成交
> As a **一线经纪人**,
> As a **Agent经纪人**,
> I want to **将已达成交易的客源标记为"成交"并关联成交房源**,
> So that **成交数据进入系统留存,房源状态自动更新**.

View File

@@ -88,8 +88,8 @@
| [US-SETTING-010](#US-SETTING-010-管理员配置首页展示内容) | 系统配置 | 管理员配置首页展示内容 | [ ] |
| [US-SETTING-011](#US-SETTING-011-管理员配置相关方规则) | 系统配置 | 管理员配置相关方规则 | [ ] |
| [US-SETTING-012](#US-SETTING-012-管理员配置客源相关参数) | 系统配置 | 管理员配置客源相关参数 | [ ] |
| [US-SYSTEM-010](#US-SYSTEM-010-平台管理员管理租户开通暂停配置) | 系统管理 | 平台管理员管理租户(开通/暂停/配置) | [ ] |
| [US-SYSTEM-011](#US-SYSTEM-011-平台管理员监控系统健康状态) | 系统管理 | 平台管理员监控系统健康状态 | [ ] |
| [US-SYSTEM-010](#US-SYSTEM-010-Platform Admin平台超级管理员管理租户开通暂停配置) | 系统管理 | Platform Admin平台超级管理员管理租户(开通/暂停/配置) | [ ] |
| [US-SYSTEM-011](#US-SYSTEM-011-Platform Admin平台超级管理员监控系统健康状态) | 系统管理 | Platform Admin平台超级管理员监控系统健康状态 | [ ] |
| [US-RELEASE-010](#US-RELEASE-010-系统发布Windows桌面客户端安装包) | 客户端发布 | 系统发布Windows桌面客户端安装包 | [ ] |
| [US-RELEASE-011](#US-RELEASE-011-客户端自动检测并更新至最新版本) | 客户端发布 | 客户端自动检测并更新至最新版本 | [ ] |
@@ -110,8 +110,8 @@
| [US-SETTING-021](#US-SETTING-021-管理员配置交易规则) | 系统配置 | 管理员配置交易规则 | [ ] |
| ~~US-SETTING-022~~ | ~~系统配置~~ | ~~管理员配置财务规则~~**已移出,财务模块 Out of Scope** | ~~[ ]~~ |
| ~~US-SETTING-023~~ | ~~系统配置~~ | ~~管理员配置合同模板~~**已移出,合同模块 Out of Scope** | ~~[ ]~~ |
| [US-SYSTEM-020](#US-SYSTEM-020-平台管理员查看操作审计日志) | 系统管理 | 平台管理员查看操作审计日志 | [ ] |
| [US-SYSTEM-021](#US-SYSTEM-021-平台管理员管理灰度发布滚动升级) | 系统管理 | 平台管理员管理灰度发布/滚动升级 | [ ] |
| [US-SYSTEM-020](#US-SYSTEM-020-Platform Admin平台超级管理员查看操作审计日志) | 系统管理 | Platform Admin平台超级管理员查看操作审计日志 | [ ] |
| [US-SYSTEM-021](#US-SYSTEM-021-Platform Admin平台超级管理员管理灰度发布滚动升级) | 系统管理 | Platform Admin平台超级管理员管理灰度发布/滚动升级 | [ ] |
---
@@ -682,13 +682,13 @@
### 系统管理(运营后台)
##### US-SYSTEM-010 平台管理员管理租户开通暂停配置
##### US-SYSTEM-010 Platform Admin平台超级管理员管理租户开通暂停配置
- 参考PRD文档`Project/fonrey/PRD/系统管理/系统管理模块PRD.md` - 租户管理(开通/暂停/配置)
- 状态:[ ]
- 验收标准可在运营后台新开通租户自动创建独立PostgreSQL Schema可暂停租户暂停后租户用户无法登录可为租户配置域名/子域名;租户操作记录写入平台操作日志
##### US-SYSTEM-011 平台管理员监控系统健康状态
##### US-SYSTEM-011 Platform Admin平台超级管理员监控系统健康状态
- 参考PRD文档`Project/fonrey/PRD/系统管理/系统管理模块PRD.md` - 系统健康监控
- 状态:[ ]
@@ -818,13 +818,13 @@
### 系统管理(运营后台)
##### US-SYSTEM-020 平台管理员查看操作审计日志
##### US-SYSTEM-020 Platform Admin平台超级管理员查看操作审计日志
- 参考PRD文档`Project/fonrey/PRD/系统管理/系统管理模块PRD.md` - 操作审计日志
- 状态:[ ]
- 验收标准规划中详细验收标准待PRD细化后补充
##### US-SYSTEM-021 平台管理员管理灰度发布滚动升级
##### US-SYSTEM-021 Platform Admin平台超级管理员管理灰度发布滚动升级
- 参考PRD文档`Project/fonrey/PRD/系统管理/系统管理模块PRD.md` - 灰度发布/滚动升级
- 状态:[ ]

View File

@@ -5489,7 +5489,7 @@ Celery Beat 定时任务每日凌晨执行超过运营配置天数如30天
- 未完成项/阻塞项
```
### US-SYSTEM-010 平台管理员管理租户开通暂停配置
### US-SYSTEM-010 Platform Admin平台超级管理员管理租户开通暂停配置
- 阶段:**P1**
- 模块:**系统管理(运营后台)**
@@ -5508,7 +5508,7 @@ Celery Beat 定时任务每日凌晨执行超过运营配置天数如30天
你是 OpenCode 编程代理。请在当前仓库根目录完成下面任务。
【任务ID】US-SYSTEM-010
【任务标题】平台管理员管理租户开通暂停配置
【任务标题】Platform Admin平台超级管理员管理租户开通暂停配置
【阶段】P1
【模块】系统管理(运营后台)
@@ -5563,7 +5563,7 @@ Celery Beat 定时任务每日凌晨执行超过运营配置天数如30天
- 未完成项/阻塞项
```
### US-SYSTEM-011 平台管理员监控系统健康状态
### US-SYSTEM-011 Platform Admin平台超级管理员监控系统健康状态
- 阶段:**P1**
- 模块:**系统管理(运营后台)**
@@ -5582,7 +5582,7 @@ Celery Beat 定时任务每日凌晨执行超过运营配置天数如30天
你是 OpenCode 编程代理。请在当前仓库根目录完成下面任务。
【任务ID】US-SYSTEM-011
【任务标题】平台管理员监控系统健康状态
【任务标题】Platform Admin平台超级管理员监控系统健康状态
【阶段】P1
【模块】系统管理(运营后台)
@@ -6651,7 +6651,7 @@ electron-builder 输出 NSIS .exe 安装包和便携版 .zip安装包经EV证
- 未完成项/阻塞项
```
### US-SYSTEM-020 平台管理员查看操作审计日志
### US-SYSTEM-020 Platform Admin平台超级管理员查看操作审计日志
- 阶段:**P2**
- 模块:**系统管理(运营后台)**
@@ -6671,7 +6671,7 @@ electron-builder 输出 NSIS .exe 安装包和便携版 .zip安装包经EV证
你是 OpenCode 编程代理。请在当前仓库根目录完成下面任务。
【任务ID】US-SYSTEM-020
【任务标题】平台管理员查看操作审计日志
【任务标题】Platform Admin平台超级管理员查看操作审计日志
【阶段】P2
【模块】系统管理(运营后台)
@@ -6725,7 +6725,7 @@ electron-builder 输出 NSIS .exe 安装包和便携版 .zip安装包经EV证
- 未完成项/阻塞项
```
### US-SYSTEM-021 平台管理员管理灰度发布滚动升级
### US-SYSTEM-021 Platform Admin平台超级管理员管理灰度发布滚动升级
- 阶段:**P2**
- 模块:**系统管理(运营后台)**
@@ -6745,7 +6745,7 @@ electron-builder 输出 NSIS .exe 安装包和便携版 .zip安装包经EV证
你是 OpenCode 编程代理。请在当前仓库根目录完成下面任务。
【任务ID】US-SYSTEM-021
【任务标题】平台管理员管理灰度发布滚动升级
【任务标题】Platform Admin平台超级管理员管理灰度发布滚动升级
【阶段】P2
【模块】系统管理(运营后台)

View File

@@ -5,7 +5,7 @@
**版本**: 1.0
**所属系统**: Fonrey 房产经纪管理系统
**关联模块**: 系统管理、权限管理
**干系人**: 工程负责人、运维负责人、系统管理员
**干系人**: 工程负责人、运维负责人、Tenant Admin租户管理员
---
@@ -24,9 +24,9 @@ Fonrey 房产经纪管理系统当前为纯 Web 应用,依赖用户自行通
| 角色 | 使用场景 | 使用频率 |
|------|---------|----------|
| 一线经纪人 | 下载安装客户端、日常登录使用系统、接受自动更新 | 每日 |
| Agent经纪人 | 下载安装客户端、日常登录使用系统、接受自动更新 | 每日 |
| 店长/经理 | 同上 | 每日 |
| 系统管理员 | 发布新版本、管理安装包下载地址、监控客户端版本分布 | 按需 |
| Tenant Admin租户管理员 | 发布新版本、管理安装包下载地址、监控客户端版本分布 | 按需 |
| IT 运维人员 | 维护更新服务器、签名证书、构建发布流水线 | 按发布周期 |
### 核心痛点
@@ -65,7 +65,7 @@ Fonrey 房产经纪管理系统当前为纯 Web 应用,依赖用户自行通
### Story 1经纪人下载并安装客户端
**As** 一线经纪人,**I want** 通过公司提供的网址下载一个安装程序并完成安装,**So that** 我可以立即打开登录界面使用 Fonrey 系统,无需手动配置浏览器。
**As** Agent经纪人**I want** 通过公司提供的网址下载一个安装程序并完成安装,**So that** 我可以立即打开登录界面使用 Fonrey 系统,无需手动配置浏览器。
**验收标准**
- [ ] 官方下载页面可通过指定 URL 访问,页面展示最新版本号、发布日期及下载按钮
@@ -80,7 +80,7 @@ Fonrey 房产经纪管理系统当前为纯 Web 应用,依赖用户自行通
### Story 2经纪人使用客户端正常登录并使用系统
**As** 一线经纪人,**I want** 打开客户端后直接访问 Fonrey 系统的完整功能,**So that** 我的日常使用体验与使用 Chrome 浏览器无差异,且不受本机安装的浏览器版本影响。
**As** Agent经纪人**I want** 打开客户端后直接访问 Fonrey 系统的完整功能,**So that** 我的日常使用体验与使用 Chrome 浏览器无差异,且不受本机安装的浏览器版本影响。
**验收标准**
- [ ] 客户端内嵌现代 Chromium 内核(如基于 Electron 或 WebView2版本不低于 Chromium 100支持现代 Web 标准ES2020、CSS Grid、Fetch API 等)
@@ -95,7 +95,7 @@ Fonrey 房产经纪管理系统当前为纯 Web 应用,依赖用户自行通
### Story 3客户端感知新版本并自动升级
**As** 一线经纪人,**I want** 客户端在有新版本时自动提示并完成升级,**So that** 我无需手动下载安装,始终使用最新版本,不会因版本落后导致功能异常。
**As** Agent经纪人**I want** 客户端在有新版本时自动提示并完成升级,**So that** 我无需手动下载安装,始终使用最新版本,不会因版本落后导致功能异常。
**验收标准**
- [ ] 客户端启动时及运行期间(每隔 4 小时)自动向更新服务器检查最新版本
@@ -108,9 +108,9 @@ Fonrey 房产经纪管理系统当前为纯 Web 应用,依赖用户自行通
---
### Story 4系统管理员发布新版本
### Story 4Tenant Admin租户管理员发布新版本
**As** 系统管理员,**I want** 通过管理后台上传新版客户端安装包并配置版本信息,**So that** 客户端能感知到更新并引导用户升级。
**As** Tenant Admin租户管理员**I want** 通过管理后台上传新版客户端安装包并配置版本信息,**So that** 客户端能感知到更新并引导用户升级。
**验收标准**
- [ ] 系统管理后台提供"客户端版本管理"页面(位于系统管理模块下)
@@ -125,7 +125,7 @@ Fonrey 房产经纪管理系统当前为纯 Web 应用,依赖用户自行通
### Story 5管理员监控客户端版本分布
**As** 系统管理员,**I want** 查看当前所有在线客户端的版本分布情况,**So that** 了解升级覆盖率,对仍在使用旧版本的客户端发出提醒或强制升级。
**As** Tenant Admin租户管理员**I want** 查看当前所有在线客户端的版本分布情况,**So that** 了解升级覆盖率,对仍在使用旧版本的客户端发出提醒或强制升级。
**验收标准**
- [ ] 客户端版本管理页面展示版本分布统计:各版本在线客户端数量及占比(饼图或条形图)

View File

@@ -26,7 +26,7 @@
| 角色 | 描述 | 使用频率 |
|------|------|----------|
| 一线经纪人 | 负责录入、维护、跟进自己名下的客源,并与客户进行带看匹配 | 每日高频 |
| Agent经纪人 | 负责录入、维护、跟进自己名下的客源,并与客户进行带看匹配 | 每日高频 |
| 店长/经理 | 查看全店/全区客源概况,监控跟进完成度及带看活跃度 | 每日 |
| 行政人员 | 审核客源信息,处理重复客户合并、来源修改审批等 | 每日 |
@@ -62,7 +62,7 @@
### Story 1经纪人录入新私客
**As** 一线经纪人,**I want** 快速录入一位新客户的基本信息和购房/租房需求,**So that** 客源进入系统统一管理并支持后续的智能配房和跟进管理。
**As** Agent经纪人**I want** 快速录入一位新客户的基本信息和购房/租房需求,**So that** 客源进入系统统一管理并支持后续的智能配房和跟进管理。
**验收标准**
- [ ] 录入页面标题为"录入私客",通过顶部导航「客源」→「+ 新增私客」或右侧浮动快捷入口「增客」触达
@@ -81,7 +81,7 @@
### Story 2经纪人查看与筛选私客列表
**As** 一线经纪人,**I want** 在客源列表中快速定位目标客户并了解其跟进状态,**So that** 提升客户管理效率,优先跟进高意向客户。
**As** Agent经纪人**I want** 在客源列表中快速定位目标客户并了解其跟进状态,**So that** 提升客户管理效率,优先跟进高意向客户。
**验收标准**
- [ ] 顶部 Tab 导航按需求类型分组:**求购 / 求租 / 暂缓 / 全部私客**Tab 间可自由切换
@@ -100,7 +100,7 @@
### Story 3经纪人查看求购私客列表
**As** 一线经纪人,**I want** 在求购 Tab 下查看所有有购房意向的私客,并通过多维度筛选快速定位目标客户,**So that** 优先匹配合适房源,提升成交效率。
**As** Agent经纪人**I want** 在求购 Tab 下查看所有有购房意向的私客,并通过多维度筛选快速定位目标客户,**So that** 优先匹配合适房源,提升成交效率。
**验收标准**
- [ ] 求购 Tab 下,状态筛选项为:不限 / 求购 / 租购
@@ -116,7 +116,7 @@
### Story 4经纪人查看求租私客列表
**As** 一线经纪人,**I want** 在求租 Tab 下查看所有有租房意向的私客,**So that** 快速为其匹配合适的出租房源。
**As** Agent经纪人**I want** 在求租 Tab 下查看所有有租房意向的私客,**So that** 快速为其匹配合适的出租房源。
**验收标准**
- [ ] 求租 Tab 下,状态筛选项为:不限 / 求租 / 租购
@@ -129,7 +129,7 @@
### Story 5经纪人管理暂缓私客
**As** 一线经纪人,**I want** 将暂时没有购房/租房需求的客户标记为"暂缓"状态,**So that** 保持活跃客源列表的精准性,同时不丢失潜在客户资源。
**As** Agent经纪人**I want** 将暂时没有购房/租房需求的客户标记为"暂缓"状态,**So that** 保持活跃客源列表的精准性,同时不丢失潜在客户资源。
**验收标准**
- [ ] 暂缓 Tab 下仅展示状态为"暂缓"的客源
@@ -140,7 +140,7 @@
### Story 6经纪人查看私客详情页
**As** 一线经纪人,**I want** 点击客源姓名后进入详情页,查看该客户的完整信息和快捷操作入口,**So that** 在一个页面内完成客户的跟进、带看、配房等核心操作。
**As** Agent经纪人**I want** 点击客源姓名后进入详情页,查看该客户的完整信息和快捷操作入口,**So that** 在一个页面内完成客户的跟进、带看、配房等核心操作。
**验收标准**
- [ ] 详情页顶部展示客源标题(需求标题+联系人姓名),旁边显示带看进度标签(如"一看"
@@ -158,7 +158,7 @@
### Story 7经纪人查看与编辑需求信息
**As** 一线经纪人,**I want** 在需求信息 Tab 中查看客户的完整购房/租房需求字段,并支持编辑,**So that** 确保需求信息准确以提升智能配房的匹配精度。
**As** Agent经纪人**I want** 在需求信息 Tab 中查看客户的完整购房/租房需求字段,并支持编辑,**So that** 确保需求信息准确以提升智能配房的匹配精度。
**验收标准**
- [ ] 需求信息 Tab 下展示以下字段三栏布局总价范围区间万元、面积范围区间、居室X居格式、装修下拉枚举、朝向多选枚举、楼层多选中楼层/低楼层等)、楼龄(范围区间)、意向商圈(多选)、意向小区(多选)、交通情况(文本)、备注(文本)
@@ -170,7 +170,7 @@
### Story 8经纪人写入与查看跟进记录
**As** 一线经纪人,**I want** 在跟进记录 Tab 中记录每一次与客户的沟通内容,并按类型筛选查看,**So that** 保留完整的跟进轨迹,便于持续维护客户关系。
**As** Agent经纪人**I want** 在跟进记录 Tab 中记录每一次与客户的沟通内容,并按类型筛选查看,**So that** 保留完整的跟进轨迹,便于持续维护客户关系。
**验收标准**
- [ ] 跟进记录区块顶部为 5 个子 Tab全部 / 写入跟进 / 敏感信息跟进 / 修改跟进 / 其他跟进
@@ -189,7 +189,7 @@
### Story 9经纪人管理带看记录
**As** 一线经纪人,**I want** 在带看 Tab 中记录与该客户的预约带看和实际带看情况,**So that** 系统化追踪带看进度,提升成交转化效率。
**As** Agent经纪人**I want** 在带看 Tab 中记录与该客户的预约带看和实际带看情况,**So that** 系统化追踪带看进度,提升成交转化效率。
**验收标准**
- [ ] 带看区块顶部提供两个子 Tab**预约** / **带看**
@@ -217,7 +217,7 @@
### Story 10经纪人查看客源解读AI行为分析
**As** 一线经纪人,**I want** 在客源解读 Tab 中查看系统根据该客户的找房行为自动生成的偏好分析,**So that** 更准确地判断客户真实需求和购房意向,提升推房精准度。
**As** Agent经纪人**I want** 在客源解读 Tab 中查看系统根据该客户的找房行为自动生成的偏好分析,**So that** 更准确地判断客户真实需求和购房意向,提升推房精准度。
**验收标准**
- [ ] 客源解读 Tab 展示说明文字:"以下数据通过客户找房行为分析生成,百分比越高代表浏览次数多",右上角提供「使用指南」链接
@@ -235,7 +235,7 @@
### Story 11经纪人使用二手配房功能推荐房源
**As** 一线经纪人,**I want** 在智能配房二手配房Tab 中查看系统为该客户匹配的房源列表,并将合适房源分享给客户,**So that** 提升推房效率,加快成交进度。
**As** Agent经纪人**I want** 在智能配房二手配房Tab 中查看系统为该客户匹配的房源列表,并将合适房源分享给客户,**So that** 提升推房效率,加快成交进度。
**验收标准**
- [ ] 二手配房区块标题右侧显示最后更新时间(如"2026-04-22 17:38 更新")及刷新说明 ⓘ 图标
@@ -262,7 +262,7 @@
### Story 12经纪人查看与筛选公客列表
**As** 一线经纪人,**I want** 在公客 Tab 中查看全公司公共客源池中的客户,并通过多维度筛选快速找到我有能力跟进的公客,**So that** 利用公客资源开拓新成交机会。
**As** Agent经纪人**I want** 在公客 Tab 中查看全公司公共客源池中的客户,并通过多维度筛选快速找到我有能力跟进的公客,**So that** 利用公客资源开拓新成交机会。
**验收标准**
- [ ] 顶部一级 Tab 切换至「公客」后激活该视图Tab 标题高亮橙色下划线
@@ -312,7 +312,7 @@
### Story 13经纪人查看成交客列表
**As** 一线经纪人,**I want** 在成交客 Tab 中查看已完成成交的客户列表,并通过筛选快速回顾历史成交记录,**So that** 为复购/转介绍跟进和业绩复盘提供数据支撑。
**As** Agent经纪人**I want** 在成交客 Tab 中查看已完成成交的客户列表,并通过筛选快速回顾历史成交记录,**So that** 为复购/转介绍跟进和业绩复盘提供数据支撑。
**验收标准**
- [ ] 顶部一级 Tab 切换至「成交客」后激活该视图(面包屑:客源 / 客源管理 / 成交客)
@@ -353,7 +353,7 @@
### Story 14经纪人编辑客源信息
**As** 一线经纪人,**I want** 对已录入的客源进行编辑,修改联系方式、基础信息或购房需求,**So that** 保持客源数据的准确性,提升智能配房匹配精度。
**As** Agent经纪人**I want** 对已录入的客源进行编辑,修改联系方式、基础信息或购房需求,**So that** 保持客源数据的准确性,提升智能配房匹配精度。
**验收标准**
- [ ] 编辑客源页面标题为「编辑客源」,通过私客详情页右侧「编辑」按钮或需求信息 Tab「编辑」链接触达
@@ -404,7 +404,7 @@
### Story 15经纪人查看客源信息概览面板
**As** 一线经纪人,**I want** 在客源详情页右侧快速概览该客源的核心信息和常用操作,**So that** 无需切换 Tab 即可掌握客户关键状态并快速执行高频操作。
**As** Agent经纪人**I want** 在客源详情页右侧快速概览该客源的核心信息和常用操作,**So that** 无需切换 Tab 即可掌握客户关键状态并快速执行高频操作。
**验收标准**
- [ ] 客源详情页右侧固定展示"信息概览"面板,不随页面滚动消失
@@ -422,7 +422,7 @@
### Story 16经纪人收藏客源至私客收藏夹
**As** 一线经纪人,**I want** 将重点客户收藏至私客收藏夹,**So that** 在私客列表中可按收藏夹快速筛选出重点跟进客户。
**As** Agent经纪人**I want** 将重点客户收藏至私客收藏夹,**So that** 在私客列表中可按收藏夹快速筛选出重点跟进客户。
**验收标准**
- [ ] 点击信息概览面板中的「☆ 收藏」触发"选择私客收藏夹"浮层
@@ -440,7 +440,7 @@
### Story 17经纪人修改客源等级
**As** 一线经纪人,**I want** 快速修改客源的购买意向等级,**So that** 确保客源等级与客户当前实际意向相符,辅助工作优先级排序。
**As** Agent经纪人**I want** 快速修改客源的购买意向等级,**So that** 确保客源等级与客户当前实际意向相符,辅助工作优先级排序。
**验收标准**
- [ ] 点击信息概览面板中的「改等级」触发"改等级"弹窗
@@ -456,7 +456,7 @@
### Story 18经纪人修改客源状态
**As** 一线经纪人,**I want** 修改客源的需求状态(求购/求租/租购),**So that** 准确反映客户当前的业务需求方向,保证配房匹配精准。
**As** Agent经纪人**I want** 修改客源的需求状态(求购/求租/租购),**So that** 准确反映客户当前的业务需求方向,保证配房匹配精准。
**验收标准**
- [ ] 点击信息概览面板中的「改状态」触发"改状态"弹窗
@@ -473,7 +473,7 @@
### Story 19经纪人手动将私客转为公客
**As** 一线经纪人或店长,**I want** 主动将某私客转入公共客源池,**So that** 让其他经纪人也能跟进该客户,提升客源利用率和成交机会。
**As** Agent经纪人或店长,**I want** 主动将某私客转入公共客源池,**So that** 让其他经纪人也能跟进该客户,提升客源利用率和成交机会。
**验收标准**
- [ ] 点击信息概览面板中的「转公客」触发"把此私客转为公客"弹窗
@@ -492,7 +492,7 @@
### Story 20经纪人将私客转为成交客
**As** 一线经纪人,**I want** 在客户完成购房/租房成交后将其标记为成交客,并录入成交信息,**So that** 完整记录成交数据,同步到成交客列表及业绩统计。
**As** Agent经纪人**I want** 在客户完成购房/租房成交后将其标记为成交客,并录入成交信息,**So that** 完整记录成交数据,同步到成交客列表及业绩统计。
**验收标准**
- [ ] 点击信息概览面板中的「转成交」触发"客户成交"弹窗
@@ -526,7 +526,7 @@
### Story 21经纪人将客源标记为无效
**As** 一线经纪人,**I want** 将无效客户(如空号/骚扰/无意向等)标记为无效,**So that** 避免对无效客户持续无效跟进,提升客源列表质量。
**As** Agent经纪人**I want** 将无效客户(如空号/骚扰/无意向等)标记为无效,**So that** 避免对无效客户持续无效跟进,提升客源列表质量。
**验收标准**
- [ ] 点击信息概览面板中的「转无效」触发"请选择无效原因"弹窗
@@ -549,7 +549,7 @@
### Story 22经纪人编辑客源基础信息快捷入口
**As** 一线经纪人,**I want** 通过信息概览面板的快捷入口直接编辑客源基础信息,**So that** 无需进入完整编辑页即可快速更新常用字段。
**As** Agent经纪人**I want** 通过信息概览面板的快捷入口直接编辑客源基础信息,**So that** 无需进入完整编辑页即可快速更新常用字段。
**验收标准**
- [ ] 点击信息概览面板中的「编辑客源」入口,触发"编辑基础信息"弹窗(抽屉浮层)
@@ -577,7 +577,7 @@
### Story 23经纪人管理客源联系人
**As** 一线经纪人,**I want** 在客源详情页查看、新增或编辑该客源的联系人信息,**So that** 确保联系方式信息完整,随时可以联系到客户。
**As** Agent经纪人**I want** 在客源详情页查看、新增或编辑该客源的联系人信息,**So that** 确保联系方式信息完整,随时可以联系到客户。
**验收标准**
@@ -620,7 +620,7 @@
### Story 24经纪人管理客源相关员工
**As** 一线经纪人或店长,**I want** 查看和修改客源的首录人与归属人,**So that** 确保客源归属关系正确,保证客源跟进责任到人。
**As** Agent经纪人或店长,**I want** 查看和修改客源的首录人与归属人,**So that** 确保客源归属关系正确,保证客源跟进责任到人。
**验收标准**
@@ -649,7 +649,7 @@
### Story 25经纪人查看客源操作日志
**As** 一线经纪人或店长,**I want** 查看某一客源的完整操作历史记录,**So that** 了解客源的全生命周期操作轨迹,追溯变更原因,辅助审计和管理。
**As** Agent经纪人或店长,**I want** 查看某一客源的完整操作历史记录,**So that** 了解客源的全生命周期操作轨迹,追溯变更原因,辅助审计和管理。
**验收标准**

View File

@@ -26,7 +26,7 @@
| 角色 | 描述 | 使用频率 |
|------|------|----------|
| 一线经纪人 | 负责录入、维护、跟进自己名下的房源 | 每日高频 |
| Agent经纪人 | 负责录入、维护、跟进自己名下的房源 | 每日高频 |
| 店长/经理 | 查看全店/全区房源概况,分配任务,监控跟进完成度 | 每日 |
| 行政人员 | 审核房源信息,维护数据质量 | 每日 |
@@ -61,7 +61,7 @@
### Story 1经纪人录入新房源
**As** 一线经纪人,**I want** 快速录入一套新房源的完整信息,**So that** 房源能进入公盘流通并留存完整的基础数据。
**As** Agent经纪人**I want** 快速录入一套新房源的完整信息,**So that** 房源能进入公盘流通并留存完整的基础数据。
**验收标准**
- [ ] 支持选择 6 种房源类型(住宅、别墅、商铺、商住、写字楼、其他),本期 P0 实现住宅P1 实现别墅,商业类型低优先级
@@ -79,7 +79,7 @@
### Story 2经纪人筛选查找目标房源
**As** 一线经纪人,**I want** 在数万条房源中快速定位符合客户需求的房源,**So that** 提升匹配推荐效率、减少无效沟通。
**As** Agent经纪人**I want** 在数万条房源中快速定位符合客户需求的房源,**So that** 提升匹配推荐效率、减少无效沟通。
**验收标准**
- [ ] 支持关键词搜索:小区名称、地址、业主姓名、电话、钥匙编号等
@@ -100,7 +100,7 @@
### Story 3经纪人变更房源状态
**As** 一线经纪人,**I want** 在房源详情页快速变更房源的交易状态并说明原因,**So that** 保持房源状态与实际情况同步,团队成员能及时感知。
**As** Agent经纪人**I want** 在房源详情页快速变更房源的交易状态并说明原因,**So that** 保持房源状态与实际情况同步,团队成员能及时感知。
**验收标准**
- [ ] 状态变更入口位于详情页顶部操作区"改状态"按钮
@@ -113,7 +113,7 @@
### Story 4经纪人在详情页快捷编辑房源核心字段
**As** 一线经纪人,**I want** 不离开房源详情页就能直接修改价格、等级、属性、现状、用途等高频字段,**So that** 减少页面跳转,快速响应业主意向变化。
**As** Agent经纪人**I want** 不离开房源详情页就能直接修改价格、等级、属性、现状、用途等高频字段,**So that** 减少页面跳转,快速响应业主意向变化。
**验收标准**
- [ ] 详情页顶部支持快捷编辑栋座单元房号(含更改理由,最多 200 字)
@@ -131,7 +131,7 @@
### Story 5经纪人记录房源跟进
**As** 一线经纪人,**I want** 随时记录与业主的沟通内容,**So that** 保留完整的跟进轨迹,团队协作时不会重复打扰业主。
**As** Agent经纪人**I want** 随时记录与业主的沟通内容,**So that** 保留完整的跟进轨迹,团队协作时不会重复打扰业主。
**验收标准**
- [ ] 在房源详情页顶部可直接唤起"写跟进"浮层,无需离开当前页面
@@ -151,7 +151,7 @@
### Story 6经纪人管理钥匙与委托
**As** 一线经纪人,**I want** 在系统中登记钥匙持有情况和委托协议信息,**So that** 提升房源维护完成度评分,并让团队知悉钥匙状态,避免带看时出现无法入场的情况。
**As** Agent经纪人**I want** 在系统中登记钥匙持有情况和委托协议信息,**So that** 提升房源维护完成度评分,并让团队知悉钥匙状态,避免带看时出现无法入场的情况。
**验收标准**
@@ -172,7 +172,7 @@
### Story 7经纪人提交实勘记录
**As** 一线经纪人,**I want** 完成实地勘察后在系统中提交实勘报告和分类照片,**So that** 提升房源维护完成度、保障房源信息真实性,给客户提供更可信的参考依据。
**As** Agent经纪人**I want** 完成实地勘察后在系统中提交实勘报告和分类照片,**So that** 提升房源维护完成度、保障房源信息真实性,给客户提供更可信的参考依据。
**验收标准**
- [ ] 实勘为独立页面包含实勘定位GPS、实勘说明最多 200 字)
@@ -186,7 +186,7 @@
### Story 8经纪人管理房源业主与联系人
**As** 一线经纪人,**I want** 在房源详情页随时新增、编辑业主/联系人信息,并查看该业主名下的其他房源,**So that** 保持联系人资料准确完整,了解业主资产全貌,避免重复打扰、提升沟通效率。
**As** Agent经纪人**I want** 在房源详情页随时新增、编辑业主/联系人信息,并查看该业主名下的其他房源,**So that** 保持联系人资料准确完整,了解业主资产全貌,避免重复打扰、提升沟通效率。
#### 验收标准 8.1:新增业主/联系人
@@ -222,7 +222,7 @@
### Story 9经纪人管理房源图片
**As** 一线经纪人,**I want** 为房源上传并分类管理照片、设置封面图,**So that** 提升房源展示质量,吸引更多买家/租客关注。
**As** Agent经纪人**I want** 为房源上传并分类管理照片、设置封面图,**So that** 提升房源展示质量,吸引更多买家/租客关注。
**验收标准**
- [ ] 支持图片分类:封面(限 1 张)、玄关、客厅、餐厅、卧室、卫生间、厨房、阳台、书房、室内其他、室外、全景
@@ -235,7 +235,7 @@
### Story 10经纪人管理房源附件
**As** 一线经纪人,**I want** 为房源上传并分类管理证件/协议等附件文件,**So that** 将核心证明材料与房源绑定留存,方便后续合规审查和团队共享查阅。
**As** Agent经纪人**I want** 为房源上传并分类管理证件/协议等附件文件,**So that** 将核心证明材料与房源绑定留存,方便后续合规审查和团队共享查阅。
**验收标准**
- [ ] 附件分类 Tab 包含:全部 / 身份证 / 房产证 / 委托书 / 其他,每个 Tab 实时显示该分类文件数量
@@ -248,7 +248,7 @@
### Story 11经纪人补全房源详细信息
**As** 一线经纪人,**I want** 不离开房源详情页补充或修改基本信息、产证信息、房屋介绍和楼盘信息,**So that** 提升房源维护完成度评分,增强房源对买家/租客的吸引力。
**As** Agent经纪人**I want** 不离开房源详情页补充或修改基本信息、产证信息、房屋介绍和楼盘信息,**So that** 提升房源维护完成度评分,增强房源对买家/租客的吸引力。
**验收标准**
- [ ] 「房源信息」Tab 下,以下区块均支持点击"编辑"触发浮窗编辑,无需跳转页面:

View File

@@ -27,9 +27,9 @@
| 角色 | 描述 | 使用频率 |
|------|------|----------|
| 运营/数据管理员 | 维护楼盘信息、楼栋结构、区域体系、学校信息的标准化数据 | 每日 |
| 一线经纪人 | 查询楼盘详情、参考价格走势、了解周边配套辅助成交 | 每日 |
| Agent经纪人 | 查询楼盘详情、参考价格走势、了解周边配套辅助成交 | 每日 |
| 店长/经理 | 监控楼盘数据完整度,分析区域市场行情 | 每周 |
| 系统管理员 | 配置区域关联关系,管理数据标准 | 不定期 |
| Tenant Admin租户管理员 | 配置区域关联关系,管理数据标准 | 不定期 |
---
@@ -227,7 +227,7 @@
### Story 7经纪人查看楼盘价格走势
**As** 一线经纪人,**I want** 在楼盘详情页查看该楼盘的挂牌价走势和历史成交数据,**So that** 在带看时能为客户提供客观的市场行情参考,增强议价信心。
**As** Agent经纪人**I want** 在楼盘详情页查看该楼盘的挂牌价走势和历史成交数据,**So that** 在带看时能为客户提供客观的市场行情参考,增强议价信心。
**验收标准**
@@ -263,7 +263,7 @@
### Story 8经纪人查看楼盘周边配套
**As** 一线经纪人,**I want** 在楼盘详情页查看该楼盘周边的交通/教育/医疗/购物/生活/娱乐配套,**So that** 在带客时快速回答客户关于生活便利性的问题,增强成交转化。
**As** Agent经纪人**I want** 在楼盘详情页查看该楼盘周边的交通/教育/医疗/购物/生活/娱乐配套,**So that** 在带客时快速回答客户关于生活便利性的问题,增强成交转化。
**验收标准**
- [ ] 周边配套 Tab 以地图为主体,楼盘位置以橙色标记点展示在地图上

View File

@@ -20,7 +20,7 @@
| 司内成交明细及套数 | true/false | 开启后,显示公司成交的房源明细信息及成交套数 |
| 区域管理 | true/false | 若启用,则可对区域商圈进行新增、合并、关联操作 |
| 查看销控盘 | true/false | 开启后,可在楼盘管理系统-楼盘里,查看销控盘。请注意:员工查看销控盘时房源地址是直接可见的,建议只给管理层开启!!! |
| 查看销控盘时,只可查看本部门作业范围内的楼盘 | true/false | 开启后,只可查看本部门作业范围内的楼盘的销控盘;关闭后,则跟作业范围无关,「查看销控盘」权限开启即可见所有楼盘的销控盘;系统管理员不受限制 |
| 查看销控盘时,只可查看本部门作业范围内的楼盘 | true/false | 开启后,只可查看本部门作业范围内的楼盘的销控盘;关闭后,则跟作业范围无关,「查看销控盘」权限开启即可见所有楼盘的销控盘;Tenant Admin租户管理员不受限制 |
---

View File

@@ -24,9 +24,9 @@
| 角色 | 描述 | 使用频率 |
|------|------|----------|
| 系统管理员 | 负责角色创建、权限配置、人员权限分配及批量操作,是本模块的核心使用者 | 每日 |
| Tenant Admin租户管理员 | 负责角色创建、权限配置、人员权限分配及批量操作,是本模块的核心使用者 | 每日 |
| 店长 / 区域经理 | 查看下属员工当前权限,按需发起权限变更申请 | 按需 |
| 一线经纪人 | 受权限约束的数据访问者,感知到功能入口的显示/隐藏变化 | 被动感知 |
| Agent经纪人 | 受权限约束的数据访问者,感知到功能入口的显示/隐藏变化 | 被动感知 |
---
@@ -60,7 +60,7 @@
### Story 1管理员查看人员权限列表
**As** 系统管理员,**I want** 在人员列表中查看全公司所有员工的当前角色与权限状态,**So that** 能快速定位权限异常人员并执行调整。
**As** Tenant Admin租户管理员**I want** 在人员列表中查看全公司所有员工的当前角色与权限状态,**So that** 能快速定位权限异常人员并执行调整。
**验收标准**
- [ ] 页面入口路径顶部导航「人事」→「组织人事」→「权限管理」面包屑显示「人事OA / 组织人事 / 权限管理」
@@ -81,7 +81,7 @@
### Story 2管理员为员工批量设置角色
**As** 系统管理员,**I want** 同时为多名员工批量设置同一角色,**So that** 在员工入职或组织架构调整时能高效完成权限初始化,无需逐一操作。
**As** Tenant Admin租户管理员**I want** 同时为多名员工批量设置同一角色,**So that** 在员工入职或组织架构调整时能高效完成权限初始化,无需逐一操作。
**验收标准**
- [ ] 在人员列表中勾选至少一名员工后,「批量设置角色」按钮从禁用变为可点击
@@ -95,7 +95,7 @@
### Story 3管理员修改个人权限
**As** 系统管理员,**I want** 为特定员工在角色权限基础上进行个性化权限调整,**So that** 满足因岗位特殊性而需要区别于通用角色权限配置的场景。
**As** Tenant Admin租户管理员**I want** 为特定员工在角色权限基础上进行个性化权限调整,**So that** 满足因岗位特殊性而需要区别于通用角色权限配置的场景。
**验收标准**
- [ ] 点击人员列表中某员工操作列的「修改权限」后,进入该员工的权限编辑页
@@ -132,7 +132,7 @@
### Story 4管理员查看并编辑特定权限项侧边抽屉
**As** 系统管理员,**I want** 在编辑单个权限项时,同时看到该权限当前应用该角色的所有人员名单,**So that** 能了解本次修改的影响范围,再决定是否确认变更。
**As** Tenant Admin租户管理员**I want** 在编辑单个权限项时,同时看到该权限当前应用该角色的所有人员名单,**So that** 能了解本次修改的影响范围,再决定是否确认变更。
**验收标准**
- [ ] 点击权限项的「编辑」按钮后,页面右侧滑出 Drawer不覆盖左侧导航
@@ -147,7 +147,7 @@
### Story 5管理员查看角色列表
**As** 系统管理员,**I want** 在角色管理页查看所有已创建的角色及其基本信息,**So that** 快速了解当前系统的角色体系,并按需编辑或删除。
**As** Tenant Admin租户管理员**I want** 在角色管理页查看所有已创建的角色及其基本信息,**So that** 快速了解当前系统的角色体系,并按需编辑或删除。
**验收标准**
- [ ] 点击顶部「角色管理」Tab 切换至角色列表页
@@ -165,7 +165,7 @@
### Story 6管理员新增角色
**As** 系统管理员,**I want** 新建一个角色并配置其权限,**So that** 为新增岗位或特殊职能定义标准权限模板,便于批量分配给对应员工。
**As** Tenant Admin租户管理员**I want** 新建一个角色并配置其权限,**So that** 为新增岗位或特殊职能定义标准权限模板,便于批量分配给对应员工。
**验收标准**
- [ ] 点击「+ 新增角色」按钮弹出 Modal标题为「添加角色」
@@ -182,7 +182,7 @@
### Story 7管理员配置角色权限
**As** 系统管理员,**I want** 在角色权限编辑页为角色精细配置各模块的权限开关和数据范围,**So that** 该角色下所有人员自动继承统一的权限配置,减少重复操作。
**As** Tenant Admin租户管理员**I want** 在角色权限编辑页为角色精细配置各模块的权限开关和数据范围,**So that** 该角色下所有人员自动继承统一的权限配置,减少重复操作。
**验收标准**
- [ ] 角色权限编辑页顶部展示:角色名称、角色类别、「编辑」按钮(支持在线修改角色基本信息)
@@ -199,7 +199,7 @@
### Story 8管理员修改角色切换员工角色
**As** 系统管理员,**I want** 在员工权限编辑页直接切换员工所属角色,**So that** 快速完成角色变更而不需要退出到人员列表再操作。
**As** Tenant Admin租户管理员**I want** 在员工权限编辑页直接切换员工所属角色,**So that** 快速完成角色变更而不需要退出到人员列表再操作。
**验收标准**
- [ ] 在员工权限编辑页顶部,角色名称旁有下拉箭头,点击展开角色选择器
@@ -427,7 +427,7 @@ Fonrey 采用 **RBAC基于角色的访问控制+ 个人权限叠加** 的
| 司内成交明细及套数 | 开关型 | 开启后,显示公司成交的房源明细信息及成交套数 |
| 区域管理 | 开关型 | 若启用,则可对区域商圈进行新增、合并、关联操作 |
| 查看销控盘 | 开关型 | 开启后,可在楼盘管理系统-楼盘里,查看销控盘;注意:员工查看销控盘时房源地址是直接可见的,建议只给管理层开启!!!|
| 查看销控盘时,只可查看本部门作业范围内的楼盘 | 开关型 | 开启后,只可查看本部门作业范围内的楼盘的销控盘;关闭,则跟作业范围无关,「查看销控盘」权限开启即可查看所有楼盘的销控盘;系统管理员不受限制 |
| 查看销控盘时,只可查看本部门作业范围内的楼盘 | 开关型 | 开启后,只可查看本部门作业范围内的楼盘的销控盘;关闭,则跟作业范围无关,「查看销控盘」权限开启即可查看所有楼盘的销控盘;Tenant Admin租户管理员不受限制 |
**楼盘资料管理**
@@ -537,8 +537,8 @@ Fonrey 采用 **RBAC基于角色的访问控制+ 个人权限叠加** 的
| 阶段 | 时间 | 受众 | 通过标准 |
|------|------|------|---------|
| 内部 Alpha | TBD | 研发 + 产品团队 | 角色 CRUD 流程通畅,权限编辑保存无误 |
| 封闭 Beta | TBD | 1-2 家试点门店系统管理员 | 批量设置角色 / 个人权限修改功能可用,无 P0 Bug |
| 正式上线 | TBD | 全部租户系统管理员 | 权限变更实时生效,错误率 < 0.5%系统管理员 CSAT ≥ 4/5 |
| 封闭 Beta | TBD | 1-2 家试点门店Tenant Admin租户管理员 | 批量设置角色 / 个人权限修改功能可用,无 P0 Bug |
| 正式上线 | TBD | 全部租户Tenant Admin租户管理员 | 权限变更实时生效,错误率 < 0.5%Tenant Admin租户管理员 CSAT ≥ 4/5 |
**回滚标准**:若权限写入后错误率 > 2%,或出现跨租户数据泄漏问题,立即回滚并通知所有租户管理员。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

View File

@@ -2,8 +2,8 @@
**状态**: Draft
**作者**: 产品经理
**最后更新**: 2026-04-25v1.4 §5.5 后端数据模型迁移至独立文档 `DATA_MODEL/DATA_MODEL_LOGIN.md`
**版本**: 1.4
**最后更新**: 2026-04-30v2.0 根据 review 后的 §4 用户故事全面同步 §5 功能详细说明:删除找回用户名流程及邮件模板;找回密码改为纯短信流程;新增 §5.5 手机验证码登录详细说明§6 技术注意事项更新短信依赖/风险/开放问题§8.2 接口清单同步正式功能状态
**版本**: 2.0
**所属系统**: Fonrey 房产经纪管理系统
**关联模块**: 组织人事管理、权限管理、系统管理
@@ -28,17 +28,17 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用多租户架
|------|--------|---------|
| 多租户环境下,客户端不知道应该连接哪个租户的服务端 | 新用户首次安装后无法正常使用 | 系统无法启动,用户体验极差 |
| 账号密码裸露登录,缺乏验证码保护 | 所有用户 | 存在暴力破解、自动化恶意登录风险 |
| 用户忘记账号或密码无自助找回通道 | 一线经纪人 | 依赖管理员手动重置,效率低 |
| 账号未与实名经纪人档案绑定 | 系统管理员、合规审计 | 操作行为无法追溯至自然人 |
| 用户忘记账号或密码无自助找回通道 | Agent经纪人 | 依赖管理员手动重置,效率低 |
| 账号未与实名经纪人档案绑定 | Tenant Admin租户管理员、合规审计 | 操作行为无法追溯至自然人 |
| 无多因素认证,安全系数低 | 管理层、数据合规 | 存在账号冒用、数据泄露风险 |
### 1.3 目标用户
| 角色 | 描述 | 使用频率 |
|------|------|----------|
| 一线经纪人 | 每日登录系统使用房源/客源功能 | 每日高频 |
| Agent经纪人 | 每日登录系统使用房源/客源功能 | 每日高频 |
| 店长 / 经理 | 登录后查看全店数据、管理任务 | 每日 |
| 系统管理员 | 管理账号创建、密码重置、租户初始化 | 按需 |
| Tenant Admin租户管理员 | 管理账号创建、密码重置、租户初始化 | 按需 |
| 新安装用户 | 首次安装客户端后需完成 Tenant 识别 | 一次性 |
---
@@ -49,7 +49,7 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用多租户架
|------|------|----------|--------|----------|
| 降低登录失败率 | 账号密码正确情况下的登录成功率 | 待统计 | ≥ 99% | 上线后 30 天 |
| 防止恶意登录 | 每日验证码拦截异常登录请求数 | 0无保护 | 建立基线同IP异常次数 > 5次/分钟触发封锁 | 上线后持续监控 |
| 提升找回账号效率 | 用户自助找回密码耗时 | 依赖管理员约1工作日 | < 3 分钟(邮件/短信自助) | 上线后 30 天 |
| 提升找回账号效率 | 用户自助找回密码耗时 | 依赖管理员约1工作日 | < 3 分钟(短信验证码自助) | 上线后 30 天 |
| 确保账号实名绑定率 | 拥有系统账号且未与员工档案绑定的账号比例 | 待统计 | 0%(强制绑定) | 上线即达标 |
| Tenant 识别成功率 | 首次安装后成功完成 Tenant 识别的用户比例 | 待统计 | ≥ 98% | 上线后 30 天 |
@@ -58,11 +58,12 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用多租户架
## 3. 非目标(本期不做)
- **手机验证码登录**:移动端小程序上线后实现,本期**接口预留**UI 入口以「即将开放」禁用态展示
> **注意**:本条已更新。手机验证码登录已升级为 **MVP 正式功能**(见 Story 5与密码登录并列提供。此非目标条目保留仅作版本记录已失效。
- **微信扫码登录**:移动端小程序上线后实现,本期**接口预留**UI 入口以「即将开放」禁用态展示
- **单点登录SSO/ 企业微信集成**:后续版本规划
- **多设备并发登录的强制踢出策略**:本期允许同账号多端登录,后续安全策略模块规划
- **登录时段限制 / IP 白名单**:安全策略模块另行规划
- **管理后台Platform Admin登录**系统管理员登录管理后台的流程属于系统管理模块,本 PRD 专注租户内用户登录
- **管理后台Platform Admin登录**Tenant Admin租户管理员登录管理后台的流程属于系统管理模块,本 PRD 专注租户内用户登录
---
@@ -72,31 +73,33 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用多租户架
### Story 1新用户首次启动客户端——Tenant 识别
**As** 新安装 Fonrey 客户端的经纪人,**I want** 在首次启动时输入所属公司的 Tenant ID 完成租户识别,**So that** 客户端能连接到正确的服务端,后续显示对应公司的登录界面和数据。
**As** 新安装 Fonrey 客户端的经纪人,**I want** 在首次启动时输入所属公司的 12位 Tenant Code 完成租户识别,**So that** 客户端能连接到正确的服务端,后续显示对应公司的登录界面和数据。
**验收标准**
- [ ] 客户端首次启动时(本地无 Tenant ID 缓存自动呈现「Tenant 识别」界面,而非直接显示登录界面
- [ ] 界面包含:产品 Logo、产品名称「Fonrey 房睿」、说明文案「请输入您公司的专属识别码」、Tenant ID 输入框、「确认」按钮
- [ ] Tenant ID 输入框支持粘贴操作,自动去除前后空格
- [ ] 客户端首次启动时(本地无 Tenant Code 缓存自动呈现「Tenant 识别」界面,而非直接显示登录界面
- [ ] 界面包含:产品 Logo、产品名称「Fonrey 房睿」、说明文案「请输入您公司的专属识别码」、Tenant Code 输入框、「确认」按钮
- [ ] Tenant Code 输入框支持粘贴操作,自动去除前后空格
- [ ] 点击「确认」后,客户端向服务端发起 Tenant 验证请求(`POST /api/auth/tenant/verify/`展示加载状态spinner
- [ ] **验证成功**服务端返回租户名称及品牌信息如公司名称、Logo URL客户端将 Tenant ID 写入本地持久化存储自动跳转至该租户的登录界面界面顶部展示「正在登录XX 房产」
- [ ] **验证失败Tenant ID 无效)**:输入框下方显示红色错误提示「识别码无效,请联系您的系统管理员获取正确的识别码」Tenant ID 不写入本地缓存;用户可重新输入
- [ ] **验证成功**服务端返回租户名称及品牌信息如公司名称、Logo URL客户端将 Tenant Code 写入本地持久化存储自动跳转至该租户的登录界面界面顶部展示「正在登录XX 房产」
- [ ] **验证失败Tenant Code 无效)**:输入框下方显示红色错误提示「识别码无效,请联系您的Tenant Admin租户管理员获取正确的识别码」Tenant Code 不写入本地缓存;用户可重新输入
- [ ] **网络异常**:显示「网络连接失败,请检查网络后重试」,提供「重试」按钮
- [ ] 非首次启动(本地已有合法 Tenant ID 缓存):直接跳过识别界面,进入登录界面
- [ ] 登录界面提供「切换公司」入口(链接文字,非主要 CTA点击后清除本地 Tenant ID 缓存并重新显示 Tenant 识别界面;确认前弹出二次确认「切换公司将退出当前账号,是否继续?」
- [ ] 非首次启动(本地已有合法 Tenant Code 缓存):直接跳过识别界面,进入登录界面
- [ ] 登录界面提供「切换公司」入口(链接文字,非主要 CTA点击后清除本地 Tenant Code 缓存并重新显示 Tenant 识别界面;确认前弹出二次确认「切换公司将退出当前账号,是否继续?」
- [ ] Tenant 验证接口属于公开接口,无需鉴权;但需对单 IP 请求频率限制(每分钟 ≤ 10 次)以防止枚举攻击
---
### Story 2经纪人通过账号密码登录
### Story 2经纪人通过手机号和密码登录
**As** 已识别租户的经纪人,**I want** 通过用户名和密码完成登录,**So that** 进入系统开始工作。
**As** 已识别租户的经纪人,**I want** 通过手机号和密码完成登录,**So that** 进入系统开始工作。
> **说明**普通员工的登录账号即为其手机号由Tenant Admin租户管理员在新增员工时自动创建无需记忆额外用户名。Tenant Admin 账号的登录名为平台运营自定义字符串,不受此约束。
**验收标准**
- [ ] 登录界面展示:租户品牌标识(公司 Logo + 公司名称)、用户名输入框、密码输入框、滑块拼图验证区域、「登录」按钮
- [ ] 用户名输入框 Placeholder「请输入用户名」;支持英文字母、数字、下划线,最大长度 50 字符
- [ ] 登录界面展示:租户品牌标识(公司 Logo + 公司名称)、手机号输入框、密码输入框、滑块拼图验证区域、「登录」按钮
- [ ] 手机号输入框 Placeholder「请输入您的手机号」;仅接受数字字符(非数字自动过滤),固定 11 位
- [ ] 密码输入框默认密文显示,右侧提供「显示/隐藏」图标切换明密文
- [ ] **行为验证码(滑块拼图)**:展示一张带缺口的背景图和一块可拖动的拼图碎片,用户通过拖动滑块将碎片移动至缺口位置完成验证;无需输入任何字符,操作直观快速
- [ ] 验证逻辑:前端记录滑动轨迹(坐标序列 + 耗时),与背景图缺口位置一同发送至服务端;服务端综合校验**位置偏差**(允许 ±5px 容差)和**轨迹特征**(是否存在人类滑动的加速/减速规律)以区分机器行为
@@ -104,92 +107,121 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用多租户架
- [ ] 验证成功后,拼图区域显示绿色对勾 + 「验证通过」文案,状态持续至本次登录提交完成
- [ ] 提供「刷新」图标按钮,允许用户主动刷新背景图(针对图片模糊或缺口不清晰的情况)
- [ ] 背景图从预置图库中随机抽取,缺口位置每次随机生成,防止固定模式被预测
- [ ] 三项(用户名、密码、验证码)均有填写后,「登录」按钮才可点击(否则置灰)
- [ ] 三项(手机号、密码、验证码)均有填写后,「登录」按钮才可点击(否则置灰)
- [ ] 点击「登录」触发前端格式校验:
- 用户名为空 → 输入框下方红色提示「请输入用户名
- 手机号为空 → 输入框下方红色提示「请输入手机号
- 手机号不满 11 位 → 提示「请输入完整的 11 位手机号」
- 密码为空 → 提示「请输入密码」
- 验证码为空 → 提示「请输入验证
- 验证码为空 → 提示「请完成滑块验证」
- [ ] 格式校验通过后,向服务端发起登录请求,按钮进入 loading 状态防止重复提交
- [ ] **登录成功**:服务端返回 Session Token客户端存储 Token跳转至系统首页;顶部显示欢迎信息「欢迎回来,{姓名}」
- [ ] **登录失败(用户名或密码错误)**:显示「用户名或密码错误,请重新输入」(不区分是用户名错误还是密码错误,防止枚举攻击);验证码自动刷新;密码输入框清空;用户名保留
- [ ] **登录成功(常规)**:服务端返回 Session Token`is_initial_password` 标记;客户端存储 Token
-`is_initial_password = False`:直接跳转系统首页,顶部显示欢迎信息「欢迎回来,{姓名}」
-`is_initial_password = True`**立即跳转「修改初始密码」强制页面**,不可关闭、不可跳过、不可访问任何其他功能页面(详见 §5.3.4
- [ ] **登录失败(手机号或密码错误)**:显示「手机号或密码错误,请重新输入」(不区分具体原因,防止枚举攻击);验证码自动刷新;密码输入框清空;手机号保留
- [ ] **登录失败(验证码错误)**:显示「验证码有误,请重新输入」;验证码自动刷新;验证码输入框清空
- [ ] **账号被锁定**(同一账号密码连续错误 ≥ 5 次):显示「账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁」;锁定状态下「登录」按钮置灰
- [ ] **账号已停用**:显示「账号已停用,请联系您的管理员」
- [ ] **Session 过期**:用户在系统内操作时 Session 过期,自动跳转至登录界面,并提示「登录已过期,请重新登录」
- [ ] 登录界面底部提供:「忘记用户名」链接、「忘记密码」链接(详见 Story 3、Story 4
- [ ] 登录界面底部提供:「忘记密码」链接(详见 Story 3移除「忘记用户名」入口普通员工用户名即手机号无需找回Tenant Admin 如忘记用户名请联系平台运营
---
### Story 3经纪人找回用户名
### Story 3经纪人找回密码
**As** 忘记用户名的经纪人,**I want** 通过绑定的邮箱或手机号找回用户名,**So that** 不依赖管理员也能自助恢复登录
**As** 忘记密码的经纪人,**I want** 通过手机号 + 短信验证码完成身份核验,重新设定密码,**So that** 无需邮箱、无需联系管理员,独立完成密码重置
> **说明**考虑到大多数Agent经纪人没有常用邮箱本期找回密码统一通过短信验证码实现废弃邮箱找回方式。账号中 `email` 字段在本系统无任何必须业务用途,完全可选。
**验收标准**
- [ ] 点击登录界面「忘记用户名」链接,跳转至「找回用户名」页面(或弹窗
- [ ] 找回方式(本期以邮箱为主,手机号为预留字段):
- 邮箱找回:输入注册邮箱,系统校验邮箱是否与已知账号匹配,匹配成功则发送包含用户名的邮件至该邮箱
- 手机号找回预留UI 入口以「即将开放」禁用态展示)
- [ ] 邮箱输入框:格式校验(包含「@」和域名),错误时提示「请输入有效的邮箱地址」
- [ ] 点击「发送」后:
- 邮箱存在且已绑定账号 → 显示「用户名已发送至您的邮箱,请查收」;发送按钮进入 60 秒倒计时不可重复点击
- 邮箱不存在 → **不提示「邮箱未注册」**(防止用户信息枚举),统一显示「如该邮箱已绑定账号,您将收到一封包含用户名的邮件」
- [ ] 邮件内容:纯文本邮件,包含用户名、发送时间,及「如非本人操作请联系管理员」说明
- [ ] 发送频率限制:同一邮箱 1 小时内最多发送 3 次
- [ ] 提供「返回登录」链接
- [ ] 点击登录界面「忘记密码」链接,跳转至「找回密码」流程Stepper 分步页面,共三步
**步骤一:输入手机号**
- [ ] 页面显示手机号输入框11 位数字,自动过滤非数字)、「获取验证码」按钮、「返回登录」链接
- [ ] 手机号为空或不足 11 位 → 点击「获取验证码」时在输入框下方提示「请输入完整的 11 位手机号」
- [ ] 手机号格式合法后,点击「获取验证码」,按钮进入 60 秒倒计时冷却态(「重新获取(59s)」),倒计时结束后按钮恢复可点击
- [ ] 服务端收到请求后:
- 若该手机号**存在**且账号状态为 `active`:向该号码发送 6 位数字短信验证码,有效期 **10 分钟**
- 若手机号**不存在**或账号已停用:页面统一提示「如该手机号已注册,验证码将在 1 分钟内发送」(**不泄露账号是否存在**
- [ ] 同一手机号 1 小时内最多发送 **5 次**短信验证码,超限后提示「发送次数过多,请 1 小时后再试」
- [ ] 短信内容模板「【Fonrey 房睿】您的密码重置验证码为 {code}10 分钟内有效,请勿泄露。」
**步骤二:输入短信验证码**
- [ ] 页面显示6 位验证码输入框(支持分格输入)、「重新发送」倒计时链接、「下一步」按钮
- [ ] 「下一步」按钮6 位验证码全部输入后方可点击
- [ ] 服务端校验验证码:
- 正确且未过期 → 进入步骤三,颁发一次性 `sms_reset_token`(有效期 15 分钟,一次性,服务端存储)
- 错误 → 提示「验证码有误,请重新输入」,错误次数 ≥ 5 次则本次验证码作废,需重新获取
- 已过期 → 提示「验证码已过期,请重新获取」
**步骤三:重置密码**
- [ ] 步骤三依赖步骤二颁发的 `sms_reset_token`(通过 URL 参数或会话状态传递Token 无效或过期 → 显示「操作已超时,请重新发起找回密码」,跳回步骤一
- [ ] **本页面复用「设置新密码」公共组件**(与首次登录强制修改密码页面为同一组件,详见 §5.3.4),保持 UI 与交互逻辑完全一致;入口上下文不同时,仅页面标题和提示文案有所差异:
| 元素 | 首次登录强制修改§5.3.4 | 找回密码步骤三(本 Story |
|------|--------------------------|--------------------------|
| 页面标题 | 「欢迎使用 Fonrey请先设置您的登录密码」 | 「重置您的登录密码」 |
| 提示文案 | 「您当前使用的是初始密码,为保障账号安全,请立即设置新密码后开始使用」 | 「请输入您的新密码,设置完成后请使用新密码重新登录」 |
| 提交按钮文案 | 「确认并进入系统」 | 「确认重置密码」 |
| 提交后跳转 | `is_initial_password = False`Session 保持,直接进入首页 | 所有 Session 立即失效,跳转登录界面并提示「密码已重置,请使用新密码登录」 |
- [ ] 提交成功后:`is_initial_password` 置为 **`False`**(找回密码属于用户主动操作,已完成身份核验,无需再触发强制修改流程)
> **注意**:与首次登录流程不同,找回密码时用户已通过短信验证码完成了身份核验,本次密码设置即视为"用户本人主动设置",不应再触发 `is_initial_password = True` 的二次强制修改。
---
### Story 4经纪人找回密码
### Story 4经纪人找回用户名(已废弃)
**As** 忘记密码的经纪人,**I want** 通过已知用户名 + 绑定邮箱(或手机号)自助重置密码,**So that** 不依赖管理员也能快速恢复登录
> **状态**已废弃。普通员工用户名固定为手机号无需找回Tenant Admin 如忘记用户名,请联系平台运营线下处理。本 Story 保留占位以维持版本记录,实现时跳过此 Story
---
### Story 5手机验证码登录MVP 实现)
**As** 已有账号的经纪人,**I want** 通过手机号 + 短信验证码直接登录,**So that** 在忘记密码或不想输入密码时,仍能快速进入系统。
> **说明**:短信基础设施(`sms_otp_records` 表、OTP 发送/校验逻辑)已在 Story 3 找回密码中建设完成,本 Story 直接复用,实现成本极低。登录界面提供「密码登录」和「验证码登录」两个并列入口,用户自由切换,两种方式均为 MVP 正式功能。
**验收标准**
- [ ] 点击登录界面「忘记密码」链接,跳转至「找回密码」流程(分步骤页面或 Stepper 组件)
- [ ] 登录界面提供两种登录方式的切换 Tab**「密码登录」**(默认选中)和 **「验证码登录」**
- [ ] 切换 Tab 时,输入区域平滑切换,已填内容清空,滑块验证状态重置
**步骤一:身份验证**
**「验证码登录」界面元素**
- [ ] 手机号输入框(规格同 Story 211 位数字,自动过滤非数字)
- [ ] 验证码输入框6 位数字分格输入)+ 「获取验证码」按钮60 秒倒计时冷却态)
- [ ] 滑块拼图验证区域(规格同 Story 2**先通过滑块验证,再允许点击「获取验证码」**
- [ ] 「登录」按钮(手机号 + 验证码均填写后方可点击)
- [ ] 用户输入:用户名 + 邮箱(本期);手机号找回为预留入口(禁用态)
- [ ] 服务端校验用户名与邮箱是否匹配,不泄露具体原因(统一提示「如信息匹配,重置链接将发送至您的邮箱」)
- [ ] 校验通过后,向绑定邮箱发送含一次性重置链接的邮件;链接有效期 **30 分钟**,使用后立即失效
- [ ] 同一账号 1 小时内最多发送 3 次重置邮件
**获取验证码逻辑**
- [ ] 用户须先完成滑块验证,「获取验证码」按钮方可点击;未完成滑块时点击 → 提示「请先完成滑块验证」
- [ ] 点击「获取验证码」后,服务端:
- 手机号格式不合法 → 前端拦截,提示「请输入完整的 11 位手机号」
- 手机号存在且状态 `active` → 发送 6 位 OTP有效期 **5 分钟**,存入 `sms_otp_records``scene = 'login'`
- 手机号不存在或已停用 → 统一响应「如该手机号已注册,验证码将在 1 分钟内发送」(防止枚举攻击)
- [ ] 同一手机号 1 小时内最多发送 **10 次**登录验证码(找回密码为独立计数,两者不共享限额);超限后提示「发送次数过多,请 1 小时后再试」
- [ ] 短信内容模板「【Fonrey 房睿】您的登录验证码为 {code}5 分钟内有效,请勿泄露。」
**步骤二:重置密码**
**登录校验逻辑**
- [ ] 点击「登录」,服务端校验 OTP
- 正确且未过期 → 登录成功,后续行为与 Story 2 密码登录完全一致(含 `is_initial_password` 判断)
- 错误 → 提示「验证码有误,请重新输入」;连续错误 ≥ 5 次 → 本次 OTP 作废,提示「验证码已失效,请重新获取」
- 已过期 → 提示「验证码已过期,请重新获取」
- [ ] **账号被锁定**(密码登录失败次数触发):验证码登录仍受账号锁定限制,锁定期间无法通过任何方式登录,提示「账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁」
> **设计说明**:账号锁定是账号维度的安全策略,不区分登录方式;否则锁定形同虚设。
- [ ] **账号已停用**:提示「账号已停用,请联系您的管理员」
- [ ] 用户点击邮件中的链接,跳转至「重置密码」页面(链接含加密 Token服务端校验 Token 有效性)
- [ ] Token 无效或已过期 → 显示「链接已过期或已使用,请重新申请」,提供「重新申请」按钮
- [ ] 页面包含:新密码输入框、确认新密码输入框
- [ ] **密码复杂度规则**(符合安全基线):
- 长度 8 ~ 32 位
- 必须包含字母(区分大小写)和数字
- 建议包含特殊符号(非强制,但页面提示推荐)
- 不得与最近 3 次历史密码相同
- [ ] 两次密码输入不一致 → 提示「两次密码输入不一致」
- [ ] 不符合复杂度 → 实时提示具体不满足的规则(逐条校验,红色 × / 绿色 ✓ 视觉指引)
- [ ] 提交成功 → 显示「密码已重置,请使用新密码登录」,自动跳转至登录界面;原所有 Session 立即失效(强制重新登录)
---
### Story 5预留——手机验证码登录接口预留v2 实现)
**As** 绑定了手机号的经纪人,**I want** 通过手机号 + 短信验证码快速登录,**So that** 在忘记密码时仍能正常登录系统。
**当前状态**:本期 UI 入口以「即将开放」禁用态展示于登录界面,接口定义预留,不开放实际功能。
**预留接口设计**(供后端提前规划):
**接口规范**
```
POST /api/auth/login/phone/
Request: { phone: string, sms_code: string, tenant_id: string }
Response: { token: string, user: {...} } | { error: string }
Request: { phone: string, sms_code: string }
Response: { token: string, is_initial_password: bool, user: {...} } | { error_code: string, message: string }
```
**绑定条件**v2 实现时的前置要求):
- 手机号必须先在「个人设置」中与用户名账号完成绑定并通过验证
- 一个手机号只能绑定一个用户名账号(同一租户内)
- 绑定手机号后,可通过手机号 + 短信验证码联合登录
---
### Story 6预留——微信扫码登录接口预留v2 实现)
@@ -221,9 +253,9 @@ POST /api/auth/wechat/callback/ # 微信扫码确认后回调,换取系
```
客户端启动
├─ 本地有 Tenant ID 缓存?
├─ 本地有 Tenant Code 缓存?
│ │
│ YES ──→ 校验缓存 Tenant ID 是否仍有效(服务端 validate
│ YES ──→ 校验缓存 Tenant Code 是否仍有效(服务端 validate
│ │
│ 有效 ──→ 直接进入登录界面
│ │
@@ -231,33 +263,33 @@ POST /api/auth/wechat/callback/ # 微信扫码确认后回调,换取系
└─ NO ──→ 显示 Tenant 识别界面
用户输入 Tenant ID → 发起验证
用户输入 Tenant Code → 发起验证
验证成功 ──→ 缓存 Tenant ID → 进入登录界面
验证成功 ──→ 缓存 Tenant Code → 进入登录界面
验证失败 ──→ 显示错误信息,保持识别界面
```
#### 5.1.2 Tenant 识别界面规范
| 元素 | 规格 |
|------|------|
| 页面背景 | 品牌色渐变(与登录界面保持一致的视觉风格) |
| Logo | Fonrey 产品 Logo居中显示 |
| 标题 | 「欢迎使用 Fonrey 房睿」 |
| 副标题 | 「请输入您公司的专属识别码以继续」 |
| Tenant ID 输入框 | 单行数字输入,固定 12 位,支持粘贴;非数字字符自动过滤,超出 12 位截断 |
| 输入框 Label | 「公司识别码Tenant ID |
| 确认按钮 | 主色调按钮,文字「确认」 |
| 错误提示 | 输入框下方红色文字,固定区域占位(不影响布局抖动) |
| 帮助文案 | 「不知道识别码?请联系您公司的系统管理员」 |
| 元素 | 规格 |
| ------------- | --------------------------------------- |
| 页面背景 | 品牌色渐变(与登录界面保持一致的视觉风格) |
| Logo | Fonrey 产品 Logo居中显示 |
| 标题 | 「欢迎使用 Fonrey 房睿」 |
| 副标题 | 「请输入您公司的专属识别码以继续」 |
| Tenant Code 输入框 | 单行数字输入,固定 12 位,支持粘贴;非数字字符自动过滤,超出 12 位截断 |
| 输入框 Label | 「公司识别码Tenant Code |
| 确认按钮 | 主色调按钮,文字「确认」 |
| 错误提示 | 输入框下方红色文字,固定区域占位(不影响布局抖动) |
| 帮助文案 | 「不知道识别码?请联系您公司的Tenant Admin租户管理员 |
#### 5.1.3 Tenant ID 格式规范
#### 5.1.3 Tenant Code 格式规范
- **格式**:固定 **12 位纯数字**,如 `202500010001`
- **生成规则**(建议):由平台运营在系统管理后台开通租户时自动生成,不允许手动指定,确保全局唯一性;可采用时间戳前缀 + 随机后缀的方式生成(如 `YYYYMM` + 6 位随机数)
- **前端校验**:输入框仅接受数字字符(非数字自动过滤),输入满 12 位后自动触发格式完成状态;少于 12 位时点击「确认」弹出提示「识别码须为 12 位数字」
- **唯一性**:全局唯一(公共 Schema 层面),同一 Tenant ID 不可分配给多个租户
- **唯一性**:全局唯一(公共 Schema 层面),同一 Tenant Code 不可分配给多个租户
- **客户端存储**Electron `app.getPath('userData')` 目录下的配置文件(加密存储,防止明文读取)
#### 5.1.4 服务端 Tenant 验证接口规范
@@ -267,7 +299,7 @@ POST /api/auth/tenant/verify/
Request Body:
{
"tenant_id": "202500010001"
"tenant_code": "202500010001"
}
Response 200 (成功):
@@ -294,31 +326,48 @@ Response 200 (失败):
#### 5.2.1 界面布局
登录界面顶部以 **Tab 切换**区分两种登录方式「密码登录」默认选中Tab 下方的表单区随当前选中 Tab 动态切换,微信扫码作为独立的「其他登录」保持禁用。
```
┌─────────────────────────────────────────┐
│ [租户 Logo] [租户公司名称] │ ← 顶部品牌区Tenant 识别后回填)
│ │
│ ┌──────────────┬──────────────────┐ │
│ │ 密码登录 ✓ │ 验证码登录 │ │ ← 登录方式 Tab默认选中「密码登录」
│ └──────────────┴──────────────────┘ │
│ │
│ ── 密码登录 Tab默认展示─────────── │
│ ┌───────────────────────┐ │
│ │ 用户名 │ │
│ │ 手机号 │ │
│ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ 密码 👁 │ │
│ └───────────────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │ [背景图 + 拼图缺口] 🔄 │ │ ← 右上角刷新图标
│ │ │ │
│ │ [拼图碎片] │ │
│ │ ├────────────────────────────── │ │
│ │ ◀ 拖动滑块完成拼图 ▶ │ │
│ │ [拼图碎片] ◀ 拖动滑块完成拼图 ▶│ │
│ └─────────────────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ 登 录 │ │ ← 主 CTA橙色
│ └───────────────────────┘ │
│ 忘记密码 │ ← 文字链接(忘记用户名入口已废弃)
│ │
忘记用户名 忘记密码 │ ← 次级入口,文字链接
── 验证码登录 Tab切换后展示──────── │
│ ┌───────────────────────┐ │
│ │ 手机号 │ │
│ └───────────────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │ [拼图验证] ← 先完成验证,再获取 │ │ ← 验证码登录下滑块前置
│ └─────────────────────────────────┘ │
│ ┌──────────────────┐ ┌─────────────┐ │
│ │ 验证码6 位) │ │ 获取验证码 │ │ ← 通过滑块后按钮可点击60s 冷却
│ └──────────────────┘ └─────────────┘ │
│ ┌───────────────────────┐ │
│ │ 登 录 │ │
│ └───────────────────────┘ │
│ 忘记密码 │
│ │
│ ─────────────── 其他登录 ──────────────│
│ [手机验证码登录 - 即将开放] │ ← 禁用态,灰色
│ [微信扫码登录 - 即将开放] │ ← 禁用态,灰色
│ │
│ 切换公司 │ ← 底部,小字链接
@@ -335,7 +384,7 @@ Response 200 (失败):
| 验证码有效期 | 单次验证会话有效,提交登录后服务端 Token 立即失效;超过 3 分钟未操作需重新加载 |
| 验证失败处理 | 拼图区域抖动动画提示,自动刷新新背景图;**不计入账号密码错误次数**(行为验证失败属独立事件) |
| 密码错误锁定 | 同一账号连续密码错误 ≥ 5 次,锁定 30 分钟;解锁方式:等待超时自动解锁 或 管理员手动解锁 |
| 密码错误计数 | 计数存于 RedisKey 格式:`login_fail:tenant_id:username`TTL 30 分钟 |
| 密码错误计数 | 计数存于 RedisKey 格式:`login_fail:tenant_id:phone`phone 即用户名/手机号)TTL 30 分钟 |
| 验证码刷新 | 登录失败(用户名/密码错误)后自动刷新拼图;用户亦可主动点击「刷新」图标重新加载背景图 |
| HTTPS | 所有登录相关请求强制 HTTPS不允许 HTTP 降级 |
| 密码传输 | 前端不做密码加密HTTPS 层保证传输安全;后端存储使用 `django.contrib.auth` 默认的 `PBKDF2+SHA256` 哈希 |
@@ -355,18 +404,19 @@ Response 200 (失败):
系统内共有两类账号创建场景,权限和规则各不相同:
** Tenant Admin 账号(每个租户唯一的超级管理账号)**
** Tenant Admin 账号(每个租户的超级管理账号)**
| 项目 | 规格 |
|------|------|
| 创建时机 | 平台运营在系统管理后台开通租户时,同步创建第一个 Tenant Admin 账号 |
| 用户名 | **由平台运营自定义设置**,格式:英文字母开头,仅含字母/数字/下划线6~30 字符,同租户内唯一 |
| 初始密码 | **由平台运营自定义设置**须符合密码复杂度规则8~32 位,含字母+数字) |
| 首次登录 | 强制修改初始密码,不可跳过 |
| 权限范围 | 拥有该租户内最高权限,可管理员工账号、角色、系统设置等 |
| 数量限制 | 每个租户仅限 1 个 Tenant Admin 账号后续可扩展为多管理员v2 规划) |
| 项目 | 规格 |
| ---- | ------------------------------------------------------------ |
| 创建时机 | 平台运营在系统管理后台开通租户时,系统**自动**以该租户联系人手机号创建 Tenant Admin 账号,无需手动设置 |
| 用户名 | **固定为该租户联系人的手机号**11 位数字),全局唯一,创建后不可更改 |
| 初始密码 | **系统统一固定初始密码**(与普通员工相同,由平台在部署配置中设定,如 `Fonrey@2025` |
| 首次登录 | 强制修改初始密码,不可跳过 |
| 权限范围 | 拥有该租户内最高权限,可管理员工账号、角色、系统设置等 |
| 数量限制 | 每个租户仅限 1 个 Tenant Admin 账号后续可扩展为多管理员v2 规划) |
| 数据来源 | 联系人手机号来自 `public.tenants.contact_phone` 字段,开通租户时由平台运营录入,必填 |
** 普通员工账号(经纪人、店长、行政等)**
** 普通员工账号(经纪人、店长、行政等)**
| 项目 | 规格 |
|------|------|
@@ -381,10 +431,10 @@ Response 200 (失败):
| 字段 | 类型 | Tenant Admin | 普通员工账号 | 说明 |
|------|------|-------------|-------------|------|
| 用户名username | CharField(30) | 平台运营自定义,字母开头,含字母/数字/下划线6~30 字符 | **固定为员工手机号**11 位数字) | 登录 ID创建后不可更改 |
| 密码password | CharField | 平台运营自定义初始密码 | **系统统一固定初始密码** | PBKDF2+SHA256 哈希存储 |
| 手机号phone | CharField(11) | 选填,加密存储 | **必填,同时作为用户名**,加密存储,同租户内唯一 | 当前阶段为登录 IDv2 启用手机验证码登录后复用此字段 |
| 邮箱email | EmailField | 选填,同租户唯一 | 选填,同租户唯一 | 用于找回密码;若为空则无法自助找回 |
| 用户名username | CharField(30) | **固定为联系人手机号**11 位数字) | **固定为员工手机号**11 位数字) | 登录 ID创建后不可更改;两类账号规则统一 |
| 密码password | CharField | **系统统一固定初始密码** | **系统统一固定初始密码** | PBKDF2+SHA256 哈希存储;首次登录强制修改 |
| 手机号phone | CharField(11) | **必填,同时作为用户名**,来源于 `public.tenants.contact_phone` | **必填,同时作为用户名**,加密存储,同租户内唯一 | 两类账号均用手机号登录,v2 启用手机验证码后复用此字段 |
| 邮箱email | EmailField | 选填,同租户唯一 | 选填,同租户唯一 | 在本系统无必须业务用途,完全可选;普通员工忘记密码通过手机短信验证码自助找回,**与邮箱无关** |
| 员工档案关联staff_id | OneToOneField → `org.Staff` | 可选关联(平台运营账号) | 必须关联 | 实名绑定 |
| 账号状态status | CharField | `active` / `disabled` / `locked` | `active` / `disabled` / `locked` | locked 为密码错误锁定30 分钟自动恢复 |
| 初始密码标记is_initial_password | BooleanField | True首次登录前 | True首次登录前 | True 时登录成功后强制跳转修改密码页 |
@@ -411,109 +461,84 @@ Response 200 (失败):
---
### 5.4 找回流程详细说明
### 5.4 找回密码详细说明
#### 5.4.1 找回用户名流程
> **说明**Story 4「找回用户名」已废弃。普通员工用户名固定为手机号无需找回Tenant Admin 如忘记用户名请联系平台运营线下处理。
> **说明**:由于普通员工的用户名即为其**手机号**,通常无需「找回用户名」功能。登录界面的「忘记用户名」入口保留,但仅对 Tenant Admin 账号有意义(其用户名为自定义字符串)。
```
用户点击「忘记用户名」
├─ 普通员工:提示「您的登录账号为您的手机号,请直接使用手机号登录」
│ 提供「返回登录」按钮
└─ Tenant Admin用户名非手机号格式
├─ 输入绑定邮箱
│ │
│ 服务端查询(不向前端返回查询结果,防止枚举)
│ │
│ 统一响应「如该邮箱已绑定账号,您将收到邮件」
│ │
│ 后台:邮箱存在 → 发送邮件(包含用户名)
│ 邮箱不存在 → 静默处理
└─ 用户查收邮件,获取用户名 → 返回登录
```
![[找回用户名流程.png]]
> **前端识别逻辑**:用户在「忘记用户名」页面输入邮箱提交后,服务端根据是否匹配到 Tenant Admin 账号决定处理路径,前端无需区分,统一展示「如该邮箱已绑定账号,您将收到邮件」。
**邮件模板(找回用户名)**
```
主题:您的 Fonrey 房睿用户名
您好,
您请求找回在 [公司名称] 的 Fonrey 账号用户名。
您的用户名为:{username}
如果这不是您的操作,请忽略此邮件。如有疑问,请联系您的系统管理员。
此邮件由系统自动发送,请勿回复。
发送时间:{datetime}
```
#### 5.4.2 找回密码流程
#### 5.4.1 找回密码流程
```
用户点击「忘记密码」
步骤1身份验证
步骤1输入手机号
├─ 输入手机号(即用户名)+ 绑定邮箱
Tenant Admin 则输入自定义用户名 + 绑定邮箱)
├─ 输入 11 位手机号,点击「获取验证码」
│ │
│ 服务端校验用户名与邮箱是否匹配
│ 服务端校验手机号是否存在且状态为 active
│ │
│ 统一响应「如信息匹配,重置链接将发送至您的邮箱」(防止枚举)
│ 统一响应「如该手机号已注册,验证码将在 1 分钟内发送」(防止枚举)
│ │
│ 后台:匹配成功 → 生成加密 Token有效期 30min→ 异步发送邮件
│ 不匹配 → 静默处理
│ 后台:存在且 active → 生成 6 位 OTP有效期 10 分钟,存入 sms_otp_records → 发送短信
│ 不存在或已停用 → 静默处理
步骤2用户点击邮件中的重置链接
步骤2输入短信验证码
├─ 服务端校验 Token 有效性
├─ 输入 6 位验证码,点击「下一步」
│ │
有效 → 展示「重置密码」表单
服务端校验 OTP
│ │
无效/过期 → 提示「链接已过期,请重新申请」,提供「重新申请」按钮
正确且未过期 → 颁发一次性 sms_reset_token有效期 15 分钟)→ 进入步骤3
│ │
│ 错误(累计 < 5 次)→ 提示「验证码有误,请重新输入」
│ 错误(累计 ≥ 5 次)→ 提示「验证已失败,请重新获取验证码」,本次 OTP 作废
│ 已过期 → 提示「验证码已过期,请重新获取」
步骤3用户输入并提交新密码
步骤3重置密码
├─ 密码复杂度校验(≥ 8 位,含字母+数字)
├─ 与历史密码对比校验(最近 3 次,含固定初始密码)
├─ 页面携带 sms_reset_token服务端校验有效性
│ │
│ 无效/过期 → 提示「操作已超时请重新发起找回密码」跳回步骤1
│ │
├─ 用户输入新密码 + 确认新密码,实时逐条校验复杂度规则(✓/✗)
└─ 校验通过 → 更新密码is_initial_password = False
→ 清除该账号所有有效 Session强制重新登录
→ 跳转登录界面,提示「密码已重置,请重新登录
```
![[找回密码流程.png]]
> **注意**:找回密码流程依赖员工账号绑定了邮箱。若员工未绑定邮箱,无法自助找回,需联系 Tenant Admin 在管理界面执行「重置密码」操作,将密码恢复为固定初始密码。
**邮件模板(重置密码)**
```
主题:重置您的 Fonrey 房睿密码
您好,
我们收到了重置您在 [公司名称] 的 Fonrey 账号密码的请求。
请点击以下链接重置密码(链接 30 分钟内有效):
{reset_link}
如果您未发起此请求,请忽略此邮件,您的密码不会被更改。
此邮件由系统自动发送,请勿回复。
发送时间:{datetime}
└─ 提交成功
→ 更新密码is_initial_password = False
→ 清除该账号所有有效 Session强制重新登录
→ 跳转登录界面,提示「密码已重置,请使用新密码登录」
```
---
### 5.5 后端数据模型设计
### 5.5 手机验证码登录详细说明
> 本节为 Story 5 的实现规范补充。短信基础设施(`sms_otp_records` 表、OTP 发送/校验逻辑)在 Story 3 找回密码中已建设完成,本节描述在**登录场景**下复用该基础设施时的关键差异点。
**与找回密码短信逻辑的差异对比**
| 维度 | 找回密码Story 3 | 验证码登录Story 5 |
|------|-------------------|---------------------|
| `scene` 字段 | `password_reset` | `login` |
| OTP 有效期 | 10 分钟 | 5 分钟 |
| 每小时发送上限 | 5 次 | 10 次 |
| 验证成功后动作 | 颁发 `sms_reset_token` → 步骤三重置密码 | 直接颁发 Session Token登录成功 |
| 短信文案 | 「密码重置验证码」 | 「登录验证码」 |
| 账号锁定影响 | 不受密码错误锁定限制(非密码登录路径) | **受账号锁定限制**(账号维度安全策略,不区分方式)|
**滑块验证前置规则**(验证码登录特有):
- 用户须先完成滑块拼图验证,「获取验证码」按钮方可点击
- 滑块验证通过后,拼图区域保持「验证通过」状态,不需要在点击「登录」前再次验证
- 切换 Tab 时,滑块验证状态重置(须重新完成验证后方可获取验证码)
**`sms_otp_records` 表复用说明**
- 不新建表,复用 `DATA_MODEL_LOGIN.md` 中定义的 `sms_otp_records`
- `scene` 字段区分场景:`login` / `password_reset`,各自独立限流计数
- 同一手机号同一 scene 同一时间只有一条有效 OTP新发送时将旧记录标记为 `used`
---
### 5.6 后端数据模型设计
> **数据模型已迁移至独立文档**,请参阅:
> **`Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md`**
@@ -521,7 +546,7 @@ Response 200 (失败):
该文档包含:
- `user_accounts` 账号主表完整字段定义、约束、索引、Django Model 代码)
- `login_attempts` 登录审计表
- `password_reset_tokens` 密码重置令牌表
- `sms_otp_records` 短信验证码记录表(找回密码 + 验证码登录共用)
- `password_histories` 历史密码记录表
- Redis 缓存结构说明
- 账号状态机与创建流程
@@ -531,13 +556,13 @@ Response 200 (失败):
---
### 5.6 Electron 客户端登录相关约定
### 5.7 Electron 客户端登录相关约定
| 约定项 | 规格 |
|--------|------|
| Tenant ID 存储 | `electron-store``app.getPath('userData')` + AES 加密,不存储明文 |
| Tenant Code 存储 | `electron-store``app.getPath('userData')` + AES 加密,不存储明文 |
| Session Token 存储 | 内存(`global` 变量)+ `session` CookieChromium 管理),不写入磁盘明文文件 |
| 登录页加载 | 客户端主进程根据 Tenant ID 构建目标 URL`https://{tenant_slug}.fonrey.com/auth/login/`),通过 `BrowserWindow.loadURL()` 加载 |
| 登录页加载 | 客户端主进程根据 Tenant Code 构建目标 URL`https://{tenant_slug}.fonrey.com/auth/login/`),通过 `BrowserWindow.loadURL()` 加载 |
| 多标签页处理 | 同一 `BrowserWindow` 内,所有页面共享同一 Session Cookie |
| 客户端登出 | 调用服务端 `POST /api/auth/logout/` 使服务端 Session 失效 + 清除 Chromium Session Cookie |
| 窗口关闭时 | Session 保留(不自动登出),下次打开客户端时若 Session 未过期,直接进入系统 |
@@ -553,8 +578,9 @@ Response 200 (失败):
|--------|------|------|
| `django.contrib.auth` | 用户认证基础框架 | 扩展 `AbstractBaseUser` 而非直接使用 `User` 模型,以支持 `username` 唯一性约束在租户维度而非全局 |
| `django-tenants` | 多租户隔离 | `UserAccount` 属于租户级 SchemaTenant 验证接口属于 `shared_apps` |
| `Redis` | 滑块验证 Token 存储、登录失败计数、密码重置 Token 缓存 | 验证 Key`captcha_token:{uuid}`TTL 3min登录失败 Key`login_fail:{tenant_id}:{username}` |
| `Celery` | 发送找回邮件 | 邮件发送异步处理,防止接口响应超时 |
| `Redis` | 滑块验证 Token 存储、登录失败计数、短信 OTP 限流计数 | 验证 Key`captcha_token:{uuid}`TTL 3min登录失败 Key`login_fail:{tenant_id}:{username}`OTP 限流 Key`sms_limit:{scene}:{phone}`TTL 1h|
| 短信服务(待选型) | 发送登录验证码 / 找回密码验证码 | 国内需选用具备短信资质的服务商(如阿里云短信、腾讯云短信);需申请短信签名和模板审核 |
| `Celery` | 异步任务处理 | 短信发送异步处理,防止接口响应超时;原邮件发送需求已废弃,短信为主要通知方式 |
| `django-ratelimit` 或自定义中间件 | 接口限流 | Tenant 验证接口、登录接口、找回密码接口均需限流 |
| `Pillow` | 滑块拼图图片处理 | 生成拼图背景图(抠出缺口区域)及对应的拼图碎片图片,输出为 Base64分别通过两个字段返回给前端 |
@@ -569,17 +595,17 @@ Response 200 (失败):
| 风险 | 可能性 | 影响 | 缓解措施 |
|------|--------|------|---------|
| 滑块验证被机器模拟轨迹绕过 | 低 | 高 | 服务端同时校验位置偏差 + 轨迹曲线特征(非线性运动特征),拒绝匀速/程序化轨迹;后续可引入设备指纹加固 |
| Tenant ID 枚举攻击(暴力试探) | 低 | 中 | Tenant 验证接口限流每IP每分钟≤10次返回结果不区分「未找到」与「已禁用」|
| 密码重置 Token 泄露 | 低 | 高 | Token 单次有效、30分钟过期、HTTPS 传输 |
| 邮件发送失败导致用户无法找回密码 | 中 | | 邮件发送失败写入告警日志,管理员可通过后台查看 Token 手动告知用户 |
| Tenant Code 枚举攻击(暴力试探) | 低 | 中 | Tenant 验证接口限流每IP每分钟≤10次返回结果不区分「未找到」与「已禁用」|
| 密码重置 Token 泄露 | 低 | 高 | `sms_reset_token` 单次有效、15 分钟过期、HTTPS 传输 |
| 短信服务故障导致用户无法找回密码或验证码登录 | 中 | | 短信发送失败写入告警日志;密码登录作为保底方式(非单一入口);建议配置备用短信服务商通道 |
| 多端同时登录同一账号 | 高(日常场景) | 低 | 本期允许,后续如需踢出,可在 Token 机制中引入版本号 |
### 6.4 开放问题(开发前需确认)
- [ ] **邮件服务商选型**:使用 SendGrid / 阿里云邮件推送 / SMTP 自建?需运维确认 — 负责人:后端负责人 — 截止:开发启动前
- [ ] **短信服务商选型**:使用阿里云短信 / 腾讯云短信 / 其他服务商?需运维确认并提前申请短信签名和模板审核(国内审核周期 13 个工作日)— 负责人:后端负责人 — 截止:开发启动前
- [ ] **Session 有效期默认值**8 小时是否满足各租户需求?是否允许租户管理员自行配置?— 负责人:产品经理 — 截止:开发启动前
- [ ] **滑块拼图实现方案**自研Pillow 生成图片 + 前端拖拽组件)还是集成第三方行为验证服务(如极验 GeeTest / 网易易盾)?自研可控但需维护图库;第三方开箱即用但引入外部依赖,需评估数据合规要求 — 负责人:后端负责人 + 安全 — 截止:开发启动前
- [ ] **账号锁定通知**:账号被锁定后,是否自动发邮件通知用户和/或管理员?— 负责人:产品经理 — 截止:开发启动前
- [ ] **账号锁定通知**:账号被锁定后,是否自动发短信通知用户和/或通知管理员(站内消息)?— 负责人:产品经理 — 截止:开发启动前
- [ ] **历史密码校验范围**:最近 3 次是否足够?是否需要额外规则(如不能与用户名相同)?— 负责人:产品经理 — 截止:开发启动前
---
@@ -602,7 +628,7 @@ Response 200 (失败):
```
[未识别 Tenant]
│ 输入有效 Tenant ID
│ 输入有效 Tenant Code
[未登录]
│ 账密登录成功
@@ -625,24 +651,20 @@ Response 200 (失败):
| 接口 | 方法 | Schema 位置 | 是否需要鉴权 | 说明 |
|------|------|------------|------------|------|
| `/api/auth/tenant/verify/` | POST | Publicshared | 否 | Tenant ID 验证 |
| `/api/auth/tenant/verify/` | POST | Publicshared | 否 | Tenant Code 验证 |
| `/api/auth/captcha/` | GET | Tenant | 否 | 获取滑块拼图验证码(返回背景图 Base64 + 碎片图 Base64 + 验证 Token |
| `/api/auth/captcha/verify/` | POST | Tenant | 否 | 提交滑动轨迹 + 位置,服务端校验并返回一次性通过凭证(供登录接口使用) |
| `/api/auth/login/` | POST | Tenant | 否 | 账号密码登录 |
| `/api/auth/login/` | POST | Tenant | 否 | 手机号 + 密码登录 |
| `/api/auth/login/phone/` | POST | Tenant | 否 | 手机号 + 短信验证码登录MVP 正式功能) |
| `/api/auth/logout/` | POST | Tenant | 是 | 登出,使 Session 失效 |
| `/api/auth/recover/username/` | POST | Tenant | 否 | 发起找回用户名 |
| `/api/auth/recover/password/request/` | POST | Tenant | 否 | 发起找回密码(发送邮件) |
| `/api/auth/recover/password/request/` | POST | Tenant | 否 | 发起找回密码(发送短信验证码) |
| `/api/auth/recover/password/verify/` | POST | Tenant | 否 | 校验短信验证码,颁发一次性 `sms_reset_token` |
| `/api/auth/recover/password/reset/` | POST | Tenant | 否Token 鉴权) | 提交新密码 |
| `/api/auth/login/phone/` | POST | Tenant | 否 | **预留**手机验证码登录 |
| `/api/auth/wechat/qrcode/` | GET | Tenant | 否 | **预留**获取微信二维码 |
| `/api/auth/wechat/callback/` | POST | Tenant | 否 | **预留**,微信扫码回调 |
| `/api/auth/wechat/qrcode/` | GET | Tenant | 否 | **预留 v2**获取微信二维码 |
| `/api/auth/wechat/callback/` | POST | Tenant | 否 | **预留 v2**微信扫码回调 |
### 8.3 相关文档参考
- 客户端发布管理模块 PRD`Project/fonrey/PRD/发布管理/客户端发布管理模块PRD.md`
- 组织人事管理模块 PRD`Project/fonrey/PRD/组织人事管理/组织人事管理模块PRD.md`
- 权限管理模块 PRD`Project/fonrey/PRD/权限管理/权限管理模块PRD.md`
- 系统管理模块 PRD`Project/fonrey/PRD/系统管理/系统管理模块PRD.md`
- 技术栈文档:`Project/fonrey/TECH_STACK/TECH_STACK.md`
- **登录管理数据模型**`Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md`
- **登录管理技术方案**`Project/fonrey/TECH_STACK/登录管理技术方案.md`

View File

@@ -33,7 +33,7 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-ten
| 角色 | 使用场景 | 频率 |
| --------------------------- | ----------- | ------ |
| 超级管理员Platform Super Admin | 全局配置、高危操作授权 | 低频(每周) |
| Platform Admin平台超级管理员Platform Super Admin | 全局配置、高危操作授权 | 低频(每周) |
| 运维人员Ops Operator | 日常租户管理、监控巡检 | 高频(每日) |
| 只读审计员Read-only Auditor | 日志查询、合规报告导出 | 中频(每周) |
@@ -97,13 +97,13 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-ten
---
### Persona B超级管理员 David系统升级与回滚
### Persona BPlatform Admin平台超级管理员 David系统升级与回滚
> 负责平台技术运维,周期性执行版本升级,关注升级稳定性与租户影响面,有权执行所有高危操作。
**Story 4**:灰度系统升级
> 作为超级管理员,我希望先对内测租户升级新版本,验证稳定后再全量推送,避免一次性影响所有客户。
> 作为Platform Admin平台超级管理员,我希望先对内测租户升级新版本,验证稳定后再全量推送,避免一次性影响所有客户。
**验收标准**
- [ ] 升级前自动执行健康检查,存在异常服务时阻断升级并提示
@@ -113,7 +113,7 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-ten
**Story 5**:升级失败回滚
> 作为超级管理员,我希望在升级出现问题时能立即回滚至上一稳定版本,并生成事件报告。
> 作为Platform Admin平台超级管理员,我希望在升级出现问题时能立即回滚至上一稳定版本,并生成事件报告。
**验收标准**
- [ ] 回滚操作触发前自动保存当前状态快照
@@ -177,7 +177,7 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-ten
| 模式 | 说明 |
|------|------|
| 软删除Soft Delete | 标记删除状态,数据保留 30 天(默认,可配置)后由 Celery 定时任务清除 |
| 硬删除Hard Delete | 立即清除所有数据、Schema、存储资源及子域名授权仅超级管理员可操作 |
| 硬删除Hard Delete | 立即清除所有数据、Schema、存储资源及子域名授权Platform Admin平台超级管理员可操作 |
删除前置条件:
1. 操作人必须确认数据导出已完成(勾选确认框)
@@ -264,7 +264,7 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-ten
**Tenant Admin 管理**
- 每个租户可设置 1 至多名 Tenant Admin超级用户
- 平台管理员可直接在后台创建新用户并赋予 Tenant Admin 角色,或从租户现有用户中指定
- Platform Admin平台超级管理员可直接在后台创建新用户并赋予 Tenant Admin 角色,或从租户现有用户中指定
- 支持查看当前 Tenant Admin 列表,执行:新增 / 替换 / 撤销权限
**Tenant Admin 权限配置RBAC**
@@ -282,7 +282,7 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-ten
**密码重置**
- 平台管理员可为任意租户的任意用户发起密码重置
- Platform Admin平台超级管理员可为任意租户的任意用户发起密码重置
- 方式一:发送重置链接至注册邮箱(用户自助重置)
- 方式二:管理员直接设置临时密码(用户首次登录后强制修改)
- 所有重置操作记录于操作审计日志
@@ -331,7 +331,7 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-ten
**灰度升级策略**
- 维护"内测租户组"列表,由超级管理员配置
- 维护"内测租户组"列表,由Platform Admin平台超级管理员配置
- 灰度阶段仅对内测租户执行升级,其余租户保持原版本
- 内测租户验证通过(手动确认)后,触发全量升级
@@ -423,7 +423,7 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-ten
**管理员设置**
- 管理员账号管理(创建、编辑、停用)
- 角色配置(超级管理员 / 运营人员 / 只读审计员)
- 角色配置(Platform Admin平台超级管理员 / 运营人员 / 只读审计员)
- MFA 设置(强制启用,支持 TOTP
- IP 白名单配置
- 登录会话管理(查看活跃会话、强制登出)
@@ -438,7 +438,7 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-ten
| IP 白名单 | 仅允许指定 IP 范围访问管理控制台 URLNginx 层或应用层限制) |
| 高危操作二次验证 | 删除租户、数据恢复、系统回滚操作触发 MFA 二次确认弹窗 |
| 会话超时 | 无操作 30 分钟后自动登出Token 失效 |
| 强制登出 | 超级管理员可在"管理员设置"中强制终止指定管理员的所有会话 |
| 强制登出 | Platform Admin平台超级管理员可在"管理员设置"中强制终止指定管理员的所有会话 |
**与租户应用隔离**
@@ -546,7 +546,7 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-ten
### 9.2 管理员角色权限矩阵
| 操作 | 超级管理员 | 运营人员 | 只读审计员 |
| 操作 | Platform Admin平台超级管理员 | 运营人员 | 只读审计员 |
|------|-----------|---------|-----------|
| 创建租户 | ✅ | ✅ | ❌ |
| 挂起 / 恢复租户 | ✅ | ✅ | ❌ |

View File

@@ -277,7 +277,7 @@
| 相关方 | 相关方说明 | 状态 | 启动权限 | 操作 |
| ------ | ------------------------------ | --- | ------------------------ | -------------- |
| 平台摄影师 | 开启平台实勘功能后,在房源预约拍摄完成后统一处理为系统管理员 | 停用 | | 权限配置 |
| 平台摄影师 | 开启平台实勘功能后,在房源预约拍摄完成后统一处理为Tenant Admin租户管理员 | 停用 | | 权限配置 |
| 维护人 | 出租或出售房源的维护人 | 停用 | | 权限配置 |
| 售维护人 | 出售房源的维护人(租、售维护人分开时自动启用) | 停用 | | 权限配置 |
| 租维护人 | 出租房源的维护人(租、售维护人分开时自动启用) | 停用 | | 权限配置 |

View File

@@ -50,12 +50,12 @@
## 4. 目标用户
**主要角色**系统管理员(租户侧,每租户 13 人)
**主要角色**Tenant Admin租户管理员(租户侧,每租户 13 人)
> 典型画像:门店运营负责人或行政主管,熟悉业务流程,无技术背景,通过系统后台进行日常运营配置。使用频率:初始开通时高频(完成初始化配置),此后低频(按需调整)。
**间接受益角色**
- 一线经纪人 — 看到的下拉选项和必填规则由管理员配置决定
- Agent经纪人 — 看到的下拉选项和必填规则由管理员配置决定
- 店长/经理 — 配置直接影响客源来源分析报表的数据质量
---
@@ -66,7 +66,7 @@
### US-SETTING-001-A管理员配置可选枚举值Lookup Items
> **As** 系统管理员,
> **As** Tenant Admin租户管理员
> **I want** 在「系统设置 → 参数配置」页面维护各业务模块的下拉选项(如客源来源、跟进目的),
> **So that** 经纪人录入时看到的选项符合公司实际业务,不再依赖研发修改代码。
@@ -117,7 +117,7 @@
### US-SETTING-001-B管理员配置房源字段必填规则
> **As** 系统管理员,
> **As** Tenant Admin租户管理员
> **I want** 按「房源用途 × 交易状态」的组合,控制哪些字段在录入时为必填/选填/隐藏,
> **So that** 系统能在录入时强制采集公司要求的关键信息,提升房源数据完整度。
@@ -165,7 +165,7 @@
### US-SETTING-001-C管理员配置客源录入规则
> **As** 系统管理员,
> **As** Tenant Admin租户管理员
> **I want** 配置新增私客时的查重范围,以及必填字段控制,
> **So that** 减少客源重复录入风险,并确保客源数据质量满足公司管理要求。
@@ -222,7 +222,7 @@
**核心设计决策**
1. **Lookup Items 与 enum_labels 分离**:固定系统枚举(装修/朝向/状态/等级)存放在 Public Schema 的 `enum_labels` 表,由平台管理员通过 migration 维护,租户无权修改。可配置枚举(来源/跟进目的)存放在 Tenant Schema 的 `lookup_items` 表,由租户管理员自主维护。详见数据模型说明文档。
1. **Lookup Items 与 enum_labels 分离**:固定系统枚举(装修/朝向/状态/等级)存放在 Public Schema 的 `enum_labels` 表,由Platform Admin平台超级管理员通过 migration 维护,租户无权修改。可配置枚举(来源/跟进目的)存放在 Tenant Schema 的 `lookup_items` 表,由租户管理员自主维护。详见数据模型说明文档。
2. **字段规则不新增字段**`field_requirement_rules` 只控制「必填/选填/隐藏」状态,字段本身的存在性由数据模型决定。避免配置层与数据模型层职责混淆。

View File

@@ -25,9 +25,9 @@
| 角色 | 描述 | 使用频率 |
|------|------|----------|
| 系统管理员 / HR 行政 | 负责新增/编辑部门、办理员工入职/离职/调岗,维护账号状态与证件信息 | 每日 |
| Tenant Admin租户管理员 / Tenant Admin租户管理员 | 负责新增/编辑部门、办理员工入职/离职/调岗,维护账号状态与证件信息 | 每日 |
| 店长 / 区域经理 | 查看本部门组织架构、员工列表,发起入职邀请 | 每日 |
| 一线经纪人 | 查看同事联系方式(通讯录),查看自己的档案信息 | 按需 |
| Agent经纪人 | 查看同事联系方式(通讯录),查看自己的档案信息 | 按需 |
| 公司管理层 | 通过架构图了解全公司组织结构,监控人员异动动态 | 按需 |
---
@@ -63,7 +63,7 @@
### Story 1管理员查看组织人员列表
**As** 系统管理员/店长,**I want** 在组织结构页面查看公司所有部门及其员工信息,**So that** 能快速掌握当前人员分布并进行管理操作。
**As** Tenant Admin租户管理员/店长,**I want** 在组织结构页面查看公司所有部门及其员工信息,**So that** 能快速掌握当前人员分布并进行管理操作。
**验收标准**
- [ ] 页面入口路径顶部导航「人事」→「组织人事」→「组织结构」面包屑显示「人事OA / 组织人事 / 组织结构」
@@ -72,7 +72,7 @@
- [ ] 点击部门节点后,右侧展示该部门及其下级部门员工列表(可通过「显示下属部门员工」下拉切换)
- [ ] 右上角显示全局系统提示(账号数量上限、实名认证不匹配人数等),提示可点击「立即筛选数据」跳转至对应筛选结果
- [ ] 页面右上角有「员工入黑名单」快捷操作入口
- [ ] 员工列表支持多条件筛选:姓名/工号/电话(文本搜索)、职务(下拉选择)、职务类别(全选/单选)、员工状态(下拉,含已选 N 个计数)、审批状态(下拉)、冻结状态(全选)、登录账号(全选)、系统管理员(请选择)、入职时间(日期范围)、离职时间(日期范围)、显示下属部门员工(显示/隐藏)、部门级别(全选)、证件状态(不限)、证件号搜索
- [ ] 员工列表支持多条件筛选:姓名/工号/电话(文本搜索)、职务(下拉选择)、职务类别(全选/单选)、员工状态(下拉,含已选 N 个计数)、审批状态(下拉)、冻结状态(全选)、登录账号(全选)、Tenant Admin租户管理员(请选择)、入职时间(日期范围)、离职时间(日期范围)、显示下属部门员工(显示/隐藏)、部门级别(全选)、证件状态(不限)、证件号搜索
- [ ] 点击「查询」按钮执行筛选,点击「清空条件」重置所有筛选项
- [ ] 员工列表支持批量操作:勾选复选框后,可执行「批量调动员工」「批量设置员工上级」,通过「更多」下拉展开更多批量操作
- [ ] 列表操作区包含:「新增员工」(主按钮,带下拉箭头)、「导出员工」、「批量调动员工」、「批量设置员工上级」、「更多」、「员工异动记录」(链接)
@@ -86,7 +86,7 @@
### Story 2管理员新增部门
**As** 系统管理员,**I want** 新增一个业务部门并配置其基本信息,**So that** 新部门能纳入组织架构并支持员工归属。
**As** Tenant Admin租户管理员**I want** 新增一个业务部门并配置其基本信息,**So that** 新部门能纳入组织架构并支持员工归属。
**验收标准**
- [ ] 点击左侧「+ 新增部门」按钮跳转至「部门新增」页面面包屑显示「人事OA / 组织人事 / 组织结构 / 部门新增」
@@ -113,7 +113,7 @@
### Story 3管理员编辑部门信息
**As** 系统管理员,**I want** 编辑已有部门的基本信息,**So that** 组织信息保持最新准确状态。
**As** Tenant Admin租户管理员**I want** 编辑已有部门的基本信息,**So that** 组织信息保持最新准确状态。
**验收标准**
- [ ] 通过部门详情页右上角「编辑」按钮进入「部门编辑」页面面包屑显示「人事OA / 组织人事 / 组织结构 / 部门编辑」
@@ -163,7 +163,7 @@
### Story 6查看员工详情 - 员工基本信息
**As** HR 管理员,**I want** 查看某员工的完整档案信息,**So that** 能全面了解员工的任职、个人、来源等情况。
**As** Tenant Admin租户管理员**I want** 查看某员工的完整档案信息,**So that** 能全面了解员工的任职、个人、来源等情况。
**验收标准**
- [ ] 点击员工列表的「查看」操作进入员工详情页,页面标题显示「[部门名称] [员工姓名]」面包屑显示「人事OA / 组织人事 / 组织结构 / 员工详情」
@@ -218,7 +218,7 @@
### Story 7查看员工详情 - 异动记录
**As** HR 管理员,**I want** 在员工详情页查看该员工的所有人事异动历史,**So that** 能追溯员工入职、调岗、上级变动等完整轨迹。
**As** Tenant Admin租户管理员**I want** 在员工详情页查看该员工的所有人事异动历史,**So that** 能追溯员工入职、调岗、上级变动等完整轨迹。
**验收标准**
- [ ] 在员工详情页左侧导航点击「异动记录」切换至异动记录 Tab
@@ -233,7 +233,7 @@
### Story 8查看员工详情 - 账号信息
**As** HR 管理员/系统管理员,**I want** 在员工详情页查看和管理该员工的系统账号及第三方平台账号,**So that** 能统一管理员工的登录凭证和外部账号绑定状态。
**As** Tenant Admin租户管理员/Tenant Admin租户管理员**I want** 在员工详情页查看和管理该员工的系统账号及第三方平台账号,**So that** 能统一管理员工的登录凭证和外部账号绑定状态。
**验收标准**
- [ ] 在员工详情页左侧导航点击「账号信息」切换至账号信息 Tab
@@ -280,7 +280,7 @@
### Story 10查看组织员工异动记录全局视图
**As** HR 管理员,**I want** 在组织结构模块查看全公司所有员工的异动记录汇总,**So that** 能统一审计和追踪所有人事变动。
**As** Tenant Admin租户管理员**I want** 在组织结构模块查看全公司所有员工的异动记录汇总,**So that** 能统一审计和追踪所有人事变动。
**验收标准**
- [ ] 异动记录入口组织结构员工列表页右上角「员工异动记录」链接跳转至异动记录汇总页面包屑显示「人事OA / 组织人事 / 组织结构 / 异动记录」
@@ -299,7 +299,7 @@
### Story 11员工离职操作
**As** HR 管理员/店长,**I want** 在组织结构员工列表中对在职员工发起离职操作,**So that** 员工状态及时变更为「离职」,并触发业务数据的归属处理流程。
**As** Tenant Admin租户管理员/店长,**I want** 在组织结构员工列表中对在职员工发起离职操作,**So that** 员工状态及时变更为「离职」,并触发业务数据的归属处理流程。
**验收标准**
- [ ] 离职操作入口员工列表行右侧「异动」下拉菜单中点击「离职」弹出「员工离职」对话框Modal 形式,背景遮罩,不跳转页面)
@@ -328,7 +328,7 @@
### Story 12员工调动操作
**As** HR 管理员,**I want** 通过右侧抽屉面板对员工发起调动操作并修改其部门、上级、职务等信息,**So that** 员工的组织归属变更即时生效并留下完整的调动记录。
**As** Tenant Admin租户管理员**I want** 通过右侧抽屉面板对员工发起调动操作并修改其部门、上级、职务等信息,**So that** 员工的组织归属变更即时生效并留下完整的调动记录。
**验收标准**
- [ ] 调动操作入口:员工列表行右侧「异动」下拉菜单中点击「调动」,从页面右侧滑出「员工调动」抽屉面板(不跳转页面,背景列表可见但交互禁用)
@@ -367,7 +367,7 @@
### Story 13查看员工奖惩记录
**As** HR 管理员/店长,**I want** 在员工详情页查看该员工的所有奖惩记录,**So that** 能了解员工的奖励与处罚历史,作为绩效管理和晋升的参考依据。
**As** Tenant Admin租户管理员/店长,**I want** 在员工详情页查看该员工的所有奖惩记录,**So that** 能了解员工的奖励与处罚历史,作为绩效管理和晋升的参考依据。
**验收标准**
- [ ] 在员工详情页左侧导航点击「奖惩记录」切换至奖惩记录 Tab当前选中项高亮橙色文字 + 左侧橙色指示条)
@@ -381,7 +381,7 @@
### Story 14新增员工奖惩记录
**As** HR 管理员,**I want** 在员工奖惩记录页面新增一条奖惩记录,**So that** 员工的奖励或处罚情况被系统留档,可追溯查询。
**As** Tenant Admin租户管理员**I want** 在员工奖惩记录页面新增一条奖惩记录,**So that** 员工的奖励或处罚情况被系统留档,可追溯查询。
**验收标准**
- [ ] 点击奖惩记录页面右上角「新增」按钮弹出「新增奖惩记录」对话框Modal 形式,标题「新增奖惩记录」,右上角有「×」关闭按钮)

View File

@@ -2,11 +2,11 @@
# Fonrey 测试规范TEST_SPEC
**版本**: 1.1
**版本**: 1.2
**项目**: Fonrey 房产经纪管理系统
**技术栈**: Django 4.x + django-tenants + PostgreSQL 16 + Redis + Celery + HTMX + Playwright
**关联文档**: `TECH_STACK/TECH_STACK.md``PRD/TASK.md`、各模块技术方案(登录/权限/房源/客源/楼盘/组织人事/系统管理)
**最后更新**: 2026-04-27
**关联文档**: `TECH_STACK/TECH_STACK.md``PRD/TASK.md``TEST_CASES/TEST_CASE_ID_SPEC.md``TEST_CASES/TEST_CASE_REGISTRY.md`各模块技术方案(登录/权限/房源/客源/楼盘/组织人事/系统管理)
**最后更新**: 2026-04-30
---
@@ -36,13 +36,13 @@ Fonrey 采用 AI 驱动迭代,测试是质量兜底。所有 P0 User Story 必
| `apps/*/services/` 业务逻辑层 | ≥ 80% |
| `apps/*/views*` 接口与视图层 | ≥ 70% |
| `apps/*/tasks.py` 异步任务 | ≥ 70% |
| E2E 核心旅程 | 5 条全部通过 |
| E2E 核心测试用例 | 覆盖指定核心用例并全部通过 |
### 2.2 质量门禁
- 每个 P0 US 对应至少一个集成测试场景集。
- PR 合并前:单元 + 集成必须全绿。
- `main/develop`:每日自动跑全量(含 E2E 核心旅程)。
- `main/develop`:每日自动跑全量(含 E2E 核心测试用例)。
---
@@ -50,7 +50,7 @@ Fonrey 采用 AI 驱动迭代,测试是质量兜底。所有 P0 User Story 必
```
┌─────────────────────────────────────────┐
│ E2E 测试(用户旅程 │ ← Playwright
│ E2E 测试(测试用例 │ ← Playwright
├─────────────────────────────────────────┤
│ 集成测试HTTP / View / Service / DB │ ← pytest-django + TenantClient
├─────────────────────────────────────────┤
@@ -71,7 +71,7 @@ Fonrey 采用 AI 驱动迭代,测试是质量兜底。所有 P0 User Story 必
### 3.3 E2E 测试
- 目标:验证真实用户关键路径。
- 约束:只覆盖核心旅程,避免把所有细节都堆到 E2E。
- 约束:只覆盖核心测试用例,避免把所有细节都堆到 E2E。
---
@@ -236,15 +236,12 @@ result = some_task.apply(args=[...])
## 九、E2E 测试规范
### 9.1 核心旅程(必须)
### 9.1 核心测试用例(必须)
| 编号 | 旅程 | 对应模块 |
|---|---|---|
| J-01 | 登录 → 进入首页 | 登录 |
| J-02 | 录入房源 → 上传图片 → 查看列表 | 房源 |
| J-03 | 录入客源 → 添加跟进 | 客源 |
| J-04 | 无权限访问受限页面 | 权限 |
| J-05 | 创建员工 → 分配角色 → 新员工登录 | 组织人事 + 权限 |
- E2E 覆盖对象采用“测试用例”定义不使用“旅程编号J-xx”。
- 每条 E2E 用例必须绑定全局唯一测试用例ID`TC-FON-XXXXXX`
- 当前登录模块核心用例以 `TEST_CASES/TEST_CASES_LOGIN_MODULE.md` 为准(`TC-FON-000001` ~ `TC-FON-000048`)。
- 其他模块(房源/客源/组织/权限等)按 `TEST_CASES/TEST_CASE_REGISTRY.md` 分配编号后补充。
### 9.2 Playwright 约束
@@ -264,9 +261,31 @@ page.wait_for_load_state('networkidle')
---
## 十、测试配置基线
## 十、测试用例编号与注册规范(强制)
### 10.1 `pytest.ini`
### 10.1 编号规范
- 测试用例ID`TC-FON-XXXXXX`(全局唯一)
- 步骤ID`TC-FON-XXXXXX-SYY`
- 详见:`TEST_CASES/TEST_CASE_ID_SPEC.md`
### 10.2 注册流程
1. 新增用例前,先在 `TEST_CASES/TEST_CASE_REGISTRY.md` 查看下一个可用编号。
2. 先登记编号段(可先 `reserved`),再编写文档和代码。
3. 合并前状态改为 `active`,并更新“当前编号水位”。
### 10.3 强约束
- 不允许按模块重置编号。
- 不允许复用已废弃编号。
- 不允许未登记编号直接入库测试代码。
---
## 十一、测试配置基线
### 11.1 `pytest.ini`
```ini
[pytest]
@@ -280,7 +299,7 @@ markers =
slow
```
### 10.2 `tests/settings_test.py` 关键项
### 11.2 `tests/settings_test.py` 关键项
- Celery eager 模式开启
- Cache 使用测试后端locmem/fakeredis
@@ -290,26 +309,27 @@ markers =
---
## 十、CI 自动化运行
## 十、CI 自动化运行
### 11.1 触发策略
### 12.1 触发策略
- 每日定时全量测试
- `main/develop` 每次 push 触发
### 11.2 流水线拆分
### 12.2 流水线拆分
1. `unit-and-integration`
2. `e2e`(依赖前者成功后执行)
### 11.3 最低产物
### 12.3 最低产物
- 覆盖率报告(终端 + 平台上传)
- E2E 失败截图 artifact
- 测试结果明细(至少含 `run_id``test_case_id``step_id``status``error_message``expected_result``actual_result`
---
## 十、AI 协作测试要求
## 十、AI 协作测试要求
每个 User Story 实现后,必须同时补齐:
@@ -326,7 +346,7 @@ markers =
---
## 十、禁止项Do NOT
## 十、禁止项Do NOT
- 禁止 Django 原生 `Client()` 进行租户集成测试
- 禁止固定等待(`sleep` / `wait_for_timeout`
@@ -334,12 +354,14 @@ markers =
- 禁止测试之间共享可变数据
- 禁止无权限/未登录场景缺失
- 禁止空测试占位后不补全
- 禁止未分配 `TC-FON-XXXXXX` 的匿名测试入库
---
## 十、文档同步规则
## 十、文档同步规则
- 新增/调整 User Story同步 `PRD/TASK.md` 与集成测试映射
- 模块 API 变更:同步对应模块技术方案
- 测试目录变更:同步本文件目录结构与 CI 脚本
- 新增测试基建fixture/工具):同步 `AGENTS.md` 与本文件
- 新增测试用例:同步 `TEST_CASES/TEST_CASE_REGISTRY.md`(编号段、水位、状态)

View File

@@ -2,281 +2,269 @@
# Fonrey 登录管理技术方案
**版本**: 3.1
**版本**: 4.0
**项目**: Fonrey 房产经纪管理系统
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery
**关联 PRD**: `PRD/登录管理/用户登录管理模块PRD.md`
**关联 PRD**: `PRD/登录管理/用户登录管理模块PRD.md`v2.0
**关联数据模型**: `DATA_MODEL/DATA_MODEL_LOGIN.md`(本方案不重复 DDL
**关联契约规范**: `TECH_STACK/API_CONTRACT.md`(全局 API 契约权威)
**最后更新**: 2026-04-27
**关联测试规范**: `TECH_STACK/测试规范.md``TEST_CASES/TEST_CASES_LOGIN_MODULE.md`
**最后更新**: 2026-04-30
---
## 一、文档定位与边界
本文件定义登录模块的:
本文件定义登录模块的实现口径
1. 模块边界与服务职责
2. API 端点设计(页面 / HTMX / JSON
3. 登录安全策略(验证码、锁定、会话、找回
4. 缓存与异步任务策略
1. 模块范围与职责边界
2. API 端点(页面 / HTMX / JSON
3. 安全策略(滑块验证、登录锁定、短信 OTP、会话
4. Redis/Celery 运行策略
5. 错误码与测试映射
> 不在本文件展开表字段索引、DDL。数据结构以 `DATA_MODEL_LOGIN.md` 为唯一权威。
> 本文件展开数据表字段索引。数据结构以 `DATA_MODEL_LOGIN.md` 为唯一权威。
---
## 二、范围定义(以 P0 为准)
## 二、范围定义(以 PRD v2.0 为准)
### 2.1 P0 必须覆盖
- Tenant ID 校验Public Schema
- 用户名 + 密码登录Tenant Schema
- 验证码挑战与一次性 pass token
- 连续失败锁定与解锁机制
- 找回用户名 / 重置密码
- 首次登录强制改密
- 安全登出与会话销毁
- Tenant Code 识别(首次启动 + 切换公司
- 密码登录(手机号/密码 + 滑块
- 首次登录强制修改密码
- 找回密码(纯短信三步流程)
- 手机验证码登录MVP 正式功能)
- 登录失败锁定 / 自动解锁 / 管理员解锁
- 安全登出与会话失效
### 2.2 预留(非本期强制)
### 2.2 非目标 / 预留
- MFAOTP / 短信
- 微信扫码登录(仅保留禁用入口与接口占位,不开放功能
- 企业 SSOOAuth2 / SAML
- 设备指纹与风险评分
- 风险评分/设备指纹
### 2.3 已废弃(不得实现)
- 找回用户名流程Story 4 已废弃)
---
## 三、模块架构边界
## 3.1 模块职责(`apps/account`
### 3.1 模块职责(`apps/account`
- 登录认证入口与 Session 建立
- 密码策略、锁定策略、登录防刷策略执行
- 找回流程与一次性重置令牌管理
- 登录/登出/失败审计事件写入
- 租户识别与租户上下文建立前置校验
- 登录鉴权、会话签发、登出销毁
- 首登改密门禁(`is_initial_password`
- 短信 OTP 发送/校验(找回密码 + 验证码登录)
- 登录与安全审计事件写入
## 3.2 多租户分层职责
### 3.2 多租户分层职责
| 层级 | Schema | 职责 |
|---|---|---|
| Tenant ID 校验 | Public | 校验租户标识、域名映射、租户状态 |
| 登录认证 | Tenant | 账号鉴权、失败计数、会话建立 |
| 密码找回 | Tenant | 身份确认、令牌签发与核销 |
| Tenant Code 校验 | Public | 校验 `tenant_code`、租户状态、品牌信息返回 |
| 登录认证 | Tenant | 账号鉴权、失败计数、锁定状态、会话签发 |
| 短信 OTP | Tenant | OTP 记录、场景区分、过期与尝试次数控制 |
## 3.3 外部依赖
### 3.3 外部依赖
| 依赖模块 | 用途 |
|---|---|
| `apps/org` | 员工状态联动(离职/冻结禁止登录) |
| `core/encryption.py` | 手机号等敏感信息加密与脱敏 |
| `core/cache.py` | 验证码票据、失败计数、频控缓存 |
| `Celery` | 找回消息异步发送、安全日报任务 |
| `apps/org` | 员工状态联动(离职/停用不可登录) |
| `core/encryption.py` | 手机号加密/哈希能力 |
| `core/cache.py` | 滑块票据、频控、锁定计数、重置 token |
| `Celery` | 短信发送异步化(可选,建议) |
---
## 四、API 设计原则
1. 登录链路固定三段:Tenant 校验 → 验证账号认证
2. 最小暴露原则:账号不存在与密码错误统一返回
3. 认证状态以数据库为准Redis 仅做加速与频控
4. 安全相关写操作后立即失效缓存,不依赖 TTL
5. HTMX 返回片段JSON 返回结构化错误体
1. 登录安全链路:**Tenant 校验 → 滑块验证 → 登录提交**
2. 防枚举:账号不存在/停用等敏感状态采用统一外显文案
3. 账号锁定是账号维度策略,不区分密码登录或验证码登录
4. Redis 仅作运行态与频控,最终状态以数据库持久化字段为准
5. 所有登录相关接口遵循 `TECH_STACK/API_CONTRACT.md` 的统一错误响应格式
---
## 五、端点清单(核心
## 五、端点清单(对齐 PRD
## 5.1 页面路由SSR
### 5.1 页面路由SSR
| 路径 | 方法 | 鉴权 | 说明 |
|---|---|---|---|
| `/account/tenant/verify/` | GET | 否 | 首次租户识别页 |
| `/account/login/` | GET | 否 | 登录页 |
| `/account/change-password/` | GET | | 首登强制改密页 |
| `/account/recover-username/` | GET | | 找回用户名页 |
| `/account/recover-password/` | GET | 否 | 找回密码页 |
| `/auth/tenant/identify/` | GET | 否 | Tenant 识别页 |
| `/auth/login/` | GET | 否 | 登录页(密码登录/验证码登录 Tab |
| `/auth/password/forgot/` | GET | | 找回密码三步页 |
| `/auth/password/change-initial/` | GET | | 首次登录强制改密页 |
## 5.2 HTMX 片段端点
> 说明:`/auth/wechat/*` 仅预留,不在 MVP 开放。
### 5.2 HTMX 片段端点
| 路径 | 方法 | 用途 | 返回 |
|---|---|---|---|
| `/account/fragments/login-form/` | GET | 登录表单局刷 | HTML 片段 |
| `/account/fragments/captcha/` | GET | 验证码区块刷新 | HTML 片段 |
| `/account/fragments/recover-step/` | GET | 找回步骤局刷 | HTML 片段 |
| `/auth/fragments/captcha/` | GET | 刷新滑块区块 | HTML 片段 |
| `/auth/fragments/login-form/` | GET | 登录 Tab 内容局刷 | HTML 片段 |
| `/auth/fragments/forgot-step/` | GET | 找回密码步骤局刷 | HTML 片段 |
## 5.3 JSON APIP0
### 5.3 JSON APIMVP
| 端点 | 方法 | 说明 |
|---|---|---|
| `/api/account/tenant/verify/` | POST | 校验 tenant_id 与可用性 |
| `/api/account/captcha/generate/` | POST | 生成验证码 challenge |
| `/api/account/captcha/verify/` | POST | 校验 challenge 并签发 pass token |
| `/api/account/login/` | POST | 登录并建立 session |
| `/api/account/logout/` | POST | 登出并销毁 session |
| `/api/account/password/change/` | POST | 登录态改密 |
| `/api/account/username/recover/` | POST | 找回用户名 |
| `/api/account/password/recover/request/` | POST | 申请重置密码令牌 |
| `/api/account/password/recover/reset/` | POST | 使用令牌重置密码 |
| `/api/auth/tenant/verify/` | POST | Tenant Code 校验(公开接口) |
| `/api/auth/captcha/` | GET | 获取滑块拼图验证码(返回背景图 Base64 + 碎片图 Base64 + 验证 Token |
| `/api/auth/captcha/verify/` | POST | 校验滑块并签发 `captcha_pass_token` |
| `/api/auth/login/` | POST | 密码登录 |
| `/api/auth/login/phone/` | POST | 手机验证码登录 |
| `/api/auth/recover/password/request/` | POST | 找回密码步骤一:发 OTP |
| `/api/auth/recover/password/verify/` | POST | 找回密码步骤二:校验 OTP颁发 `sms_reset_token` |
| `/api/auth/recover/password/reset/` | POST | 找回密码步骤三:提交新密码 |
| `/api/auth/password/change-initial/` | POST | 首次登录强制改密提交 |
| `/api/auth/logout/` | POST | 登出销毁会话 |
---
## 六、关键 API 规范(请求/响应)
## 六、关键流程约束
## 6.1 登录
### 6.1 Tenant 识别
`POST /api/account/login/`
- `tenant_code` 固定 12 位数字,前后空格自动 trim
- 成功返回租户名称、Logo URL、登录地址
- 失败返回:`valid=false` + 统一错误信息
- 接口公开但必须限流:单 IP 每分钟 ≤ 10 次
### 6.2 密码登录
请求体(示例):
```json
{
"tenant_id": "fonrey-sh",
"username": "agent_001",
"password": "******",
"phone": "13800138000",
"password": "***",
"captcha_pass_token": "token"
}
```
成功 `200`
关键规则
- 滑块通过后方可提交登录
- 密码连续错误 ≥ 5 次,锁定 30 分钟
- `is_initial_password = true` 时强制跳转改密页
- 错误文案统一,不泄露账号存在性细节
### 6.3 找回密码(纯短信三步)
#### 步骤一:发送 OTP
- `scene = password_reset`
- OTP 有效期10 分钟
- 同手机号频控5 次/小时
- 手机号不存在/停用:统一提示“如该手机号已注册,验证码将在 1 分钟内发送”
#### 步骤二:校验 OTP
- 正确且未过期:签发 `sms_reset_token`15 分钟,一次性)
- 错误累计尝试≥5 次作废该 OTP
- 过期:提示重新获取
#### 步骤三:重置密码
- 必须携带有效 `sms_reset_token`
- 成功后:`is_initial_password = false`
- 该用户所有会话立即失效,跳回登录页
### 6.4 手机验证码登录MVP 正式)
请求体(示例):
```json
{
"message": "登录成功",
"redirect_url": "/home/"
"phone": "13800138000",
"sms_code": "123456"
}
```
失败码:`ACCOUNT_LOGIN_INVALID_CREDENTIAL` / `ACCOUNT_LOCKED` / `ACCOUNT_CAPTCHA_INVALID`
## 6.2 申请重置密码
`POST /api/account/password/recover/request/`
```json
{
"tenant_id": "fonrey-sh",
"username": "agent_001",
"contact": "138****0000"
}
```
规则:
- 频率限制(建议 5 次/小时)
- 统一返回文案,避免枚举账号存在性
关键规则:
- 获取登录验证码前必须先通过滑块
- `scene = login`
- OTP 有效期5 分钟
- 同手机号频控10 次/小时(与 `password_reset` 独立计数)
- OTP 错误 ≥5 次作废
- 账号 `locked``disabled` 时,验证码登录同样拒绝
---
## 七、HTMX 交互约定
## 7.1 Header 约定
- 请求头:`HX-Request: true`
- 成功触发:`HX-Trigger: {"toast":{"level":"success","message":"操作成功"}}`
- 失败触发:`HX-Trigger: {"toast":{"level":"error","message":"操作失败"}}`
- 登录成功:`HX-Redirect: /home/`
## 7.2 模板分片命名
- `templates/account/fragments/login_form.html`
- `templates/account/fragments/captcha_panel.html`
- `templates/account/fragments/recover_step.html`
---
## 八、权限与数据范围
## 8.1 访问控制
- 匿名可访问:租户校验、登录、找回相关端点
- 登录后访问:改密、登出
- 首登未改密用户仅允许访问改密页面
## 8.2 审计字段要求
登录与找回相关操作至少记录:
- tenant_schema
- username或脱敏标识
- ip / user_agent
- result_code
- created_at
---
## 九、异步任务与缓存策略
## 9.1 Celery 任务
| 任务 | 触发时机 | 说明 |
|---|---|---|
| `account_send_recover_message_task` | 找回请求成功后 | 异步发送邮件/短信 |
| `account_security_digest_task` | 定时任务 | 汇总锁定、失败、异常登录统计 |
## 9.2 Redis Key 规范
## 七、Redis Key 规范(对齐 PRD
| Key | TTL | 说明 |
|---|---|---|
| `{schema}:account:captcha:{challenge_id}` | 180s | 验证码挑战态 |
| `{schema}:account:captcha:pass:{token}` | 180s | 验证通过一次性票据 |
| `{schema}:account:login_fail:{username}` | 1800s | 登录失败计数 |
| `{schema}:account:recover:rate:{username}` | 3600s | 找回频控 |
| `captcha_token:{uuid}` | 3 分钟 | 滑块验证会话 |
| `captcha_pass:{uuid}` | 3 分钟 | 一次性通过凭证 |
| `login_fail:{tenant_id}:{username}` | 30 分钟 | 登录失败计数 |
| `sms_limit:password_reset:{phone}` | 1 小时 | 找回密码 OTP 发送频控≤5次 |
| `sms_limit:login:{phone}` | 1 小时 | 登录 OTP 发送频控≤10次 |
| `sms_reset_token:{token}` | 15 分钟 | 找回密码步骤三凭证 |
| `tenant_verify_ip:{ip}` | 1 分钟 | Tenant 校验接口 IP 限流≤10次 |
---
## 十、性能与可靠性约束
## 八、安全与合规
- 登录链路接口目标:`p95 < 300ms`(不含外部消息发送)
- 找回请求接口目标:`p95 < 400ms`(消息发送异步化)
- 缓存故障时系统应降级到 DB 校验,但保留频控硬兜底
- 验证码组件不可用时,应返回明确错误并禁止跳过登录防护
1. 密码仅允许 Django 安全哈希PBKDF2/Argon2
2. OTP 明文不得入库,仅存哈希。
3. 敏感字段日志脱敏手机号、token、验证码
4. 会话过期或登出后,受保护页面必须重定向登录。
5. HTTPS 强制,不允许明文传输降级。
---
## 十一、安全与合规
## 九、错误码建议(登录模块)
1. 密码仅允许 Django 安全哈希PBKDF2 / Argon2
2. 连续失败 N 次锁定(建议 5 次30 分钟)。
3. 重置令牌一次性使用,过期失效,不可复用。
4. 登出必须销毁会话,旧页面刷新需重定向登录。
5. 敏感字段仅脱敏返回,禁止明文日志输出。
---
## 十二、错误码建议
| code | HTTP | 场景 |
| code | HTTP | 中文含义 |
|---|---|---|
| `ACCOUNT_TENANT_INVALID` | 400 | tenant 无效或停用 |
| `ACCOUNT_CAPTCHA_INVALID` | 400 | 验证码失败/过期 |
| `ACCOUNT_LOGIN_INVALID_CREDENTIAL` | 401 | 账号或密码错误 |
| `ACCOUNT_LOCKED` | 423 | 账号锁定中 |
| `ACCOUNT_PASSWORD_WEAK` | 400 | 新密码不满足复杂度 |
| `ACCOUNT_RESET_TOKEN_INVALID` | 400 | 重置令牌无效/已使用 |
| `AUTH_TENANT_NOT_FOUND` | 400 | 租户识别码无效 |
| `AUTH_TENANT_RATE_LIMITED` | 429 | Tenant 校验请求过于频繁 |
| `AUTH_CAPTCHA_INVALID` | 400 | 滑块验证失败 |
| `AUTH_INVALID_CREDENTIAL` | 401 | 手机号或密码错误 |
| `AUTH_ACCOUNT_LOCKED` | 423 | 账号已锁定 |
| `AUTH_ACCOUNT_DISABLED` | 403 | 账号已停用 |
| `AUTH_SMS_OTP_INVALID` | 400 | 短信验证码错误 |
| `AUTH_SMS_OTP_EXPIRED` | 400 | 短信验证码过期 |
| `AUTH_SMS_OTP_RATE_LIMITED` | 429 | OTP 发送超限 |
| `AUTH_SMS_RESET_TOKEN_INVALID` | 400 | 重置凭证无效或过期 |
| `AUTH_PASSWORD_WEAK` | 400 | 新密码不满足强度规则 |
---
## 十、测试映射(P0
## 十、测试映射(与全局编号对齐
| 场景 | 最低覆盖 |
|---|---|
| Tenant 校验 | 有效/无效/停用租户 |
| 登录链路 | 验证码通过、失败锁定、解锁后登录 |
| 找回流程 | 频控、令牌一次性、过期处理 |
| 首登改密 | 未改密不可进入业务页 |
| 登出 | 会话销毁、回退重定向 |
- 测试编号规范:`TEST_CASES/TEST_CASE_ID_SPEC.md`
- 注册表:`TEST_CASES/TEST_CASE_REGISTRY.md`
- 登录模块用例:`TEST_CASES/TEST_CASES_LOGIN_MODULE.md`
测试文件:`tests/integration/account/test_us_account.py`
建议最小执行集:
- Tenant 识别:`TC-FON-000001` ~ `000010`
- 密码登录与锁定:`TC-FON-000011` ~ `000028`
- 首登改密:`TC-FON-000029` ~ `000033`
- 找回密码:`TC-FON-000034` ~ `000044`
- 验证码登录:`TC-FON-000045` ~ `000048`
---
## 十、落地顺序建议
## 十、落地顺序建议
1. 先实现 tenant 校验 + 登录主链路
2. 再接验证码 + 失败锁定
3. 再实现找回用户名/密码
4. 最后补安全摘要任务与审计报表
1. Tenant 识别 + 密码登录主链路
2. 滑块与失败锁定
3. 首登改密门禁
4. 找回密码三步(短信)
5. 手机验证码登录scene=login
6. 报表与监控补齐
---
## 十、文档同步规则
## 十、文档同步规则
- 登录数据结构调整:同步 `DATA_MODEL_LOGIN.md`
- 安全策略或门禁调整:同步 `权限管理系统技术方案.md`
- API 变更:同步本文件与登录 PRD 验收条目
- PRD 登录模块变更:同步本文件
- 数据结构调整:同步 `DATA_MODEL_LOGIN.md`
- 测试用例新增/变更:同步 `TEST_CASES/TEST_CASE_REGISTRY.md` 与登录用例文档
- API 契约调整:同步 `TECH_STACK/API_CONTRACT.md`

View File

@@ -0,0 +1,390 @@
# Fonrey 登录模块测试用例文档(可自动化)
> 文档版本v1.0
> 适用范围:`PRD/登录管理/用户登录管理模块PRD.md` v2.0
> 用例编号范围:`TC-FON-000001` ~ `TC-FON-000048`(全局唯一)
> 编号规范:`TEST_CASES/TEST_CASE_ID_SPEC.md`
---
## 1. 目标与原则
1. 本文档用于让工程师直接生成自动化测试代码API 集成测试 + Web E2E
2. 每个测试用例均包含**唯一ID**和**步骤ID**,可直接用于失败定位与测试报告。
3. 用例覆盖登录模块 MVP 正式范围:
- Story 1Tenant 识别
- Story 2手机号+密码登录
- Story 3短信找回密码
- Story 5短信验证码登录
4. Story 4找回用户名已废弃不实现。
5. Story 6微信扫码为预留不实现仅验证禁用态。
---
## 2. 自动化执行与报告要求(工程实现必须遵循)
- 执行层:
- API 集成:`pytest + pytest-django + TenantClient`
- Web E2E`playwright`
- 每步必须输出:`run_id / test_case_id / step_id / status / expected_result / actual_result / error_message`
- 失败时必须附带:
- Web截图路径
- API请求响应快照 + 关键日志路径
---
## 3. 全量测试用例清单48条
### 3.0 登录模块 API 端点口径(以 PRD 为准)
- `POST /api/auth/tenant/verify/`
- `GET /api/auth/captcha/`
- `POST /api/auth/captcha/verify/`
- `POST /api/auth/login/`
- `POST /api/auth/login/phone/`
- `POST /api/auth/recover/password/request/`
- `POST /api/auth/recover/password/verify/`
- `POST /api/auth/recover/password/reset/`
- `POST /api/auth/logout/`
> 注:测试代码生成与接口调用必须使用以上路径。
## A. Tenant 识别Story 1
### TC-FON-000001 Tenant Code 页面首启展示
- 级别E2E
- 前置:本地无 Tenant Code 缓存
- 步骤:
- `TC-FON-000001-S01` 启动应用/打开登录入口页
- `TC-FON-000001-S02` 检查是否进入 Tenant 识别页
- `TC-FON-000001-S03` 校验页面元素Logo、文案、输入框、确认按钮
- 预期:显示 Tenant 识别页且元素完整
### TC-FON-000002 Tenant Code 输入去空格与粘贴
- 级别E2E
- 前置:在 Tenant 识别页
- 步骤:
- `TC-FON-000002-S01` 粘贴 ` 202500010001 `
- `TC-FON-000002-S02` 点击确认
- `TC-FON-000002-S03` 观察发送请求参数
- 预期:请求中的 tenant_code 为 `202500010001`
### TC-FON-000003 Tenant Code 非12位拦截
- 级别E2E
- 前置Tenant 识别页
- 步骤:
- `TC-FON-000003-S01` 输入 `20250001`
- `TC-FON-000003-S02` 点击确认
- `TC-FON-000003-S03` 检查错误提示
- 预期提示“识别码须为12位数字”不发请求
### TC-FON-000004 Tenant 验证成功流程
- 级别API+E2E
- 前置:合法 tenant_code 存在
- 步骤:
- `TC-FON-000004-S01` 调用 `POST /api/auth/tenant/verify/`
- `TC-FON-000004-S02` 校验 `valid=true` 且返回租户品牌信息
- `TC-FON-000004-S03` 前端跳转登录页并展示“正在登录XX房产”
- 预期:成功跳转并写入本地缓存
### TC-FON-000005 Tenant 验证失败(无效识别码)
- 级别API+E2E
- 前置tenant_code 不存在
- 步骤:
- `TC-FON-000005-S01` 调用 `POST /api/auth/tenant/verify/`
- `TC-FON-000005-S02` 校验 `valid=false` 与错误码
- `TC-FON-000005-S03` 校验前端错误提示与不落缓存
- 预期:提示“识别码无效...”,允许重试
### TC-FON-000006 Tenant 验证网络异常重试
- 级别E2E
- 前置:模拟网络失败
- 步骤:
- `TC-FON-000006-S01` 点击确认触发请求失败
- `TC-FON-000006-S02` 检查“网络连接失败”提示
- `TC-FON-000006-S03` 点击重试并恢复
- 预期:重试可再次发起请求
### TC-FON-000007 非首启跳过识别页
- 级别E2E
- 前置:本地已有合法 Tenant Code
- 步骤:
- `TC-FON-000007-S01` 打开应用
- `TC-FON-000007-S02` 检查是否直接进入登录页
- `TC-FON-000007-S03` 检查租户品牌信息回填
- 预期:跳过识别页
### TC-FON-000008 切换公司入口流程
- 级别E2E
- 前置:已在登录页
- 步骤:
- `TC-FON-000008-S01` 点击“切换公司”
- `TC-FON-000008-S02` 校验二次确认文案
- `TC-FON-000008-S03` 确认后检查缓存清除并回到识别页
- 预期:缓存清除成功并跳回识别页
### TC-FON-000009 Tenant 验证接口无需鉴权
- 级别API
- 前置:未登录
- 步骤:
- `TC-FON-000009-S01` 不带 token 调用验证接口
- `TC-FON-000009-S02` 校验响应状态
- `TC-FON-000009-S03` 校验业务结构
- 预期接口可访问非401/403
### TC-FON-000010 Tenant 验证接口IP限流
- 级别API
- 前置同IP连续请求
- 步骤:
- `TC-FON-000010-S01` 1分钟内发起11次请求
- `TC-FON-000010-S02` 校验前10次正常
- `TC-FON-000010-S03` 校验第11次被限流
- 预期触发每分钟≤10次限制
---
## B. 密码登录Story 2
### TC-FON-000011 密码登录页元素完整
- 级别E2E
- 步骤:`S01` 打开密码登录页;`S02` 检查手机号/密码/滑块/登录按钮;`S03` 检查忘记密码入口
- 预期:元素齐全
### TC-FON-000012 手机号输入仅数字11位
- 级别E2E
- 步骤:`S01` 输入含字母字符;`S02` 观察自动过滤;`S03` 检查长度限制
- 预期仅数字最长11位
### TC-FON-000013 密码明文/密文切换
- 级别E2E
- 步骤:`S01` 输入密码;`S02` 点击眼睛图标显示明文;`S03` 再次点击恢复密文
- 预期:切换正常
### TC-FON-000014 三项未完成时登录按钮置灰
- 级别E2E
- 步骤:`S01` 只填手机号;`S02` 再填密码;`S03` 未完成滑块时检查按钮状态
- 预期:按钮不可点击
### TC-FON-000015 滑块验证失败不计入密码错误次数
- 级别API+E2E
- 步骤:
- `TC-FON-000015-S01` 调用 `GET /api/auth/captcha/` 获取 challenge
- `TC-FON-000015-S02` 调用 `POST /api/auth/captcha/verify/` 提交错误轨迹
- `TC-FON-000015-S03` 检查滑块失败提示与刷新,且登录失败计数未增加
- 预期:不计入账号锁定计数
### TC-FON-000016 滑块验证成功状态保持至提交
- 级别E2E
- 步骤:`S01` 完成滑块;`S02` 检查“验证通过”状态;`S03` 立即点登录
- 预期:状态有效并允许提交
### TC-FON-000017 登录前端校验-手机号为空
- 级别E2E
- 步骤:`S01` 留空手机号;`S02` 点登录;`S03` 检查提示
- 预期:提示“请输入手机号”
### TC-FON-000018 登录前端校验-手机号不足11位
- 级别E2E
- 步骤:`S01` 输入10位`S02` 点登录;`S03` 检查提示
- 预期提示“请输入完整的11位手机号”
### TC-FON-000019 登录前端校验-密码为空
- 级别E2E
- 步骤:`S01` 填手机号与滑块通过;`S02` 密码留空;`S03` 点登录
- 预期:提示“请输入密码”
### TC-FON-000020 登录前端校验-未完成滑块
- 级别E2E
- 步骤:`S01` 填手机号密码;`S02` 不做滑块;`S03` 点登录
- 预期:提示“请完成滑块验证”
### TC-FON-000021 密码登录成功is_initial_password=False
- 级别API+E2E
- 步骤:
- `TC-FON-000021-S01` 调用 `POST /api/auth/login/` 提交正确手机号/密码与 `captcha_pass_token`
- `TC-FON-000021-S02` 校验返回 token 与 `is_initial_password=false`
- `TC-FON-000021-S03` 校验跳首页欢迎语
- 预期:登录成功进入首页
### TC-FON-000022 密码登录成功is_initial_password=True
- 级别API+E2E
- 步骤:`S01` 使用初始密码登录;`S02` 校验返回标志true`S03` 校验跳转强制改密页
- 预期:不可访问其他页面
### TC-FON-000023 密码错误提示统一
- 级别API+E2E
- 步骤:`S01` 提交错误密码;`S02` 校验错误文案;`S03` 校验密码框清空+手机号保留+滑块刷新
- 预期:提示“手机号或密码错误,请重新输入”
### TC-FON-000024 连续错误5次触发账号锁定
- 级别API
- 步骤:`S01` 连续5次密码错误`S02` 校验第5次后状态locked`S03` 校验locked_until=now+30min
- 预期:账号锁定成功
### TC-FON-000025 锁定期间登录被拒绝
- 级别API+E2E
- 步骤:`S01` 对locked账号发起登录`S02` 校验提示文案;`S03` 校验登录按钮置灰
- 预期:拒绝登录
### TC-FON-000026 30分钟后自动解锁
- 级别API
- 步骤:`S01` 构造锁定到期账号;`S02` 时间推进30分钟后重试`S03` 校验恢复登录能力
- 预期:自动解锁
### TC-FON-000027 账号停用登录拦截
- 级别API+E2E
- 步骤:`S01` 停用账号发起登录;`S02` 校验错误码;`S03` 校验前端提示
- 预期:提示“账号已停用,请联系您的管理员”
### TC-FON-000028 Session过期跳转登录
- 级别E2E
- 步骤:`S01` 进入受保护页面;`S02` 使session过期`S03` 执行动作触发跳转
- 预期:跳回登录并提示“登录已过期,请重新登录”
---
## C. 首次登录强制改密Story 2/3 公共密码组件)
### TC-FON-000029 首次登录强制改密页不可跳过
- 级别E2E
- 步骤:`S01` 初始密码登录;`S02` 尝试访问其他功能路由;`S03` 检查仍停留改密流程
- 预期:不可绕过
### TC-FON-000030 新密码强度校验
- 级别API+E2E
- 步骤:`S01` 输入弱密码;`S02` 提交;`S03` 检查强度错误提示
- 预期:拒绝弱密码
### TC-FON-000031 新密码不得与最近3次重复
- 级别API
- 步骤:`S01` 构造历史密码3条`S02` 提交重复密码;`S03` 校验错误码
- 预期:拒绝历史重复
### TC-FON-000032 首次改密成功后状态更新
- 级别API
- 步骤:`S01` 提交合法新密码;`S02` 校验is_initial_password=false`S03` 校验password_histories新增
- 预期:状态正确更新
### TC-FON-000033 首次改密成功后直接进入系统
- 级别E2E
- 步骤:`S01` 完成改密提交;`S02` 检查成功反馈;`S03` 校验跳首页
- 预期:进入首页
---
## D. 找回密码Story 3
### TC-FON-000034 忘记密码入口可达
- 级别E2E
- 步骤:`S01` 登录页点击忘记密码;`S02` 校验进入Stepper`S03` 校验步骤一元素
- 预期:进入找回密码流程
### TC-FON-000035 步骤一手机号格式校验
- 级别E2E
- 步骤:`S01` 输入不足11位手机号`S02` 点获取验证码;`S03` 检查提示
- 预期提示“请输入完整的11位手机号”
### TC-FON-000036 步骤一发送验证码成功active账号
- 级别API+E2E
- 步骤:
- `TC-FON-000036-S01` 调用 `POST /api/auth/recover/password/request/` 提交 active 手机号
- `TC-FON-000036-S02` 校验进入 60 秒倒计时
- `TC-FON-000036-S03` 校验 `sms_otp_records` 写入 `scene=password_reset` 且有效期 10 分钟
- 预期:发送成功并记录正确
### TC-FON-000037 步骤一防枚举响应(不存在/停用账号)
- 级别API
- 步骤:`S01` 分别提交不存在和停用手机号;`S02` 校验响应文案一致;`S03` 校验不泄露账号状态
- 预期:统一提示“如该手机号已注册...”
### TC-FON-000038 找回密码短信发送频控5次/小时)
- 级别API
- 步骤:`S01` 同手机号发送6次`S02` 校验前5次可用`S03` 校验第6次超限
- 预期提示“发送次数过多请1小时后再试”
### TC-FON-000039 步骤二验证码正确进入步骤三
- 级别API+E2E
- 步骤:
- `TC-FON-000039-S01` 调用 `POST /api/auth/recover/password/verify/` 提交正确 6 位验证码
- `TC-FON-000039-S02` 校验颁发 `sms_reset_token`15 分钟)
- `TC-FON-000039-S03` 页面进入步骤三
- 预期:通过校验并进入重置页
### TC-FON-000040 步骤二验证码错误与5次作废
- 级别API
- 步骤:`S01` 连续输入错误验证码5次`S02` 检查前4次提示错误`S03` 第5次后验证码作废
- 预期:提示“验证码已失效,请重新获取”
### TC-FON-000041 步骤二验证码过期
- 级别API
- 步骤:`S01` 使用超时验证码提交;`S02` 校验错误码;`S03` 校验提示文案
- 预期:提示“验证码已过期,请重新获取”
### TC-FON-000042 步骤三token无效或过期
- 级别API+E2E
- 步骤:`S01` 使用无效sms_reset_token访问步骤三`S02` 校验错误提示;`S03` 跳回步骤一
- 预期:提示“操作已超时,请重新发起找回密码”
### TC-FON-000043 步骤三重置成功后会话失效
- 级别API
- 步骤:
- `TC-FON-000043-S01` 调用 `POST /api/auth/recover/password/reset/`(携带有效 `sms_reset_token`)重置密码
- `TC-FON-000043-S02` 校验 `is_initial_password=false`
- `TC-FON-000043-S03` 校验该用户历史 session 失效
- 预期:需重新登录
### TC-FON-000044 重置成功后用新密码登录
- 级别E2E
- 步骤:`S01` 完成找回密码全流程;`S02` 跳回登录页;`S03` 用新密码登录成功
- 预期:登录成功,提示“密码已重置,请使用新密码登录”
---
## E. 验证码登录Story 5
### TC-FON-000045 登录方式Tab切换与状态重置
- 级别E2E
- 步骤:`S01` 从密码登录切换到验证码登录;`S02` 检查表单区切换;`S03` 校验原输入与滑块状态清空
- 预期:状态重置正确
### TC-FON-000046 未完成滑块禁止获取登录验证码
- 级别E2E
- 步骤:`S01` 进入验证码登录Tab`S02` 直接点获取验证码;`S03` 检查提示
- 预期:提示“请先完成滑块验证”
### TC-FON-000047 登录验证码发送与频控10次/小时,独立于找回密码)
- 级别API
- 步骤:`S01` 发送登录验证码11次scene=login`S02` 校验第11次超限`S03` 校验找回密码scene不受影响
- 预期login场景10次/小时scene隔离计数
### TC-FON-000048 验证码登录成功/失败/锁定限制
- 级别API+E2E
- 步骤:
- `TC-FON-000048-S01` 调用 `POST /api/auth/login/phone/`,正确 OTP 登录成功,返回 token 与 `is_initial_password`
- `TC-FON-000048-S02` 调用 `POST /api/auth/login/phone/`,错误 OTP 提示“验证码有误”5 次后作废
- `TC-FON-000048-S03` 账号 locked 时调用 `POST /api/auth/login/phone/` 同样被拒绝
- 预期:三类行为均符合 PRD
---
## 4. 工程实现指引(给测试开发工程师)
1. **目录建议**
- `tests/integration/login/test_tc_fon_000001_000048.py`
- `tests/e2e/login/test_tc_fon_000001_000048.spec.ts`
2. **命名规范**
- 函数名必须带用例ID例如`def test_tc_fon_000024_account_lock_after_5_failures():`
3. **步骤日志**
- 每步执行前后打印 step_id断言失败时把 step_id 写入异常消息
4. **报告聚合**
- 生成 `reports/login_run_<run_id>.json` + `reports/login_run_<run_id>.html`
5. **CI 门禁**
- `TC-FON-000001` ~ `TC-FON-000048` 全量通过才允许合并
---
## 5. 变更规则
- 新增登录用例:从 `TC-FON-000049` 开始递增
- 后续房源/客源模块:继续用同一全局序列,不得重号
- 禁止删除历史用例ID可标记 `deprecated` 但保留编号与历史报告可追溯性

View File

@@ -0,0 +1,56 @@
# 测试用例全局编号规范Fonrey
## 1. 目标
确保所有模块(登录、房源、客源等)测试用例编号**全局唯一**,便于自动化执行、失败定位、统计报表。
## 2. 编号规则
- **测试用例ID**`TC-FON-XXXXXX`
- `TC`Test Case
- `FON`Fonrey
- `XXXXXX`6位递增数字左侧补0`000001`
- **步骤ID**`TC-FON-XXXXXX-SYY`
- `SYY`:步骤序号(`S01``S02`...
### 示例
- 用例:`TC-FON-000018`
- 第3步`TC-FON-000018-S03`
## 3. 分配原则
1. 全项目共用一个递增序列,不按模块重置。
2. 新增用例必须取“当前最大ID + 1”。
3. 废弃用例保留ID不复用。
4. 若拆分用例新增子用例使用新ID不改旧ID。
## 4. 自动化报告字段(必须)
每次自动化执行输出以下字段:
- `run_id`本次执行唯一ID如时间戳
- `test_case_id``TC-FON-XXXXXX`
- `step_id``TC-FON-XXXXXX-SYY`
- `status``passed` / `failed` / `blocked` / `skipped`
- `error_message`:失败信息
- `actual_result`:实际结果
- `expected_result`:预期结果
- `screenshot_path`失败截图Web/E2E
- `log_path`:后端日志/请求响应日志
- `started_at` / `ended_at`
## 5. 报告粒度要求
1. 报告必须能定位到**具体失败步骤**step_id
2. 汇总页至少包含:
- 总用例数、通过数、失败数、跳过数
- 按模块统计(登录/房源/客源)
- Top失败步骤排行按 step_id
3. 详情页展示:
- 失败步骤前后 1~2 步执行上下文
- 请求/响应(脱敏后)
- 关键断言差异Expected vs Actual
## 6. 当前序列占用(本次)
- 登录模块使用:`TC-FON-000001` ~ `TC-FON-000048`
- 下一可用ID`TC-FON-000049`

View File

@@ -0,0 +1,77 @@
# Fonrey 测试用例编号注册表(全局唯一)
> 用途:统一管理全项目测试用例编号,避免撞号,支持自动化报告追踪。
> 适用范围:登录、房源、客源、组织人事、权限、系统设置等全部模块。
> 编号规范:见 `TEST_CASES/TEST_CASE_ID_SPEC.md`
---
## 1) 全局规则(强制)
1. 全项目共用一个递增序列:`TC-FON-XXXXXX`
2. 不按模块重置编号。
3. 新增用例必须先在本注册表登记后再写代码。
4. 废弃用例保留编号,不得复用。
5. 拆分/重构用例时,新用例使用新编号,旧编号可标记为 `deprecated`
---
## 2) 当前编号水位
- **已分配到**`TC-FON-000048`
- **下一个可用编号**`TC-FON-000049`
- **最后更新人**Atlas
- **最后更新时间**2026-04-30
> 说明:下一个新增用例(不论哪个模块)都应从 `TC-FON-000049` 开始。
---
## 3) 编号段注册总览(按批次)
| 批次ID | 模块 | 编号范围 | 数量 | 状态 | 文档 |
|---|---|---:|---:|---|---|
| BATCH-LOGIN-001 | 登录模块 | TC-FON-000001 ~ TC-FON-000048 | 48 | active | `TEST_CASES/TEST_CASES_LOGIN_MODULE.md` |
**状态枚举**
- `active`:有效且执行中
- `deprecated`:已废弃但保留追溯
- `reserved`:已预留待落地
---
## 4) 逐号注册明细(可选,按需扩展)
> 当前先采用“编号段注册”。若后续需要逐号追踪,可在本节追加明细表。
| test_case_id | 模块 | 标题 | 状态 | 首次版本 | 备注 |
|---|---|---|---|---|---|
| TC-FON-000001 | 登录 | Tenant Code 页面首启展示 | active | v1.0 | 见登录用例文档 |
| TC-FON-000048 | 登录 | 验证码登录成功/失败/锁定限制 | active | v1.0 | 见登录用例文档 |
---
## 5) 新增编号操作流程(团队统一)
1. 打开本文件,查看“下一个可用编号”。
2. 按需申请连续编号段(建议每次 5/10/20 条)。
3. 在“编号段注册总览”新增一行,状态先标 `reserved`
4. 完成测试用例文档与代码后,改为 `active`
5. 同步更新“当前编号水位”。
---
## 6) 合并门禁建议CI
建议在 CI 中增加校验:
- 检查是否存在重复 `TC-FON-XXXXXX`
- 检查编号是否小于等于当前水位且未登记
- 检查新增用例是否已在本注册表存在对应编号段
---
## 7) 变更记录
| 日期 | 变更人 | 变更内容 |
|---|---|---|
| 2026-04-30 | Atlas | 初始化注册表;登记登录模块 000001~000048下一号设为 000049 |

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1280">
<title>Fonrey 登录管理 · 静态原型</title>
<title>Fonrey 登录管理 · Tenant 识别Story 1</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script>
@@ -12,36 +12,17 @@
extend: {
colors: {
primary: {
50: '#F0FDFA',
100: '#CCFBF1',
200: '#99F6E4',
500: '#14B8A6',
600: '#0F766E',
700: '#115E59',
800: '#134E4A'
50: '#F0FDFA', 100: '#CCFBF1', 200: '#99F6E4',
500: '#14B8A6', 600: '#0F766E', 700: '#115E59', 800: '#134E4A'
},
neutral: {
50: '#F8FAFC',
100: '#F1F5F9',
200: '#E2E8F0',
300: '#CBD5E1',
400: '#94A3B8',
500: '#64748B',
600: '#475569',
700: '#334155',
800: '#1E293B',
900: '#0F172A'
50: '#F8FAFC', 100: '#F1F5F9', 200: '#E2E8F0', 300: '#CBD5E1',
400: '#94A3B8', 500: '#64748B', 600: '#475569', 700: '#334155',
800: '#1E293B', 900: '#0F172A'
},
success: { 50: '#F0FDF4', 600: '#16A34A' },
warning: { 50: '#FFFBEB', 600: '#D97706' },
danger: { 50: '#FEF2F2', 600: '#DC2626' },
info: { 50: '#EFF6FF', 600: '#2563EB' }
},
boxShadow: {
xs: '0 1px 2px rgba(15,23,42,0.04)'
},
fontFamily: {
sans: ['Inter', 'PingFang SC', 'Microsoft YaHei', 'sans-serif']
danger: { 50: '#FEF2F2', 600: '#DC2626' }
}
}
}
@@ -49,12 +30,9 @@
</script>
<style>
[x-cloak] { display: none !important; }
.tabular-nums { font-variant-numeric: tabular-nums; }
.captcha-track { background: linear-gradient(90deg, #E2E8F0 0%, #F1F5F9 100%); }
.captcha-success { background: linear-gradient(90deg, #F0FDF4 0%, #16A34A 100%); }
</style>
</head>
<body class="min-h-screen bg-neutral-50 font-sans text-sm text-neutral-700 antialiased" x-data="loginPrototype()">
<body class="min-h-screen bg-neutral-50 font-sans text-sm text-neutral-700 antialiased" x-data="tenantVerifyPage()">
<div class="fixed inset-0 -z-10">
<div class="absolute inset-y-0 left-0 w-[56%] bg-gradient-to-br from-primary-800 via-primary-700 to-primary-600"></div>
<div class="absolute -top-16 -left-24 w-96 h-96 rounded-full bg-white/10 blur-2xl"></div>
@@ -69,369 +47,126 @@
<span class="text-base font-semibold">Fonrey 房睿</span>
</div>
<h1 class="mt-8 text-4xl font-semibold leading-tight">面向经纪业务的<br>高密度工作台</h1>
<h1 class="mt-8 text-4xl font-semibold leading-tight">欢迎使用 Fonrey 房睿</h1>
<p class="mt-4 text-primary-100 text-base leading-7 max-w-xl">
多租户隔离、角色权限控制、房客源高频操作一致体验
本页面原型覆盖 Tenant 识别、账号密码登录、验证码验证、锁定与会话过期等 P0 场景
首次启动客户端时,请先输入 12 位公司识别码完成租户识别
识别成功后将自动进入该租户的登录页面
</p>
</div>
<div class="grid grid-cols-2 gap-4 max-w-2xl">
<div class="rounded-lg border border-white/20 bg-white/10 p-4">
<div class="text-xs text-primary-100">多租户识别</div>
<div class="mt-1 text-xl font-semibold tabular-nums">12位 Tenant ID</div>
<div class="text-xs text-primary-100">识别码格式</div>
<div class="mt-1 text-xl font-semibold">12 位纯数字</div>
</div>
<div class="rounded-lg border border-white/20 bg-white/10 p-4">
<div class="text-xs text-primary-100">安全策略</div>
<div class="mt-1 text-xl font-semibold tabular-nums">5次失败锁定30分钟</div>
<div class="mt-1 text-xl font-semibold">公共接口限流保护</div>
</div>
</div>
</section>
<section class="col-span-5 px-10 py-10 flex items-center justify-center">
<div class="w-full max-w-md rounded-xl bg-white border border-neutral-200 shadow-lg p-6">
<h2 class="text-xl font-semibold text-neutral-800">Tenant 识别</h2>
<p class="mt-2 text-sm text-neutral-500">请输入您公司的专属识别码以继续</p>
<template x-if="view === 'tenant'">
<div x-cloak>
<h2 class="text-xl font-semibold text-neutral-800">欢迎使用 Fonrey 房睿</h2>
<p class="mt-2 text-sm text-neutral-500">请输入您公司的专属识别码,以进入对应租户登录页</p>
<div class="mt-6 space-y-1.5">
<label for="tenant-code" class="block text-sm font-medium text-neutral-700">公司识别码Tenant Code<span class="text-danger-600">*</span></label>
<input
id="tenant-code"
type="text"
inputmode="numeric"
maxlength="12"
:disabled="loading"
x-model="tenantCode"
@input="sanitizeTenantCode"
placeholder="请输入12位数字识别码"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20 disabled:bg-neutral-100 disabled:text-neutral-400"
aria-describedby="tenant-help tenant-error"
>
<p id="tenant-help" class="text-xs text-neutral-500">支持粘贴,系统将自动去除前后空格并过滤非数字字符</p>
<div class="min-h-[22px]">
<p id="tenant-error" x-show="errorText" x-text="errorText" class="text-xs text-danger-600"></p>
</div>
</div>
<div class="mt-6 space-y-1.5">
<label for="tenant-id" class="block text-sm font-medium text-neutral-700">公司识别码Tenant ID<span class="text-danger-600">*</span></label>
<input
id="tenant-id"
type="text"
inputmode="numeric"
maxlength="12"
:disabled="tenantLoading"
x-model="tenantId"
@input="sanitizeTenantId"
placeholder="请输入12位数字识别码"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20 disabled:bg-neutral-100 disabled:text-neutral-400"
aria-describedby="tenant-help tenant-error"
>
<p id="tenant-help" class="text-xs text-neutral-500">支持粘贴,系统将自动去除空格与非数字字符</p>
<div class="min-h-[22px]">
<p id="tenant-error" x-show="tenantError" x-text="tenantError" class="text-xs text-danger-600"></p>
</div>
</div>
<button
type="button"
@click="submitTenantCode"
:disabled="loading"
class="mt-1 inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg x-show="loading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
<span x-text="loading ? '识别中…' : '确认'"></span>
</button>
<button
type="button"
@click="submitTenant"
:disabled="tenantLoading"
class="mt-1 inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg x-show="tenantLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
<span x-text="tenantLoading ? '识别中…' : '确认'"></span>
</button>
<template x-if="tenantNetworkError">
<div class="mt-3 rounded-md border border-warning-600/30 bg-warning-50 px-3 py-2 text-xs text-warning-600 flex items-center justify-between">
<span>网络连接失败,请检查网络后重试</span>
<button @click="tenantNetworkError=false" class="text-primary-600 hover:underline">重试</button>
</div>
</template>
<p class="mt-4 text-xs text-neutral-500">不知道识别码?请联系您公司的系统管理员</p>
<template x-if="networkError">
<div class="mt-3 rounded-md border border-warning-600/30 bg-warning-50 px-3 py-2 text-xs text-warning-600 flex items-center justify-between gap-2">
<span>网络连接失败,请检查网络后重试</span>
<button type="button" @click="networkError=false" class="text-primary-600 hover:underline">重试</button>
</div>
</template>
<template x-if="view === 'login'">
<div x-cloak>
<div class="flex items-center justify-between mb-4">
<div class="min-w-0">
<p class="text-xs text-neutral-500">正在登录</p>
<p class="text-sm font-semibold text-neutral-800 truncate" x-text="tenantName"></p>
</div>
<button type="button" @click="openSwitchModal = true" class="text-sm text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">切换公司</button>
</div>
<template x-if="sessionExpiredNotice">
<div class="mb-3 rounded-md border border-warning-600/30 bg-warning-50 px-3 py-2 text-xs text-warning-600 flex items-start justify-between gap-2">
<span>登录已过期,请重新登录</span>
<button @click="sessionExpiredNotice=false" class="text-neutral-500 hover:text-neutral-700" aria-label="关闭会话过期提示"></button>
</div>
</template>
<h2 class="text-xl font-semibold text-neutral-800">账号登录</h2>
<p class="mt-1 text-sm text-neutral-500">请输入用户名和密码,并完成行为验证</p>
<form class="mt-5 space-y-4" @submit.prevent="submitLogin">
<div class="space-y-1">
<label for="username" class="block text-sm font-medium text-neutral-700">用户名<span class="text-danger-600">*</span></label>
<input id="username" type="text" x-model.trim="username" placeholder="请输入用户名"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="loginLoading || accountState==='locked' || accountState==='disabled'">
</div>
<div class="space-y-1">
<label for="password" class="block text-sm font-medium text-neutral-700">密码<span class="text-danger-600">*</span></label>
<div class="relative">
<input id="password" :type="passwordVisible ? 'text' : 'password'" x-model="password" placeholder="请输入密码"
class="block w-full px-3 py-2 pr-10 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="loginLoading || accountState==='locked' || accountState==='disabled'">
<button type="button" @click="passwordVisible=!passwordVisible" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700" aria-label="显示或隐藏密码">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.009 9.963 7.178.07.207.07.431 0 .638C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.009-9.964-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
</button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-neutral-700">行为验证<span class="text-danger-600">*</span></label>
<button type="button" @click="refreshCaptcha" class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700" aria-label="刷新验证码">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"/></svg>
刷新
</button>
</div>
<div class="rounded-lg border border-neutral-200 p-3">
<div class="relative h-24 rounded-md overflow-hidden bg-gradient-to-r from-neutral-100 to-neutral-200">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(15,118,110,0.18),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(37,99,235,0.15),transparent_45%)]"></div>
<div class="absolute top-7 h-10 w-9 rounded bg-neutral-300/90 border border-neutral-400/70" :style="`left:${captchaTarget}%`"></div>
<div class="absolute top-7 h-10 w-9 rounded border border-primary-600/70 bg-primary-100/90 transition-all" :style="`left: calc(${sliderValue}% - 18px)`"></div>
</div>
<div class="mt-2 rounded-md p-2 border"
:class="captchaState==='pass' ? 'captcha-success border-success-600/30' : 'captcha-track border-neutral-200'">
<input type="range" min="0" max="100" step="1" x-model="sliderValue" @change="verifyCaptcha" @input="captchaState='idle'"
:disabled="captchaState==='pass' || loginLoading || accountState==='locked' || accountState==='disabled'"
class="w-full accent-primary-600">
</div>
<p class="mt-1 text-xs"
:class="captchaState==='pass' ? 'text-success-600' : (captchaState==='fail' ? 'text-danger-600' : 'text-neutral-500')"
x-text="captchaState==='pass' ? '验证通过' : (captchaState==='fail' ? '验证失败,请重试' : '拖动滑块完成拼图')"></p>
</div>
</div>
<template x-if="loginError">
<div class="rounded-md border border-danger-600/30 bg-danger-50 px-3 py-2 text-xs text-danger-600" x-text="loginError"></div>
</template>
<button type="submit"
class="inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!canSubmit || loginLoading || accountState==='locked' || accountState==='disabled'">
<svg x-show="loginLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
<span x-text="loginLoading ? '登录中…' : '登录'"></span>
</button>
</form>
<div class="mt-3 flex items-center justify-between text-xs">
<a href="javascript:void(0)" class="text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">忘记用户名</a>
<a href="javascript:void(0)" class="text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">忘记密码</a>
</div>
<div class="mt-4 border-t border-neutral-200 pt-4 space-y-2">
<button type="button" disabled class="w-full px-3 py-2 rounded-md border border-neutral-300 bg-neutral-100 text-neutral-400 cursor-not-allowed text-sm">手机验证码登录(即将开放)</button>
<button type="button" disabled class="w-full px-3 py-2 rounded-md border border-neutral-300 bg-neutral-100 text-neutral-400 cursor-not-allowed text-sm">微信扫码登录(即将开放)</button>
</div>
<details class="mt-4 rounded-md border border-neutral-200 bg-neutral-50 p-2">
<summary class="cursor-pointer text-xs text-neutral-500">原型状态切换(仅评审演示)</summary>
<div class="mt-2 grid grid-cols-2 gap-2 text-xs">
<button @click="simulateInvalidCredential" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟账号密码错误</button>
<button @click="simulateLock" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟账号锁定</button>
<button @click="simulateDisabled" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟账号停用</button>
<button @click="sessionExpiredNotice=true" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟会话过期</button>
<button @click="resetLoginState" class="col-span-2 px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">重置状态</button>
</div>
</details>
</div>
<template x-if="successText">
<div class="mt-3 rounded-md border border-success-600/30 bg-success-50 px-3 py-2 text-xs text-success-600" x-text="successText"></div>
</template>
<p class="mt-4 text-xs text-neutral-500">不知道识别码请联系您公司的Tenant Admin租户管理员</p>
</div>
</section>
</main>
<div x-show="openSwitchModal" x-cloak class="fixed inset-0 z-50" @keydown.escape.window="openSwitchModal=false">
<div class="absolute inset-0 bg-neutral-900/40" @click="openSwitchModal=false"></div>
<div class="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
<div class="w-full max-w-sm bg-white rounded-xl shadow-lg border border-neutral-200 pointer-events-auto">
<div class="p-5 text-center space-y-3">
<div class="mx-auto w-12 h-12 rounded-full bg-warning-50 flex items-center justify-center">
<svg class="w-6 h-6 text-warning-600" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9.303 3.376c.866 1.5-.217 3.374-1.948 3.374H4.646c-1.73 0-2.813-1.874-1.948-3.374l7.354-12.748c.866-1.5 3.03-1.5 3.896 0l7.355 12.748Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5h.008v.008H12v-.008Z"/></svg>
</div>
<h3 class="text-base font-semibold text-neutral-800">切换公司</h3>
<p class="text-sm text-neutral-500">切换公司将清除当前租户识别信息,并返回识别页。是否继续?</p>
</div>
<div class="flex items-center justify-center gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50">
<button @click="openSwitchModal=false" class="px-4 py-1.5 text-sm border border-neutral-300 rounded-md bg-white hover:bg-neutral-50">取消</button>
<button @click="confirmSwitchCompany" class="px-4 py-1.5 text-sm rounded-md bg-danger-600 text-white hover:bg-danger-600/90">继续切换</button>
</div>
</div>
</div>
</div>
<script>
function loginPrototype() {
function tenantVerifyPage() {
return {
view: 'tenant',
tenantId: '',
tenantName: '',
tenantLoading: false,
tenantError: '',
tenantNetworkError: false,
tenantCode: '',
loading: false,
networkError: false,
errorText: '',
successText: '',
username: '',
password: '',
passwordVisible: false,
captchaTarget: 46,
sliderValue: 0,
captchaState: 'idle',
loginLoading: false,
loginError: '',
accountState: 'active',
failedCount: 0,
sessionExpiredNotice: false,
openSwitchModal: false,
sanitizeTenantId() {
this.tenantId = this.tenantId.replace(/\D/g, '').slice(0, 12)
this.tenantError = ''
this.tenantNetworkError = false
sanitizeTenantCode() {
this.tenantCode = this.tenantCode.replace(/\s+/g, '').replace(/\D/g, '').slice(0, 12)
this.errorText = ''
this.networkError = false
this.successText = ''
},
submitTenant() {
this.tenantError = ''
this.tenantNetworkError = false
submitTenantCode() {
this.errorText = ''
this.networkError = false
this.successText = ''
if (this.tenantId.length !== 12) {
this.tenantError = '识别码须为 12 位数字'
if (this.tenantCode.length !== 12) {
this.errorText = '识别码须为 12 位数字'
return
}
this.tenantLoading = true
setTimeout(() => {
this.tenantLoading = false
this.loading = true
if (this.tenantId === '999999999999') {
this.tenantNetworkError = true
setTimeout(() => {
this.loading = false
if (this.tenantCode === '999999999999') {
this.networkError = true
return
}
if (this.tenantId === '202500010001') {
this.tenantName = '沪居地产(演示租户)'
localStorage.setItem('tenant_id', this.tenantId)
localStorage.setItem('tenant_name', this.tenantName)
// 串联到 Story 2 独立登录页
this.view = 'login'
if (this.tenantCode === '202500010001') {
const tenantName = '沪居地产(演示租户)'
localStorage.setItem('tenant_code', this.tenantCode)
localStorage.setItem('tenant_name', tenantName)
this.successText = `识别成功,正在登录:${tenantName}`
setTimeout(() => {
window.location.href = `./登录_账号密码_UI.html?tenantId=${this.tenantId}&tenantName=${encodeURIComponent(this.tenantName)}`
}, 350)
this.resetLoginState()
} else {
this.tenantError = '识别码无效,请联系您的系统管理员获取正确的识别码'
window.location.href = `./登录_账号密码_UI.html?tenantCode=${this.tenantCode}&tenantName=${encodeURIComponent(tenantName)}`
}, 500)
return
}
this.errorText = '识别码无效请联系您的Tenant Admin租户管理员获取正确的识别码'
}, 800)
},
refreshCaptcha() {
this.captchaTarget = Math.floor(Math.random() * 60) + 20
this.sliderValue = 0
this.captchaState = 'idle'
},
verifyCaptcha() {
const diff = Math.abs(this.sliderValue - this.captchaTarget)
if (diff <= 3) {
this.captchaState = 'pass'
this.loginError = ''
} else {
this.captchaState = 'fail'
setTimeout(() => this.refreshCaptcha(), 700)
}
},
get canSubmit() {
return this.username.trim() && this.password && this.captchaState === 'pass'
},
submitLogin() {
this.loginError = ''
if (this.accountState === 'locked') {
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
return
}
if (this.accountState === 'disabled') {
this.loginError = '账号已停用,请联系您的管理员'
return
}
if (!this.canSubmit) {
this.loginError = '请先完成用户名、密码和行为验证'
return
}
this.loginLoading = true
setTimeout(() => {
this.loginLoading = false
const credentialPass = this.username === 'agent_001' && this.password === 'Fonrey@2025'
if (credentialPass) {
this.loginError = ''
this.failedCount = 0
this.sessionExpiredNotice = false
this.password = ''
this.refreshCaptcha()
// 静态原型串联:登录成功后跳转到主页(当前用房源列表页作为首页)
setTimeout(() => {
window.location.href = './房源列表_UI.html?from=login&login=success'
}, 350)
return
}
this.failedCount += 1
this.loginError = '用户名或密码错误,请重新输入'
this.password = ''
this.refreshCaptcha()
if (this.failedCount >= 5) {
this.accountState = 'locked'
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
}
}, 900)
},
simulateInvalidCredential() {
this.loginError = '用户名或密码错误,请重新输入'
this.password = ''
this.refreshCaptcha()
},
simulateLock() {
this.accountState = 'locked'
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
},
simulateDisabled() {
this.accountState = 'disabled'
this.loginError = '账号已停用,请联系您的管理员'
},
resetLoginState() {
this.username = ''
this.password = ''
this.passwordVisible = false
this.loginLoading = false
this.loginError = ''
this.failedCount = 0
this.accountState = 'active'
this.sessionExpiredNotice = false
this.refreshCaptcha()
},
confirmSwitchCompany() {
this.openSwitchModal = false
this.view = 'tenant'
this.tenantName = ''
this.tenantId = ''
this.tenantError = ''
this.tenantNetworkError = false
this.resetLoginState()
}
}
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1280">
<title>Fonrey 登录管理 · 账号密码登录Story 2</title>
<title>Fonrey 登录管理 · 双方式登录Story 2 + Story 5</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script>
@@ -22,8 +22,7 @@
},
success: { 50: '#F0FDF4', 600: '#16A34A' },
warning: { 50: '#FFFBEB', 600: '#D97706' },
danger: { 50: '#FEF2F2', 600: '#DC2626' },
info: { 50: '#EFF6FF', 600: '#2563EB' }
danger: { 50: '#FEF2F2', 600: '#DC2626' }
}
}
}
@@ -41,7 +40,7 @@
.captcha-shake { animation: shake .22s linear 2; }
</style>
</head>
<body class="min-h-screen bg-neutral-50 font-sans text-sm text-neutral-700 antialiased" x-data="story2LoginPage()">
<body class="min-h-screen bg-neutral-50 font-sans text-sm text-neutral-700 antialiased" x-data="dualLoginPage()" x-init="init()">
<div class="fixed inset-0 -z-10">
<div class="absolute inset-y-0 left-0 w-[56%] bg-gradient-to-br from-primary-800 via-primary-700 to-primary-600"></div>
<div class="absolute -top-16 -left-24 w-96 h-96 rounded-full bg-white/10 blur-2xl"></div>
@@ -56,10 +55,10 @@
<span class="text-base font-semibold">Fonrey 房睿</span>
</div>
<h1 class="mt-8 text-4xl font-semibold leading-tight">经纪人账号登录</h1>
<h1 class="mt-8 text-4xl font-semibold leading-tight">经纪人登录</h1>
<p class="mt-4 text-primary-100 text-base leading-7 max-w-xl">
已完成 Tenant 识别,请使用经纪人账号和密码登录。
本页对应 PRD《用户登录管理模块》User Story 2
支持两种登录方式:手机号密码登录、手机号验证码登录。
微信扫码登录保留为“即将开放”禁用态入口
</p>
</div>
@@ -70,7 +69,7 @@
</div>
<div class="rounded-lg border border-white/20 bg-white/10 p-4">
<div class="text-xs text-primary-100">登录策略</div>
<div class="mt-1 text-xl font-semibold">账号密码 + 滑块验证</div>
<div class="mt-1 text-xl font-semibold">双 Tab 登录 + 滑块验证</div>
</div>
</div>
</section>
@@ -90,7 +89,7 @@
<p class="text-xs text-neutral-500">正在登录</p>
<p class="text-sm font-semibold text-neutral-800 truncate" x-text="tenantName || '租户未识别'"></p>
</div>
<button type="button" @click="openSwitchModal = true" class="text-sm text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">切换公司</button>
<button type="button" @click="openSwitchModal=true" class="text-sm text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">切换公司</button>
</div>
<template x-if="sessionExpiredNotice">
@@ -100,79 +99,230 @@
</div>
</template>
<h2 class="text-xl font-semibold text-neutral-800">账号登录</h2>
<p class="mt-1 text-sm text-neutral-500">请输入用户名和密码,并完成行为验证</p>
<form class="mt-5 space-y-4" @submit.prevent="submitLogin">
<div class="space-y-1">
<label for="username" class="block text-sm font-medium text-neutral-700">用户名<span class="text-danger-600">*</span></label>
<input id="username" type="text" x-model.trim="username" placeholder="请输入用户名" maxlength="50"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="loginLoading || accountState==='locked' || accountState==='disabled' || tenantMissing">
<p class="text-xs text-neutral-500">支持英文字母、数字、下划线,最大 50 字符</p>
<template x-if="loginSuccessNotice">
<div class="mb-3 rounded-md border border-success-600/30 bg-success-50 px-3 py-2 text-xs text-success-600 flex items-start justify-between gap-2">
<span>密码已重置,请使用新密码登录</span>
<button @click="loginSuccessNotice=false" class="text-neutral-500 hover:text-neutral-700" aria-label="关闭重置成功提示"></button>
</div>
</template>
<div class="space-y-1">
<label for="password" class="block text-sm font-medium text-neutral-700">密码<span class="text-danger-600">*</span></label>
<div class="relative">
<input id="password" :type="passwordVisible ? 'text' : 'password'" x-model="password" placeholder="请输入密码"
class="block w-full px-3 py-2 pr-10 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="loginLoading || accountState==='locked' || accountState==='disabled' || tenantMissing">
<button type="button" @click="passwordVisible=!passwordVisible" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700" aria-label="显示或隐藏密码">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.009 9.963 7.178.07.207.07.431 0 .638C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.009-9.964-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
</button>
</div>
</div>
<h2 class="text-xl font-semibold text-neutral-800">登录</h2>
<p class="mt-1 text-sm text-neutral-500">请选择登录方式并完成验证</p>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-neutral-700">行为验证<span class="text-danger-600">*</span></label>
<button type="button" @click="refreshCaptcha" class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700" aria-label="刷新验证码">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"/></svg>
刷新
</button>
<div class="mt-4 grid grid-cols-2 rounded-lg border border-neutral-200 bg-neutral-50 p-1">
<button
type="button"
@click="switchTab('password')"
class="h-9 rounded-md text-sm font-medium transition"
:class="activeTab==='password' ? 'bg-white text-primary-700 shadow-xs border border-primary-200' : 'text-neutral-600 hover:text-neutral-800'"
>密码登录</button>
<button
type="button"
@click="switchTab('sms')"
class="h-9 rounded-md text-sm font-medium transition"
:class="activeTab==='sms' ? 'bg-white text-primary-700 shadow-xs border border-primary-200' : 'text-neutral-600 hover:text-neutral-800'"
>验证码登录</button>
</div>
<template x-if="activeTab==='password'">
<form class="mt-5 space-y-4" @submit.prevent="submitPasswordLogin" x-cloak>
<div class="space-y-1">
<label for="phone-password" class="block text-sm font-medium text-neutral-700">手机号<span class="text-danger-600">*</span></label>
<input
id="phone-password"
type="text"
inputmode="numeric"
maxlength="11"
x-model="phonePassword"
@input="sanitizePhone('password')"
placeholder="请输入您的手机号"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="passwordLoading || accountState!=='active' || tenantMissing"
>
<p x-show="passwordFieldError" x-text="passwordFieldError" class="text-xs text-danger-600"></p>
</div>
<div :class="captchaState==='fail' ? 'captcha-shake' : ''" class="rounded-lg border border-neutral-200 p-3">
<div class="relative h-24 rounded-md overflow-hidden bg-gradient-to-r from-neutral-100 to-neutral-200">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(15,118,110,0.18),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(37,99,235,0.15),transparent_45%)]"></div>
<div class="absolute top-7 h-10 w-9 rounded bg-neutral-300/90 border border-neutral-400/70" :style="`left:${captchaTarget}%`"></div>
<div class="absolute top-7 h-10 w-9 rounded border border-primary-600/70 bg-primary-100/90 transition-all" :style="`left: calc(${sliderValue}% - 18px)`"></div>
<div class="space-y-1">
<label for="password" class="block text-sm font-medium text-neutral-700">密码<span class="text-danger-600">*</span></label>
<div class="relative">
<input
id="password"
:type="passwordVisible ? 'text' : 'password'"
x-model="password"
placeholder="请输入密码"
class="block w-full px-3 py-2 pr-10 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="passwordLoading || accountState!=='active' || tenantMissing"
>
<button type="button" @click="passwordVisible=!passwordVisible" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700" aria-label="显示或隐藏密码">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.009 9.963 7.178.07.207.07.431 0 .638C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.009-9.964-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
</button>
</div>
<p x-show="passwordInputError" x-text="passwordInputError" class="text-xs text-danger-600"></p>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-neutral-700">滑块验证<span class="text-danger-600">*</span></label>
<button type="button" @click="refreshCaptcha('password')" class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700" aria-label="刷新滑块验证">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"/></svg>
刷新
</button>
</div>
<div class="mt-2 rounded-md p-2 border" :class="captchaState==='pass' ? 'captcha-success border-success-600/30' : 'captcha-track border-neutral-200'">
<input type="range" min="0" max="100" step="1" x-model="sliderValue" @change="verifyCaptcha" @input="captchaState='idle'"
:disabled="captchaState==='pass' || loginLoading || accountState==='locked' || accountState==='disabled' || tenantMissing"
class="w-full accent-primary-600">
<div :class="passwordCaptchaState==='fail' ? 'captcha-shake' : ''" class="rounded-lg border border-neutral-200 p-3">
<div class="relative h-24 rounded-md overflow-hidden bg-gradient-to-r from-neutral-100 to-neutral-200">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(15,118,110,0.18),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(37,99,235,0.15),transparent_45%)]"></div>
<div class="absolute top-7 h-10 w-9 rounded bg-neutral-300/90 border border-neutral-400/70" :style="`left:${passwordCaptchaTarget}%`"></div>
<div class="absolute top-7 h-10 w-9 rounded border border-primary-600/70 bg-primary-100/90 transition-all" :style="`left: calc(${passwordSliderValue}% - 18px)`"></div>
</div>
<div class="mt-2 rounded-md p-2 border" :class="passwordCaptchaState==='pass' ? 'captcha-success border-success-600/30' : 'captcha-track border-neutral-200'">
<input
type="range"
min="0"
max="100"
step="1"
x-model="passwordSliderValue"
@change="verifyCaptcha('password')"
@input="passwordCaptchaState='idle'"
:disabled="passwordCaptchaState==='pass' || passwordLoading || accountState!=='active' || tenantMissing"
class="w-full accent-primary-600"
>
</div>
<p class="mt-1 text-xs"
:class="passwordCaptchaState==='pass' ? 'text-success-600' : (passwordCaptchaState==='fail' ? 'text-danger-600' : 'text-neutral-500')"
x-text="passwordCaptchaState==='pass' ? '验证通过' : (passwordCaptchaState==='fail' ? '验证失败,请重试' : '拖动滑块完成拼图')"></p>
</div>
</div>
<template x-if="passwordError">
<div class="rounded-md border border-danger-600/30 bg-danger-50 px-3 py-2 text-xs text-danger-600" x-text="passwordError"></div>
</template>
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!canPasswordLogin || passwordLoading || accountState!=='active' || tenantMissing"
>
<svg x-show="passwordLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
<span x-text="passwordLoading ? '登录中…' : '登录'"></span>
</button>
</form>
</template>
<template x-if="activeTab==='sms'">
<form class="mt-5 space-y-4" @submit.prevent="submitSmsLogin" x-cloak>
<div class="space-y-1">
<label for="phone-sms" class="block text-sm font-medium text-neutral-700">手机号<span class="text-danger-600">*</span></label>
<input
id="phone-sms"
type="text"
inputmode="numeric"
maxlength="11"
x-model="phoneSms"
@input="sanitizePhone('sms')"
placeholder="请输入您的手机号"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="smsLoading || accountState!=='active' || tenantMissing"
>
<p x-show="smsPhoneError" x-text="smsPhoneError" class="text-xs text-danger-600"></p>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-neutral-700">滑块验证<span class="text-danger-600">*</span></label>
<button type="button" @click="refreshCaptcha('sms')" class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700" aria-label="刷新滑块验证">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"/></svg>
刷新
</button>
</div>
<p class="mt-1 text-xs"
:class="captchaState==='pass' ? 'text-success-600' : (captchaState==='fail' ? 'text-danger-600' : 'text-neutral-500')"
x-text="captchaState==='pass' ? '验证通过' : (captchaState==='fail' ? '验证码有误,请重新输入' : '拖动滑块完成拼图')"></p>
<div :class="smsCaptchaState==='fail' ? 'captcha-shake' : ''" class="rounded-lg border border-neutral-200 p-3">
<div class="relative h-24 rounded-md overflow-hidden bg-gradient-to-r from-neutral-100 to-neutral-200">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(15,118,110,0.18),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(37,99,235,0.15),transparent_45%)]"></div>
<div class="absolute top-7 h-10 w-9 rounded bg-neutral-300/90 border border-neutral-400/70" :style="`left:${smsCaptchaTarget}%`"></div>
<div class="absolute top-7 h-10 w-9 rounded border border-primary-600/70 bg-primary-100/90 transition-all" :style="`left: calc(${smsSliderValue}% - 18px)`"></div>
</div>
<div class="mt-2 rounded-md p-2 border" :class="smsCaptchaState==='pass' ? 'captcha-success border-success-600/30' : 'captcha-track border-neutral-200'">
<input
type="range"
min="0"
max="100"
step="1"
x-model="smsSliderValue"
@change="verifyCaptcha('sms')"
@input="smsCaptchaState='idle'"
:disabled="smsCaptchaState==='pass' || smsLoading || accountState!=='active' || tenantMissing"
class="w-full accent-primary-600"
>
</div>
<p class="mt-1 text-xs"
:class="smsCaptchaState==='pass' ? 'text-success-600' : (smsCaptchaState==='fail' ? 'text-danger-600' : 'text-neutral-500')"
x-text="smsCaptchaState==='pass' ? '验证通过' : (smsCaptchaState==='fail' ? '验证失败,请重试' : '拖动滑块完成拼图')"></p>
</div>
</div>
</div>
<template x-if="loginError">
<div class="rounded-md border border-danger-600/30 bg-danger-50 px-3 py-2 text-xs text-danger-600" x-text="loginError"></div>
</template>
<div class="space-y-1">
<label for="sms-code" class="block text-sm font-medium text-neutral-700">短信验证码<span class="text-danger-600">*</span></label>
<div class="grid grid-cols-[1fr_auto] gap-2">
<input
id="sms-code"
type="text"
inputmode="numeric"
maxlength="6"
x-model="smsCode"
@input="sanitizeSmsCode"
placeholder="请输入6位验证码"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="smsLoading || accountState!=='active' || tenantMissing"
>
<button
type="button"
@click="sendSmsCode"
class="px-3 h-[38px] rounded-md border border-primary-200 text-primary-700 bg-primary-50 hover:bg-primary-100 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!canSendSms || accountState!=='active' || tenantMissing"
x-text="otpCountdown>0 ? `重新获取(${otpCountdown}s)` : (otpSending ? '发送中…' : '获取验证码')"
></button>
</div>
<p x-show="smsCodeError" x-text="smsCodeError" class="text-xs text-danger-600"></p>
</div>
<button type="submit"
class="inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!canSubmit || loginLoading || accountState==='locked' || accountState==='disabled' || tenantMissing">
<svg x-show="loginLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
<span x-text="loginLoading ? '登录中…' : '登录'"></span>
</button>
</form>
<template x-if="smsError">
<div class="rounded-md border border-danger-600/30 bg-danger-50 px-3 py-2 text-xs text-danger-600" x-text="smsError"></div>
</template>
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!canSmsLogin || smsLoading || accountState!=='active' || tenantMissing"
>
<svg x-show="smsLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
<span x-text="smsLoading ? '登录中…' : '登录'"></span>
</button>
</form>
</template>
<div class="mt-3 flex items-center justify-between text-xs">
<a href="javascript:void(0)" class="text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">忘记用户名</a>
<a href="javascript:void(0)" class="text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">忘记密码</a>
<a href="./登录_重置密码_UI.html?mode=recover" class="text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">忘记密码</a>
<span class="text-neutral-400">账号锁定后请联系管理员</span>
</div>
<div class="mt-4 border-t border-neutral-200 pt-4 space-y-2">
<button type="button" disabled class="w-full px-3 py-2 rounded-md border border-neutral-300 bg-neutral-100 text-neutral-400 cursor-not-allowed text-sm">手机验证码登录(即将开放)</button>
<button type="button" disabled class="w-full px-3 py-2 rounded-md border border-neutral-300 bg-neutral-100 text-neutral-400 cursor-not-allowed text-sm">微信扫码登录(即将开放)</button>
</div>
<details class="mt-4 rounded-md border border-neutral-200 bg-neutral-50 p-2">
<summary class="cursor-pointer text-xs text-neutral-500">原型状态切换(仅评审演示)</summary>
<div class="mt-2 grid grid-cols-2 gap-2 text-xs">
<button @click="simulateLock" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟账号锁定</button>
<button @click="simulateDisabled" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟账号停用</button>
<button @click="sessionExpiredNotice=true" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟会话过期</button>
<button @click="resetAllState" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">重置状态</button>
</div>
</details>
</div>
</section>
</main>
@@ -186,7 +336,7 @@
<svg class="w-6 h-6 text-warning-600" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9.303 3.376c.866 1.5-.217 3.374-1.948 3.374H4.646c-1.73 0-2.813-1.874-1.948-3.374l7.354-12.748c.866-1.5 3.03-1.5 3.896 0l7.355 12.748Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5h.008v.008H12v-.008Z"/></svg>
</div>
<h3 class="text-base font-semibold text-neutral-800">切换公司</h3>
<p class="text-sm text-neutral-500">切换公司将清除当前租户识别信息,并返回识别页。是否继续?</p>
<p class="text-sm text-neutral-500">切换公司将退出当前账号并清除租户识别缓存,是否继续?</p>
</div>
<div class="flex items-center justify-center gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50">
<button @click="openSwitchModal=false" class="px-4 py-1.5 text-sm border border-neutral-300 rounded-md bg-white hover:bg-neutral-50">取消</button>
@@ -197,115 +347,329 @@
</div>
<script>
function story2LoginPage() {
function dualLoginPage() {
return {
tenantId: '',
tenantCode: '',
tenantName: '',
tenantMissing: false,
sessionExpiredNotice: false,
loginSuccessNotice: false,
openSwitchModal: false,
username: '',
activeTab: 'password',
accountState: 'active', // active | locked | disabled
phonePassword: '',
password: '',
passwordVisible: false,
captchaTarget: 46,
sliderValue: 0,
captchaState: 'idle',
loginLoading: false,
loginError: '',
accountState: 'active',
failedCount: 0,
sessionExpiredNotice: false,
openSwitchModal: false,
passwordFieldError: '',
passwordInputError: '',
passwordError: '',
passwordLoading: false,
passwordFailCount: 0,
phoneSms: '',
smsCode: '',
smsPhoneError: '',
smsCodeError: '',
smsError: '',
smsLoading: false,
otpSending: false,
otpCountdown: 0,
otpTimer: null,
otpSent: false,
otpFailCount: 0,
mockOtpCode: '123456',
passwordCaptchaTarget: 46,
passwordSliderValue: 0,
passwordCaptchaState: 'idle',
smsCaptchaTarget: 52,
smsSliderValue: 0,
smsCaptchaState: 'idle',
init() {
const params = new URLSearchParams(window.location.search)
const tenantId = params.get('tenantId') || localStorage.getItem('tenant_id') || ''
const tenantName = params.get('tenantName') || localStorage.getItem('tenant_name') || ''
this.tenantId = tenantId
this.tenantName = tenantName
this.tenantMissing = !tenantId || !tenantName
this.tenantCode = params.get('tenantCode') || localStorage.getItem('tenant_code') || ''
this.tenantName = params.get('tenantName') || localStorage.getItem('tenant_name') || ''
this.tenantMissing = !this.tenantCode || !this.tenantName
this.sessionExpiredNotice = params.get('reason') === 'session_expired'
this.refreshCaptcha()
this.loginSuccessNotice = params.get('reason') === 'password_reset_success'
this.refreshCaptcha('password')
this.refreshCaptcha('sms')
},
refreshCaptcha() {
this.captchaTarget = Math.floor(Math.random() * 60) + 20
this.sliderValue = 0
this.captchaState = 'idle'
switchTab(tab) {
if (this.activeTab === tab) return
this.activeTab = tab
this.clearTabStates()
},
verifyCaptcha() {
const diff = Math.abs(this.sliderValue - this.captchaTarget)
if (diff <= 3) {
this.captchaState = 'pass'
this.loginError = ''
clearTabStates() {
this.passwordFieldError = ''
this.passwordInputError = ''
this.passwordError = ''
this.smsPhoneError = ''
this.smsCodeError = ''
this.smsError = ''
this.phonePassword = ''
this.password = ''
this.phoneSms = ''
this.smsCode = ''
this.otpSent = false
this.otpFailCount = 0
this.stopOtpCountdown()
this.refreshCaptcha('password')
this.refreshCaptcha('sms')
},
sanitizePhone(scene) {
if (scene === 'password') {
this.phonePassword = this.phonePassword.replace(/\D/g, '').slice(0, 11)
this.passwordFieldError = ''
} else {
this.captchaState = 'fail'
setTimeout(() => this.refreshCaptcha(), 700)
this.phoneSms = this.phoneSms.replace(/\D/g, '').slice(0, 11)
this.smsPhoneError = ''
}
},
get canSubmit() {
return this.username.trim() && this.password && this.captchaState === 'pass'
sanitizeSmsCode() {
this.smsCode = this.smsCode.replace(/\D/g, '').slice(0, 6)
this.smsCodeError = ''
},
submitLogin() {
this.loginError = ''
refreshCaptcha(scene) {
if (scene === 'password') {
this.passwordCaptchaTarget = Math.floor(Math.random() * 60) + 20
this.passwordSliderValue = 0
this.passwordCaptchaState = 'idle'
} else {
this.smsCaptchaTarget = Math.floor(Math.random() * 60) + 20
this.smsSliderValue = 0
this.smsCaptchaState = 'idle'
}
},
verifyCaptcha(scene) {
if (scene === 'password') {
const diff = Math.abs(this.passwordSliderValue - this.passwordCaptchaTarget)
if (diff <= 3) {
this.passwordCaptchaState = 'pass'
this.passwordError = ''
} else {
this.passwordCaptchaState = 'fail'
setTimeout(() => this.refreshCaptcha('password'), 700)
}
return
}
const diff = Math.abs(this.smsSliderValue - this.smsCaptchaTarget)
if (diff <= 3) {
this.smsCaptchaState = 'pass'
this.smsError = ''
} else {
this.smsCaptchaState = 'fail'
setTimeout(() => this.refreshCaptcha('sms'), 700)
}
},
get canPasswordLogin() {
return this.phonePassword.length === 11 && !!this.password && this.passwordCaptchaState === 'pass'
},
get canSendSms() {
return this.phoneSms.length === 11 && this.smsCaptchaState === 'pass' && this.otpCountdown === 0 && !this.otpSending
},
get canSmsLogin() {
return this.phoneSms.length === 11 && this.smsCode.length === 6
},
submitPasswordLogin() {
this.passwordError = ''
this.passwordFieldError = ''
this.passwordInputError = ''
if (this.accountState === 'locked') {
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
this.passwordError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
return
}
if (this.accountState === 'disabled') {
this.loginError = '账号已停用,请联系您的管理员'
this.passwordError = '账号已停用,请联系您的管理员'
return
}
if (!this.username.trim()) {
this.loginError = '请输入用户名'
if (this.phonePassword.length === 0) {
this.passwordFieldError = '请输入手机号'
return
}
if (this.phonePassword.length < 11) {
this.passwordFieldError = '请输入完整的 11 位手机号'
return
}
if (!this.password) {
this.loginError = '请输入密码'
this.passwordInputError = '请输入密码'
return
}
if (this.captchaState !== 'pass') {
this.loginError = '请输入验证'
if (this.passwordCaptchaState !== 'pass') {
this.passwordError = '请完成滑块验证'
return
}
this.loginLoading = true
this.passwordLoading = true
setTimeout(() => {
this.loginLoading = false
this.passwordLoading = false
const credentialPass = this.username === 'agent_001' && this.password === 'Fonrey@2025'
const normalPass = this.phonePassword === '13800138000' && this.password === 'Fonrey@2025'
const initialPasswordPass = this.phonePassword === '13800138001' && this.password === 'Fonrey@2025'
if (credentialPass) {
this.loginError = ''
this.failedCount = 0
this.password = ''
this.refreshCaptcha()
const displayName = '王顺'
window.location.href = `./房源列表_UI.html?from=login&login=success&name=${encodeURIComponent(displayName)}`
if (normalPass) {
window.location.href = './房源列表_UI.html?from=login&login=success&name=' + encodeURIComponent('王顺')
return
}
this.failedCount += 1
this.loginError = '用户名或密码错误,请重新输入'
this.password = ''
this.refreshCaptcha()
if (initialPasswordPass) {
window.location.href = './登录_重置密码_UI.html?mode=initial&phone=' + this.phonePassword
return
}
if (this.failedCount >= 5) {
this.passwordFailCount += 1
this.passwordError = '手机号或密码错误,请重新输入'
this.password = ''
this.refreshCaptcha('password')
if (this.passwordFailCount >= 5) {
this.accountState = 'locked'
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
this.passwordError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
}
}, 900)
},
sendSmsCode() {
this.smsError = ''
this.smsPhoneError = ''
if (this.smsCaptchaState !== 'pass') {
this.smsError = '请先完成滑块验证'
return
}
if (this.phoneSms.length === 0 || this.phoneSms.length < 11) {
this.smsPhoneError = '请输入完整的 11 位手机号'
return
}
this.otpSending = true
setTimeout(() => {
this.otpSending = false
this.otpSent = true
this.smsError = '验证码已发送请注意查收演示码123456'
this.startOtpCountdown()
}, 600)
},
submitSmsLogin() {
this.smsError = ''
this.smsPhoneError = ''
this.smsCodeError = ''
if (this.accountState === 'locked') {
this.smsError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
return
}
if (this.accountState === 'disabled') {
this.smsError = '账号已停用,请联系您的管理员'
return
}
if (this.phoneSms.length < 11) {
this.smsPhoneError = '请输入完整的 11 位手机号'
return
}
if (this.smsCode.length < 6) {
this.smsCodeError = '请输入 6 位验证码'
return
}
if (!this.otpSent) {
this.smsError = '请先获取验证码'
return
}
this.smsLoading = true
setTimeout(() => {
this.smsLoading = false
if (this.smsCode !== this.mockOtpCode) {
this.otpFailCount += 1
if (this.otpFailCount >= 5) {
this.smsError = '验证码已失效,请重新获取'
this.smsCode = ''
this.otpSent = false
this.otpFailCount = 0
this.stopOtpCountdown()
} else {
this.smsError = '验证码有误,请重新输入'
}
return
}
const initialUser = this.phoneSms === '13800138001'
if (initialUser) {
window.location.href = './登录_重置密码_UI.html?mode=initial&phone=' + this.phoneSms
return
}
window.location.href = './房源列表_UI.html?from=login&login=success&name=' + encodeURIComponent('王顺')
}, 800)
},
startOtpCountdown() {
this.stopOtpCountdown()
this.otpCountdown = 60
this.otpTimer = setInterval(() => {
this.otpCountdown -= 1
if (this.otpCountdown <= 0) {
this.stopOtpCountdown()
}
}, 1000)
},
stopOtpCountdown() {
if (this.otpTimer) {
clearInterval(this.otpTimer)
this.otpTimer = null
}
this.otpCountdown = 0
},
simulateLock() {
this.accountState = 'locked'
this.passwordError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
this.smsError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
},
simulateDisabled() {
this.accountState = 'disabled'
this.passwordError = '账号已停用,请联系您的管理员'
this.smsError = '账号已停用,请联系您的管理员'
},
resetAllState() {
this.accountState = 'active'
this.passwordFailCount = 0
this.passwordLoading = false
this.smsLoading = false
this.passwordVisible = false
this.sessionExpiredNotice = false
this.clearTabStates()
},
confirmSwitchCompany() {
this.openSwitchModal = false
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant_code')
localStorage.removeItem('tenant_name')
window.location.href = './登录_UI.html?from=switch-company'
}

View File

@@ -0,0 +1,247 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1280">
<title>Fonrey 登录管理 · 设置新密码</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
50: '#F0FDFA', 100: '#CCFBF1', 200: '#99F6E4',
500: '#14B8A6', 600: '#0F766E', 700: '#115E59', 800: '#134E4A'
},
neutral: {
50: '#F8FAFC', 100: '#F1F5F9', 200: '#E2E8F0', 300: '#CBD5E1',
400: '#94A3B8', 500: '#64748B', 600: '#475569', 700: '#334155',
800: '#1E293B', 900: '#0F172A'
},
success: { 50: '#F0FDF4', 600: '#16A34A' },
warning: { 50: '#FFFBEB', 600: '#D97706' },
danger: { 50: '#FEF2F2', 600: '#DC2626' }
}
}
}
}
</script>
<style>
[x-cloak] { display: none !important; }
</style>
</head>
<body class="min-h-screen bg-neutral-50 font-sans text-sm text-neutral-700 antialiased" x-data="passwordResetPage()" x-init="init()">
<div class="fixed inset-0 -z-10">
<div class="absolute inset-y-0 left-0 w-[56%] bg-gradient-to-br from-primary-800 via-primary-700 to-primary-600"></div>
<div class="absolute -top-16 -left-24 w-96 h-96 rounded-full bg-white/10 blur-2xl"></div>
<div class="absolute bottom-0 left-1/3 w-80 h-80 rounded-full bg-primary-200/15 blur-2xl"></div>
</div>
<main class="mx-auto max-w-[1440px] min-h-screen grid grid-cols-12">
<section class="col-span-7 px-12 py-12 text-white flex flex-col justify-between">
<div>
<div class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-white/10 border border-white/20">
<div class="w-7 h-7 rounded-md bg-primary-500/90 flex items-center justify-center text-white font-semibold">F</div>
<span class="text-base font-semibold">Fonrey 房睿</span>
</div>
<h1 class="mt-8 text-4xl font-semibold leading-tight" x-text="pageTitle"></h1>
<p class="mt-4 text-primary-100 text-base leading-7 max-w-xl" x-text="pageDesc"></p>
</div>
<div class="grid grid-cols-2 gap-4 max-w-2xl">
<div class="rounded-lg border border-white/20 bg-white/10 p-4">
<div class="text-xs text-primary-100">密码规则</div>
<div class="mt-1 text-xl font-semibold">至少 8 位,含字母+数字</div>
</div>
<div class="rounded-lg border border-white/20 bg-white/10 p-4">
<div class="text-xs text-primary-100">当前模式</div>
<div class="mt-1 text-xl font-semibold" x-text="mode === 'initial' ? '首次登录强制修改' : '找回密码重置'"></div>
</div>
</div>
</section>
<section class="col-span-5 px-10 py-10 flex items-center justify-center">
<div class="w-full max-w-md rounded-xl bg-white border border-neutral-200 shadow-lg p-6">
<h2 class="text-xl font-semibold text-neutral-800" x-text="pageTitle"></h2>
<p class="mt-2 text-sm text-neutral-500" x-text="pageDesc"></p>
<form class="mt-5 space-y-4" @submit.prevent="submitReset">
<div class="space-y-1">
<label for="new-password" class="block text-sm font-medium text-neutral-700">新密码<span class="text-danger-600">*</span></label>
<div class="relative">
<input
id="new-password"
:type="newPasswordVisible ? 'text' : 'password'"
x-model="newPassword"
@input="validatePassword"
placeholder="请输入新密码"
class="block w-full px-3 py-2 pr-10 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="submitting"
>
<button type="button" @click="newPasswordVisible=!newPasswordVisible" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700" aria-label="显示或隐藏新密码">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.009 9.963 7.178.07.207.07.431 0 .638C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.009-9.964-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
</button>
</div>
<p x-show="newPasswordError" x-text="newPasswordError" class="text-xs text-danger-600"></p>
</div>
<div class="space-y-1">
<label for="confirm-password" class="block text-sm font-medium text-neutral-700">确认新密码<span class="text-danger-600">*</span></label>
<div class="relative">
<input
id="confirm-password"
:type="confirmPasswordVisible ? 'text' : 'password'"
x-model="confirmPassword"
@input="validatePassword"
placeholder="请再次输入新密码"
class="block w-full px-3 py-2 pr-10 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="submitting"
>
<button type="button" @click="confirmPasswordVisible=!confirmPasswordVisible" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700" aria-label="显示或隐藏确认密码">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.009 9.963 7.178.07.207.07.431 0 .638C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.009-9.964-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
</button>
</div>
<p x-show="confirmPasswordError" x-text="confirmPasswordError" class="text-xs text-danger-600"></p>
</div>
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 space-y-2">
<p class="text-xs font-medium text-neutral-700">密码强度校验</p>
<div class="text-xs flex items-center gap-2" :class="rules.minLength ? 'text-success-600' : 'text-neutral-500'">
<span x-text="rules.minLength ? '✓' : '✗'"></span>
<span>长度至少 8 位</span>
</div>
<div class="text-xs flex items-center gap-2" :class="rules.hasLetter ? 'text-success-600' : 'text-neutral-500'">
<span x-text="rules.hasLetter ? '✓' : '✗'"></span>
<span>包含字母</span>
</div>
<div class="text-xs flex items-center gap-2" :class="rules.hasNumber ? 'text-success-600' : 'text-neutral-500'">
<span x-text="rules.hasNumber ? '✓' : '✗'"></span>
<span>包含数字</span>
</div>
</div>
<template x-if="globalError">
<div class="rounded-md border border-danger-600/30 bg-danger-50 px-3 py-2 text-xs text-danger-600" x-text="globalError"></div>
</template>
<template x-if="successMessage">
<div class="rounded-md border border-success-600/30 bg-success-50 px-3 py-2 text-xs text-success-600" x-text="successMessage"></div>
</template>
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="submitting"
x-text="submitting ? '提交中…' : submitText"
></button>
</form>
<div class="mt-4 text-xs text-neutral-500" x-show="mode === 'recover'">
<a href="./登录_账号密码_UI.html" class="text-primary-600 hover:underline">返回登录页</a>
</div>
</div>
</section>
</main>
<script>
function passwordResetPage() {
return {
mode: 'recover', // initial | recover
pageTitle: '',
pageDesc: '',
submitText: '',
newPassword: '',
confirmPassword: '',
newPasswordVisible: false,
confirmPasswordVisible: false,
newPasswordError: '',
confirmPasswordError: '',
globalError: '',
successMessage: '',
submitting: false,
rules: {
minLength: false,
hasLetter: false,
hasNumber: false
},
init() {
const params = new URLSearchParams(window.location.search)
this.mode = params.get('mode') === 'initial' ? 'initial' : 'recover'
if (this.mode === 'initial') {
this.pageTitle = '欢迎使用 Fonrey请先设置您的登录密码'
this.pageDesc = '您当前使用的是初始密码,为保障账号安全,请立即设置新密码后开始使用'
this.submitText = '确认并进入系统'
} else {
this.pageTitle = '重置您的登录密码'
this.pageDesc = '请输入您的新密码,设置完成后请使用新密码重新登录'
this.submitText = '确认重置密码'
}
},
validatePassword() {
this.newPasswordError = ''
this.confirmPasswordError = ''
this.globalError = ''
this.rules.minLength = this.newPassword.length >= 8
this.rules.hasLetter = /[A-Za-z]/.test(this.newPassword)
this.rules.hasNumber = /\d/.test(this.newPassword)
if (this.confirmPassword && this.newPassword !== this.confirmPassword) {
this.confirmPasswordError = '两次输入密码不一致'
}
},
submitReset() {
this.successMessage = ''
this.globalError = ''
this.validatePassword()
if (!this.newPassword) {
this.newPasswordError = '请输入新密码'
return
}
if (!this.rules.minLength || !this.rules.hasLetter || !this.rules.hasNumber) {
this.globalError = '密码强度不足,请满足全部规则后再提交'
return
}
if (!this.confirmPassword) {
this.confirmPasswordError = '请再次输入新密码'
return
}
if (this.newPassword !== this.confirmPassword) {
this.confirmPasswordError = '两次输入密码不一致'
return
}
this.submitting = true
setTimeout(() => {
this.submitting = false
if (this.mode === 'initial') {
this.successMessage = '密码设置成功,正在进入系统…'
setTimeout(() => {
window.location.href = './房源列表_UI.html?from=reset&mode=initial&status=success'
}, 700)
return
}
this.successMessage = '密码已重置,请使用新密码登录'
setTimeout(() => {
window.location.href = './登录_账号密码_UI.html?reason=password_reset_success'
}, 900)
}, 800)
}
}
}
</script>
</body>
</html>

View File

@@ -1,239 +1,219 @@
# 登录管理 UI 设计文档
# 登录管理 UI 设计文档PRD v2.0 对齐)
> **版本**v1.0 · **日期**2026-04-27
> **依赖规范**`UI_SYSTEM/UI_SYSTEM.md v1.2`、`UI_SYSTEM/组件规范设计.md v1.0`
> **PRD 来源**`PRD/登录管理/用户登录管理模块PRD.md`Story 1、Story 2 + 会话相关要求)
> **版本**v2.0 · **日期**2026-04-30
> **PRD 来源**`PRD/登录管理/用户登录管理模块PRD.md`v2.0
> **数据模型来源**`DATA_MODEL/DATA_MODEL_LOGIN.md`
> **技术约束来源**`TECH_STACK/登录管理技术方案.md`
> **技术约束来源**`TECH_STACK/登录管理技术方案.md`、`AGENTS.md`
---
## 1. 模块概述
## 1. 模块目标与覆盖范围
### 1.1 设计目标P0
本次 UI 调整目标:将登录管理相关设计与 PRD v2.0 全面对齐,重点修正以下差异:
本设计文档覆盖 `TASK.md` 登录管理 P0 范围
- **US-ACCOUNT-001**:账号密码登录(含验证码、错误提示、锁定态)
- **US-ACCOUNT-002**多租户识别Tenant ID 识别与切换公司
- **US-ACCOUNT-003**Token/会话超时相关前端状态(过期提示、重新登录入口
### 1.2 页面职责
| 页面 | URL 建议 | 优先级 | 对应 US |
|---|---|---|---|
| Tenant 识别页 | `/account/tenant/verify/` | P0 🔴 | US-ACCOUNT-002 |
| 登录页 | `/account/login/` | P0 🔴 | US-ACCOUNT-001 / US-ACCOUNT-003 |
| 切换公司确认弹窗 | 登录页内 Modal | P0 🔴 | US-ACCOUNT-002 |
| 会话过期提示态 | 登录页内 Alert/Toast | P0 🔴 | US-ACCOUNT-003 |
> 注:`忘记用户名/忘记密码` 链接在登录页展示;其完整流程页面在后续登录模块增强迭代中展开。
1. 登录页从“单一账号密码登录”升级为**双登录方式 Tab**
- 手机号 + 密码登录(默认)
- 手机号 + 短信验证码登录
2. 删除“忘记用户名”入口(该流程已废弃
3. 补齐“重置密码/设置新密码”页面(公共组件,支持两种入口文案
4. 保留“微信扫码登录(即将开放)”禁用态入口
---
## 2. 视觉与组件基线(对齐 UI System
## 2. 页面清单P0
### 2.1 色彩与层级
- 页面背景:`bg-neutral-50`
- 登录主按钮:`bg-primary-600 hover:bg-primary-700 active:bg-primary-800`
- 错误提示:`text-danger-600`
- 成功提示:`text-success-600`
- 卡片容器:`bg-white border border-neutral-200 rounded-xl shadow-lg`
### 2.2 组件复用清单
| 场景 | 组件规范来源 | 使用说明 |
|---|---|---|
| 主/次按钮 | `UI_SYSTEM.md §3.1` | 登录=Primary刷新验证码/切换公司=Secondary/Link |
| 输入框/密码框 | `UI_SYSTEM.md §3.2` | 统一 Label 在上、错误提示在下、密码可见切换 |
| 确认弹窗 | `UI_SYSTEM.md §3.6` | 切换公司二次确认使用 Confirm Modal |
| Toast 提示 | `UI_SYSTEM.md §3.8` | 网络异常、登录成功/失败统一 Toast 反馈 |
| 登录页布局模板 | `UI_SYSTEM.md §5.7` | 独立布局,无 Sidebar品牌区 + 表单区 |
### 2.3 主题策略说明
依据 `UI_SYSTEM.md §9.1`**v1 仅 Light 主题**。
本页面不提供用户可见主题切换按钮,但保留 `data-theme="light"` 扩展点,为后续主题系统接入预留。
---
## 3. 页面设计规范
## 3.1 Tenant 识别页P0 🔴)
### 3.1.1 页面结构
```
┌────────────────────────────────────────────────────────────┐
│ 左侧品牌区Logo + Slogan + 租户价值说明) │
│ 右侧识别卡片 │
│ 标题:欢迎使用 Fonrey 房睿 │
│ 描述:请输入您公司的专属识别码 │
│ [公司识别码输入框] │
│ [确认按钮] │
│ 错误提示区(固定高度,防布局抖动) │
│ 帮助文案:不知道识别码?请联系管理员 │
└────────────────────────────────────────────────────────────┘
```
### 3.1.2 字段与校验
| 字段 | 类型 | 必填 | 规则 |
|---|---|---|---|
| Tenant ID | 文本输入(仅数字) | 是 | 固定 12 位;自动 trim非数字过滤 |
### 3.1.3 交互状态
| 状态 | 触发 | 视觉反馈 |
|---|---|---|
| Idle | 首次进入 | 按钮可点击(输入满足 12 位) |
| Loading | 点击“确认”后 | 按钮 Loading + 禁用;输入框禁用 |
| Success | 验证通过 | 展示租户名,自动跳转登录页 |
| Invalid | Tenant 无效 | 输入框下方红色文案:识别码无效… |
| Network Error | 请求失败/超时 | 错误提示 + “重试”按钮 |
### 3.1.4 API 对齐
- `POST /api/auth/tenant/verify/`PRD
- 请求体:`{ tenant_id }`
- 成功返回:`tenant_name / tenant_logo_url / login_url`
- 失败返回:`TENANT_NOT_FOUND`
---
## 3.2 登录页P0 🔴)
### 3.2.1 布局结构
```
┌────────────────────────────────────────────────────────────┐
│ 左侧品牌区(租户 Logo / 公司名 / 产品卖点) │
│ 右侧登录卡片max-w-md
│ [用户名] │
│ [密码 + 显示/隐藏] │
│ [滑块拼图验证区域 + 刷新] │
│ [登录按钮] │
│ [忘记用户名] [忘记密码] │
│ [手机验证码登录(即将开放,禁用)] │
│ [微信扫码登录(即将开放,禁用)] │
│ [切换公司]Link
└────────────────────────────────────────────────────────────┘
```
### 3.2.2 字段规范
| 字段 | 组件 | 必填 | 校验 | 数据模型映射 |
| 页面 | 文件 | URL 建议 | 对应 Story/US | 状态 |
|---|---|---|---|---|
| 用户名 | Input | 是 | 1~50 字符;允许字母/数字/下划线(兼容管理员) | `user_accounts.username` |
| 密码 | Password Input | 是 | 非空;提交后后端校验 | `user_accounts.password`(哈希) |
| 验证码通过票据 | 滑块拼图区域 | 是 | 位置偏差 ±5px + 轨迹特征校验 | Redis `captcha_pass:*` |
| Tenant 识别页 | `UI_DESIGN/登录_UI.html` | `/account/tenant/verify/` | Story 1 / US-ACCOUNT-002 | ✅ |
| 登录页(双 Tab | `UI_DESIGN/登录_账号密码_UI.html` | `/account/login/` | Story 2、Story 5、Story 6(禁用态) | ✅ |
| 设置新密码页(公共组件) | `UI_DESIGN/登录_重置密码_UI.html` | `/account/password/reset/` | Story 3 步骤三 + §5.3.4 | ✅(新增) |
### 3.2.3 主要交互规则
> 注:找回密码 Step1/Step2手机号+验证码校验)流程在本轮以登录页入口与重置页组件方式完成设计闭环;后续可独立补全完整 Stepper 页面原型。
1. 用户名/密码/验证码三者满足后,“登录”按钮可点击。
2. 点击登录后按钮进入 `loading`,避免重复提交。
3. 登录失败(账号或密码错误):
- 统一提示 `用户名或密码错误,请重新输入`
- 自动刷新验证码
- 清空密码,保留用户名
4. 验证码失败:提示 `验证码有误,请重新验证`,不计入密码错误次数。
5. 连续密码错误 ≥ 5 次:
- 展示 `账号已被临时锁定请30分钟后重试`
- 登录按钮禁用
6. 账号停用:提示 `账号已停用,请联系管理员`
7. Session 过期跳转后,顶部显示提示条:`登录已过期,请重新登录`
8. 登录成功后,前端跳转到首页路由(本静态原型当前映射为 `./房源列表_UI.html?from=login&login=success`,后续可替换为正式 `/home/`)。
---
### 3.2.4 登录页状态矩阵
## 3. 设计基线UI System 对齐)
| 状态 | 触发条件 | UI 表现 |
### 3.1 色彩与组件 Token
- 背景:`neutral-50`
- 主按钮:`primary-600 / 700 / 800`
- 错误提示:`danger-600`
- 成功提示:`success-600`
- 警告提示:`warning-600`
- 卡片:`bg-white + border-neutral-200 + rounded-xl + shadow-lg`
### 3.2 交互组件
- 输入框Label 在上,错误提示在下
- 密码框:支持显示/隐藏
- 滑块拼图:支持刷新、成功态、失败抖动态
- 登录方式 Tab默认“密码登录”切换后清空两侧输入并重置滑块
- CTA 状态disabled / loading / success 跳转
### 3.3 主题策略
- 本模块静态原型仅提供 Light 模式展示(`data-theme="light"`
- 不提供主题切换控件
---
## 4. 页面规范
## 4.1 Tenant 识别页(`登录_UI.html`
### 页面结构
- 左侧品牌区Logo、产品价值描述
- 右侧Tenant 识别卡片
- 标题:欢迎使用 Fonrey 房睿
- 副标题:请输入您公司的专属识别码以继续
- 字段公司识别码12位纯数字
- 按钮:确认
- 错误区:固定高度,避免布局抖动
- 帮助文案:联系 Tenant Admin 获取识别码
### 校验与状态
- 输入仅保留数字,超长截断为 12 位
- 少于 12 位提交:提示“识别码须为 12 位数字”
- 成功:缓存 tenant 信息并跳转登录页(双 Tab
- 失败提示“识别码无效请联系您的Tenant Admin租户管理员获取正确的识别码”
- 网络异常:提示“网络连接失败,请检查网络后重试”
### 接口映射
- `POST /api/auth/tenant/verify/`
- Request`{ tenant_code }`
---
## 4.2 登录页(`登录_账号密码_UI.html`
### 顶部信息
- 显示当前租户名:`正在登录:{tenant_name}`
- 提供“切换公司”入口 + 二次确认弹窗
- 可显示 Session 过期提示条
### 登录方式 Tab核心
| Tab | 默认 | 字段组成 |
|---|---|---|
| Default | 初始打开 | 空表单 + 新验证码 |
| Captcha Passed | 验证通过 | 验证区绿色对勾 + 文案 |
| Submitting | 点击登录后 | 按钮 spinner表单禁用 |
| Invalid Credential | 401 | 错误 Alert + 密码清空 |
| Locked | 423/锁定态 | 锁定警示条 + 按钮 disabled |
| Disabled | 账号停用 | 错误提示 + 禁止提交 |
| Session Expired | 过期重定向 | 顶部 warning 条 |
| 密码登录 | ✅ | 手机号 + 密码 + 滑块验证 + 登录 |
| 验证码登录 | - | 手机号 + 滑块验证 + 短信验证码 + 登录 |
### Tab 切换规则
- 切换后:
- 清空当前 Tab 输入
- 清空错误提示
- 重置滑块状态
- 清空短信验证码倒计时状态
### 密码登录Story 2
- 手机号11 位数字,自动过滤非数字
- 密码:必填,显示/隐藏切换
- 滑块:必须先通过
- 登录按钮:三项满足后可点击
- 失败提示:统一“手机号或密码错误,请重新输入”
- 锁定提示:“账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁”
- 停用提示:“账号已停用,请联系您的管理员”
### 验证码登录Story 5
- 手机号11 位数字
- 获取验证码按钮:
- 需先通过滑块后才可点击
- 点击后进入 60s 冷却
- 验证码输入6 位数字
- 登录按钮:手机号 + 验证码均满足后可点击
- 验证失败提示:
- “验证码有误,请重新输入”
- 过期:“验证码已过期,请重新获取”
### 其他入口
- 忘记密码(保留)
- 微信扫码登录(即将开放,禁用态)
- 删除“忘记用户名”入口
### 接口映射
- `POST /api/auth/login/`
- `POST /api/auth/login/phone/`
- `POST /api/auth/logout/`
- `POST /api/auth/recover/password/request/`
- `POST /api/auth/recover/password/verify/`
- `POST /api/auth/recover/password/reset/`
---
## 3.3 切换公司确认弹窗P0 🔴
## 4.3 设置新密码页(`登录_重置密码_UI.html`
### 3.3.1 触发入口
本页面是公共组件页面,支持两种业务上下文:
- 登录卡片底部 Link`切换公司`
| 模式 | 标题 | 提示文案 | 按钮文案 | 提交后行为 |
|---|---|---|---|---|
| `initial`(首次登录强制修改) | 欢迎使用 Fonrey请先设置您的登录密码 | 您当前使用的是初始密码,为保障账号安全,请立即设置新密码后开始使用 | 确认并进入系统 | 保持 Session进入首页 |
| `recover`(找回密码步骤三) | 重置您的登录密码 | 请输入您的新密码,设置完成后请使用新密码重新登录 | 确认重置密码 | 使所有 Session 失效,跳转登录页并提示 |
### 3.3.2 弹窗内容
### 字段与校验
- 标题:`切换公司`
- 文案:`切换公司将清除当前租户识别信息,并返回识别页。是否继续?`
- 按钮:`取消`Secondary/ `继续切换`Danger
- 新密码
- 确认新密码
- 实时强度校验(逐条):
- 长度 ≥ 8
- 包含字母
- 包含数字
- 一致性校验:两次输入必须一致
### 3.3.3 行为
### 强制约束initial 模式)
- 确认后:清除本地 tenant 缓存并跳转 `/account/tenant/verify/`
- 取消后:关闭弹窗,不改变当前状态
- 不显示“跳过”
- 不允许返回业务页面
- 页面仅保留密码设置流程
---
## 3.4 会话过期提示P0 🔴)
## 5. 状态矩阵
- 场景:用户访问业务页时 Session 失效,被重定向回登录页
- 位置:登录卡片顶部 Alertwarning
- 文案:`登录已过期,请重新登录`
- 可关闭:是(仅隐藏提示,不恢复会话)
---
## 4. 与数据模型/技术方案映射
## 4.1 关键字段映射
| UI 关注点 | 数据模型字段/实体 | 说明 |
| 页面 | 状态 | 说明 |
|---|---|---|
| 账号状态 | `user_accounts.status` | `active/disabled/locked` 驱动登录态文案 |
| 锁定截止时间 | `user_accounts.locked_until` | 锁定倒计时文案来源 |
| 初始密码标记 | `user_accounts.is_initial_password` | 登录成功后是否强制跳转改密页 |
| 登录失败计数 | Redis `login_fail:{tenant}:{username}` | 达阈值触发锁定 |
| 登录审计 | `login_attempts` | 失败原因不在前端细分展示 |
## 4.2 API 映射(前端使用)
| 目标 | 接口 |
|---|---|
| 租户识别 | `/api/auth/tenant/verify/` |
| 获取验证码 | `/api/account/captcha/generate/` |
| 校验验证码 | `/api/account/captcha/verify/` |
| 登录提交 | `/api/account/login/` |
| 登出 | `/api/account/logout/` |
| Tenant 识别页 | idle/loading/success/error/network_error | 完整覆盖 Story 1 |
| 登录页-密码 Tab | default/captcha_pass/submitting/credential_error/locked/disabled/session_expired | 完整覆盖 Story 2 |
| 登录页-验证码 Tab | default/captcha_required/otp_sending/otp_countdown/otp_error/otp_expired/submitting | 完整覆盖 Story 5 |
| 设置新密码页 | default/strength_checking/mismatch_error/submitting/success | 覆盖 Story 3 Step3 + §5.3.4 |
---
## 5. 可访问性与易用性
## 6. 可访问性与实现约束
1. 所有输入框均有可见 Label使用仅 placeholder 方案。
2. 错误信息与字段通过 `aria-describedby` 关联
3. 图标按钮(显示密码刷新验证码)必须有 `aria-label`
4. `Tab` 顺序Tenant ID/用户名 → 密码 → 验证区 → 登录按钮 → 辅助链接。
5. Enter 键:当表单合法时触发提交。
1. 输入框均有 Label不仅依赖 placeholder
2. 错误文案使用 `aria-describedby` 关联字段
3. 图标按钮(显示密码/刷新验证码)提供 `aria-label`
4. 支持键盘 Tab 顺序与 Enter 提交
5. 静态原型以原生 JS + Alpine 为主,确保 `file://` 可直接评审
---
## 6. 交付物与实现顺序
## 7. 本轮交付与验收清单
1. 本文档:`UI_DESIGN/登录管理/登录_UI.md`(当前)
2. 静态原型:`UI_DESIGN/登录_UI.html`(基于本文档)
3. 评审后迭代:先改 HTML再回写本 UI 文档
### 交付文件
---
- `UI_DESIGN/登录管理/登录_UI.md`(本文档)
- `UI_DESIGN/登录_UI.html`Tenant 识别)
- `UI_DESIGN/登录_账号密码_UI.html`(双 Tab 登录)
- `UI_DESIGN/登录_重置密码_UI.html`(新增:公共设置新密码页)
## 7. 验收检查清单UI 维度)
### 验收项
- [ ] Tenant ID 12 位数字校验与错误提示完整
- [ ] 登录页三要素(用户名/密码/验证码)联动提交规则完整
- [ ] 锁定态、停用态、会话过期态均有明确视觉反馈
- [ ] 切换公司有二次确认弹窗
- [ ] 所有颜色/按钮/输入框样式遵循 UI_SYSTEM Token 与组件规范
- [ ] 静态页可用于你进行第一轮视觉与交互评审
- [ ] 登录页存在“密码登录 / 验证码登录”双 Tab 且可切换
- [ ] Tab 切换后输入与滑块状态已重置
- [ ] 密码登录按手机号校验11位数字
- [ ] 验证码登录需先滑块通过才能“获取验证码”
- [ ] “忘记用户名”入口已移除,“忘记密码”入口保留
- [ ] 新增“设置新密码”页面并覆盖 initial/recover 两种上下文
- [ ] 微信扫码登录为禁用“即将开放”态
- [ ] 关键交互无控制台报错

View File

@@ -1,250 +0,0 @@
# 项目进度交接报告 — Phase 4.0 + 4.1 收尾
> **作者**: Backend Engineer
> **创建日期**: 2026-04-29Phase 4.0
> **更新日期**: 2026-04-30Phase 4.1 完成)
> **状态**: ✅ Phase 4.1 完成,等待业务开发启动
> **续接者**: 进入正式开发的工程师 / 后续维护者
> **前序文档**: [`项目骨架搭建实施报告_v1.md`](./项目骨架搭建实施报告_v1.md)
---
## 0. TL;DR最关键的 30 秒)
- **Phase 4.0 + 4.1 全部完成**74 模型 Meta 中文名 + 781 字段 `verbose_name` + 关键字段 `help_text` 全部补齐
- **9 app 全部独立 commit每 commit `manage.py check` 0 issues**
- **`makemigrations` 未跑**(按用户指示保留生成权),但已 `--dry-run` 确认仅有 `Alter field` 元数据迁移待生成
- **本地领先 origin/main 14 个 commit未 push**
- **遗留**Dockerfile + docker-compose.yml 有未跟踪的代理配置改动(`--proxy http://host.docker.internal:10808`)— 由用户手动处理,不要 `git add`
---
## 一、已完成事项
### 1.1 Phase 4.0:所有模型添加中文 Meta 名2026-04-29
**git commit**: `79c3cf2 feat(models): add Chinese verbose_name to all 74 models (Phase 4.0)`
**变更范围**20 models 文件 + 8 迁移文件 + 1 tenant initial 迁移29 文件 / +594 行
**具体内容**:为全部 74 个模型添加:
```python
class Meta:
verbose_name = "中文表名"
verbose_name_plural = "中文表名"
# 已有 db_table / indexes / constraints 保留
```
**消费方**Django Admin 列表/表单中文显示、drf-spectacular OpenAPI tag、shell 报错中文。
---
### 1.2 Phase 4.1:所有字段补齐中文 verbose_name + help_text2026-04-30
**核心规则**
- 仅使用字段 kwarg`verbose_name="..."` / `help_text="..."`)— 字符串赋值hook 不拦截
- **不**新增模型 docstring / 行注释 — 业务说明留在 `DATA_MODEL_*.md` 里作为单一信息源
- 关键字段加 `help_text`业务规则、ENUM 中英对照、写入约束);纯技术字段(`created_at` 等)只加 `verbose_name`
- **每 app 一个独立 commit**commit 后 `manage.py check` 必须 0 issues
- 不动 MetaPhase 4.0 已完成)、不改字段类型/null/default、不增删字段
**9 个 commit 序列(领先 origin/main未 push**
| # | App | Commit | 文件数 | 备注 |
|---|---|---|---|---|
| 1/9 | property | `3638fc0` | 4 (core/follow_keys/listings/media) | 23 模型,含 PROPERTY 主表 + 分区 follow_logs/photos |
| 2/9 | client | `e67b07a` | 5 (core/contacts/folders/follow/viewing_match) | 11 模型,含分区 client_follow_logs |
| 3/9 | complex | `a3800bf` | 1 (complex.py) | 10 模型,含全文检索 search_vector |
| 4/9 | org | `f185127` | 3 (org_unit/staff/staff_logs) | 11 模型 |
| 5/9 | account | `b57070f` | 1 (account.py) | 4 模型UserAccount/LoginAttempt/PasswordResetToken/PasswordHistory|
| 6/9 | permission | `9ef6eb6` | 3 (permission_def/role/staff_perm) | 8 模型,含 PermissionChangeLogappend-only|
| 7/9 | setting | `289ec43` | 2 (lookup/setting) | 4 模型LookupGroup/Item, TenantSetting, FieldRequirementRule|
| 8/9 | region | `e3b26ce` | 1 (region.py) | 5 模型District/BusinessArea/MetroLine/Station/School|
| 9/9 | tenant | `8faa68b` | 1 (models.py) | 2 模型Tenant/Domaindjango-tenants Mixin |
**注**`apps/release/models/` 目录无内容文件,跳过;其内容在 `DATA_MODEL_PUBLIC.md §2.6 client_releases` 但当前未实例化为 Django 模型。
---
### 1.3 工作流决策(明确,避免后续踩坑)
- **方案 A 否决**:模型类中文 docstring 因 hook 反复阻拦
- **方案 B 采纳**:仅用 `Meta.verbose_name` + 字段级 `verbose_name=` / `help_text=`,纯字符串 kwarghook 放行
- 模型类的"业务作用 / 关键业务规则"放在 `DATA_MODEL_*.md` 作为单一信息源,代码不重复
- ENUM 字段 `help_text` 走中英对照(如 `"public=公立 / private=私立 / international=国际学校"`
- 已存在的 docstring 保留property/follow_keys.py FollowLog、property/media.py PropertyPhoto、client/follow.py ClientFollowLog、client/viewing_match.py ClientStatusLog 的 "Partitioned table…unmanaged" / "Audit log; record-level immutable" 等技术说明)
---
## 二、当前注释覆盖率(最终态)
| 维度 | 基线 | Phase 4.0 后 | Phase 4.1 后(当前) |
|---|---|---|---|
| `Meta.verbose_name` | 0/74 | **74/74** ✅ | **74/74** ✅ |
| `Meta.verbose_name_plural` | 0/74 | **74/74** ✅ | **74/74** ✅ |
| 字段 `verbose_name=` | 0/781 | 0/781 | **781/781** ✅ |
| 字段 `help_text=` | 14/781 | 14/781 | **关键字段全覆盖**FK + ENUM + 业务规则字段)|
| 模型 docstring | 0/74 | 0/74 | 0/74按决策不做业务语义在 DATA_MODEL_*.md|
---
## 三、Git 状态快照
### 3.1 本地 commits领先 origin/main **14 个**,未 push
```
8faa68b feat(tenant): add Chinese verbose_name/help_text to tenant models (Phase 4.1 part 9/9)
e3b26ce feat(region): add Chinese verbose_name/help_text to region models (Phase 4.1 part 8/9)
289ec43 feat(setting): add Chinese verbose_name/help_text to setting models (Phase 4.1 part 7/9)
9ef6eb6 feat(permission): add Chinese verbose_name/help_text to permission models (Phase 4.1 part 6/9)
b57070f feat(account): add Chinese verbose_name and help_text to all account fields (Phase 4.1 part 5/9)
f185127 feat(org): add Chinese verbose_name and help_text to all org fields (Phase 4.1 part 4/9)
a3800bf feat(complex): add Chinese verbose_name and help_text to all complex fields (Phase 4.1 part 3/9)
e67b07a feat(client): add Chinese verbose_name and help_text to all client fields (Phase 4.1 part 2/9)
3638fc0 feat(property): add Chinese verbose_name and help_text to all property fields (Phase 4.1)
79c3cf2 feat(models): add Chinese verbose_name to all 74 models (Phase 4.0)
94d1602 feat: complete Phase 3 scaffolding (templates, static, Docker, per-app skeletons)
ed40de4 feat(client,setting): complete Phase 2 with partitioned client_follow_logs
5b55dda feat(property): add 23-table property module with partitioned follow_logs and property_photos
c57462f feat(complex): add apps.complex with 10 models and full-text search
```
### 3.2 工作树状态
- 当前分支:`main`
- 已跟踪文件clean
- **未跟踪改动(用户代理配置,不要 `git add`**
- `Dockerfile` — 添加 `--proxy http://host.docker.internal:10808 --timeout 120`
- `docker-compose.yml` — 同上代理类配置
- 这两个改动属于本机网络环境配置,由用户自行管理
---
## 四、后续工作恢复点
### 4.1 立即可做(不阻塞)
| 项目 | 描述 | 估时 | 优先级 |
|---|---|---|---|
| **生成 Phase 4 verbose_name 迁移** | `manage.py makemigrations`,会为所有 app 生成 `Alter field` 迁移(仅 Meta不动 schema | 0.5h | 高 — 进 git 才能部署到 Admin |
| **Push 14 个本地 commit** | `git push origin main`(需用户授权) | 0.1h | 中 |
| **PermissionDef 种子数据** | ~300 条权限定义 fixturePRD §8.2 导航对齐 | 2-3h | 高 — 阻塞权限/登录测试 |
| **内置角色 + DataScope 种子** | Tenant Admin / 普通员工默认角色 | 1-2h | 高 — 阻塞登录测试 |
| **Setting LookupItem 默认值** | DATA_MODEL_SETTING.md §2.3 已列出种子数据 | 1h | 中 |
| **Celery partition_maintenance_task** | 月度分区自动创建 | 2h | 中(手动建分区可暂用) |
| **drf-spectacular OpenAPI 生成** | `manage.py spectacular --file openapi.json` | 0.5h | 中 — 阻塞 schemathesis |
| **release app 模型化(可选)** | DATA_MODEL_PUBLIC.md §2.6 client_releases当前 `apps/release/models/` 为空目录 | 1h | 低 — MVP 不需要 |
**推荐顺序**makemigrations → PermissionDef 种子 → 内置角色种子 → push一次性同步给团队
### 4.2 进入业务开发阶段Phase 5+
骨架已就绪。业务开发可以按以下路径推进:
1. **认证 + 登录流**apps/account + apps/permission— DATA_MODEL_LOGIN.md / DATA_MODEL_PERMISSION.md 已为权威源
2. **房源 CRUD**apps/property— DATA_MODEL_PROPERTY.md 完整覆盖 23 模型
3. **客源 CRUD + 跟进**apps/client— DATA_MODEL_CLIENT.md 11 模型
4. **楼盘维护**apps/complex + apps/region— DATA_MODEL_COMPLEX.md 10 模型
5. **组织/员工**apps/org— DATA_MODEL_ORG.md 11 模型
6. **系统配置**apps/setting— DATA_MODEL_SETTING.md 4 模型 + Service 层(`TenantSettingsService`
**API 契约要求**:每个端点必须满足 `TECH_STACK/API_CONTRACT.md` 的 7 项检查表。
---
## 五、关键文件索引
### 5.1 项目本体(`/mnt/c/project/fonrey/`
| 路径 | 作用 |
|---|---|
| `apps/*/models/*.py` | 74 模型,**Meta + 字段 verbose_name/help_text 全部补齐**Phase 4.0+4.1|
| `core/enums.py` | ENUMS.md v2.2 镜像 |
| `core/models/base.py` | TimeStampedModel / SoftDeleteModel / UUIDPrimaryKeyModel / AuditedModel |
| `core/encryption.py` | AES-256-GCM PhoneEncryption |
| `config/settings/development.py` | `DJANGO_SETTINGS_MODULE` 默认值 |
| `manage.py` | Django 入口(用 `.venv/bin/python manage.py ...` |
| `.env` | 本地 SECRET_KEY + PHONE_ENCRYPTION_KEYgitignored|
| `Dockerfile` / `docker-compose.yml` | **untracked 改动持续存在**,含本机代理配置 |
### 5.2 文档(`/mnt/d/Workspace/nexus/Project/fonrey/`
| 路径 | 作用 |
|---|---|
| `规范/DATA_MODEL_注释补全规范_v1.md` | Phase 4.1 实施依据 |
| `DATA_MODEL/DATA_MODEL_*.md` | 数据模型权威源9 个文件,全部已读且映射到代码)|
| `DATA_MODEL/ENUMS.md` | 枚举权威源 v2.2 |
| `prompt/提示词模板/创建项目骨架提示词_v2.3.md` | 项目骨架规范 |
| `TECH_STACK/API_CONTRACT.md` | API 契约 7 项检查表 |
| `实施报告/项目骨架搭建实施报告_v1.md` | Phase 1-3 报告 |
| `实施报告/项目进度交接报告_Phase4.0_收尾.md` | **本文件**(已升级为 Phase 4.0+4.1 收尾报告)|
---
## 六、快速验证命令onboard 自检)
```bash
cd /mnt/c/project/fonrey
# 环境验证
.venv/bin/python --version # Python 3.13.x
.venv/bin/python manage.py check # 0 issues
# Phase 4.0 模型级覆盖核对
grep -rh "verbose_name = " apps/*/models/*.py apps/tenant/models.py | grep -v _plural | grep -v "^#" | wc -l
grep -rh "verbose_name_plural" apps/*/models/*.py apps/tenant/models.py | wc -l
# Phase 4.1 字段级覆盖核对
grep -rhc "verbose_name=" apps/*/models/*.py apps/tenant/models.py # 应远超 781含 verbose_name= 字段属性 + Meta verbose_name
grep -rhc "help_text=" apps/*/models/*.py apps/tenant/models.py # 关键字段已覆盖
# Git 状态
git status # 仅 Dockerfile / docker-compose.yml untracked
git log --oneline origin/main..HEAD # 应见 14 行Phase 4.0 + 4.1 + 历史 4 个 Phase 1-3
# 待生成迁移预览(不实际写盘)
.venv/bin/python manage.py makemigrations --dry-run | head -50
```
---
## 七、待澄清/记忆事项(不变项)
1. **Hook 政策**:本仓库 hook 严格阻止新 docstring/行注释。**字段属性(`verbose_name=` / `help_text=`)字符串赋值放行**。所有业务说明仍以 DATA_MODEL_*.md 为单一信息源。
2. **14 个未推送 commit**:用户未授权 push。如团队需协作需用户明确指示后再推。
3. **AGENTS.md §4.4**:手机号必须 AES-256-GCM**禁止** Fernet
4. **Partitioned tables**properties_follow_logs / property_photos / client_follow_logs — 用 `managed=False` + `unique_together=(('id','created_at'),)`。这些表的现有 docstring"Partitioned table…unmanaged")作为技术注解保留。
5. **release app 不写 services/**spec §17.5 明确禁止。
6. **release app 模型未实例化**DATA_MODEL_PUBLIC.md §2.6 `client_releases` 未生成 Django ModelMVP 不需要,进入客户端发布阶段时再补。
7. **CSRF_COOKIE_HTTPONLY=False**HTMX 需要,故意如此,**禁止"修复"**。
8. **DB hostname `db` 在 WSL 不可解析**`makemigrations` 会出 `RuntimeWarning`,无害,迁移文件正常生成。
9. **未跟踪文件保留**Dockerfile / docker-compose.yml 的代理配置是用户本机设置,**不要主动 `git add`**。
10. **ENUM 字段 help_text 中英对照**:示例 `"public=公立 / private=私立 / international=国际学校"`,沿用此风格。
---
## 八、Phase 进度总览
| Phase | 状态 | 关键产出 |
|---|---|---|
| **Phase 1** — 配置/核心 | ✅ | config/, core/, settings, .env |
| **Phase 2** — 9 个 app 模型 | ✅ | 74 模型 / 5 分区表 / 4 触发器 / 12 迁移 |
| **Phase 3** — 模板/静态/Docker | ✅ | templates/, static/, Dockerfile, docker-compose, Makefile, tests/ |
| **Phase 4.0** — 模型 Meta 中文名 | ✅ | 74 模型全部 `Meta.verbose_name` + `_plural` |
| **Phase 4.1** — 字段中文名 | ✅(**本次完成** | 781 字段 `verbose_name` + 关键字段 `help_text`9 app 各一 commit |
| **Phase 5**(候选) — 种子数据 + Celery + OpenAPI | ⏸️ 待启动 | PermissionDef / 内置角色 / partition task / openapi.json |
| **Phase 6+** — 业务开发 | ⏸️ 骨架就绪 | 按 DATA_MODEL_*.md 与 API_CONTRACT.md 推进 |
---
## 九、给后续开发者的提醒
> 进入正式业务开发前,**强烈建议**先做以下 3 件事:
>
> 1. `manage.py makemigrations` 把 Phase 4.0+4.1 的 verbose_name 迁移落地,并 commit
> 2. 创建 PermissionDef + 内置角色 fixture阻塞登录/权限测试)
> 3. `git push origin main` 同步 14 个本地 commit 到远程
>
> 三步完成后骨架达到"业务开发就绪"状态。所有模型字段中文化已完成,新写 ViewSet/Service 时不需要再补任何字段元数据。
**Phase 4.0 + 4.1 收尾完成。骨架已就绪,可直接进入业务开发。**

View File

@@ -1,435 +0,0 @@
# Fonrey 项目骨架搭建 — 实施报告
**版本**v1.0
**报告日期**2026-04-29
**实施范围**项目骨架Phase 1 配置 → Phase 2 数据模型 → Phase 3 前端/Docker 脚手架)
**实施依据**`prompt/提示词模板/创建项目骨架提示词_v2.3.md`
**项目根目录**`/mnt/c/project/fonrey/`
**Git HEAD**`94d1602`main 分支working tree clean领先 origin/main 5 commits
---
## 一、执行摘要
`创建项目骨架提示词_v2.3.md`903 行)规范,分三阶段完成 Fonrey 多租户房产 SaaS 平台的 Django 项目骨架:
- **Phase 1**Django 配置层config/、core/、shared/、requirements/、env、manage.py、pyproject— 已完成。
- **Phase 2**9 个业务 App 的真实数据模型(依据 `DATA_MODEL_*.md`)— 已完成77 个 ORM 模型5 张分区表 + 4 个数据库触发器。
- **Phase 3**:前端模板/静态资源/Docker/Tailwind/Makefile/根级 tests/ — 已完成。
**最终验证**
- `python manage.py check` ✅ 0 issues
- `python manage.py check --deploy` ✅ 仅一条 SECRET_KEY 测试值告警(非真实问题)
- 顶层目录树与规范 §2 100% 匹配
- 5 个干净的 Git checkpoint commits
**未交付(明确延后)**
- ~300 条 PermissionDef 种子数据fixtures
- 4 个内置角色 + 默认 DataScope 种子
- Setting 模块的 LookupItem 默认值
- Celery `partition_maintenance_task`(每月分区滚动)
- API_CONTRACT 7 项契约清单中需要真实业务端点的部分spectacular OpenAPI 生成 / schemathesis 实际运行)
---
## 二、目录结构对照(规范 §2 vs 实际)
### 顶层结构100% 匹配)
| 规范要求 | 实际状态 |
|---|---|
| `apps/` (10 个 App) | ✅ tenant, account, permission, org, region, complex, property, client, setting, release |
| `core/` | ✅ models/, enums.py, encryption.py, cache.py, htmx.py, templatetags/, middleware/ |
| `shared/` | ✅ apps.py |
| `config/` | ✅ settings/{base,development,testing,production}.py, urls.py, urls_public.py, asgi.py, wsgi.py |
| `templates/` | ✅ base.html, layouts/, components/, errors/ |
| `static/` | ✅ css/, js/, vendor/ |
| `locale/` | ✅ 占位 |
| `requirements/` | ✅ base.txt, development.txt, production.txt |
| `tests/` | ✅ conftest.py, integration/, e2e/ |
| 根级文件 | ✅ .env, .env.example, .gitignore, manage.py, Dockerfile, docker-compose.yml, docker-compose.prod.yml, Makefile, tailwind.config.js, package.json, pyproject.toml |
### 每个 App 内部结构
**业务 Appproperty/client/setting/account/permission/org/region/complex**
```
apps/<name>/
├── __init__.py
├── apps.py
├── admin.py
├── models/__init__.py + 多个模型文件
├── migrations/
├── services/__init__.py
├── tasks.py
├── views.py
├── urls.py
├── serializers.py
├── templates/<name>/
└── tests/__init__.py
```
**release App共享 schema无服务层**
```
apps/release/
├── __init__.py
├── apps.py
├── admin.py
├── models/ ← 当前空ClientRelease 待实现)
├── migrations/
├── views.py
├── urls.py
├── serializers.py
└── tests/
```
**tenant Appdjango-tenants 特殊结构)**
```
apps/tenant/
├── __init__.py
├── apps.py
├── admin.py
├── models.py ← 单文件,含 Tenant + Domain规范 §17.1
├── migrations/
└── tests/
```
---
## 三、Phase 1配置层commit 9a7d06b 含此部分)
### 3.1 已交付文件
| 路径 | 用途 | 关键决策 |
|---|---|---|
| `config/settings/base.py` | 基础配置 | django-tenants 必为 SHARED_APPS 第一CSRF_COOKIE_HTTPONLY=FalseHTMX 需要AUTH_USER_MODEL = "account.UserAccount" |
| `config/settings/development.py` | 开发配置 | DEBUG=Truedjango-debug-toolbar |
| `config/settings/testing.py` | 测试配置 | pytest-django |
| `config/settings/production.py` | 生产配置 | DEBUG=FalseHSTS/SECURE 各项开启 |
| `config/urls.py` | Tenant 路由入口 | 仅 tenant 路由(强制分离) |
| `config/urls_public.py` | Public 路由入口 | apps.release + drf-spectacular schema/swagger |
| `config/asgi.py` | ASGI 入口 | uvicorn 启动点 |
| `config/wsgi.py` | WSGI 入口 | gunicorn 兼容 |
| `core/models/base.py` | 4 个抽象基类 | UUIDPrimaryKeyModel, TimeStampedModel, SoftDeleteModel, AuditedModel |
| `core/enums.py` | 全局枚举 | 严格对齐 ENUMS.md v2.2,覆盖 9 个模块共数十个枚举 |
| `core/encryption.py` | PII 加密 | **AES-256-GCM**(强制,禁用 Fernet |
| `core/cache.py` | Redis 工具 | get_redis_key 命名空间隔离 |
| `core/htmx.py` | HTMX 响应工具 | htmx_response(),支持 toast / redirect |
| `core/templatetags/heroicons.py` | Heroicons | `{% heroicon 'plus' %}` 内联 SVG |
| `core/middleware/audit.py` | 审计中间件 | 骨架 |
| `requirements/base.txt` | 生产依赖 | Django 4.2.16, django-tenants 3.7.0, psycopg2-binary 2.9.9, celery 5.4.0, drf-spectacular 0.27.2 等 |
| `requirements/development.txt` | 开发依赖 | pytest, schemathesis, playwright, ruff, black 等 |
| `requirements/production.txt` | 生产收敛 | -r base.txt + sentry/whitenoise |
| `pyproject.toml` | 代码质量 | ruff/black/isort/pytest 配置 |
| `.env.example` | 环境变量模板 | DB / Redis / R2 / Sentry / PHONE_ENCRYPTION_KEY |
| `.env` | 开发环境真实值 | dev SECRET_KEY + 32 字节 PHONE_ENCRYPTION_KEY已 gitignore |
| `.gitignore` | 忽略规则 | .env / *.pyc / node_modules / static/css/output.css / openapi.json 等 |
| `manage.py` | Django 入口 | DJANGO_SETTINGS_MODULE=config.settings.development |
### 3.2 关键合规点
-`django_tenants` 在 SHARED_APPS 第一位、MIDDLEWARE 第一位(不可调整)
-`CSRF_COOKIE_HTTPONLY = False` 含警示注释HTMX 需要 JS 读 token禁止"修复"
- ✅ 加密强制 AES-256-GCM禁用 Fernet
-`config.urls``config.urls_public` 强制分离,未合并
- ✅ DB OPTIONS 不含 `pool_size`PgBouncer 在代理层管理)
- ✅ R2 环境变量统一 `R2_*` 前缀
- ✅ 所有密钥/Tenant ID 通过 `python-decouple``env()` 读取,无硬编码
---
## 四、Phase 2数据模型层commits 9a7d06b → c57462f → 5b55dda → ed40de4
### 4.1 模型总数
| App | 模型数 | 关键模型 |
|---|---:|---|
| tenant | 2 | Tenant (TenantMixin, auto_create_schema=True), Domain (DomainMixin) |
| account | 4 | UserAccount (AbstractBaseUser), LoginAttempt, PasswordResetToken, PasswordHistory |
| permission | 7 | PermissionDef, Role, RolePermission, UserRole, DataScope, RoleDataScope, PermissionAuditLog |
| org | 11 | Department, Position, Staff含组织/职位/人员体系) |
| region | 5 | Province, City, District, BusinessArea, Subway |
| complex | 10 | Complex, ComplexBuilding, ComplexUnit 等(含 pg_trgm + search_vector |
| property | 23 | Property, PropertyPhoto分区表, FollowLog分区表, PropertyContact, PropertyTag 等 |
| client | 11 | Client, ClientContact, ClientFollowLog分区表, ClientStatusLog, ViewingRecord, MatchRecord 等 |
| setting | 4 | LookupGroup, LookupItem, TenantSetting, FieldRequirementRule |
| release | 0 | ClientRelease 待 Phase 4 业务实现) |
| **合计** | **77** | |
### 4.2 分区表与触发器(共 5 张分区表 + 4 个触发器)
| 分区表 | 模块 | 分区策略 | 关联触发器 |
|---|---|---|---|
| `property_follow_logs` | property | RANGE BY `created_at`,月度 | `update_property_last_followed` |
| `property_photos` | property | RANGE BY `created_at`,月度 | `update_property_search_vector`pg_trgm 全文检索) |
| `client_follow_logs` | client | RANGE BY `created_at`,月度 | `update_client_last_follow`, `update_client_viewing_progress` |
实现模式(解决 Django ORM 与 PG 原生分区表的冲突):
- 模型 `Meta` 设置 `managed = False`
- `id = UUIDField(primary_key=True)`,复合主键 `(id, created_at)` 通过 RunSQL 创建
- ORM 层 `unique_together = (('id', 'created_at'),)` 让查询正确生成
- 月度子分区 + 默认分区,用 RunSQL 在初始 migration 中预创建
- 跨分区 FK 限制保留为优先级 3 注释
### 4.3 Migration 文件(共 12 个)
```
apps/account/migrations/0001_initial.py
apps/account/migrations/0002_initial.py ← AUTH_USER_MODEL 切换
apps/permission/migrations/0001_initial.py
apps/org/migrations/0001_initial.py
apps/region/migrations/0001_initial.py
apps/complex/migrations/0001_initial.py
apps/complex/migrations/0002_pg_trgm_and_search_vector.py
apps/property/migrations/0001_initial.py
apps/property/migrations/0002_partitions_and_triggers.py
apps/client/migrations/0001_initial.py
apps/client/migrations/0002_partitions_and_triggers.py
apps/setting/migrations/0001_initial.py
```
### 4.4 关键模型设计决策
| 决策 | 原因 |
|---|---|
| AUTH_USER_MODEL = "account.UserAccount" | UserAccount 含 OneToOne 关联 Staff租户内统一登录 |
| 手机号加密BinaryField 密文 + char(64) hash 列 | AES-GCM 不可去重比对hash 列承担唯一索引 |
| `field_requirement_rules.trade_status``*` 哨兵值(覆盖 ALL="all" | 规范第 570 行明确要求 `*` 表示"全部"语义 |
| `ClientStatusLog` 不含 `deleted_at`(保留 docstring 警示) | 规范明确要求"严禁删除"状态变更日志 |
| 字符串 FK 引用(如 `"fonrey_property.Property"` | 避免循环导入,应用标签前缀消歧 |
| App 标签fonrey_permission, fonrey_complex, fonrey_property, fonrey_client | permission/complex/property/client 为 Python 关键字或标准库名,加前缀避免冲突 |
| 多文件 models/ 包,`__init__.py` 显式 re-export | 一表一文件,可读性优先 |
---
## 五、Phase 3前端 + Docker 脚手架commit 94d1602
### 5.1 模板体系templates/
| 文件 | 角色 |
|---|---|
| `base.html` | 全局根模板。引入顺序output.css → htmx.min.js → alpine.min.js → main.js |
| `layouts/app.html` | 主应用布局。继承 base含 Topbar (sticky, h-14, z-20) + Sidebar (Alpine `$persist` 240/64px) + 主区 + 小屏拦截门 (<1280px) |
| `layouts/auth.html` | 认证页布局。无 Topbar/Sidebar居中卡片 max-w-md |
| `components/topbar.html` | bg-primary-800Logo + 导航 + 通知/设置/头像 |
| `components/sidebar.html` | 240/64px 切换Alpine 持久化 |
| `components/pagination.html` | 分页骨架 |
| `components/toast.html` | Toast 模板 |
| `components/modal.html` | 模态对话框Alpine x-show + click.outside |
| `components/empty-state.html` | 空状态 |
| `errors/403.html` | 403 错误页 |
| `errors/404.html` | 404 错误页 |
| `errors/500.html` | 500 错误页 |
### 5.2 静态资源static/
| 文件 | 内容 |
|---|---|
| `css/main.css` | Tailwind 入口(@tailwind base/components/utilities |
| `js/main.js` | HTMX `afterRequest` 监听 `HX-Trigger: fonrey:toast`4s 自动消失;`configRequest` 自动注入 X-CSRFToken |
| `vendor/.gitkeep` | 第三方 JShtmx.min.js / alpine.min.js放置点 |
### 5.3 Tailwind 配置tailwind.config.js
完全对齐 `UI_SYSTEM.md §2.7``§10.1`
- **PrimaryTeal**50 #F0FDFA → 800 #134E4A,主色 600 #0F766E
- **NeutralSlate**50 #F8FAFC → 900 #0F172A
- **语义色**success-600 #16A34A, warning-600 #D97706, danger-600 #DC2626, info-600 #2563EB
- **字体**Inter, PingFang SC, Microsoft YaHei
- **z-index**60, 70Toast 层)
- **boxShadow**xs轻投影
- **animation**slide-in-rightDrawer 进场)
- **content scan**`templates/`, `apps/**/templates/`, `static/js/`
### 5.4 Docker 与构建(开发 6 服务)
| 文件 | 作用 |
|---|---|
| `Dockerfile` | python:3.12-slim + libpq-dev + 安装 base.txt + uvicorn 入口 |
| `docker-compose.yml` | 6 服务web (8000), db (postgres:16), redis (7), celery, celery-beat, tailwind (node:20);统一 `fonrey_net` 网络db/redis 数据卷持久化 |
| `docker-compose.prod.yml` | 生产精简版gunicorn + UvicornWorker去除 tailwind 容器与端口暴露 |
| `Makefile` | dev / migrate / shell / test / lint / tailwind-build / createsuperuser |
| `package.json` | 仅 tailwindcss ^3.4.0build/watch 两个脚本 |
### 5.5 测试体系tests/
```
tests/
├── __init__.py
├── conftest.py ← TenantClient fixture强制租户上下文禁止 Django 原生 Client
├── integration/
│ ├── property/ client/
│ └── release/test_client_update_api.py ← schemathesis 契约测试占位
└── e2e/ ← playwright E2E 占位
```
### 5.6 每 App 骨架补全
property / client / setting 三个 App 的非模型骨架services/、tasks.py、views.py、urls.py、serializers.py、templates/<app>/、tests/已补齐admin.py 在所有 10 个 App 上添加(空文件,后续禁用 Django Admin 但保留模块入口)。
---
## 六、规范 §16 执行清单逐项验证
| # | 任务 | 状态 |
|---:|---|:---:|
| 1 | 创建根目录及完整目录树 | ✅ |
| 2 | pyproject.toml / .gitignore / .env.example / Makefile | ✅ |
| 3 | requirements/ 三个文件 | ✅ |
| 4 | config/settings/base.py | ✅ |
| 5 | development.py / testing.py / production.py | ✅ |
| 6 | config/urls.py 与 urls_public.py 分离 | ✅ |
| 7 | config/asgi.py | ✅ |
| 8 | core/models/base.py 四个抽象基类 | ✅ |
| 8b | core/enums.py对齐 ENUMS.md v2.2 | ✅ |
| 9 | core/encryption.pyAES-256-GCM | ✅ |
| 10 | core/cache.pyRedis 工具) | ✅ |
| 11 | core/htmx.pyhtmx_response 工具) | ✅ |
| 12 | core/templatetags/heroicons.py | ✅ |
| 13 | core/middleware/audit.py | ✅ |
| 14 | 每 App 目录结构apps/release 除外) | ✅ |
| 15 | apps/tenant/models.pyTenant + Domain | ✅ |
| 16 | templates/ 完整目录树 + base/app/auth | ✅ |
| 17 | components/ 6 个骨架 | ✅ |
| 18 | errors/ 三个错误页 | ✅ |
| 19 | static/css/main.css | ✅ |
| 20 | static/js/main.js | ✅ |
| 21 | tailwind.config.js | ✅ |
| 22 | package.json | ✅ |
| 23 | Dockerfile | ✅ |
| 24 | docker-compose.yml6 服务) | ✅ |
| 25 | manage.py | ✅ |
| 26 | `manage.py check --deploy` 无致命错误 | ✅ 0 errors仅 SECRET_KEY 测试值告警 |
| 27 | 目录树与 §2 100% 匹配 | ✅ |
| 28 | API_CONTRACT 7 项核对 | ⚠️ 部分(详见第七节) |
---
## 七、API_CONTRACT 7 项核对(规范 §15
| # | 项 | 状态 | 备注 |
|---:|---|:---:|---|
| 1 | 路径与方法一致 | 🟡 N/A | 业务端点尚未实现(骨架阶段) |
| 2 | 请求参数一致 | 🟡 N/A | 同上 |
| 3 | 响应 envelopeok/data/meta vs ok/error/code/details | 🟡 N/A | DRF 自定义 renderer 待 Phase 4 |
| 4 | 错误码 UPPER_SNAKE_CASE | 🟡 N/A | 同上 |
| 5 | OpenAPI 注解 `@extend_schema` | 🟡 待定 | drf-spectacular 已装、urls_public.py 已挂 schema/swagger 路由,待业务视图编写时补 |
| 6 | `python manage.py spectacular --file openapi.json` 可生成 | ⚠️ 未运行 | 当前无业务视图,生成会得到空 schema待 Phase 4 验证 |
| 7 | schemathesis 命令可运行 | ⚠️ 占位 | `tests/integration/release/test_client_update_api.py` 已有 skip 占位 |
**结论**:骨架阶段第 14 项 N/A无端点、57 项基础设施就绪等待业务实现。骨架本身不阻塞契约清单。
---
## 八、Git 提交历史
```
94d1602 feat: complete Phase 3 scaffolding (templates, static, Docker, per-app skeletons)
ed40de4 feat(client,setting): complete Phase 2 with partitioned client_follow_logs
5b55dda feat(property): add 23-table property module with partitioned follow_logs and property_photos
c57462f feat(complex): add apps.complex with 10 models and full-text search
9a7d06b feat: scaffold Django multi-tenant project with 5 of 9 apps
```
每次 commit 后均执行 `manage.py check`,全部通过。
---
## 九、代码量统计
| 目录 | 总行数 |
|---|---:|
| `apps/` | 5837 |
| `core/` | 1028 |
| `config/` | 269 |
| `shared/` | 6 |
| `templates/` | 142 |
| `static/` (css+js) | 35 |
| `tests/` | 15 |
| **合计** | **~7332** |
文件总数208不含 .venv / .git / __pycache__
---
## 十、未交付项(明确延后清单)
| 项 | 原因 | 建议落地阶段 |
|---|---|---|
| ~300 条 PermissionDef 种子(`apps/permission/fixtures/permission_defs.json` | 内容来自 `DATA_MODEL_PERMISSION.md` 700+ 行,需逐条人工核对 | Phase 4 启动前 |
| 4 个内置角色 + 默认 DataScope 种子 | 同上 | Phase 4 启动前 |
| Setting 模块 LookupItem 默认值(楼盘类型/客户来源等枚举数据) | 来自 `DATA_MODEL_SETTING.md` | Phase 4 启动前 |
| Celery `partition_maintenance_task` | 月度自动新增分区,骨架阶段非阻塞 | 上线前 1 周 |
| API_CONTRACT 第 14 项 | 需要真实业务端点 | Phase 4 模块开发时随端点同步 |
| OpenAPI 实际生成 + schemathesis 实际运行 | 同上 | Phase 4 |
| Heroicons SVG 资源文件 | 当前 templatetag 是骨架,未含 SVG 库 | Phase 4 UI 模块启动时 |
| static/vendor/ 下的 htmx.min.js / alpine.min.js | 通过 npm 或 CDN 任选,未决策 | Phase 4 启动前 |
---
## 十一、关键约束遵守审计
| 约束(规范原文) | 遵守状态 | 证据 |
|---|:---:|---|
| 不得自行发明技术方案,不得引入文档未授权第三方库 | ✅ | requirements/base.txt 仅含规范明列依赖 |
| 绝对禁止 React/Vue/Angular | ✅ | 仅 HTMX + Alpine + Tailwind |
| `django_tenants` 在 SHARED_APPS / MIDDLEWARE 首位 | ✅ | `config/settings/base.py` |
| `CSRF_COOKIE_HTTPONLY = False` | ✅ | base.py 含警示注释 |
| AES-256-GCM 加密,禁用 Fernet | ✅ | `core/encryption.py` |
| `apps/release/` 无 services/、tasks.py | ✅ | 实际目录验证 |
| 不在 DB OPTIONS 注入 pool_size | ✅ | base.py DATABASES 配置 |
| 所有密钥/Tenant ID 不出现在 Python 文件 | ✅ | 统一 `config()` / `env()` 读取 |
| `config/urls.py``urls_public.py` 强制分离 | ✅ | 两文件独立维护 |
| 逐步创建并验证 | ✅ | 5 个 commit 各自 `manage.py check` 通过 |
| .gitignore 包含 .env / *.pyc / node_modules / static/css/output.css 等 | ✅ | 全部覆盖 |
---
## 十二、下一步建议
按优先级:
1. **Phase 4 起步前必做**
- 编写 PermissionDef + 内置角色 + DataScope 三组 fixtures
- 编写 Setting 模块 LookupItem 默认值 fixtures
- 决定 vendor JS 加载方式npm install 还是直接放置静态文件)
- 准备 Heroicons SVG 库(推荐 `heroicons` Python 包或手动放 SVG
2. **Phase 4 实施时同步推进**
- 第一个真实业务端点(建议从 release/client_update API 起步)落地后立即跑 spectacular + schemathesis闭环 API_CONTRACT 7 项
- 在每个业务视图 PR 中强制要求 `@extend_schema` 注解
3. **上线前 1 周**
- 实现 Celery `partition_maintenance_task`,配置 celery-beat 月初执行
- 用真实 32 字节随机值替换 `.env.example` 占位的 PHONE_ENCRYPTION_KEY并在 Vault/Secret Manager 中托管
4. **可选优化**
- 增加 `pre-commit` 钩子ruff + black + isort + django-check
- 增加 GitHub Actions CIlint + test + spectacular dry-run
---
## 十三、附录:项目快速启动命令
```bash
# 本地(无 Docker
cd /mnt/c/project/fonrey
.venv/bin/python manage.py check
.venv/bin/python manage.py makemigrations --dry-run
.venv/bin/python manage.py spectacular --file openapi.json # 待业务视图就绪后运行
# Docker推荐
make dev # docker compose up
make migrate # 共享 schema + 租户 schema 双重 migrate
make shell # shell_plus
make test # pytest apps/
make lint # ruff + black
make tailwind-build # 生成 static/css/output.css
```
---
**报告完**

View File

@@ -1,398 +0,0 @@
# Fonrey 项目骨架搭建实施报告 — v2
**版本**v2.0
**报告日期**2026-04-30
**实施范围**Phase 1 配置 → Phase 2 数据模型 → Phase 3 前端/Docker 脚手架 → **Phase 4.0 模型 verbose_name****Phase 4.1 字段 verbose_name/help_text****Phase 5 PermissionDef 拆分 + 154 条 seed + 7 内置角色 + 矩阵 + Lookup 默认值 + 租户自动 seed**
**实施依据**`prompt/提示词模板/创建项目骨架提示词_v2.3.md``PRD/权限管理/PERMISSION_SEED_MVP_BATCH1.md``PRD/权限管理/权限管理模块PRD.md §5.5.2``PRD/权限管理/角色权限矩阵.md``DATA_MODEL/DATA_MODEL_SETTING.md §2.3`
**项目根目录**`/mnt/c/project/fonrey/`
**Git HEAD**`aaf3981`main 分支,已 push 到 origin/main
**v1 → v2 增量**14 个 commit`79c3cf2``aaf3981`),覆盖 Phase 4.0、4.1、5
---
## 一、自 v1 以来的执行摘要
v1 报告2026-04-29覆盖 Phase 13 骨架。v2 在不改动 v1 的前提下追加 Phase 4.0、4.1、5 的实施记录与最终交付状态。
### 1.1 Phase 4.0 — 模型级 verbose_namecommit `79c3cf2`
为全部 74 个具体 ORM 模型(不含抽象基类)补齐 `Meta.verbose_name` / `verbose_name_plural` 中文显示名,对齐 `DATA_MODEL_*.md`
### 1.2 Phase 4.1 — 字段级 verbose_name + help_textcommits `3638fc0` … `8faa68b`9 commit
为全部 781 个字段补齐中文 `verbose_name``help_text`,按 9 个业务模块串行落盘:
| 顺序 | 模块 | commit |
|---:|---|---|
| 1 | property | `3638fc0` |
| 2 | client | `e67b07a` |
| 3 | complex | `a3800bf` |
| 4 | org | `f185127` |
| 5 | account | `b57070f` |
| 6 | permission | `9ef6eb6` |
| 7 | setting | `289ec43` |
| 8 | region | `e3b26ce` |
| 9 | tenant | `8faa68b` |
随后单独一个 commit `d00ff12` 生成对应的 9 份 verbose_name/help_text Alter migration共 3880 行)。
### 1.3 Phase 5 — 权限种子与租户自动初始化
| 子任务 | commit | 说明 |
|---|---|---|
| 拆 PermissionDef 到 SHARED app | `b9245cd` | 新建 `apps.permission_def`label `fonrey_permission_def`),保留表名 `permission_defs``fonrey_permission.PermissionDef` FK 字符串改为 `fonrey_permission_def.PermissionDef` |
| 154 条 PermissionDef seed + 7 内置角色 + 154×7 矩阵 + Lookup 默认值 + 租户 post_save 自动 seed | `aaf3981` | data migration + 三个 service + 一个 signal |
### 1.4 最终验证
- `python manage.py check` ✅ 0 issues
- `python manage.py makemigrations --dry-run` ✅ No changes detected
- 154 条 PermissionDef 全部就位migration 文件 grep `"code":` 计数 = 154
- 154 条 RolePermission 矩阵全部就位service 文件 matrix dict 键数 = 154
- main 已 push 到 origin/main
---
## 二、v1 → v2 顶层变化
| 维度 | v1 (2026-04-29) | v2 (2026-04-30) |
|---|---|---|
| App 数 | 10 | **11**(新增 `apps.permission_def` |
| ORM 模型数 | 77 | 77PermissionDef 从 `permission` 迁移到 `permission_def` |
| Migration 文件数 | 12 | **33** |
| 业务代码 LoC不含 migrations | ~7332 | ~8095 |
| Phase 4 verbose_name/help_text | 未做 | ✅ 全部 781 字段 + 74 模型 |
| PermissionDef seed | 未做v1 列入"未交付" | ✅ 154 条 |
| 内置角色 + 矩阵 | 未做v1 列入"未交付" | ✅ 7 角色 + 154×7 |
| LookupItem 默认值 | 未做v1 列入"未交付" | ✅ |
| 租户自动 seed | 未做 | ✅ `apps.tenant.signals` post_save |
---
## 三、Phase 4.0/4.1 实施细节
### 3.1 verbose_name 与 help_text 来源
权威源为 `/mnt/d/Workspace/nexus/Project/fonrey/DATA_MODEL/DATA_MODEL_*.md`9 个模块文件 + ENUMS.md v2.2)。补齐策略:
- 模型 `Meta.verbose_name` 取 PRD 中文表名(如 `verbose_name = "房源"`
- 字段 `verbose_name` 取 PRD 字段中文标题
- 字段 `help_text` 取 PRD 字段 "说明" 列ENUM 字段统一采用 `code=中文` 对照格式(如 `"public=公立 / private=私立"`
- BinaryField 加密手机号字段统一标注"AES-256-GCM 密文"
### 3.2 verbose_name migration 策略
由于 verbose_name/help_text 不影响 schema全部归并为 9 个 `AlterField` migration每模块一份不与原有 schema migration 混用。`d00ff12` 一次性生成全部 9 份dry-run 后 `manage.py check``makemigrations --dry-run` 双双干净。
### 3.3 受影响模型
74 个具体模型全部覆盖(不含抽象基类 `UUIDPrimaryKeyModel` / `TimeStampedModel` / `SoftDeleteModel` / `AuditedModel`)。
---
## 四、Phase 5 实施细节
### 4.1 PermissionDef 拆 SHARED 的动机与实现
**问题**v1 阶段 PermissionDef 在 `apps.permission`TENANT_APPS。这意味着每个新租户都要复制全部 154 条权限定义;权限矩阵升级时需要在每个 schema 重复 migrate管理员视角无法对全局权限做统一编辑。
**方案**:拆出独立 SHARED app `apps.permission_def`label `fonrey_permission_def`),保留物理表名 `permission_defs`(避免 RENAME。所有租户的 `Role.permission_def` / `StaffPermissionOverride.permission_def` FK 跨 schema 指向 `public.permission_defs`django-tenants 默认 `search_path` 包含 `public`FK 自然解析)。
**FK 字符串改写**
| 文件 | 行 | 原 | 新 |
|---|---|---|---|
| `apps/permission/models/role.py` | 94 | `"fonrey_permission.PermissionDef"` | `"fonrey_permission_def.PermissionDef"` |
| `apps/permission/models/staff_perm.py` | 89 | `"fonrey_permission.PermissionDef"` | `"fonrey_permission_def.PermissionDef"` |
**Migration 顺序**fresh DB无历史数据
1. `permission_def/0001_initial`:在 public schema `CREATE TABLE permission_defs`
2. `permission/0004_alter_..._delete_permissiondef`:依赖 `permission_def/0001`,对每个 tenant schema 既 `AlterField`FK 字符串切换Django state 一致性)也 `DeleteModel(PermissionDef)`(清理 tenant schema 中本不应存在的孤儿表)
3. `permission_def/0002_seed_permission_defs`:在 public schema bulk_create 154 条
**SHARED_APPS 顺序**`config/settings/base.py`
```python
SHARED_APPS = [
"django_tenants",
"apps.tenant",
"apps.release",
"apps.permission_def", # 新增
"shared",
...
]
```
### 4.2 154 条 PermissionDef seed
文件:[`apps/permission_def/migrations/0002_seed_permission_defs.py`](file:///mnt/c/project/fonrey/apps/permission_def/migrations/0002_seed_permission_defs.py)2358 行)
实现:
```python
def seed(apps, schema_editor):
PermissionDef = apps.get_model("fonrey_permission_def", "PermissionDef")
PermissionDef.objects.bulk_create([PermissionDef(**d) for d in PERMISSION_DEFS])
def unseed(apps, schema_editor):
PermissionDef = apps.get_model("fonrey_permission_def", "PermissionDef")
PermissionDef.objects.filter(code__in=[d["code"] for d in PERMISSION_DEFS]).delete()
```
公共元数据(每条都含):`is_active=True, is_deprecated=False, is_system=True, version=1`
按权威源 `PERMISSION_SEED_MVP_BATCH1.md` 共 3 批:
| 批 | 模块 | 条数 | 主代码前缀 |
|---:|---|---:|---|
| 1 | property | 66 | `property.listing.*``property.contact.*``property.address.*``property.key.*``property.commission.*``property.image.*` |
| 2 | client | 36 | `client.list.*``client.contact.*``client.viewing.*``client.match.*``client.transaction.*` |
| 3 | home + complex + org | 52 | `home.*``complex.*``org.*` |
| **合计** | | **154** | |
`module` 字段使用 `PermissionModule` enum`org.*` 代码 → `module="hr"`PRD 组织人事即 HR 模块);`complex.*` 代码 → `module="property"`(小区是房源子集)。
### 4.3 7 个内置角色 + 154×7 矩阵 service
文件:[`apps/permission/services/seed_default_roles.py`](file:///mnt/c/project/fonrey/apps/permission/services/seed_default_roles.py)218 行)
7 角色映射到 `PermissionRoleCategory` 枚举(仅 `agent / store_manager / director / operator / custom` 5 选项):
| 角色名 | category |
|---|---|
| 置业顾问 | `agent` |
| 店管 | `store_manager` |
| 区管 | `custom` |
| 区总 | `custom` |
| 副总 | `custom` |
| 总经 | `director` |
| 其他职能 | `operator` |
矩阵符号转写:
| PRD 符号 | 落库 `value` |
|---|---|
| `✓` | `{"v": True}` |
| `✗` | `{"v": False}` |
| `本人` / `本部` / `全部` / `—` | `{"v": "self"}` / `{"v": "dept"}` / `{"v": "all"}` / `{"v": "none"}` |
| 整型 N | `{"v": N}` |
| `∞` | `{"v": -1}` |
入口:`def seed_default_roles(schema_name: str) -> None`。调用方在 `schema_context` 内调用即可(参数 `schema_name` 仅作日志 hint
实现:`Role.objects.get_or_create(name=..., defaults={...})` 7 次 → 遍历 154 个 code`{code: PermissionDef}` 建表,组装 7×154 个 `RolePermission``bulk_create(ignore_conflicts=True)`
**幂等保证**`get_or_create` + `bulk_create(ignore_conflicts=True)`;对缺失 code 仅 `logger.warning` 跳过、不抛错。
### 4.4 LookupItem 默认值 service
文件:[`apps/setting/services/seed_default_lookups.py`](file:///mnt/c/project/fonrey/apps/setting/services/seed_default_lookups.py)113 行)
依据 `DATA_MODEL_SETTING.md §2.3`,注入:
- 3 个 `LookupGroup`(按模块)
- 各组的 `LookupItem` 默认枚举值
- 1 个 `TenantSetting` 兜底默认行
- `FieldRequirementRule` 默认规则
入口:`def seed_default_lookups(schema_name: str) -> None`。同样依赖调用方提供 schema 上下文。
### 4.5 租户自动 seed signal
文件:[`apps/tenant/signals.py`](file:///mnt/c/project/fonrey/apps/tenant/signals.py)36 行)
```python
def _register():
Tenant = apps.get_model("tenant", "Tenant")
@receiver(post_save, sender=Tenant)
def on_tenant_created(sender, instance, created, **kwargs):
if not created or instance.schema_name == "public":
return
try:
with schema_context(instance.schema_name):
seed_default_roles(instance.schema_name)
seed_default_lookups(instance.schema_name)
except Exception:
logger.exception("Failed to seed defaults for tenant %s", instance.schema_name)
```
注册:`apps/tenant/apps.py` `ready()` 调用 `signals._register()`lazy 注册避开 Django app loading 早期 model 引用)。
**容错**try/except 包裹 seed 调用,失败仅记录日志,不阻断租户创建本身。
### 4.6 Phase 5 关键决策
| 决策 | 原因 |
|---|---|
| PermissionDef 拆出 SHARED 而非 RENAME 表 | 避免破坏式迁移,保持表名 `permission_defs` |
| seed 用 RunPython data migration 而非 fixtures | 项目偏好,便于 `--reverse``apps.get_model` 历史模型 |
| 角色 + 矩阵 + Lookup 用 service 函数而非 data migration | 三者写入 tenant schema`schema_context`migration 只走 default 连接 |
| tenant post_save 自动调用 service | 用户明确选择"新建租户时自动 seed",避免 ops 手动跑命令 |
| 不 seed `staff_data_scopes` 表 | 用户明确指示scope 类 PermissionDef 的 `default_value` 已承担兜底 |
| Platform admin 角色不在本批 | 用户明确指示,归 `apps.admin_console`(待建) |
| signal 用 lazy `_register()` 而非模块级 `@receiver` | 避免 ready() 早期 `apps.get_model` 不可用 |
---
## 五、最新目录结构v2 增量标注)
```
apps/
├── __init__.py
├── account/ (4 模型)
├── client/ (11 模型)
├── complex/ (10 模型)
├── org/ (11 模型)
├── permission/ (6 模型 + services/seed_default_roles.py) ← v2: 6 模型PermissionDef 已迁出)
├── permission_def/ (1 模型) ← v2 新增 SHARED app
├── property/ (23 模型)
├── region/ (5 模型)
├── release/ (0 模型)
├── setting/ (4 模型 + services/seed_default_lookups.py) ← v2: 新增 service
└── tenant/ (2 模型 + signals.py) ← v2: 新增 signal
```
模型计数77与 v1 一致PermissionDef 从 permission 迁到 permission_def
---
## 六、Migration 总览33 份)
| App | Migration | 用途 |
|---|---|---|
| account | 0001 / 0002 | 初始 + AUTH_USER_MODEL 切换 |
| account | 0003 / 0004 | Phase 4.0 / 4.1 verbose_name + help_text |
| client | 0001 | 初始 |
| client | 0002 | 分区表 + 触发器 |
| client | 0003 / 0004 | Phase 4.0 / 4.1 |
| complex | 0001 | 初始 |
| complex | 0002 | pg_trgm + search_vector |
| complex | 0003 / 0004 | Phase 4.0 / 4.1 |
| org | 0001 | 初始 |
| org | 0002 / 0003 | Phase 4.0 / 4.1 |
| permission | 0001 / 0002 / 0003 | 初始 + Phase 4.0/4.1 |
| permission | **0004** | **v2 新增**FK 切换到 fonrey_permission_def + DeleteModel(PermissionDef) |
| permission_def | **0001** | **v2 新增**CREATE TABLE permission_defspublic schema |
| permission_def | **0002** | **v2 新增**154 条 PermissionDef bulk_create |
| property | 0001 | 初始 |
| property | 0002 | 分区表 + 触发器 |
| property | 0003 / 0004 | Phase 4.0 / 4.1 |
| region | 0001 / 0002 / 0003 | 初始 + Phase 4.0/4.1 |
| setting | 0001 / 0002 / 0003 | 初始 + Phase 4.0/4.1 |
| tenant | 0001 / 0002 | 初始 + Phase 4.0/4.1 |
合计 33 个 migration。`makemigrations --dry-run` 干净。
---
## 七、Git 提交历史v1 → v2 增量)
```
aaf3981 feat(permission): seed 154 PermissionDefs + 7 builtin roles + matrix + lookups + tenant auto-seed
b9245cd feat(permission): extract PermissionDef into shared apps.permission_def
5dedd19 docker file & docker compose change
d00ff12 feat(migrations): add Phase 4.0+4.1 verbose_name/help_text migrations
8faa68b feat(tenant): add Chinese verbose_name/help_text to tenant models (Phase 4.1 part 9/9)
e3b26ce feat(region): add Chinese verbose_name/help_text to region models (Phase 4.1 part 8/9)
289ec43 feat(setting): add Chinese verbose_name/help_text to setting models (Phase 4.1 part 7/9)
9ef6eb6 feat(permission): add Chinese verbose_name/help_text to permission models (Phase 4.1 part 6/9)
b57070f feat(account): add Chinese verbose_name and help_text to all account fields (Phase 4.1 part 5/9)
f185127 feat(org): add Chinese verbose_name and help_text to all org fields (Phase 4.1 part 4/9)
a3800bf feat(complex): add Chinese verbose_name and help_text to all complex fields (Phase 4.1 part 3/9)
e67b07a feat(client): add Chinese verbose_name and help_text to all client fields (Phase 4.1 part 2/9)
3638fc0 feat(property): add Chinese verbose_name and help_text to all property fields (Phase 4.1)
79c3cf2 feat(models): add Chinese verbose_name to all 74 models (Phase 4.0)
```
每次 commit 后 `manage.py check` 0 issues。所有 commit 已 push 到 origin/main`5dedd19..aaf3981`)。
---
## 八、未交付清单v2 视角)
v1 列出的"未交付项"在 v2 的状态:
| v1 列项 | v2 状态 |
| ----------------------------------- | ----------------------------------------------------------------------------------------- |
| ~300 条 PermissionDef 种子 | ✅ 已交付 154 条MVP Batch 1Batch 2/3 待补 |
| 内置角色 + 默认 DataScope 种子 | 🟡 7 内置角色 + 矩阵已交付DataScope **不 seed**用户明确指示scope 类 PermissionDef `default_value` 承担兜底) |
| Setting LookupItem 默认值 | ✅ 已交付 |
| Celery `partition_maintenance_task` | ⏸ 仍未做,建议上线前 1 周落地 |
| API_CONTRACT 第 14 项 | ⏸ Phase 6+ 业务端点开发时同步推进 |
| OpenAPI 实际生成 + schemathesis 实际运行 | ⏸ 同上 |
| Heroicons SVG 资源文件 | ⏸ Phase 6+ UI 开发时落地 |
| static/vendor/ JShtmx/alpine | ⏸ 同上 |
v2 新增/识别的待办:
| 项 | 优先级 | 说明 |
|---|:---:|---|
| Platform admin 角色 + 路由 + 视图 | 中 | 独立任务,归未来 `apps.admin_console`SHARED |
| PermissionDef MVP Batch 2/3合同/交易/数据/营销/移动端等模块) | 中 | 沿用 `0002_seed_permission_defs` 同模式追加 data migration |
| `seed_default_roles` 中未使用的 `schema_context` import | 低 | 微清理,不阻断 |
| signal 失败重试或离线补偿命令 | 中 | 当前 try/except + log建议补 `manage.py reseed_tenant <schema>` 命令 |
---
## 九、关键约束遵守审计v2 增量)
v1 §11 全部约束在 v2 仍然遵守。v2 新增约束:
| 约束 | 遵守状态 | 证据 |
|---|:---:|---|
| 不引入 docstring/无谓注释hook 强制) | ✅ | Phase 4.1 + 5 全部新文件 grep `^\s*#` 与 docstring 极少,仅保留必要的 BDD/regex 注释 |
| 不修改 Dockerfile / docker-compose.yml | ✅ | Phase 5 期间未触动;用户独立 commit `5dedd19` 覆盖 docker 改动 |
| 不 commit 未明确授权的 untracked 文件 | ✅ | 仅 commit 计划内文件,每次 commit 列表精确 |
| `manage.py check` 每次 commit 后 0 issues | ✅ | 14 个 commit 全部验证 |
| `makemigrations --dry-run` 收尾时 No changes detected | ✅ | `aaf3981` 后实际跑过 |
| PermissionDef 公共元数据完整is_system / version=1 等) | ✅ | seed migration 154 条全部含 |
| 不 seed staff_data_scopes | ✅ | 仅 PermissionDef + Role + RolePermission + Lookup |
| Platform admin 不混入本批 | ✅ | 7 角色不含 platform_admin |
| FK 跨 SHARED/TENANT 字符串引用正确 | ✅ | `manage.py check` 通过即证 |
---
## 十、下一步建议v2 视角)
### 10.1 Phase 6 启动前必做
1. 决定 vendor JS 加载方式npm install 还是直接放置静态文件)
2. 准备 Heroicons SVG 库
3. 实现 `apps.admin_console`SHARED + Platform admin 内置角色
### 10.2 Phase 6 业务模块开发与契约闭环
- 第一个真实业务端点(建议从 `release/client_update` 起步)落地后立即跑 `spectacular` + `schemathesis`,闭环 API_CONTRACT 7 项
- 在每个业务视图 PR 中强制 `@extend_schema`
- 每新增 PermissionDef 需要同步追加 `permission_def/000N_seed_*.py`,且新建 RolePermission 时使用 `seed_default_roles` 增量化
- 撰写 `manage.py reseed_tenant <schema>` 命令用于 signal 失败补偿
### 10.3 上线前 1 周
- 实现 Celery `partition_maintenance_task`property_follow_logs / property_photos / client_follow_logs 月度滚动)
- 用真实 32 字节随机值替换 `.env` 占位的 `PHONE_ENCRYPTION_KEY`,托管至 Vault/Secret Manager
- 验证租户创建 → signal 自动 seed 全链路含异常路径seed 失败时的补偿命令)
### 10.4 可选
- pre-commit 钩子ruff + black + isort + django-check
- GitHub Actions CIlint + test + spectacular dry-run + makemigrations --dry-run 必须 No changes
---
## 十一、附录v2 时点验证命令
```bash
cd /mnt/c/project/fonrey
.venv/bin/python manage.py check # System check identified no issues (0 silenced)
.venv/bin/python manage.py makemigrations --dry-run # No changes detected
grep -c '"code":' apps/permission_def/migrations/0002_seed_permission_defs.py # 154
grep -c '^\s*"[a-z_]*\.[a-z_.]*":' apps/permission/services/seed_default_roles.py # 154
git log --oneline 94d1602..HEAD | wc -l # 14
```
---
**报告完**v2.0 — 2026-04-30