登录模块审核
This commit is contained in:
@@ -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 Admin:username 由平台运营自定义(字母开头,6~30 位)
|
||||
- 普通员工:username 固定为员工手机号(11 位数字)
|
||||
- Tenant Admin:username 固定为该租户联系人手机号(来源于 public.tenants.contact_phone)
|
||||
注意:此表位于租户 Schema,username 唯一性约束在 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}` | STRING(user_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 故障时锁定状态不丢失 |
|
||||
|
||||
@@ -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, -- 操作人员工 ID(RESTRICT:操作记录保留,操作人不可删除)
|
||||
operator_ip INET, -- 操作人来源 IP
|
||||
user_agent TEXT, -- 操作人客户端 UA
|
||||
reason TEXT NOT NULL DEFAULT '', -- 操作理由(可选,审计留存)
|
||||
|
||||
PRIMARY KEY (id, operated_at) -- 分区表主键必须包含分区键
|
||||
) PARTITION BY RANGE (operated_at);
|
||||
|
||||
@@ -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,多租户共享,禁止下沉到租户层 |
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
111
Project/fonrey/DATA_MODEL/SCHEMA_CHANGES.md
Normal file
111
Project/fonrey/DATA_MODEL/SCHEMA_CHANGES.md
Normal 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 Schema,django-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
|
||||
|
||||
---
|
||||
|
||||
*后续如有新增字段,按同样格式追加到本文件。*
|
||||
177
Project/fonrey/PRD/PERSONA_定义.md
Normal file
177
Project/fonrey/PRD/PERSONA_定义.md
Normal 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) |
|
||||
| **账号归属** | 各租户(房产经纪公司)的系统管理员 |
|
||||
| **账号数量** | 每个租户 1~3 个,由 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` |
|
||||
@@ -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 **成交数据进入系统留存,房源状态自动更新**.
|
||||
|
||||
|
||||
@@ -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` - 灰度发布/滚动升级
|
||||
- 状态:[ ]
|
||||
|
||||
@@ -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
|
||||
【模块】系统管理(运营后台)
|
||||
|
||||
|
||||
@@ -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 4:Tenant 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** 了解升级覆盖率,对仍在使用旧版本的客户端发出提醒或强制升级。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 客户端版本管理页面展示版本分布统计:各版本在线客户端数量及占比(饼图或条形图)
|
||||
|
||||
@@ -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** 了解客源的全生命周期操作轨迹,追溯变更原因,辅助审计和管理。
|
||||
|
||||
**验收标准**:
|
||||
|
||||
|
||||
@@ -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 下,以下区块均支持点击"编辑"触发浮窗编辑,无需跳转页面:
|
||||
|
||||
@@ -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 以地图为主体,楼盘位置以橙色标记点展示在地图上
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
| 司内成交明细及套数 | true/false | 开启后,显示公司成交的房源明细信息及成交套数 |
|
||||
| 区域管理 | true/false | 若启用,则可对区域商圈进行新增、合并、关联操作 |
|
||||
| 查看销控盘 | true/false | 开启后,可在楼盘管理系统-楼盘里,查看销控盘。请注意:员工查看销控盘时房源地址是直接可见的,建议只给管理层开启!!! |
|
||||
| 查看销控盘时,只可查看本部门作业范围内的楼盘 | true/false | 开启后,只可查看本部门作业范围内的楼盘的销控盘;关闭后,则跟作业范围无关,「查看销控盘」权限开启即可见所有楼盘的销控盘;系统管理员不受限制 |
|
||||
| 查看销控盘时,只可查看本部门作业范围内的楼盘 | true/false | 开启后,只可查看本部门作业范围内的楼盘的销控盘;关闭后,则跟作业范围无关,「查看销控盘」权限开启即可见所有楼盘的销控盘;Tenant Admin(租户管理员)不受限制 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 |
@@ -2,8 +2,8 @@
|
||||
|
||||
**状态**: Draft
|
||||
**作者**: 产品经理
|
||||
**最后更新**: 2026-04-25(v1.4 §5.5 后端数据模型迁移至独立文档 `DATA_MODEL/DATA_MODEL_LOGIN.md`)
|
||||
**版本**: 1.4
|
||||
**最后更新**: 2026-04-30(v2.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 2,11 位数字,自动过滤非数字)
|
||||
- [ ] 验证码输入框(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 分钟;解锁方式:等待超时自动解锁 或 管理员手动解锁 |
|
||||
| 密码错误计数 | 计数存于 Redis,Key 格式:`login_fail:tenant_id:username`,TTL 30 分钟 |
|
||||
| 密码错误计数 | 计数存于 Redis,Key 格式:`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) | 选填,加密存储 | **必填,同时作为用户名**,加密存储,同租户内唯一 | 当前阶段为登录 ID;v2 启用手机验证码登录后复用此字段 |
|
||||
| 邮箱(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` Cookie(Chromium 管理),不写入磁盘明文文件 |
|
||||
| 登录页加载 | 客户端主进程根据 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` 属于租户级 Schema,Tenant 验证接口属于 `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 自建?需运维确认 — 负责人:后端负责人 — 截止:开发启动前
|
||||
- [ ] **短信服务商选型**:使用阿里云短信 / 腾讯云短信 / 其他服务商?需运维确认并提前申请短信签名和模板审核(国内审核周期 1–3 个工作日)— 负责人:后端负责人 — 截止:开发启动前
|
||||
- [ ] **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 | Public(shared) | 否 | Tenant ID 验证 |
|
||||
| `/api/auth/tenant/verify/` | POST | Public(shared) | 否 | 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`
|
||||
|
||||
@@ -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 B:Platform 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 范围访问管理控制台 URL(Nginx 层或应用层限制) |
|
||||
| 高危操作二次验证 | 删除租户、数据恢复、系统回滚操作触发 MFA 二次确认弹窗 |
|
||||
| 会话超时 | 无操作 30 分钟后自动登出,Token 失效 |
|
||||
| 强制登出 | 超级管理员可在"管理员设置"中强制终止指定管理员的所有会话 |
|
||||
| 强制登出 | Platform Admin(平台超级管理员)可在"管理员设置"中强制终止指定管理员的所有会话 |
|
||||
|
||||
**与租户应用隔离**:
|
||||
|
||||
@@ -546,7 +546,7 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-ten
|
||||
|
||||
### 9.2 管理员角色权限矩阵
|
||||
|
||||
| 操作 | 超级管理员 | 运营人员 | 只读审计员 |
|
||||
| 操作 | Platform Admin(平台超级管理员) | 运营人员 | 只读审计员 |
|
||||
|------|-----------|---------|-----------|
|
||||
| 创建租户 | ✅ | ✅ | ❌ |
|
||||
| 挂起 / 恢复租户 | ✅ | ✅ | ❌ |
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
|
||||
| 相关方 | 相关方说明 | 状态 | 启动权限 | 操作 |
|
||||
| ------ | ------------------------------ | --- | ------------------------ | -------------- |
|
||||
| 平台摄影师 | 开启平台实勘功能后,在房源预约拍摄完成后统一处理为系统管理员 | 停用 | | 权限配置 |
|
||||
| 平台摄影师 | 开启平台实勘功能后,在房源预约拍摄完成后统一处理为Tenant Admin(租户管理员) | 停用 | | 权限配置 |
|
||||
| 维护人 | 出租或出售房源的维护人 | 停用 | | 权限配置 |
|
||||
| 售维护人 | 出售房源的维护人(租、售维护人分开时自动启用) | 停用 | | 权限配置 |
|
||||
| 租维护人 | 出租房源的维护人(租、售维护人分开时自动启用) | 停用 | | 权限配置 |
|
||||
|
||||
@@ -50,12 +50,12 @@
|
||||
|
||||
## 4. 目标用户
|
||||
|
||||
**主要角色**:系统管理员(租户侧,每租户 1~3 人)
|
||||
**主要角色**:Tenant Admin(租户管理员)(租户侧,每租户 1~3 人)
|
||||
|
||||
> 典型画像:门店运营负责人或行政主管,熟悉业务流程,无技术背景,通过系统后台进行日常运营配置。使用频率:初始开通时高频(完成初始化配置),此后低频(按需调整)。
|
||||
|
||||
**间接受益角色**:
|
||||
- 一线经纪人 — 看到的下拉选项和必填规则由管理员配置决定
|
||||
- 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` 只控制「必填/选填/隐藏」状态,字段本身的存在性由数据模型决定。避免配置层与数据模型层职责混淆。
|
||||
|
||||
|
||||
@@ -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 形式,标题「新增奖惩记录」,右上角有「×」关闭按钮)
|
||||
|
||||
@@ -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`(编号段、水位、状态)
|
||||
|
||||
@@ -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 非目标 / 预留
|
||||
|
||||
- MFA(OTP / 短信)
|
||||
- 微信扫码登录(仅保留禁用入口与接口占位,不开放功能)
|
||||
- 企业 SSO(OAuth2 / 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 API(P0)
|
||||
### 5.3 JSON API(MVP)
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|---|---|---|
|
||||
| `/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`
|
||||
|
||||
390
Project/fonrey/TEST_CASES/TEST_CASES_LOGIN_MODULE.md
Normal file
390
Project/fonrey/TEST_CASES/TEST_CASES_LOGIN_MODULE.md
Normal 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 1:Tenant 识别
|
||||
- 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` 但保留编号与历史报告可追溯性
|
||||
56
Project/fonrey/TEST_CASES/TEST_CASE_ID_SPEC.md
Normal file
56
Project/fonrey/TEST_CASES/TEST_CASE_ID_SPEC.md
Normal 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`
|
||||
77
Project/fonrey/TEST_CASES/TEST_CASE_REGISTRY.md
Normal file
77
Project/fonrey/TEST_CASES/TEST_CASE_REGISTRY.md
Normal 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 |
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
247
Project/fonrey/UI_DESIGN/登录_重置密码_UI.html
Normal file
247
Project/fonrey/UI_DESIGN/登录_重置密码_UI.html
Normal 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>
|
||||
@@ -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 失效,被重定向回登录页
|
||||
- 位置:登录卡片顶部 Alert(warning)
|
||||
- 文案:`登录已过期,请重新登录`
|
||||
- 可关闭:是(仅隐藏提示,不恢复会话)
|
||||
|
||||
---
|
||||
|
||||
## 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 两种上下文
|
||||
- [ ] 微信扫码登录为禁用“即将开放”态
|
||||
- [ ] 关键交互无控制台报错
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
# 项目进度交接报告 — Phase 4.0 + 4.1 收尾
|
||||
|
||||
> **作者**: Backend Engineer
|
||||
> **创建日期**: 2026-04-29(Phase 4.0)
|
||||
> **更新日期**: 2026-04-30(Phase 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_text(2026-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
|
||||
- 不动 Meta(Phase 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 模型,含 PermissionChangeLog(append-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/Domain),django-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=`,纯字符串 kwarg,hook 放行
|
||||
- 模型类的"业务作用 / 关键业务规则"放在 `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 条权限定义 fixture,PRD §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_KEY(gitignored)|
|
||||
| `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 Model;MVP 不需要,进入客户端发布阶段时再补。
|
||||
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 收尾完成。骨架已就绪,可直接进入业务开发。**
|
||||
@@ -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 内部结构
|
||||
|
||||
**业务 App(property/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 App(django-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=False(HTMX 需要);AUTH_USER_MODEL = "account.UserAccount" |
|
||||
| `config/settings/development.py` | 开发配置 | DEBUG=True,django-debug-toolbar |
|
||||
| `config/settings/testing.py` | 测试配置 | pytest-django |
|
||||
| `config/settings/production.py` | 生产配置 | DEBUG=False,HSTS/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-800,Logo + 导航 + 通知/设置/头像 |
|
||||
| `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` | 第三方 JS(htmx.min.js / alpine.min.js)放置点 |
|
||||
|
||||
### 5.3 Tailwind 配置(tailwind.config.js)
|
||||
|
||||
完全对齐 `UI_SYSTEM.md §2.7` 与 `§10.1`:
|
||||
|
||||
- **Primary(Teal)**:50 #F0FDFA → 800 #134E4A,主色 600 #0F766E
|
||||
- **Neutral(Slate)**:50 #F8FAFC → 900 #0F172A
|
||||
- **语义色**:success-600 #16A34A, warning-600 #D97706, danger-600 #DC2626, info-600 #2563EB
|
||||
- **字体**:Inter, PingFang SC, Microsoft YaHei
|
||||
- **z-index**:60, 70(Toast 层)
|
||||
- **boxShadow**:xs(轻投影)
|
||||
- **animation**:slide-in-right(Drawer 进场)
|
||||
- **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.0,build/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.py(AES-256-GCM) | ✅ |
|
||||
| 10 | core/cache.py(Redis 工具) | ✅ |
|
||||
| 11 | core/htmx.py(htmx_response 工具) | ✅ |
|
||||
| 12 | core/templatetags/heroicons.py | ✅ |
|
||||
| 13 | core/middleware/audit.py | ✅ |
|
||||
| 14 | 每 App 目录结构(apps/release 除外) | ✅ |
|
||||
| 15 | apps/tenant/models.py(Tenant + 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.yml(6 服务) | ✅ |
|
||||
| 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 | 响应 envelope(ok/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 占位 |
|
||||
|
||||
**结论**:骨架阶段第 1–4 项 N/A(无端点)、5–7 项基础设施就绪等待业务实现。骨架本身不阻塞契约清单。
|
||||
|
||||
---
|
||||
|
||||
## 八、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 第 1–4 项 | 需要真实业务端点 | 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 CI(lint + 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**报告完**
|
||||
@@ -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 1–3 骨架。v2 在不改动 v1 的前提下追加 Phase 4.0、4.1、5 的实施记录与最终交付状态。
|
||||
|
||||
### 1.1 Phase 4.0 — 模型级 verbose_name(commit `79c3cf2`)
|
||||
|
||||
为全部 74 个具体 ORM 模型(不含抽象基类)补齐 `Meta.verbose_name` / `verbose_name_plural` 中文显示名,对齐 `DATA_MODEL_*.md`。
|
||||
|
||||
### 1.2 Phase 4.1 — 字段级 verbose_name + help_text(commits `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 | 77(PermissionDef 从 `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_defs(public 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 1);Batch 2/3 待补 |
|
||||
| 内置角色 + 默认 DataScope 种子 | 🟡 7 内置角色 + 矩阵已交付;DataScope **不 seed**(用户明确指示,scope 类 PermissionDef `default_value` 承担兜底) |
|
||||
| Setting LookupItem 默认值 | ✅ 已交付 |
|
||||
| Celery `partition_maintenance_task` | ⏸ 仍未做,建议上线前 1 周落地 |
|
||||
| API_CONTRACT 第 1–4 项 | ⏸ Phase 6+ 业务端点开发时同步推进 |
|
||||
| OpenAPI 实际生成 + schemathesis 实际运行 | ⏸ 同上 |
|
||||
| Heroicons SVG 资源文件 | ⏸ Phase 6+ UI 开发时落地 |
|
||||
| static/vendor/ JS(htmx/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 CI(lint + 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)
|
||||
Reference in New Issue
Block a user