记录:从零到一做小说阅读器能遇到多少坑(一)

1,274 阅读4分钟

前言

作为n年小说读者,用过很多小说阅读器,最近几年都用的微信读书,但是...微信读书从一开始可以免费导入500本,到后来限制200本,到现在限制每月3本...

image.png

GitHub 链接

demo

概述

观察了下市场上有的阅读器,大致分为纯移动端页面、阅读APP、阅读小程序等方式,而我们的目的仅仅是满足自身阅读需求,作为一名摸鱼摆烂能手,掌握的技术栈自然很少...哈哈哈,所以专挑了自己熟悉的,毕竟咱们求的是快速解决需求

需求

  • 拿出手机就能看
    不论是小程序、APP、H5都可以实现
  • 支持记录阅读进度、手机上传小说
    需要一个后台服务,储存阅读进度,也需要有个地方存放小说
  • 共享设备
    如果更换手机或需要在平板上阅读也需要支持

综上所述,考虑上多平台,可以选择taro或uniapp等,但是摆烂人不会taro,也不想使用uniapp,再加上ios app上架还是个大问题,最后直接考虑了h5页面,如果后续有app需求或小程序需求再说,毕竟咱们一开始的目的只要能看就行
浏览器看小说不会很难受么?毕竟有底部的搜索栏(safari),正在纠结时,发现了safari支持添加页面添加到主屏幕,添加后效果如图所示

image.png

ebbbc59879bb93b4f5f5ad0d4dd5b06.jpg

nestjs

掌握的技术栈少之又少,除了当年学校里学的java(现在也忘得差不多了)基本一窍不通,最后选了nestjs来练手
新手第一次写,写错轻喷

CURD

npm i -g @nestjs/cli // 全局安装
Nest nest new project-name // 创建项目

安装完成后,可以通过以下命令生成一个包含CRUD操作的控制器:

nest g resource user

官方也有提供数据的库的连法,可以参考下:

npm install typeorm mysql
@Module({
  imports: [
    ...,
    TypeOrmModule.forRootAsync({
      useFactory(configService: ConfigService) {
        return {
          type: 'mysql',
          host: configService.get('DB_HOST'),
          connectorPackage: 'mysql2', //驱动包
          port: configService.get('DB_PORT'), // 端口号
          username: configService.get('DB_USER'), // 用户名
          password: configService.get('DB_PASSWD'), // 密码
          database: configService.get('DB_DATABASE'), //数据库名
          entities: [
            User,
            File,
            Dict,
            Book,
          ], //数据库对应的Entity
          synchronize: !isProd, //是否自动同步实体文件,生产环境建议关闭
        };
      },
      inject: [ConfigService],
    }),
   ...
  ],
})

增删改查service实现

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
    private readonly fileService: FileService,
  ) {}

  async create(createUserDto: CreateUserDto) {
    return await this.userRepository.save(createUserDto);
  }

  async findAll() {
    return await this.userRepository.find();
  }

  async findOne(id: string) {
    const user = await this.userRepository.findOneBy({ id });
    let head_img;
    if (user.head_img) {
      head_img = await this.fileService.findOne(user.head_img);
    }
    return { ...user, head_img };
  }

  async findOneByUsername(username: string) {
    const user = await this.userRepository.findOneBy({ username });
    let head_img;
    if (user.head_img) {
      head_img = await this.fileService.findOne(user.head_img);
    }
    return { ...user, head_img };
  }

  async update(id: string, updateUserDto: UpdateUserDto) {
    return await this.userRepository.update({ id }, updateUserDto);
  }

  async remove(id: string) {
    return await this.userRepository.update({ id }, { status: 0 });
  }
}

token

用户有了,接下来就是登录,如法炮制写了auth模块,唯一的区别是,我们需要利用缓存存起来用户登录生成的token,并用于后续的验证
利用redis存储token

import { RedisClientType } from 'redis';
@Injectable()
export class CacheService {
  constructor(@Inject('REDIS_CLIENT') private redisClient: RedisClientType) {}
  //获取值
  async get(key) {
    let value = await this.redisClient.get(key);
    try {
      value = JSON.parse(value);
    } catch (error) {}
    return value;
  }
}

token校验

@Module({
  ...,
  providers: [
    ...,
    {
      provide: APP_GUARD,
      useClass: UserGuard,
    },
  ],
})
@Injectable()
export class UserGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private cacheService: CacheService,
  ) {}

  /**
   * 判断请求是否通过身份验证
   * @param context 执行上下文
   * @returns 是否通过身份验证
   */
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      //即将调用的方法
      context.getHandler(),
      //controller类型
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }
    const request = context.switchToHttp().getRequest(); // 获取请求对象
    const token = this.extractTokenFromHeader(request); // 从请求头中提取token
    if (!token) {
      throw new HttpException('token无效', HttpStatus.UNAUTHORIZED); // 如果没有token,抛出验证不通过异常
    }
    const realToken: any = await this.cacheService.get(token);
    if (!realToken) {
      throw new HttpException('token无效', HttpStatus.UNAUTHORIZED); // 如果没有token,抛出验证不通过异常
    }
    return true; // 身份验证通过
  }

  /**
   * 从请求头中提取token
   * @param request 请求对象
   * @returns 提取到的token
   */
  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? []; // 从Authorization头中提取token
    return type === 'Bearer' ? token : undefined; // 如果是Bearer类型的token,返回token;否则返回undefined
  }
}

跨域问题

解决方式很简单,在main.ts加上

app.enableCors();

文件上传

轻松实现,文件存取

file.controller.ts
 代码解读
复制代码
  @Post('uploadBook')
  @UseInterceptors(
    FileInterceptor('file', {
      storage: bookStorage,
      // 限制图片大小
      limits: {
        fileSize: 1024 * 1024 * 10, // 2M
      },
    }),
  )
  async uploadBook(@UploadedFile() file: Express.Multer.File, @Req() request) {
    const _file = {
      name: file.originalname,
      filename: file.filename,
      path: file.path,
      mimetype: file.mimetype,
      size: file.size,
    };
    return await this.fileService.create(_file, request);
  }
typescript
 代码解读
复制代码
export const fileName = (name: string) => {
  return `${Date.now()}-${name}`;
};
export const bookStorage = diskStorage({
  destination: `./public/books/${dayjs().format('YYYY-MM')}`,
  // 自定义上传的文件名字,
  filename: (req, file, cb) => {
    // const buffer = Buffer.from(file.originalname, 'binary');
    return cb(null, fileName(file.originalname));
  },
});

txt文件编码问题,显示乱码

还是想的太简单,实际写完存取之后发现,存储的文件名称乱码、读取的txt内容内容乱码/(ㄒoㄒ)/~~

考虑过前端来做这些转码的工作的,然后发现大多数库并不支持浏览器,还是在存储文件的时候做更稳妥

实现思路大致是这样的:

  • 检测文件编码,使用 jschardet 库对 buffer 进行编码检测,如果未识别到编码,则默认使用 UTF-8
  • 转换为 UTF-8,如果检测到的编码不是 UTF-8:用 iconv-lite 库解码原始 buffer 为字符串,再将解码后的内容重新编码为 UTF-8 格式的 buffer
  • 重写文件,使用 fs.writeFile 将转码后的 utf8Buffer 重写到原文件路径上
file.controller.ts
 代码解读
复制代码
  @Post('uploadBook')
  @UseInterceptors(
    FileInterceptor('file', {
      storage: bookStorage,
      // 限制图片大小
      limits: {
        fileSize: 1024 * 1024 * 10, // 2M
      },
    }),
  )
  async uploadBook(@UploadedFile() file: Express.Multer.File, @Req() request) {
    const filePath = file.path;
    const buffer = await fs.readFile(filePath); // 读取文件内容

    // 检测文件编码
    const detection = jschardet.detect(buffer);
    const encoding = detection.encoding?.toLowerCase() || 'utf-8';

    // 转换为 UTF-8
    const utf8Content = iconv.decode(buffer, encoding);
    const utf8Buffer = iconv.encode(utf8Content, 'utf-8');

    // 重写文件为 UTF-8
    await fs.writeFile(filePath, utf8Buffer);
    const _file = {
      name: Buffer.from(file.originalname, 'latin1').toString('utf8'),
      filename: file.filename,
      path: file.path,
      mimetype: file.mimetype,
      size: file.size,
    };
    return await this.fileService.create(_file, request);
  }
utils.ts
 代码解读
复制代码
export const fileName = (name: string) => {
  const originalName = Buffer.from(name, 'latin1').toString('utf8');
  return `${Date.now()}-${originalName}`;
};
export const bookStorage = diskStorage({
  destination: `./public/books/${dayjs().format('YYYY-MM')}`,
  // 自定义上传的文件名字
  filename: (req, file, cb) => {
    // const buffer = Buffer.from(file.originalname, 'binary');
    return cb(null, fileName(file.originalname));
  },
});