2.5 通知

72 阅读3分钟

中间件

session.schema.ts

新建src/session/session.schema.ts。

import { Prop } from "../schema.ts";
import { User } from "../user/user.schema.ts";

export const SESSION_KEY = "session-id";

export class Session {
  @Prop()
  userId?: string;

  @Prop()
  success?: string;

  @Prop()
  error?: string;
  
  user?: User | null;
}

从上文知道,SESSION_KEY可以是随机的,任何一个固定的字符串都可以,当然,网络协议默认的字段除外。这里的值你可以自行修改。

一个session就是一个会话,我们的通知功能依赖于此,包含了用户id(userId)、成功提示信息(success)、错误提示信息(error)这三个字段,而且这三个都不是一个session必需的。

session.interface.ts

新建src/session/session.interface.ts

import { User } from "../user/user.schema.ts";

export interface CreateSession {
  userId?: string;
  success?: string;
  error?: string;
}

export interface UpdateSession {
  id: string;
  userId?: string;
  success?: string;
  error?: string;
}

export interface ISession {
  id: string;
  user?: User;
  success?: string;
  error?: string;
}

session.service.ts

新建src/session/session.service.ts,提供增删改查4个方法。

import { Injectable } from "oak_nest";
import { InjectModel, Model } from "../model.ts";
import { Session } from "./session.schema.ts";
import type { CreateSession, UpdateSession } from "./session.interface.ts";
import { UserService } from "../user/user.service.ts";

@Injectable()
export class SessionService {
  constructor(
    @InjectModel(Session) private readonly model: Model<Session>,
    private readonly userService: UserService,
  ) {
  }
  async save(params: CreateSession): Promise<string> {
    const id = await this.model.insertOne(params);
    return id.toString();
  }

  async findById(id: string, isWithUserInfo: boolean) {
    const session = await this.model.findById(id);
    if (!session) {
      return;
    }
    if (isWithUserInfo && session.userId) {
      session.user = await this.userService.getUserById(session.userId);
    }
    return session;
  }

  update(params: UpdateSession) {
    return this.model.findByIdAndUpdate(params.id, params);
  }

  deleteById(id: string) {
    return this.model.findByIdAndDelete(id);
  }
  
  findAll(): Promise<Session[]> {
    return this.model.findAll();
  }
}

session.decorator.ts

新建src/session/session.decorator.ts,提供一个Flash装饰器。

import { Context, createParamDecorator } from "oak_nest";
import type { CreateSession } from "./session.interface.ts";

export const Flash = createParamDecorator(
  (context: Context): Flash => {
    return (key, val) => {
      context.state[key] = val;
    };
  },
);

export type Flash = (
  key: keyof CreateSession,
  val: string | boolean | number,
) => void;

context.state是oak框架保留的一个object类型的字段,它的信息会随着中间件的流转而逐层传递。

session.middleware.ts

新建src/session/session.middleware.ts,也就是中间件:

import { assert, Context, Factory, Next } from "oak_nest";
import { SESSION_KEY } from "./session.schema.ts";
import { SessionService } from "./session.service.ts";

export async function SessionMiddleware(context: Context, next: Next) {
  const sessionService = await Factory(SessionService);
  let sessionId = await context.cookies.get(SESSION_KEY);
  let session;
  if (sessionId) {
    session = await sessionService.findById(sessionId, true);
    if (!session) {
      console.warn(`没有找到session: ${sessionId}`);
    }
  } else {
    console.warn(`cookie中没有找到${SESSION_KEY}`);
  }
  if (!session) {
    sessionId = await sessionService.save({});
    console.log(`创建session: ${sessionId}`);
    session = {
      id: sessionId,
    };
  }
  
  context.state.session = session;
  context.state.locals = { // 重点是这一句,把session信息赋给state.locals
    success: session.success,
    error: session.error,
    user: session.user,
  };
  console.log("session:", session);
  
  await next();

  assert(sessionId);
  await context.cookies.set(SESSION_KEY, sessionId);

  // 处理flash的消息,或者清理信息
  const { success, error, userId } = context.state;
  if (success || error || userId !== undefined) {
    await sessionService.update({
      id: sessionId,
      success,
      error,
      userId,
    });
  } else {
    if (session.error || session.success) { // 通知消息只显示一次
      await sessionService.update({
        id: sessionId,
        error: "",
        success: "",
      });
    }
  }
}

通知主要靠这一篇实现,我们之前创建的views/components/notification.ejs用到了success和error:

image.png

核心思想是在调用接口前(next()方法上面的代码)获取session或者创建session,而在调用接口之后,将sessionId存到cookie里,返回给前端,最后更新或清理session信息。

user.controller.ts

修改src/user/user.controller.ts,只需要把上次的两个TODO实现,调用flash函数,设置state状态。

import { Flash } from "../session/session.decorator.ts";
import {
  Context,
  Controller,
  Delete,
  Get,
  Params,
  Post,
  Res,
  Response,
  REDIRECT_BACK,
  FormData,
  validateParams,
} from "oak_nest";
import type { FormDataFormattedBody } from "oak_nest";

@Controller("/")
export class UserController {

  @Post("signup")
  async signup(
    @FormData() params: FormDataFormattedBody<CreateUserDto>,
    @Res() res: Response,
    @Flash() flash: Flash,
  ) {
    try {
      ... //原来代码不变
      // 提示注册成功
      flash("success", "注册成功");
      flash("userId", id);
      res.redirect("/posts");
    } catch (e) {
      // 提示错误
      flash("error", e.message);
      this.logger.error(e.message);
      res.redirect(REDIRECT_BACK);
    }
  }
  
  @Get("currentUserInfo")
  currentUserInfo(context: Context) {
    const user = { ...context.state.locals?.user };
    delete user.password;
    return user;
  }
}

main.ts

修改src/main.ts,在异常中间件后面使用session中间件:

import { SessionMiddleware } from "./session/session.middleware.ts";

const app = await NestFactory.create(AppModule);

... 异常中间件

app.use(SessionMiddleware);

验证

清理cookie,再次访问http://localhost:8000/signup,再注册一个用户。

image.png

打开右上角设置,能看到已经变成已登陆状态。

image.png

在命令窗口的控制台也能看到打印的session信息。

访问http://localhost:8000/currentUserInfo,能看到打印的用户信息:

image.png

再回到注册页面,特意输入不一样的密码,提交,看提示:

image.png

再刷新页面,能看到通知信息没有了。

到此,我们这个通知就基本上成功了。

作业

思考下,目前我们的session还有什么缺陷?应该怎么优化?