|
|
@@ -0,0 +1,303 @@
|
|
|
+# 故事015.013:人才用户手机号登录支持
|
|
|
+
|
|
|
+## Status
|
|
|
+
|
|
|
+Approved
|
|
|
+
|
|
|
+## Story
|
|
|
+
|
|
|
+**作为** 人才用户,
|
|
|
+**我想要** 能够使用手机号和密码登录人才小程序,
|
|
|
+**以便** 当我不记得身份证号或残疾证号时,仍然可以方便地登录系统。
|
|
|
+
|
|
|
+## Acceptance Criteria
|
|
|
+
|
|
|
+1. 人才用户可使用手机号和密码成功登录
|
|
|
+2. 手机号登录验证逻辑正确,通过users2表的phone字段查找用户
|
|
|
+3. 现有的身份证号/残疾证号登录方式仍然正常工作
|
|
|
+4. 登录错误提示友好,区分"用户不存在"和"密码错误"
|
|
|
+5. API文档更新,包含手机号登录的说明
|
|
|
+6. 所有测试通过(单元测试和集成测试)
|
|
|
+
|
|
|
+## Tasks / Subtasks
|
|
|
+
|
|
|
+- [ ] 任务1:扩展人才用户登录服务,支持手机号查找 (AC: 1, 2)
|
|
|
+ - [ ] 1.1 修改`UserService.getTalentUserByIdentifier`方法,支持手机号查找
|
|
|
+ - [ ] 1.2 添加查找逻辑: 先通过phone字段在users2表中查找人才用户
|
|
|
+ - [ ] 1.3 保留原有逻辑: 如果手机号查找失败,继续通过身份证号/残疾证号查找
|
|
|
+ - [ ] 1.4 确保只返回userType='talent'的用户
|
|
|
+ - [ ] 1.5 包含关联查询(person, roles, avatarFile)
|
|
|
+
|
|
|
+- [ ] 任务2:更新登录Schema和API文档 (AC: 5)
|
|
|
+ - [ ] 2.1 更新`TalentLoginSchema`的identifier字段描述
|
|
|
+ - [ ] 2.2 添加说明: "身份证号、残疾证号或手机号"
|
|
|
+ - [ ] 2.3 更新OpenAPI文档的example和description
|
|
|
+ - [ ] 2.4 更新登录路由的错误提示消息
|
|
|
+
|
|
|
+- [ ] 任务3:优化错误提示信息 (AC: 4)
|
|
|
+ - [ ] 3.1 区分手机号不存在的错误提示
|
|
|
+ - [ ] 3.2 区分身份证号/残疾证号不存在的错误提示
|
|
|
+ - [ ] 3.3 统一错误消息为"账号或密码错误"(安全考虑)
|
|
|
+ - [ ] 3.4 更新登录路由的错误响应
|
|
|
+
|
|
|
+- [ ] 任务4:编写和更新测试 (AC: 6)
|
|
|
+ - [ ] 4.1 添加手机号登录成功的测试用例
|
|
|
+ - [ ] 4.2 添加手机号不存在导致登录失败的测试用例
|
|
|
+ - [ ] 4.3 验证身份证号/残疾证号登录仍然正常工作(回归测试)
|
|
|
+ - [ ] 4.4 测试手机号+密码正确但用户类型不是talent的场景
|
|
|
+ - [ ] 4.5 确保所有现有测试仍然通过
|
|
|
+
|
|
|
+## Dev Notes
|
|
|
+
|
|
|
+### 现有实现分析
|
|
|
+
|
|
|
+**当前登录流程**:
|
|
|
+```typescript
|
|
|
+// 位置: packages/core-module/auth-module/src/services/auth.service.ts:151
|
|
|
+async talentLogin(identifier: string, password: string) {
|
|
|
+ const user = await this.userService.getTalentUserByIdentifier(identifier);
|
|
|
+ // ... 验证密码并生成token
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**当前查找逻辑** (UserService.getTalentUserByIdentifier):
|
|
|
+```typescript
|
|
|
+// 位置: packages/core-module/user-module/src/services/user.service.ts:259
|
|
|
+async getTalentUserByIdentifier(identifier: string) {
|
|
|
+ // 1. 先通过身份证号/残疾证号查找disabled_person表
|
|
|
+ const disabledPerson = await this.getDisabledPersonByIdentifier(identifier);
|
|
|
+ // 2. 再通过person_id查找users2表
|
|
|
+ return await this.repository.findOne({
|
|
|
+ where: { personId: disabledPerson.id, userType: UserType.TALENT }
|
|
|
+ });
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**数据库字段**:
|
|
|
+- `users2.phone`: 手机号字段(可为空) [Source: user.entity.ts:18]
|
|
|
+- `users2.user_type`: 用户类型枚举 [Source: user.entity.ts:34]
|
|
|
+- `users2.person_id`: 残疾人ID外键 [Source: user.entity.ts:54]
|
|
|
+
|
|
|
+### 技术实现方案
|
|
|
+
|
|
|
+**方案1: 扩展getTalentUserByIdentifier方法(推荐)**
|
|
|
+
|
|
|
+修改`UserService.getTalentUserByIdentifier`方法,添加手机号查找逻辑:
|
|
|
+
|
|
|
+```typescript
|
|
|
+async getTalentUserByIdentifier(identifier: string): Promise<UserEntity | null> {
|
|
|
+ try {
|
|
|
+ // 1. 先尝试通过手机号直接查找users2表
|
|
|
+ const userByPhone = await this.repository.findOne({
|
|
|
+ where: {
|
|
|
+ phone: identifier,
|
|
|
+ userType: UserType.TALENT
|
|
|
+ },
|
|
|
+ relations: ['person', 'roles', 'avatarFile']
|
|
|
+ });
|
|
|
+ if (userByPhone) {
|
|
|
+ return userByPhone;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 如果手机号查找失败,继续原有的身份证号/残疾证号查找逻辑
|
|
|
+ const disabledPerson = await this.getDisabledPersonByIdentifier(identifier);
|
|
|
+ if (!disabledPerson) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return await this.repository.findOne({
|
|
|
+ where: {
|
|
|
+ personId: disabledPerson.id,
|
|
|
+ userType: UserType.TALENT
|
|
|
+ },
|
|
|
+ relations: ['person', 'roles', 'avatarFile']
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Error getting talent user by identifier:', error);
|
|
|
+ throw new Error(`Failed to get talent user: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**优势**:
|
|
|
+- ✅ 最小化代码改动
|
|
|
+- ✅ 复用现有登录流程
|
|
|
+- ✅ 向后兼容,不影响现有功能
|
|
|
+- ✅ 手机号查找优先,性能更好(直接查询users2表)
|
|
|
+
|
|
|
+**方案2: 创建独立的手机号登录方法(不推荐)**
|
|
|
+
|
|
|
+创建单独的`talentLoginByPhone`方法和路由。
|
|
|
+
|
|
|
+**劣势**:
|
|
|
+- ❌ 需要额外维护一套API端点
|
|
|
+- ❌ 前端需要调用不同的登录接口
|
|
|
+- ❌ 代码重复度高
|
|
|
+
|
|
|
+### 用户体验考虑
|
|
|
+
|
|
|
+**登录标识符优先级**:
|
|
|
+1. **手机号** - 最常用,最方便输入
|
|
|
+2. **身份证号** - 备用方案
|
|
|
+3. **残疾证号** - 备用方案
|
|
|
+
|
|
|
+**输入提示优化**:
|
|
|
+- 登录页面placeholder: "请输入手机号/身份证号/残疾证号"
|
|
|
+- 表单验证: 至少1个字符(不限制格式,因为手机号、身份证号、残疾证号格式不同)
|
|
|
+
|
|
|
+**错误处理**:
|
|
|
+- 统一错误消息: "账号或密码错误"(安全考虑,不泄露用户是否存在)
|
|
|
+- 登录失败后提供"忘记密码"入口
|
|
|
+
|
|
|
+### 数据完整性要求
|
|
|
+
|
|
|
+**重要**: 手机号登录依赖于`users2.phone`字段有值!
|
|
|
+
|
|
|
+**管理员创建人才用户时的注意事项**:
|
|
|
+1. 必须填写`users2.phone`字段(手机号)
|
|
|
+2. 手机号应该是残疾人的真实联系方式
|
|
|
+3. 可以从`disabled_person.phone`复制到`users2.phone`
|
|
|
+
|
|
|
+**数据迁移建议**:
|
|
|
+如果历史数据中`users2.phone`为空,需要批量补充:
|
|
|
+```sql
|
|
|
+-- 从disabled_person表同步手机号到users2表
|
|
|
+UPDATE users2 u
|
|
|
+SET phone = dp.phone
|
|
|
+FROM disabled_person dp
|
|
|
+WHERE u.person_id = dp.id
|
|
|
+ AND u.phone IS NULL
|
|
|
+ AND dp.phone IS NOT NULL;
|
|
|
+```
|
|
|
+
|
|
|
+### 安全考虑
|
|
|
+
|
|
|
+**手机号验证**:
|
|
|
+- 不强制验证手机号格式(灵活性考虑)
|
|
|
+- 依赖数据库唯一性约束
|
|
|
+- 密码验证逻辑不变(bcrypt比较)
|
|
|
+
|
|
|
+**防暴力破解**:
|
|
|
+- 保持现有的JWT认证机制
|
|
|
+- 可考虑添加登录失败次数限制(后续优化)
|
|
|
+
|
|
|
+**隐私保护**:
|
|
|
+- 错误消息不泄露用户是否存在
|
|
|
+- 手机号不在日志中明文显示
|
|
|
+
|
|
|
+### 测试策略
|
|
|
+
|
|
|
+**单元测试场景** (UserService):
|
|
|
+1. 手机号存在且为talent用户 → 返回用户
|
|
|
+2. 手机号存在但不是talent用户 → 返回null
|
|
|
+3. 手机号不存在 → 尝试身份证号/残疾证号查找
|
|
|
+4. 身份证号存在且为talent用户 → 返回用户
|
|
|
+5. 残疾证号存在且为talent用户 → 返回用户
|
|
|
+6. 所有查找方式都失败 → 返回null
|
|
|
+
|
|
|
+**集成测试场景** (AuthService + 路由):
|
|
|
+1. 手机号+密码正确 → 登录成功,返回token
|
|
|
+2. 手机号正确但密码错误 → 登录失败,401错误
|
|
|
+3. 手机号不存在 → 登录失败,401错误
|
|
|
+4. 手机号存在但用户类型不是talent → 登录失败,401错误
|
|
|
+5. 身份证号登录仍然正常工作(回归测试)
|
|
|
+6. 残疾证号登录仍然正常工作(回归测试)
|
|
|
+
|
|
|
+**前端集成测试** (故事017.002后续):
|
|
|
+- 登录页面接受三种输入方式
|
|
|
+- 输入框placeholder和验证规则更新
|
|
|
+- 错误提示友好清晰
|
|
|
+
|
|
|
+### API文档更新
|
|
|
+
|
|
|
+**Schema更新** (rencai-auth.schema.ts):
|
|
|
+```typescript
|
|
|
+export const TalentLoginSchema = z.object({
|
|
|
+ identifier: z.string().min(1, '账号不能为空').openapi({
|
|
|
+ example: '13800138000', // 改为手机号示例
|
|
|
+ description: '手机号、身份证号或残疾证号' // 更新描述
|
|
|
+ }),
|
|
|
+ password: z.string().min(6, '密码至少6个字符').openapi({
|
|
|
+ example: 'password123',
|
|
|
+ description: '登录密码'
|
|
|
+ })
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+**路由文档更新** (rencai/login.route.ts):
|
|
|
+```typescript
|
|
|
+description: '人才用户登录接口,支持手机号、身份证号或残疾证号登录'
|
|
|
+```
|
|
|
+
|
|
|
+**错误响应更新**:
|
|
|
+```typescript
|
|
|
+message: '账号或密码错误' // 统一错误消息
|
|
|
+```
|
|
|
+
|
|
|
+### 性能影响评估
|
|
|
+
|
|
|
+**查询优化**:
|
|
|
+- 手机号查询: 直接查询users2表(1次查询)
|
|
|
+- 身份证号/残疾证号查询: 先查disabled_person,再查users2(2次查询)
|
|
|
+- 手机号优先查找性能更好
|
|
|
+
|
|
|
+**索引建议**:
|
|
|
+- `users2.phone`字段应添加普通索引(如果还没有)
|
|
|
+- `users2.user_type`已有索引 [Source: user.entity.ts:40]
|
|
|
+- 复合索引: `(phone, user_type)` 可进一步提升性能
|
|
|
+
|
|
|
+### 依赖关系
|
|
|
+
|
|
|
+**依赖故事**:
|
|
|
+- 故事015-01(数据库schema扩展) - 已完成 ✅
|
|
|
+- 故事015-02(人才用户认证API) - 已完成 ✅
|
|
|
+
|
|
|
+**后续故事影响**:
|
|
|
+- 故事017.002(登录与首页实现) - 可能需要更新登录页面提示
|
|
|
+- 管理后台用户管理 - 需要确保创建人才用户时填写手机号
|
|
|
+
|
|
|
+### 风险与注意事项
|
|
|
+
|
|
|
+**风险1: 历史数据手机号为空**
|
|
|
+- **影响**: 老人才用户无法使用手机号登录
|
|
|
+- **缓解**: 数据迁移脚本,从disabled_person表同步手机号
|
|
|
+- **优先级**: P0(必须在实现前完成)
|
|
|
+
|
|
|
+**风险2: 手机号重复**
|
|
|
+- **影响**: 可能导致登录冲突
|
|
|
+- **缓解**: 数据库unique约束(需要添加)
|
|
|
+- **优先级**: P0
|
|
|
+
|
|
|
+**风险3: 手机号格式不统一**
|
|
|
+- **影响**: 查询失败
|
|
|
+- **缓解**: 存储时统一格式,查询时去除空格和特殊字符
|
|
|
+- **优先级**: P1(后续优化)
|
|
|
+
|
|
|
+**风险4: 现有登录方式受影响**
|
|
|
+- **影响**: 身份证号/残疾证号登录异常
|
|
|
+- **缓解**: 完整的回归测试
|
|
|
+- **优先级**: P0
|
|
|
+
|
|
|
+## Change Log
|
|
|
+
|
|
|
+| Date | Version | Description | Author |
|
|
|
+|------|---------|-------------|--------|
|
|
|
+| 2025-12-26 | 1.0 | 创建故事文档 | James |
|
|
|
+
|
|
|
+## Dev Agent Record
|
|
|
+
|
|
|
+### Agent Model Used
|
|
|
+
|
|
|
+_待实现时填写_
|
|
|
+
|
|
|
+### Debug Log References
|
|
|
+
|
|
|
+_待实现时填写_
|
|
|
+
|
|
|
+### Completion Notes List
|
|
|
+
|
|
|
+_待实现时填写_
|
|
|
+
|
|
|
+### File List
|
|
|
+
|
|
|
+_待实现时填写_
|