# 第四章 用户系统 > 本章讲解 fmode-ng 中的用户管理功能,包括登录、注册、会话管理、路由守卫和权限控制。用户系统是大多数应用的基础,理解本章内容对于构建完整的业务应用至关重要。 --- ## 4.1 Parse 用户系统概述 Parse Server 内置了完整的用户管理系统,核心是 `_User` 表。每个用户自动包含以下字段: | 字段 | 类型 | 说明 | |------|------|------| | `objectId` | string | 用户唯一 ID | | `username` | string | 用户名(唯一) | | `email` | string | 邮箱(可选,唯一) | | `sessionToken` | string | 登录会话令牌 | | `createdAt` | Date | 注册时间 | | `updatedAt` | Date | 最后更新时间 | > **重要**:密码字段 `password` 在数据库中是加密存储的,永远不会在查询结果中返回。 ### 用户认证流程 ``` ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ 用户 │ │ 前端应用 │ │ Parse Server │ │ 输入 │ │ (Angular) │ │ (后端) │ └────┬─────┘ └──────┬───────┘ └──────┬───────┘ │ │ │ │ 用户名+密码 │ │ │──────────────────>│ │ │ │ Cloud.run │ │ │ ('user_login') │ │ │────────────────────>│ │ │ │ 验证密码 │ │ │ 生成 sessionToken │ │ sessionToken │ │ │<────────────────────│ │ │ │ │ │ User.become(token) │ │ │ → 设置登录态 │ │ │ │ │ 登录成功 │ │ │<──────────────────│ │ ``` --- ## 4.2 用户登录 ### 4.2.1 方式一:通过云函数登录(推荐) 项目中推荐使用云函数 `user_login` 处理登录逻辑,可以在服务端进行额外的安全验证(如 RSA 解密、登录日志等): ```typescript import { MSQParse } from '../../app/parse'; async function login(username: string, password: string): Promise { try { // 1. 调用登录云函数 const response = await MSQParse.Cloud.run('user_login', { username: username, password: password }); // 2. 检查返回结果 if (response.success && response.sessionToken) { // 3. 用 sessionToken 设置前端登录态 await MSQParse.User.become(response.sessionToken); console.log('登录成功!当前用户:', MSQParse.User.current()); return true; } else { console.error('登录失败:', response?.message); return false; } } catch (error: any) { console.error('登录请求失败:', error.message); return false; } } ``` ### 4.2.2 方式二:通过 REST API 登录 直接调用 Parse Server 的 REST 登录接口: ```typescript const PARSE_URL = 'https://server-msq.fmode.cn/parse'; const APP_ID = 'msq-voc'; async function loginViaREST(username: string, password: string): Promise { const resp = await fetch(`${PARSE_URL}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': APP_ID }, body: JSON.stringify({ username, password }) }); const result = await resp.json(); if (result.sessionToken) { // 在 SDK 中设置登录态 await MSQParse.User.become(result.sessionToken); return true; } console.error('登录失败:', result.error || result); return false; } ``` ### 4.2.3 RSA 加密登录(安全增强) 在生产环境中,密码不应以明文传输。项目中使用 RSA 公钥加密: ```typescript import JSEncrypt from 'jsencrypt'; // RSA 公钥(由服务端提供,私钥保留在服务端) const RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6NxiJAxa... -----END PUBLIC KEY-----`; function encryptRSA(text: string): string { const encryptor = new JSEncrypt(); encryptor.setPublicKey(RSA_PUBLIC_KEY); const encrypted = encryptor.encrypt(text); if (!encrypted) { throw new Error('RSA 加密失败,请检查公钥格式'); } return encrypted; } // 使用加密后的数据登录 async function secureLogin(username: string, password: string) { const encryptedUsername = encryptRSA(username); const encryptedPassword = encryptRSA(password); const response = await MSQParse.Cloud.run('user_login', { username: encryptedUsername, password: encryptedPassword }); if (response.success && response.sessionToken) { await MSQParse.User.become(response.sessionToken); return true; } return false; } ``` ### 安装 jsencrypt ```bash npm install jsencrypt ``` --- ## 4.3 关键 API 详解 ### 4.3.1 User.become(sessionToken) `become()` 是登录流程中最关键的方法。它告诉 SDK:「使用这个 sessionToken 作为当前登录用户」。 ```typescript // 调用后,SDK 内部会: // 1. 用 sessionToken 向 Parse Server 验证有效性 // 2. 获取用户完整信息 // 3. 在 localStorage 中保存登录态 // 4. 后续所有请求自动带上该用户身份 await MSQParse.User.become('r:abc123sessionToken'); ``` **为什么不用 `User.logIn()`?** - `Cloud.run('user_login')` 不会自动在前端设置登录态 - 云函数返回的是 `sessionToken`,需要手动调用 `become()` 来同步状态 - `logIn()` 会直接向 Parse Server 发起登录请求,不经过云函数中的自定义逻辑 ### 4.3.2 User.current() 获取当前登录的用户对象: ```typescript const user = MSQParse.User.current(); if (user) { // 已登录 console.log('用户 ID:', user.id); // objectId console.log('用户名:', user.get('username')); // username console.log('Session:', user.getSessionToken()); // sessionToken console.log('创建时间:', user.get('createdAt')); // 注册时间 // 获取自定义字段 console.log('角色:', user.get('role')); console.log('昵称:', user.get('nickname')); } else { // 未登录 console.log('用户未登录'); } ``` > **注意**:`User.current()` 可能返回 Promise(取决于 SDK 版本),在 async 函数中建议加 `await`: > ```typescript > const user = await MSQParse.User.current(); > ``` ### 4.3.3 User.logOut() 退出登录,清除本地的登录态: ```typescript async function logout() { try { // 清理自定义的 token(如 AI token) localStorage.removeItem('FMODE_AI_TOKEN'); // 退出 Parse 登录 await MSQParse.User.logOut(); console.log('已退出登录'); // 跳转到登录页 this.router.navigate(['/login']); } catch (error) { console.error('退出登录失败:', error); } } ``` **logOut 做了什么**: 1. 向 Parse Server 发送登出请求,使 sessionToken 失效 2. 清除 localStorage 中的用户信息 3. 清除 SDK 内部的用户缓存 --- ## 4.4 获取用户信息的实用方法 ### 获取用户 ID ```typescript function getUserId(): string | null { const user = MSQParse.User.current(); if (user) { return user.id || user.objectId || null; } return null; } ``` ### 获取 SessionToken ```typescript function getSessionToken(): string | null { const user = MSQParse.User.current(); if (user && typeof user.getSessionToken === 'function') { return user.getSessionToken() || null; } return null; } ``` ### 在 RxJS 中使用(Service 模式) ```typescript import { from, Observable, of } from 'rxjs'; import { map, catchError, timeout } from 'rxjs/operators'; /** * 获取当前用户 ID(Observable 版本,带超时保护) */ getOwnerId(): Observable { return from(MSQParse.User.current()).pipe( timeout(3000), // 3 秒超时,防止卡死 map((u: any) => { if (u && (u.id || u.objectId)) { return u.id || u.objectId; } return null; }), catchError(() => of(null)) ); } /** * 获取 sessionToken(Observable 版本) */ getSessionToken(): Observable { return from(Promise.resolve(MSQParse.User.current())).pipe( timeout(3000), map((user: any) => { if (user && typeof user.getSessionToken === 'function') { return user.getSessionToken() || null; } return null; }), catchError(() => of(null)) ); } ``` --- ## 4.5 路由守卫 (AuthGuard) 路由守卫用于保护需要登录才能访问的页面。未登录的用户会被重定向到登录页。 ### 完整的 AuthGuard 实现 ```typescript // src/app/core/guards/auth.guard.ts import { Injectable } from '@angular/core'; import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router } from '@angular/router'; import { Observable } from 'rxjs'; import { MSQParse } from '../../parse'; @Injectable({ providedIn: 'root' }) export class AuthGuard implements CanActivate { constructor(private router: Router) {} canActivate( route: ActivatedRouteSnapshot, state: RouterStateSnapshot ): Observable | Promise | boolean | UrlTree { return this.checkLogin(state.url); } /** * 检查用户是否已登录 * @param url 用户试图访问的 URL(用于登录后重定向) */ async checkLogin(url: string): Promise { try { const currentUser = await MSQParse.User.current(); if (currentUser) { // 已登录 → 允许访问 return true; } } catch (e) { console.error('Auth Check Error:', e); } // 未登录 → 重定向到登录页,携带原始 URL return this.router.createUrlTree(['/login'], { queryParams: { returnUrl: url } }); } } ``` ### 在路由中使用 AuthGuard ```typescript // src/app/app.routes.ts import { Routes } from '@angular/router'; import { AuthGuard } from './core/guards/auth.guard'; export const routes: Routes = [ // 登录页:不需要 guard { path: 'login', loadComponent: () => import('./modules/login/login.component') }, // 仪表盘:需要登录 { path: 'dashboard', canActivate: [AuthGuard], loadComponent: () => import('./modules/dashboard/dashboard.component') }, // 监控页:需要登录 { path: 'monitoring', canActivate: [AuthGuard], loadChildren: () => import('./modules/monitoring/monitoring.routes') }, // 默认重定向 { path: '', redirectTo: '/dashboard', pathMatch: 'full' }, { path: '**', redirectTo: '/dashboard' } ]; ``` ### 登录后重定向回原始页面 ```typescript // login.component.ts export class LoginComponent { returnUrl = '/dashboard'; constructor(private router: Router, private route: ActivatedRoute) { // 从 URL 参数中获取原始页面地址 this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/dashboard'; } async onSubmit() { const success = await this.login(this.username, this.password); if (success) { // 登录成功后跳转回原始页面 this.router.navigateByUrl(this.returnUrl); } } } ``` --- ## 4.6 用户修改密码 ```typescript async changePassword(oldPassword: string, newPassword: string): Promise { try { const user = await MSQParse.User.current(); if (!user) { console.error('用户未登录'); return false; } // 通过云函数修改密码(推荐,可以在服务端验证旧密码) const result = await MSQParse.Cloud.run('change_password', { userId: user.id, oldPassword: oldPassword, newPassword: newPassword }); if (result.success) { console.log('密码修改成功'); // 修改密码后可能需要重新登录 return true; } return false; } catch (err: any) { console.error('修改密码失败:', err.message); return false; } } ``` --- ## 4.7 完整的登录组件示例 以下是项目中实际使用的登录组件核心逻辑: ```typescript import { Component } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { Router, ActivatedRoute } from '@angular/router'; import { MSQParse } from '../../app/parse'; @Component({ selector: 'app-login', templateUrl: './login.component.html', styleUrls: ['./login.component.scss'] }) export class LoginComponent { loginForm: FormGroup; isLoading = false; errorMessage = ''; returnUrl = '/dashboard'; constructor( private fb: FormBuilder, private router: Router, private route: ActivatedRoute ) { this.loginForm = this.fb.group({ username: ['', [Validators.required, Validators.minLength(3)]], password: ['', [Validators.required, Validators.minLength(6)]] }); this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/dashboard'; } async onSubmit(): Promise { if (!this.loginForm.valid) { this.loginForm.markAllAsTouched(); return; } this.isLoading = true; this.errorMessage = ''; const { username, password } = this.loginForm.value; try { // 1. 调用登录云函数 const response = await MSQParse.Cloud.run('user_login', { username, password }); if (response.success && response.sessionToken) { // 2. 设置前端登录态 await MSQParse.User.become(response.sessionToken); console.log('当前登录用户:', MSQParse.User.current()); // 3. 同步 AI 鉴权(可选,详见第七章) await this.ensureNovaAuth(username, password); // 4. 跳转 this.router.navigateByUrl(this.returnUrl); } else { this.errorMessage = response?.message || '登录失败,请检查账号密码'; } } catch (error: any) { this.errorMessage = error?.message || '登录请求失败,请重试'; } finally { this.isLoading = false; } } /** * 同步 Nova AI 鉴权 * AI API 需要 Nova(ncloudmaster) 的 token,与 MSQ 用户不互通 */ private async ensureNovaAuth(username: string, password: string): Promise { const NOVA_URL = 'https://server.fmode.cn/parse'; const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'ncloudmaster' }; try { // 尝试登录 Nova const loginResp = await fetch(`${NOVA_URL}/login`, { method: 'POST', headers, body: JSON.stringify({ username, password }) }); const loginResult = await loginResp.json(); if (loginResult.sessionToken) { localStorage.setItem('FMODE_AI_TOKEN', loginResult.sessionToken); return; } // 用户不存在 → 自动注册 if (loginResult.code === 101) { const signupResp = await fetch(`${NOVA_URL}/users`, { method: 'POST', headers, body: JSON.stringify({ username, password }) }); const signupResult = await signupResp.json(); if (signupResult.sessionToken) { localStorage.setItem('FMODE_AI_TOKEN', signupResult.sessionToken); } } } catch (err) { console.warn('[AI Auth] 鉴权异常:', err); } } } ``` --- ## 4.8 会话管理最佳实践 ### 会话持久化 Parse SDK 自动将 sessionToken 保存在 `localStorage` 中。这意味着: - 用户**刷新页面**后仍然保持登录状态 - 用户**关闭浏览器再打开**后仍然保持登录状态 - 只有调用 `logOut()` 或 token 过期后才会失去登录态 ### 多标签页同步 由于 sessionToken 存储在 `localStorage` 中,同一浏览器的多个标签页**共享**登录状态: - 标签页 A 登录 → 标签页 B 也自动变为已登录 - 标签页 A 退出 → 标签页 B 需要刷新后才会检测到 ### Token 管理总结 | Token | 存储位置 | 用途 | 清理时机 | |-------|----------|------|----------| | Parse sessionToken | localStorage (SDK 管理) | MSQ 业务操作 | `User.logOut()` | | FMODE_AI_TOKEN | localStorage (手动管理) | AI API 鉴权 | 手动 `removeItem` | --- ## 4.9 本章小结 本章你学到了: 1. **登录流程** — Cloud.run → sessionToken → User.become 2. **三种登录方式** — 云函数登录、REST API 登录、RSA 加密登录 3. **关键 API** — `User.become()`、`User.current()`、`User.logOut()` 4. **路由守卫** — AuthGuard 保护需要登录的页面 5. **会话管理** — sessionToken 自动持久化、多标签页共享 6. **AI 鉴权同步** — 登录时同步 Nova token,退出时清理 **上一章**:[第三章 数据操作 — 增删改查](./03-数据操作-增删改查.md) **下一章**:[第五章 云函数编写与部署](./05-云函数编写与部署.md)