04-用户系统.md 17 KB

第四章 用户系统

本章讲解 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 解密、登录日志等):

import { MSQParse } from '../../app/parse';

async function login(username: string, password: string): Promise<boolean> {
  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 登录接口:

const PARSE_URL = 'https://server-msq.fmode.cn/parse';
const APP_ID = 'msq-voc';

async function loginViaREST(username: string, password: string): Promise<boolean> {
  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 公钥加密:

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

npm install jsencrypt

4.3 关键 API 详解

4.3.1 User.become(sessionToken)

become() 是登录流程中最关键的方法。它告诉 SDK:「使用这个 sessionToken 作为当前登录用户」。

// 调用后,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()

获取当前登录的用户对象:

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:

> 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,退出时清理

          上一章:第三章 数据操作 — 增删改查
          下一章:第五章 云函数编写与部署