> = {
+ [K in Uppercase]: `${T}:${Lowercase}`
+ }
+ type AddPrefixToObjectValue> = {
+ [K in keyof P]: K extends string ? `${T}:${P[K]}` : never
+ }
+
+/** 资源操作需要特定的权限 */
+export function Perm(permission: string | string[]) {
+ return applyDecorators(SetMetadata(PERMISSION_KEY, permission))
+}
+
+/** (此举非必需)保存通过 definePermission 定义的所有权限,可用于前端开发人员开发阶段的 ts 类型提示,避免前端权限定义与后端定义不匹配 */
+let permissions: string[] = []
+/**
+ * 定义权限,同时收集所有被定义的权限
+ *
+ * - 通过对象形式定义, eg:
+ * ```ts
+ * definePermission('app:health', {
+ * NETWORK: 'network'
+ * };
+ * ```
+ *
+ * - 通过字符串数组形式定义, eg:
+ * ```ts
+ * definePermission('app:health', ['network']);
+ * ```
+ */
+export function definePermission>(modulePrefix: T, actionMap: U): AddPrefixToObjectValue
+export function definePermission>(modulePrefix: T, actions: U): TupleToObject
+export function definePermission(modulePrefix: string, actions) {
+ if (isPlainObject(actions)) {
+ Object.entries(actions).forEach(([key, action]) => {
+ actions[key] = `${modulePrefix}:${action}`
+ })
+ permissions = [...new Set([...permissions, ...Object.values(actions)])]
+ return actions
+ }
+ else if (Array.isArray(actions)) {
+ const permissionFormats = actions.map(action => `${modulePrefix}:${action}`)
+ permissions = [...new Set([...permissions, ...permissionFormats])]
+
+ return actions.reduce((prev, action) => {
+ prev[action.toUpperCase()] = `${modulePrefix}:${action}`
+ return prev
+ }, {})
+ }
+}
+
+/** 获取所有通过 definePermission 定义的权限 */
+export const getDefinePermissions = () => permissions
diff --git a/src/modules/auth/decorators/public.decorator.ts b/src/modules/auth/decorators/public.decorator.ts
new file mode 100644
index 0000000..c3409ca
--- /dev/null
+++ b/src/modules/auth/decorators/public.decorator.ts
@@ -0,0 +1,8 @@
+import { SetMetadata } from '@nestjs/common'
+
+import { PUBLIC_KEY } from '../auth.constant'
+
+/**
+ * 当接口不需要检测用户登录时添加该装饰器
+ */
+export const Public = () => SetMetadata(PUBLIC_KEY, true)
diff --git a/src/modules/auth/decorators/resource.decorator.ts b/src/modules/auth/decorators/resource.decorator.ts
new file mode 100644
index 0000000..73143b0
--- /dev/null
+++ b/src/modules/auth/decorators/resource.decorator.ts
@@ -0,0 +1,12 @@
+import { SetMetadata, applyDecorators } from '@nestjs/common'
+
+import { ObjectLiteral, ObjectType, Repository } from 'typeorm'
+
+import { RESOURCE_KEY } from '../auth.constant'
+
+export type Condition = (Repository: Repository, items: number[], user: IAuthUser) => Promise
+
+export interface ResourceObject { entity: ObjectType, condition: Condition }
+export function Resource(entity: ObjectType, condition?: Condition) {
+ return applyDecorators(SetMetadata(RESOURCE_KEY, { entity, condition }))
+}
diff --git a/src/modules/auth/dto/account.dto.ts b/src/modules/auth/dto/account.dto.ts
new file mode 100644
index 0000000..8b3a5ee
--- /dev/null
+++ b/src/modules/auth/dto/account.dto.ts
@@ -0,0 +1,64 @@
+import { ApiProperty, OmitType, PartialType, PickType } from '@nestjs/swagger'
+import {
+ IsEmail,
+ IsOptional,
+ IsString,
+ Matches,
+ MaxLength,
+ MinLength,
+} from 'class-validator'
+
+import { MenuEntity } from '~/modules/system/menu/menu.entity'
+
+export class AccountUpdateDto {
+ @ApiProperty({ description: '用户呢称' })
+ @IsString()
+ @IsOptional()
+ nickname: string
+
+ @ApiProperty({ description: '用户邮箱' })
+ @IsEmail()
+ email: string
+
+ @ApiProperty({ description: '用户QQ' })
+ @IsOptional()
+ @IsString()
+ @Matches(/^[0-9]+$/)
+ @MinLength(5)
+ @MaxLength(11)
+ qq: string
+
+ @ApiProperty({ description: '用户手机号' })
+ @IsOptional()
+ @IsString()
+ phone: string
+
+ @ApiProperty({ description: '用户头像' })
+ @IsOptional()
+ @IsString()
+ avatar: string
+
+ @ApiProperty({ description: '用户备注' })
+ @IsOptional()
+ @IsString()
+ remark: string
+}
+
+export class ResetPasswordDto {
+ @ApiProperty({ description: '临时token', example: 'uuid' })
+ @IsString()
+ accessToken: string
+
+ @ApiProperty({ description: '密码', example: 'a123456' })
+ @IsString()
+ @Matches(/^\S*(?=\S{6,})(?=\S*\d)(?=\S*[A-Za-z])\S*$/)
+ @MinLength(6)
+ password: string
+}
+
+export class MenuMeta extends PartialType(OmitType(MenuEntity, ['parentId', 'createdAt', 'updatedAt', 'id', 'roles', 'path', 'name'] as const)) {
+ title: string
+}
+export class AccountMenus extends PickType(MenuEntity, ['id', 'path', 'name', 'component'] as const) {
+ meta: MenuMeta
+}
diff --git a/src/modules/auth/dto/auth.dto.ts b/src/modules/auth/dto/auth.dto.ts
new file mode 100644
index 0000000..abaf379
--- /dev/null
+++ b/src/modules/auth/dto/auth.dto.ts
@@ -0,0 +1,43 @@
+import { ApiProperty } from '@nestjs/swagger'
+
+import { IsString, Matches, MaxLength, MinLength } from 'class-validator'
+
+export class LoginDto {
+ @ApiProperty({ description: '手机号/邮箱' })
+ @IsString()
+ @MinLength(4)
+ username: string
+
+ @ApiProperty({ description: '密码', example: 'a123456' })
+ @IsString()
+ @Matches(/^\S*(?=\S{6,})(?=\S*\d)(?=\S*[A-Za-z])\S*$/)
+ @MinLength(6)
+ password: string
+
+ @ApiProperty({ description: '验证码标识' })
+ @IsString()
+ captchaId: string
+
+ @ApiProperty({ description: '用户输入的验证码' })
+ @IsString()
+ @MinLength(4)
+ @MaxLength(4)
+ verifyCode: string
+}
+
+export class RegisterDto {
+ @ApiProperty({ description: '账号' })
+ @IsString()
+ username: string
+
+ @ApiProperty({ description: '密码' })
+ @IsString()
+ @Matches(/^\S*(?=\S{6,})(?=\S*\d)(?=\S*[A-Za-z])\S*$/)
+ @MinLength(6)
+ @MaxLength(16)
+ password: string
+
+ @ApiProperty({ description: '语言', examples: ['EN', 'ZH'] })
+ @IsString()
+ lang: string
+}
diff --git a/src/modules/auth/dto/captcha.dto.ts b/src/modules/auth/dto/captcha.dto.ts
new file mode 100644
index 0000000..c24d3d8
--- /dev/null
+++ b/src/modules/auth/dto/captcha.dto.ts
@@ -0,0 +1,53 @@
+import { ApiProperty } from '@nestjs/swagger'
+import { Type } from 'class-transformer'
+import {
+ IsEmail,
+ IsInt,
+ IsMobilePhone,
+ IsOptional,
+ IsString,
+} from 'class-validator'
+
+export class ImageCaptchaDto {
+ @ApiProperty({
+ required: false,
+ default: 100,
+ description: '验证码宽度',
+ })
+ @Type(() => Number)
+ @IsInt()
+ @IsOptional()
+ readonly width: number = 100
+
+ @ApiProperty({
+ required: false,
+ default: 50,
+ description: '验证码宽度',
+ })
+ @Type(() => Number)
+ @IsInt()
+ @IsOptional()
+ readonly height: number = 50
+}
+
+export class SendEmailCodeDto {
+ @ApiProperty({ description: '邮箱' })
+ @IsEmail({}, { message: '邮箱格式不正确' })
+ email: string
+}
+
+export class SendSmsCodeDto {
+ @ApiProperty({ description: '手机号' })
+ @IsMobilePhone('zh-CN', {}, { message: '手机号格式不正确' })
+ phone: string
+}
+
+export class CheckCodeDto {
+ @ApiProperty({ description: '手机号/邮箱' })
+ @IsString()
+ account: string
+
+ @ApiProperty({ description: '验证码' })
+ @IsString()
+ code: string
+}
diff --git a/src/modules/auth/entities/access-token.entity.ts b/src/modules/auth/entities/access-token.entity.ts
new file mode 100644
index 0000000..d56afc8
--- /dev/null
+++ b/src/modules/auth/entities/access-token.entity.ts
@@ -0,0 +1,40 @@
+import {
+ BaseEntity,
+ Column,
+ CreateDateColumn,
+ Entity,
+ JoinColumn,
+ ManyToOne,
+ OneToOne,
+ PrimaryGeneratedColumn,
+} from 'typeorm'
+
+import { UserEntity } from '~/modules/user/user.entity'
+
+import { RefreshTokenEntity } from './refresh-token.entity'
+
+@Entity('user_access_tokens')
+export class AccessTokenEntity extends BaseEntity {
+ @PrimaryGeneratedColumn('uuid')
+ id!: string
+
+ @Column({ length: 500 })
+ value!: string
+
+ @Column({ comment: '令牌过期时间' })
+ expired_at!: Date
+
+ @CreateDateColumn({ comment: '令牌创建时间' })
+ created_at!: Date
+
+ @OneToOne(() => RefreshTokenEntity, refreshToken => refreshToken.accessToken, {
+ cascade: true,
+ })
+ refreshToken!: RefreshTokenEntity
+
+ @ManyToOne(() => UserEntity, user => user.accessTokens, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn({ name: 'user_id' })
+ user!: UserEntity
+}
diff --git a/src/modules/auth/entities/refresh-token.entity.ts b/src/modules/auth/entities/refresh-token.entity.ts
new file mode 100644
index 0000000..cec190b
--- /dev/null
+++ b/src/modules/auth/entities/refresh-token.entity.ts
@@ -0,0 +1,32 @@
+import {
+ BaseEntity,
+ Column,
+ CreateDateColumn,
+ Entity,
+ JoinColumn,
+ OneToOne,
+ PrimaryGeneratedColumn,
+} from 'typeorm'
+
+import { AccessTokenEntity } from './access-token.entity'
+
+@Entity('user_refresh_tokens')
+export class RefreshTokenEntity extends BaseEntity {
+ @PrimaryGeneratedColumn('uuid')
+ id!: string
+
+ @Column({ length: 500 })
+ value!: string
+
+ @Column({ comment: '令牌过期时间' })
+ expired_at!: Date
+
+ @CreateDateColumn({ comment: '令牌创建时间' })
+ created_at!: Date
+
+ @OneToOne(() => AccessTokenEntity, accessToken => accessToken.refreshToken, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ accessToken!: AccessTokenEntity
+}
diff --git a/src/modules/auth/guards/jwt-auth.guard.ts b/src/modules/auth/guards/jwt-auth.guard.ts
new file mode 100644
index 0000000..0cbe88c
--- /dev/null
+++ b/src/modules/auth/guards/jwt-auth.guard.ts
@@ -0,0 +1,105 @@
+import {
+ ExecutionContext,
+ Injectable,
+ UnauthorizedException,
+} from '@nestjs/common'
+import { Reflector } from '@nestjs/core'
+import { AuthGuard } from '@nestjs/passport'
+import { FastifyRequest } from 'fastify'
+import { isEmpty, isNil } from 'lodash'
+
+import { BusinessException } from '~/common/exceptions/biz.exception'
+import { ErrorEnum } from '~/constants/error-code.constant'
+import { AuthService } from '~/modules/auth/auth.service'
+
+import { checkIsDemoMode } from '~/utils'
+
+import { AuthStrategy, PUBLIC_KEY } from '../auth.constant'
+import { TokenService } from '../services/token.service'
+
+// https://docs.nestjs.com/recipes/passport#implement-protected-route-and-jwt-strategy-guards
+@Injectable()
+export class JwtAuthGuard extends AuthGuard(AuthStrategy.JWT) {
+ constructor(
+ private reflector: Reflector,
+ private authService: AuthService,
+ private tokenService: TokenService,
+ ) {
+ super()
+ }
+
+ async canActivate(context: ExecutionContext): Promise {
+ const isPublic = this.reflector.getAllAndOverride(PUBLIC_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ])
+ const request = context.switchToHttp().getRequest()
+ // const response = context.switchToHttp().getResponse()
+
+ // TODO 此处代码的作用是判断如果在演示环境下,则拒绝用户的增删改操作,去掉此代码不影响正常的业务逻辑
+ if (request.method !== 'GET' && !request.url.includes('/auth/login'))
+ checkIsDemoMode()
+
+ const isSse = request.headers.accept === 'text/event-stream'
+
+ if (isSse && !request.headers.authorization?.startsWith('Bearer')) {
+ const { token } = request.query as Record
+ if (token)
+ request.headers.authorization = `Bearer ${token}`
+ }
+
+ const Authorization = request.headers.authorization
+
+ let result: any = false
+ try {
+ result = await super.canActivate(context)
+ }
+ catch (e) {
+ // 需要后置判断 这样携带了 token 的用户就能够解析到 request.user
+ if (isPublic)
+ return true
+
+ if (isEmpty(Authorization))
+ throw new UnauthorizedException('未登录')
+
+ // 判断 token 是否存在, 如果不存在则认证失败
+ const accessToken = isNil(Authorization)
+ ? undefined
+ : await this.tokenService.checkAccessToken(Authorization!)
+
+ if (!accessToken)
+ throw new UnauthorizedException('令牌无效')
+ }
+
+ // SSE 请求
+ if (isSse) {
+ const { uid } = request.params as Record
+
+ if (Number(uid) !== request.user.uid)
+ throw new UnauthorizedException('路径参数 uid 与当前 token 登录的用户 uid 不一致')
+ }
+
+ const pv = await this.authService.getPasswordVersionByUid(request.user.uid)
+ if (pv !== `${request.user.pv}`) {
+ // 密码版本不一致,登录期间已更改过密码
+ throw new BusinessException(ErrorEnum.INVALID_LOGIN)
+ }
+
+ // 不允许多端登录
+ // const cacheToken = await this.authService.getTokenByUid(request.user.uid);
+ // if (Authorization !== cacheToken) {
+ // // 与redis保存不一致 即二次登录
+ // throw new ApiException(ErrorEnum.CODE_1106);
+ // }
+
+ return result
+ }
+
+ handleRequest(err, user, info) {
+ // You can throw an exception based on either "info" or "err" arguments
+ if (err || !user)
+ throw err || new UnauthorizedException()
+
+ return user
+ }
+}
diff --git a/src/modules/auth/guards/local.guard.ts b/src/modules/auth/guards/local.guard.ts
new file mode 100644
index 0000000..c2de171
--- /dev/null
+++ b/src/modules/auth/guards/local.guard.ts
@@ -0,0 +1,11 @@
+import { ExecutionContext, Injectable } from '@nestjs/common'
+import { AuthGuard } from '@nestjs/passport'
+
+import { AuthStrategy } from '../auth.constant'
+
+@Injectable()
+export class LocalGuard extends AuthGuard(AuthStrategy.LOCAL) {
+ async canActivate(context: ExecutionContext) {
+ return true
+ }
+}
diff --git a/src/modules/auth/guards/rbac.guard.ts b/src/modules/auth/guards/rbac.guard.ts
new file mode 100644
index 0000000..8545adb
--- /dev/null
+++ b/src/modules/auth/guards/rbac.guard.ts
@@ -0,0 +1,76 @@
+import {
+ CanActivate,
+ ExecutionContext,
+ Injectable,
+ UnauthorizedException,
+} from '@nestjs/common'
+import { Reflector } from '@nestjs/core'
+import { FastifyRequest } from 'fastify'
+
+import { BusinessException } from '~/common/exceptions/biz.exception'
+import { ErrorEnum } from '~/constants/error-code.constant'
+import { AuthService } from '~/modules/auth/auth.service'
+
+import { ALLOW_ANON_KEY, PERMISSION_KEY, PUBLIC_KEY, Roles } from '../auth.constant'
+
+@Injectable()
+export class RbacGuard implements CanActivate {
+ constructor(
+ private reflector: Reflector,
+ private authService: AuthService,
+ ) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ const isPublic = this.reflector.getAllAndOverride(PUBLIC_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ])
+
+ if (isPublic)
+ return true
+
+ const request = context.switchToHttp().getRequest()
+
+ const { user } = request
+ if (!user)
+ throw new UnauthorizedException('登录无效')
+
+ // allowAnon 是需要登录后可访问(无需权限), Public 则是无需登录也可访问.
+ const allowAnon = this.reflector.get(
+ ALLOW_ANON_KEY,
+ context.getHandler(),
+ )
+ if (allowAnon)
+ return true
+
+ const payloadPermission = this.reflector.getAllAndOverride<
+ string | string[]
+ >(PERMISSION_KEY, [context.getHandler(), context.getClass()])
+
+ // 控制器没有设置接口权限,则默认通过
+ if (!payloadPermission)
+ return true
+
+ // 管理员放开所有权限
+ if (user.roles.includes(Roles.ADMIN))
+ return true
+
+ const allPermissions = await this.authService.getPermissionsCache(user.uid) ?? await this.authService.getPermissions(user.uid)
+ // console.log(allPermissions)
+ let canNext = false
+
+ // handle permission strings
+ if (Array.isArray(payloadPermission)) {
+ // 只要有一个权限满足即可
+ canNext = payloadPermission.every(i => allPermissions.includes(i))
+ }
+
+ if (typeof payloadPermission === 'string')
+ canNext = allPermissions.includes(payloadPermission)
+
+ if (!canNext)
+ throw new BusinessException(ErrorEnum.NO_PERMISSION)
+
+ return true
+ }
+}
diff --git a/src/modules/auth/guards/resource.guard.ts b/src/modules/auth/guards/resource.guard.ts
new file mode 100644
index 0000000..b78da0c
--- /dev/null
+++ b/src/modules/auth/guards/resource.guard.ts
@@ -0,0 +1,87 @@
+import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'
+import { Reflector } from '@nestjs/core'
+import { FastifyRequest } from 'fastify'
+
+import { isArray, isEmpty, isNil } from 'lodash'
+
+import { DataSource, In, Repository } from 'typeorm'
+
+import { BusinessException } from '~/common/exceptions/biz.exception'
+
+import { ErrorEnum } from '~/constants/error-code.constant'
+
+import { PUBLIC_KEY, RESOURCE_KEY, Roles } from '../auth.constant'
+import { ResourceObject } from '../decorators/resource.decorator'
+
+@Injectable()
+export class ResourceGuard implements CanActivate {
+ constructor(
+ private reflector: Reflector,
+ private dataSource: DataSource,
+ ) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ const isPublic = this.reflector.getAllAndOverride(PUBLIC_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ])
+
+ const request = context.switchToHttp().getRequest()
+ const isSse = request.headers.accept === 'text/event-stream'
+ // 忽略 sse 请求
+ if (isPublic || isSse)
+ return true
+
+ const { user } = request
+
+ if (!user)
+ return false
+
+ // 如果是检查资源所属,且不是超级管理员,还需要进一步判断是否是自己的数据
+ const { entity, condition } = this.reflector.get(
+ RESOURCE_KEY,
+ context.getHandler(),
+ ) ?? { entity: null, condition: null }
+
+ if (entity && !user.roles.includes(Roles.ADMIN)) {
+ const repo: Repository = this.dataSource.getRepository(entity)
+
+ /**
+ * 获取请求中的 items (ids) 验证数据拥有者
+ * @param request
+ */
+ const getRequestItems = (request?: FastifyRequest): number[] => {
+ const { params = {}, body = {}, query = {} } = (request ?? {}) as any
+ const id = params.id ?? body.id ?? query.id
+
+ if (id)
+ return [id]
+
+ const { items } = body
+ return !isNil(items) && isArray(items) ? items : []
+ }
+
+ const items = getRequestItems(request)
+ if (isEmpty(items))
+ throw new BusinessException(ErrorEnum.REQUESTED_RESOURCE_NOT_FOUND)
+
+ if (condition)
+ return condition(repo, items, user)
+
+ const recordQuery = {
+ where: {
+ id: In(items),
+ user: { id: user.uid },
+ },
+ relations: ['user'],
+ }
+
+ const records = await repo.find(recordQuery)
+
+ if (isEmpty(records))
+ throw new BusinessException(ErrorEnum.REQUESTED_RESOURCE_NOT_FOUND)
+ }
+
+ return true
+ }
+}
diff --git a/src/modules/auth/models/auth.model.ts b/src/modules/auth/models/auth.model.ts
new file mode 100644
index 0000000..faeada1
--- /dev/null
+++ b/src/modules/auth/models/auth.model.ts
@@ -0,0 +1,14 @@
+import { ApiProperty } from '@nestjs/swagger'
+
+export class ImageCaptcha {
+ @ApiProperty({ description: 'base64格式的svg图片' })
+ img: string
+
+ @ApiProperty({ description: '验证码对应的唯一ID' })
+ id: string
+}
+
+export class LoginToken {
+ @ApiProperty({ description: 'JWT身份Token' })
+ token: string
+}
diff --git a/src/modules/auth/services/captcha.service.ts b/src/modules/auth/services/captcha.service.ts
new file mode 100644
index 0000000..612a6b7
--- /dev/null
+++ b/src/modules/auth/services/captcha.service.ts
@@ -0,0 +1,40 @@
+import { InjectRedis } from '@liaoliaots/nestjs-redis'
+import { Injectable } from '@nestjs/common'
+
+import Redis from 'ioredis'
+import { isEmpty } from 'lodash'
+
+import { BusinessException } from '~/common/exceptions/biz.exception'
+import { ErrorEnum } from '~/constants/error-code.constant'
+import { genCaptchaImgKey } from '~/helper/genRedisKey'
+import { CaptchaLogService } from '~/modules/system/log/services/captcha-log.service'
+
+@Injectable()
+export class CaptchaService {
+ constructor(
+ @InjectRedis() private redis: Redis,
+
+ private captchaLogService: CaptchaLogService,
+ ) {}
+
+ /**
+ * 校验图片验证码
+ */
+ async checkImgCaptcha(id: string, code: string): Promise {
+ const result = await this.redis.get(genCaptchaImgKey(id))
+ if (isEmpty(result) || code.toLowerCase() !== result.toLowerCase())
+ throw new BusinessException(ErrorEnum.INVALID_VERIFICATION_CODE)
+
+ // 校验成功后移除验证码
+ await this.redis.del(genCaptchaImgKey(id))
+ }
+
+ async log(
+ account: string,
+ code: string,
+ provider: 'sms' | 'email',
+ uid?: number,
+ ): Promise {
+ await this.captchaLogService.create(account, code, provider, uid)
+ }
+}
diff --git a/src/modules/auth/services/token.service.ts b/src/modules/auth/services/token.service.ts
new file mode 100644
index 0000000..e680ecc
--- /dev/null
+++ b/src/modules/auth/services/token.service.ts
@@ -0,0 +1,160 @@
+import { Inject, Injectable } from '@nestjs/common'
+import { JwtService } from '@nestjs/jwt'
+import dayjs from 'dayjs'
+
+import { ISecurityConfig, SecurityConfig } from '~/config'
+import { RoleService } from '~/modules/system/role/role.service'
+import { UserEntity } from '~/modules/user/user.entity'
+import { generateUUID } from '~/utils'
+
+import { AccessTokenEntity } from '../entities/access-token.entity'
+import { RefreshTokenEntity } from '../entities/refresh-token.entity'
+
+/**
+ * 令牌服务
+ */
+@Injectable()
+export class TokenService {
+ constructor(
+ private jwtService: JwtService,
+ private roleService: RoleService,
+ @Inject(SecurityConfig.KEY) private securityConfig: ISecurityConfig,
+ ) {}
+
+ /**
+ * 根据accessToken刷新AccessToken与RefreshToken
+ * @param accessTokenSign
+ * @param response
+ */
+ async refreshToken(accessToken: AccessTokenEntity) {
+ const { user, refreshToken } = accessToken
+
+ if (refreshToken) {
+ const now = dayjs()
+ // 判断refreshToken是否过期
+ if (now.isAfter(refreshToken.expired_at))
+ return null
+
+ const roleIds = await this.roleService.getRoleIdsByUser(user.id)
+ const roleValues = await this.roleService.getRoleValues(roleIds)
+
+ // 如果没过期则生成新的access_token和refresh_token
+ const token = await this.generateAccessToken(user.id, roleValues)
+
+ await accessToken.remove()
+ return token
+ }
+ return null
+ }
+
+ generateJwtSign(payload: any) {
+ const jwtSign = this.jwtService.sign(payload)
+
+ return jwtSign
+ }
+
+ async generateAccessToken(uid: number, roles: string[] = []) {
+ const payload: IAuthUser = {
+ uid,
+ pv: 1,
+ roles,
+ }
+
+ const jwtSign = this.jwtService.sign(payload)
+
+ // 生成accessToken
+ const accessToken = new AccessTokenEntity()
+ accessToken.value = jwtSign
+ accessToken.user = { id: uid } as UserEntity
+ accessToken.expired_at = dayjs()
+ .add(this.securityConfig.jwtExprire, 'second')
+ .toDate()
+
+ await accessToken.save()
+
+ // 生成refreshToken
+ const refreshToken = await this.generateRefreshToken(accessToken, dayjs())
+
+ return {
+ accessToken: jwtSign,
+ refreshToken,
+ }
+ }
+
+ /**
+ * 生成新的RefreshToken并存入数据库
+ * @param accessToken
+ * @param now
+ */
+ async generateRefreshToken(
+ accessToken: AccessTokenEntity,
+ now: dayjs.Dayjs,
+ ): Promise {
+ const refreshTokenPayload = {
+ uuid: generateUUID(),
+ }
+
+ const refreshTokenSign = this.jwtService.sign(refreshTokenPayload, {
+ secret: this.securityConfig.refreshSecret,
+ })
+
+ const refreshToken = new RefreshTokenEntity()
+ refreshToken.value = refreshTokenSign
+ refreshToken.expired_at = now
+ .add(this.securityConfig.refreshExpire, 'second')
+ .toDate()
+ refreshToken.accessToken = accessToken
+
+ await refreshToken.save()
+
+ return refreshTokenSign
+ }
+
+ /**
+ * 检查accessToken是否存在
+ * @param value
+ */
+ async checkAccessToken(value: string) {
+ return AccessTokenEntity.findOne({
+ where: { value },
+ relations: ['user', 'refreshToken'],
+ cache: true,
+ })
+ }
+
+ /**
+ * 移除AccessToken且自动移除关联的RefreshToken
+ * @param value
+ */
+ async removeAccessToken(value: string) {
+ const accessToken = await AccessTokenEntity.findOne({
+ where: { value },
+ })
+ if (accessToken)
+ await accessToken.remove()
+ }
+
+ /**
+ * 移除RefreshToken
+ * @param value
+ */
+ async removeRefreshToken(value: string) {
+ const refreshToken = await RefreshTokenEntity.findOne({
+ where: { value },
+ relations: ['accessToken'],
+ })
+ if (refreshToken) {
+ if (refreshToken.accessToken)
+ await refreshToken.accessToken.remove()
+ await refreshToken.remove()
+ }
+ }
+
+ /**
+ * 验证Token是否正确,如果正确则返回所属用户对象
+ * @param token
+ */
+ async verifyAccessToken(token: string): Promise {
+ return this.jwtService.verify(token)
+ }
+}
diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts
new file mode 100644
index 0000000..1fc00fd
--- /dev/null
+++ b/src/modules/auth/strategies/jwt.strategy.ts
@@ -0,0 +1,24 @@
+import { Inject, Injectable } from '@nestjs/common'
+import { PassportStrategy } from '@nestjs/passport'
+import { ExtractJwt, Strategy } from 'passport-jwt'
+
+import { ISecurityConfig, SecurityConfig } from '~/config'
+
+import { AuthStrategy } from '../auth.constant'
+
+@Injectable()
+export class JwtStrategy extends PassportStrategy(Strategy, AuthStrategy.JWT) {
+ constructor(
+ @Inject(SecurityConfig.KEY) private securityConfig: ISecurityConfig,
+ ) {
+ super({
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+ ignoreExpiration: false,
+ secretOrKey: securityConfig.jwtSecret,
+ })
+ }
+
+ async validate(payload: IAuthUser) {
+ return payload
+ }
+}
diff --git a/src/modules/auth/strategies/local.strategy.ts b/src/modules/auth/strategies/local.strategy.ts
new file mode 100644
index 0000000..69c59a9
--- /dev/null
+++ b/src/modules/auth/strategies/local.strategy.ts
@@ -0,0 +1,24 @@
+import { Injectable } from '@nestjs/common'
+import { PassportStrategy } from '@nestjs/passport'
+import { Strategy } from 'passport-local'
+
+import { AuthStrategy } from '../auth.constant'
+import { AuthService } from '../auth.service'
+
+@Injectable()
+export class LocalStrategy extends PassportStrategy(
+ Strategy,
+ AuthStrategy.LOCAL,
+) {
+ constructor(private authService: AuthService) {
+ super({
+ usernameField: 'credential',
+ passwordField: 'password',
+ })
+ }
+
+ async validate(username: string, password: string): Promise {
+ const user = await this.authService.validateUser(username, password)
+ return user
+ }
+}
diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts
new file mode 100644
index 0000000..ad81caa
--- /dev/null
+++ b/src/modules/health/health.controller.ts
@@ -0,0 +1,71 @@
+import { Controller, Get } from '@nestjs/common'
+import { ApiTags } from '@nestjs/swagger'
+import {
+ DiskHealthIndicator,
+ HealthCheck,
+ HttpHealthIndicator,
+ MemoryHealthIndicator,
+ TypeOrmHealthIndicator,
+} from '@nestjs/terminus'
+
+import { Perm, definePermission } from '../auth/decorators/permission.decorator'
+
+export const PermissionHealth = definePermission('app:health', {
+ NETWORK: 'network',
+ DB: 'database',
+ MH: 'memory-heap',
+ MR: 'memory-rss',
+ DISK: 'disk',
+} as const)
+
+@ApiTags('Health - 健康检查')
+@Controller('health')
+export class HealthController {
+ constructor(
+ private http: HttpHealthIndicator,
+ private db: TypeOrmHealthIndicator,
+ private memory: MemoryHealthIndicator,
+ private disk: DiskHealthIndicator,
+ ) {}
+
+ @Get('network')
+ @HealthCheck()
+ @Perm(PermissionHealth.NETWORK)
+ async checkNetwork() {
+ return this.http.pingCheck('buqiyuan', 'https://buqiyuan.gitee.io/')
+ }
+
+ @Get('database')
+ @HealthCheck()
+ @Perm(PermissionHealth.DB)
+ async checkDatabase() {
+ return this.db.pingCheck('database')
+ }
+
+ @Get('memory-heap')
+ @HealthCheck()
+ @Perm(PermissionHealth.MH)
+ async checkMemoryHeap() {
+ // the process should not use more than 200MB memory
+ return this.memory.checkHeap('memory-heap', 200 * 1024 * 1024)
+ }
+
+ @Get('memory-rss')
+ @HealthCheck()
+ @Perm(PermissionHealth.MR)
+ async checkMemoryRSS() {
+ // the process should not have more than 200MB RSS memory allocated
+ return this.memory.checkRSS('memory-rss', 200 * 1024 * 1024)
+ }
+
+ @Get('disk')
+ @HealthCheck()
+ @Perm(PermissionHealth.DISK)
+ async checkDisk() {
+ return this.disk.checkStorage('disk', {
+ // The used disk storage should not exceed 75% of the full disk size
+ thresholdPercent: 0.75,
+ path: '/',
+ })
+ }
+}
diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts
new file mode 100644
index 0000000..141fadc
--- /dev/null
+++ b/src/modules/health/health.module.ts
@@ -0,0 +1,11 @@
+import { HttpModule } from '@nestjs/axios'
+import { Module } from '@nestjs/common'
+import { TerminusModule } from '@nestjs/terminus'
+
+import { HealthController } from './health.controller'
+
+@Module({
+ imports: [TerminusModule, HttpModule],
+ controllers: [HealthController],
+})
+export class HealthModule {}
diff --git a/src/modules/netdisk/manager/manage.class.ts b/src/modules/netdisk/manager/manage.class.ts
new file mode 100644
index 0000000..ef774c1
--- /dev/null
+++ b/src/modules/netdisk/manager/manage.class.ts
@@ -0,0 +1,68 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+export type FileType = 'file' | 'dir';
+
+export class SFileInfo {
+ @ApiProperty({ description: '文件id' })
+ id: string;
+
+ @ApiProperty({ description: '文件类型', enum: ['file', 'dir'] })
+ type: FileType;
+
+ @ApiProperty({ description: '文件名称' })
+ name: string;
+
+ @ApiProperty({ description: '存入时间', type: Date })
+ putTime?: Date;
+
+ @ApiProperty({ description: '文件大小, byte单位' })
+ fsize?: string;
+
+ @ApiProperty({ description: '文件的mime-type' })
+ mimeType?: string;
+
+ @ApiProperty({ description: '所属目录' })
+ belongTo?: string;
+}
+
+export class SFileList {
+ @ApiProperty({ description: '文件列表', type: [SFileInfo] })
+ list: SFileInfo[];
+
+ @ApiProperty({ description: '分页标志,空则代表加载完毕' })
+ marker?: string;
+}
+
+export class UploadToken {
+ @ApiProperty({ description: '上传token' })
+ token: string;
+}
+
+export class SFileInfoDetail {
+ @ApiProperty({ description: '文件大小,int64类型,单位为字节(Byte)' })
+ fsize: number;
+
+ @ApiProperty({ description: '文件HASH值' })
+ hash: string;
+
+ @ApiProperty({ description: '文件MIME类型,string类型' })
+ mimeType: string;
+
+ @ApiProperty({
+ description:
+ '文件存储类型,2 表示归档存储,1 表示低频存储,0表示普通存储。',
+ })
+ type: number;
+
+ @ApiProperty({ description: '文件上传时间', type: Date })
+ putTime: Date;
+
+ @ApiProperty({ description: '文件md5值' })
+ md5: string;
+
+ @ApiProperty({ description: '上传人' })
+ uploader: string;
+
+ @ApiProperty({ description: '文件备注' })
+ mark?: string;
+}
diff --git a/src/modules/netdisk/manager/manage.controller.ts b/src/modules/netdisk/manager/manage.controller.ts
new file mode 100644
index 0000000..652d6aa
--- /dev/null
+++ b/src/modules/netdisk/manager/manage.controller.ts
@@ -0,0 +1,153 @@
+import { Body, Controller, Get, Post, Query } from '@nestjs/common'
+import {
+ ApiOkResponse,
+ ApiOperation,
+ ApiTags,
+} from '@nestjs/swagger'
+
+import { BusinessException } from '~/common/exceptions/biz.exception'
+import { ErrorEnum } from '~/constants/error-code.constant'
+import { AuthUser } from '~/modules/auth/decorators/auth-user.decorator'
+
+import { Perm, definePermission } from '~/modules/auth/decorators/permission.decorator'
+
+import { checkIsDemoMode } from '~/utils'
+
+import { SFileInfoDetail, SFileList, UploadToken } from './manage.class'
+import {
+ DeleteDto,
+ FileInfoDto,
+ FileOpDto,
+ GetFileListDto,
+ MKDirDto,
+ MarkFileDto,
+ RenameDto,
+} from './manage.dto'
+import { NetDiskManageService } from './manage.service'
+
+export const permissions = definePermission('netdisk:manage', {
+ LIST: 'list',
+ CREATE: 'create',
+ INFO: 'info',
+ UPDATE: 'update',
+ DELETE: 'delete',
+ MKDIR: 'mkdir',
+ TOKEN: 'token',
+ MARK: 'mark',
+ DOWNLOAD: 'download',
+ RENAME: 'rename',
+ CUT: 'cut',
+ COPY: 'copy',
+} as const)
+
+@ApiTags('NetDiskManage - 网盘管理模块')
+@Controller('manage')
+export class NetDiskManageController {
+ constructor(private manageService: NetDiskManageService) {}
+
+ @Get('list')
+ @ApiOperation({ summary: '获取文件列表' })
+ @ApiOkResponse({ type: SFileList })
+ @Perm(permissions.LIST)
+ async list(@Query() dto: GetFileListDto): Promise {
+ return await this.manageService.getFileList(dto.path, dto.marker, dto.key)
+ }
+
+ @Post('mkdir')
+ @ApiOperation({ summary: '创建文件夹,支持多级' })
+ @Perm(permissions.MKDIR)
+ async mkdir(@Body() dto: MKDirDto): Promise {
+ const result = await this.manageService.checkFileExist(
+ `${dto.path}${dto.dirName}/`,
+ )
+ if (result)
+ throw new BusinessException(ErrorEnum.OSS_FILE_OR_DIR_EXIST)
+
+ await this.manageService.createDir(`${dto.path}${dto.dirName}`)
+ }
+
+ @Get('token')
+ @ApiOperation({ summary: '获取上传Token,无Token前端无法上传' })
+ @ApiOkResponse({ type: UploadToken })
+ @Perm(permissions.TOKEN)
+ async token(@AuthUser() user: IAuthUser): Promise {
+ checkIsDemoMode()
+
+ return {
+ token: this.manageService.createUploadToken(`${user.uid}`),
+ }
+ }
+
+ @Get('info')
+ @ApiOperation({ summary: '获取文件详细信息' })
+ @ApiOkResponse({ type: SFileInfoDetail })
+ @Perm(permissions.INFO)
+ async info(@Query() dto: FileInfoDto): Promise {
+ return await this.manageService.getFileInfo(dto.name, dto.path)
+ }
+
+ @Post('mark')
+ @ApiOperation({ summary: '添加文件备注' })
+ @Perm(permissions.MARK)
+ async mark(@Body() dto: MarkFileDto): Promise {
+ await this.manageService.changeFileHeaders(dto.name, dto.path, {
+ mark: dto.mark,
+ })
+ }
+
+ @Get('download')
+ @ApiOperation({ summary: '获取下载链接,不支持下载文件夹' })
+ @ApiOkResponse({ type: String })
+ @Perm(permissions.DOWNLOAD)
+ async download(@Query() dto: FileInfoDto): Promise {
+ return this.manageService.getDownloadLink(`${dto.path}${dto.name}`)
+ }
+
+ @Post('rename')
+ @ApiOperation({ summary: '重命名文件或文件夹' })
+ @Perm(permissions.RENAME)
+ async rename(@Body() dto: RenameDto): Promise {
+ const result = await this.manageService.checkFileExist(
+ `${dto.path}${dto.toName}${dto.type === 'dir' ? '/' : ''}`,
+ )
+ if (result)
+ throw new BusinessException(ErrorEnum.OSS_FILE_OR_DIR_EXIST)
+
+ if (dto.type === 'file')
+ await this.manageService.renameFile(dto.path, dto.name, dto.toName)
+ else
+ await this.manageService.renameDir(dto.path, dto.name, dto.toName)
+ }
+
+ @Post('delete')
+ @ApiOperation({ summary: '删除文件或文件夹' })
+ @Perm(permissions.DELETE)
+ async delete(@Body() dto: DeleteDto): Promise {
+ await this.manageService.deleteMultiFileOrDir(dto.files, dto.path)
+ }
+
+ @Post('cut')
+ @ApiOperation({ summary: '剪切文件或文件夹,支持批量' })
+ @Perm(permissions.CUT)
+ async cut(@Body() dto: FileOpDto): Promise {
+ if (dto.originPath === dto.toPath)
+ throw new BusinessException(ErrorEnum.OSS_NO_OPERATION_REQUIRED)
+
+ await this.manageService.moveMultiFileOrDir(
+ dto.files,
+ dto.originPath,
+ dto.toPath,
+ )
+ }
+
+ @Post('copy')
+ @ApiOperation({ summary: '复制文件或文件夹,支持批量' })
+ @Perm(permissions.COPY)
+ async copy(@Body() dto: FileOpDto): Promise {
+ await this.manageService.copyMultiFileOrDir(
+ dto.files,
+ dto.originPath,
+ dto.toPath,
+ )
+ }
+}
diff --git a/src/modules/netdisk/manager/manage.dto.ts b/src/modules/netdisk/manager/manage.dto.ts
new file mode 100644
index 0000000..984ef9b
--- /dev/null
+++ b/src/modules/netdisk/manager/manage.dto.ts
@@ -0,0 +1,162 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
+import { Type } from 'class-transformer'
+import {
+ ArrayMaxSize,
+ IsNotEmpty,
+ IsOptional,
+ IsString,
+ Matches,
+ Validate,
+ ValidateIf,
+ ValidateNested,
+ ValidationArguments,
+ ValidatorConstraint,
+ ValidatorConstraintInterface,
+} from 'class-validator'
+import { isEmpty } from 'lodash'
+
+import { NETDISK_HANDLE_MAX_ITEM } from '~/constants/oss.constant'
+
+@ValidatorConstraint({ name: 'IsLegalNameExpression', async: false })
+export class IsLegalNameExpression implements ValidatorConstraintInterface {
+ validate(value: string, args: ValidationArguments) {
+ try {
+ if (isEmpty(value))
+ throw new Error('dir name is empty')
+
+ if (value.includes('/'))
+ throw new Error('dir name not allow /')
+
+ return true
+ }
+ catch (e) {
+ return false
+ }
+ }
+
+ defaultMessage(_args: ValidationArguments) {
+ // here you can provide default error message if validation failed
+ return 'file or dir name invalid'
+ }
+}
+
+export class FileOpItem {
+ @ApiProperty({ description: '文件类型', enum: ['file', 'dir'] })
+ @IsString()
+ @Matches(/(^file$)|(^dir$)/)
+ type: string
+
+ @ApiProperty({ description: '文件名称' })
+ @IsString()
+ @IsNotEmpty()
+ @Validate(IsLegalNameExpression)
+ name: string
+}
+
+export class GetFileListDto {
+ @ApiProperty({ description: '分页标识' })
+ @IsOptional()
+ @IsString()
+ marker: string
+
+ @ApiProperty({ description: '当前路径' })
+ @IsString()
+ path: string
+
+ @ApiPropertyOptional({ description: '搜索关键字' })
+ @Validate(IsLegalNameExpression)
+ @ValidateIf(o => !isEmpty(o.key))
+ @IsString()
+ key: string
+}
+
+export class MKDirDto {
+ @ApiProperty({ description: '文件夹名称' })
+ @IsNotEmpty()
+ @IsString()
+ @Validate(IsLegalNameExpression)
+ dirName: string
+
+ @ApiProperty({ description: '所属路径' })
+ @IsString()
+ path: string
+}
+
+export class RenameDto {
+ @ApiProperty({ description: '文件类型' })
+ @IsString()
+ @Matches(/(^file$)|(^dir$)/)
+ type: string
+
+ @ApiProperty({ description: '更改的名称' })
+ @IsString()
+ @IsNotEmpty()
+ @Validate(IsLegalNameExpression)
+ toName: string
+
+ @ApiProperty({ description: '原来的名称' })
+ @IsString()
+ @IsNotEmpty()
+ @Validate(IsLegalNameExpression)
+ name: string
+
+ @ApiProperty({ description: '路径' })
+ @IsString()
+ path: string
+}
+
+export class FileInfoDto {
+ @ApiProperty({ description: '文件名' })
+ @IsString()
+ @IsNotEmpty()
+ @Validate(IsLegalNameExpression)
+ name: string
+
+ @ApiProperty({ description: '文件所在路径' })
+ @IsString()
+ path: string
+}
+
+export class DeleteDto {
+ @ApiProperty({ description: '需要操作的文件或文件夹', type: [FileOpItem] })
+ @Type(() => FileOpItem)
+ @ArrayMaxSize(NETDISK_HANDLE_MAX_ITEM)
+ @ValidateNested({ each: true })
+ files: FileOpItem[]
+
+ @ApiProperty({ description: '所在目录' })
+ @IsString()
+ path: string
+}
+
+export class MarkFileDto {
+ @ApiProperty({ description: '文件名' })
+ @IsString()
+ @IsNotEmpty()
+ @Validate(IsLegalNameExpression)
+ name: string
+
+ @ApiProperty({ description: '文件所在路径' })
+ @IsString()
+ path: string
+
+ @ApiProperty({ description: '备注信息' })
+ @IsString()
+ mark: string
+}
+
+export class FileOpDto {
+ @ApiProperty({ description: '需要操作的文件或文件夹', type: [FileOpItem] })
+ @Type(() => FileOpItem)
+ @ArrayMaxSize(NETDISK_HANDLE_MAX_ITEM)
+ @ValidateNested({ each: true })
+ files: FileOpItem[]
+
+ @ApiProperty({ description: '操作前的目录' })
+ @IsString()
+ originPath: string
+
+ @ApiProperty({ description: '操作后的目录' })
+ @IsString()
+ toPath: string
+}
diff --git a/src/modules/netdisk/manager/manage.service.ts b/src/modules/netdisk/manager/manage.service.ts
new file mode 100644
index 0000000..3574a65
--- /dev/null
+++ b/src/modules/netdisk/manager/manage.service.ts
@@ -0,0 +1,930 @@
+import { basename, extname } from 'node:path'
+
+import { Injectable } from '@nestjs/common'
+import { ConfigService } from '@nestjs/config'
+import { isEmpty } from 'lodash'
+import * as qiniu from 'qiniu'
+import { auth, conf, rs } from 'qiniu'
+
+import { ConfigKeyPaths } from '~/config'
+import { NETDISK_COPY_SUFFIX, NETDISK_DELIMITER, NETDISK_HANDLE_MAX_ITEM, NETDISK_LIMIT } from '~/constants/oss.constant'
+
+import { AccountInfo } from '~/modules/user/user.model'
+import { UserService } from '~/modules/user/user.service'
+
+import { generateRandomValue } from '~/utils'
+
+import { SFileInfo, SFileInfoDetail, SFileList } from './manage.class'
+import { FileOpItem } from './manage.dto'
+
+@Injectable()
+export class NetDiskManageService {
+ private config: conf.ConfigOptions
+ private mac: auth.digest.Mac
+ private bucketManager: rs.BucketManager
+
+ private get qiniuConfig() {
+ return this.configService.get('oss', { infer: true })
+ }
+
+ constructor(
+ private configService: ConfigService,
+ private userService: UserService,
+ ) {
+ this.mac = new qiniu.auth.digest.Mac(
+ this.qiniuConfig.accessKey,
+ this.qiniuConfig.secretKey,
+ )
+ this.config = new qiniu.conf.Config({
+ zone: this.qiniuConfig.zone,
+ })
+ // bucket manager
+ this.bucketManager = new qiniu.rs.BucketManager(this.mac, this.config)
+ }
+
+ /**
+ * 获取文件列表
+ * @param prefix 当前文件夹路径,搜索模式下会被忽略
+ * @param marker 下一页标识
+ * @returns iFileListResult
+ */
+ async getFileList(prefix = '', marker = '', skey = ''): Promise {
+ // 是否需要搜索
+ const searching = !isEmpty(skey)
+ return new Promise((resolve, reject) => {
+ this.bucketManager.listPrefix(
+ this.qiniuConfig.bucket,
+ {
+ prefix: searching ? '' : prefix,
+ limit: NETDISK_LIMIT,
+ delimiter: searching ? '' : NETDISK_DELIMITER,
+ marker,
+ },
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ if (respInfo.statusCode === 200) {
+ // 如果这个nextMarker不为空,那么还有未列举完毕的文件列表,下次调用listPrefix的时候,
+ // 指定options里面的marker为这个值
+ const fileList: SFileInfo[] = []
+ // 处理目录,但只有非搜索模式下可用
+ if (!searching && !isEmpty(respBody.commonPrefixes)) {
+ // dir
+ for (const dirPath of respBody.commonPrefixes) {
+ const name = (dirPath as string)
+ .substr(0, dirPath.length - 1)
+ .replace(prefix, '')
+ if (isEmpty(skey) || name.includes(skey)) {
+ fileList.push({
+ name: (dirPath as string)
+ .substr(0, dirPath.length - 1)
+ .replace(prefix, ''),
+ type: 'dir',
+ id: generateRandomValue(10),
+ })
+ }
+ }
+ }
+ // handle items
+ if (!isEmpty(respBody.items)) {
+ // file
+ for (const item of respBody.items) {
+ // 搜索模式下处理
+ if (searching) {
+ const pathList: string[] = item.key.split(NETDISK_DELIMITER)
+ // dir is empty stirng, file is key string
+ const name = pathList.pop()
+ if (
+ item.key.endsWith(NETDISK_DELIMITER)
+ && pathList[pathList.length - 1].includes(skey)
+ ) {
+ // 结果是目录
+ const ditName = pathList.pop()
+ fileList.push({
+ id: generateRandomValue(10),
+ name: ditName,
+ type: 'dir',
+ belongTo: pathList.join(NETDISK_DELIMITER),
+ })
+ }
+ else if (name.includes(skey)) {
+ // 文件
+ fileList.push({
+ id: generateRandomValue(10),
+ name,
+ type: 'file',
+ fsize: item.fsize,
+ mimeType: item.mimeType,
+ putTime: new Date(Number.parseInt(item.putTime) / 10000),
+ belongTo: pathList.join(NETDISK_DELIMITER),
+ })
+ }
+ }
+ else {
+ // 正常获取列表
+ const fileKey = item.key.replace(prefix, '') as string
+ if (!isEmpty(fileKey)) {
+ fileList.push({
+ id: generateRandomValue(10),
+ name: fileKey,
+ type: 'file',
+ fsize: item.fsize,
+ mimeType: item.mimeType,
+ putTime: new Date(Number.parseInt(item.putTime) / 10000),
+ })
+ }
+ }
+ }
+ }
+ resolve({
+ list: fileList,
+ marker: respBody.marker || null,
+ })
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ },
+ )
+ })
+ }
+
+ /**
+ * 获取文件信息
+ */
+ async getFileInfo(name: string, path: string): Promise {
+ return new Promise((resolve, reject) => {
+ this.bucketManager.stat(
+ this.qiniuConfig.bucket,
+ `${path}${name}`,
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ if (respInfo.statusCode === 200) {
+ const detailInfo: SFileInfoDetail = {
+ fsize: respBody.fsize,
+ hash: respBody.hash,
+ md5: respBody.md5,
+ mimeType: respBody.mimeType.split('/x-qn-meta')[0],
+ putTime: new Date(Number.parseInt(respBody.putTime) / 10000),
+ type: respBody.type,
+ uploader: '',
+ mark: respBody?.['x-qn-meta']?.['!mark'] ?? '',
+ }
+ if (!respBody.endUser) {
+ resolve(detailInfo)
+ }
+ else {
+ this.userService
+ .getAccountInfo(Number.parseInt(respBody.endUser))
+ .then((user: AccountInfo) => {
+ if (isEmpty(user)) {
+ resolve(detailInfo)
+ }
+ else {
+ detailInfo.uploader = user.username
+ resolve(detailInfo)
+ }
+ })
+ }
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ },
+ )
+ })
+ }
+
+ /**
+ * 修改文件MimeType
+ */
+ async changeFileHeaders(
+ name: string,
+ path: string,
+ headers: { [k: string]: string },
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ this.bucketManager.changeHeaders(
+ this.qiniuConfig.bucket,
+ `${path}${name}`,
+ headers,
+ (err, _, respInfo) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ if (respInfo.statusCode === 200) {
+ resolve()
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ },
+ )
+ })
+ }
+
+ /**
+ * 创建文件夹
+ * @returns true创建成功
+ */
+ async createDir(dirName: string): Promise {
+ const safeDirName = dirName.endsWith('/') ? dirName : `${dirName}/`
+ return new Promise((resolve, reject) => {
+ // 上传一个空文件以用于显示文件夹效果
+ const formUploader = new qiniu.form_up.FormUploader(this.config)
+ const putExtra = new qiniu.form_up.PutExtra()
+ formUploader.put(
+ this.createUploadToken(''),
+ safeDirName,
+ ' ',
+ putExtra,
+ (respErr, respBody, respInfo) => {
+ if (respErr) {
+ reject(respErr)
+ return
+ }
+ if (respInfo.statusCode === 200) {
+ resolve()
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ },
+ )
+ })
+ }
+
+ /**
+ * 检查文件是否存在,同可检查目录
+ */
+ async checkFileExist(filePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ // fix path end must a /
+
+ // 检测文件夹是否存在
+ this.bucketManager.stat(
+ this.qiniuConfig.bucket,
+ filePath,
+ (respErr, respBody, respInfo) => {
+ if (respErr) {
+ reject(respErr)
+ return
+ }
+ if (respInfo.statusCode === 200) {
+ // 文件夹存在
+ resolve(true)
+ }
+ else if (respInfo.statusCode === 612) {
+ // 文件夹不存在
+ resolve(false)
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ },
+ )
+ })
+ }
+
+ /**
+ * 创建Upload Token, 默认过期时间一小时
+ * @returns upload token
+ */
+ createUploadToken(endUser: string): string {
+ const policy = new qiniu.rs.PutPolicy({
+ scope: this.qiniuConfig.bucket,
+ insertOnly: 1,
+ fsizeLimit: 1024 ** 2 * 10,
+ endUser,
+ })
+ const uploadToken = policy.uploadToken(this.mac)
+ return uploadToken
+ }
+
+ /**
+ * 重命名文件
+ * @param dir 文件路径
+ * @param name 文件名称
+ */
+ async renameFile(dir: string, name: string, toName: string): Promise {
+ const fileName = `${dir}${name}`
+ const toFileName = `${dir}${toName}`
+ const op = {
+ force: true,
+ }
+ return new Promise((resolve, reject) => {
+ this.bucketManager.move(
+ this.qiniuConfig.bucket,
+ fileName,
+ this.qiniuConfig.bucket,
+ toFileName,
+ op,
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err)
+ }
+ else {
+ if (respInfo.statusCode === 200) {
+ resolve()
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ }
+ },
+ )
+ })
+ }
+
+ /**
+ * 移动文件
+ */
+ async moveFile(dir: string, toDir: string, name: string): Promise {
+ const fileName = `${dir}${name}`
+ const toFileName = `${toDir}${name}`
+ const op = {
+ force: true,
+ }
+ return new Promise((resolve, reject) => {
+ this.bucketManager.move(
+ this.qiniuConfig.bucket,
+ fileName,
+ this.qiniuConfig.bucket,
+ toFileName,
+ op,
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err)
+ }
+ else {
+ if (respInfo.statusCode === 200) {
+ resolve()
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ }
+ },
+ )
+ })
+ }
+
+ /**
+ * 复制文件
+ */
+ async copyFile(dir: string, toDir: string, name: string): Promise {
+ const fileName = `${dir}${name}`
+ // 拼接文件名
+ const ext = extname(name)
+ const bn = basename(name, ext)
+ const toFileName = `${toDir}${bn}${NETDISK_COPY_SUFFIX}${ext}`
+ const op = {
+ force: true,
+ }
+ return new Promise((resolve, reject) => {
+ this.bucketManager.copy(
+ this.qiniuConfig.bucket,
+ fileName,
+ this.qiniuConfig.bucket,
+ toFileName,
+ op,
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err)
+ }
+ else {
+ if (respInfo.statusCode === 200) {
+ resolve()
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ }
+ },
+ )
+ })
+ }
+
+ /**
+ * 重命名文件夹
+ */
+ async renameDir(path: string, name: string, toName: string): Promise {
+ const dirName = `${path}${name}`
+ const toDirName = `${path}${toName}`
+ let hasFile = true
+ let marker = ''
+ const op = {
+ force: true,
+ }
+ const bucketName = this.qiniuConfig.bucket
+ while (hasFile) {
+ await new Promise((resolve, reject) => {
+ // 列举当前目录下的所有文件
+ this.bucketManager.listPrefix(
+ this.qiniuConfig.bucket,
+ {
+ prefix: dirName,
+ limit: NETDISK_HANDLE_MAX_ITEM,
+ marker,
+ },
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ if (respInfo.statusCode === 200) {
+ const moveOperations = respBody.items.map((item) => {
+ const { key } = item
+ const destKey = key.replace(dirName, toDirName)
+ return qiniu.rs.moveOp(
+ bucketName,
+ key,
+ bucketName,
+ destKey,
+ op,
+ )
+ })
+ this.bucketManager.batch(
+ moveOperations,
+ (err2, respBody2, respInfo2) => {
+ if (err2) {
+ reject(err2)
+ return
+ }
+ if (respInfo2.statusCode === 200) {
+ if (isEmpty(respBody.marker))
+ hasFile = false
+ else
+ marker = respBody.marker
+
+ resolve()
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo2.statusCode}, Info: ${respInfo2.statusMessage}`,
+ ),
+ )
+ }
+ },
+ )
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ },
+ )
+ })
+ }
+ }
+
+ /**
+ * 获取七牛下载的文件url链接
+ * @param key 文件路径
+ * @returns 连接
+ */
+ getDownloadLink(key: string): string {
+ if (this.qiniuConfig.access === 'public') {
+ return this.bucketManager.publicDownloadUrl(this.qiniuConfig.domain, key)
+ }
+ else if (this.qiniuConfig.access === 'private') {
+ return this.bucketManager.privateDownloadUrl(
+ this.qiniuConfig.domain,
+ key,
+ Date.now() / 1000 + 36000,
+ )
+ }
+ throw new Error('qiniu config access type not support')
+ }
+
+ /**
+ * 删除文件
+ * @param dir 删除的文件夹目录
+ * @param name 文件名
+ */
+ async deleteFile(dir: string, name: string): Promise {
+ return new Promise((resolve, reject) => {
+ this.bucketManager.delete(
+ this.qiniuConfig.bucket,
+ `${dir}${name}`,
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ if (respInfo.statusCode === 200) {
+ resolve()
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ },
+ )
+ })
+ }
+
+ /**
+ * 删除文件夹
+ * @param dir 文件夹所在的上级目录
+ * @param name 文件目录名称
+ */
+ async deleteMultiFileOrDir(
+ fileList: FileOpItem[],
+ dir: string,
+ ): Promise {
+ const files = fileList.filter(item => item.type === 'file')
+ if (files.length > 0) {
+ // 批处理文件
+ const copyOperations = files.map((item) => {
+ const fileName = `${dir}${item.name}`
+ return qiniu.rs.deleteOp(this.qiniuConfig.bucket, fileName)
+ })
+ await new Promise((resolve, reject) => {
+ this.bucketManager.batch(copyOperations, (err, respBody, respInfo) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ if (respInfo.statusCode === 200) {
+ resolve()
+ }
+ else if (respInfo.statusCode === 298) {
+ reject(new Error('操作异常,但部分文件夹删除成功'))
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ })
+ })
+ }
+ // 处理文件夹
+ const dirs = fileList.filter(item => item.type === 'dir')
+ if (dirs.length > 0) {
+ // 处理文件夹的复制
+ for (let i = 0; i < dirs.length; i++) {
+ const dirName = `${dir}${dirs[i].name}/`
+ let hasFile = true
+ let marker = ''
+ while (hasFile) {
+ await new Promise((resolve, reject) => {
+ // 列举当前目录下的所有文件
+ this.bucketManager.listPrefix(
+ this.qiniuConfig.bucket,
+ {
+ prefix: dirName,
+ limit: NETDISK_HANDLE_MAX_ITEM,
+ marker,
+ },
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ if (respInfo.statusCode === 200) {
+ const moveOperations = respBody.items.map((item) => {
+ const { key } = item
+ return qiniu.rs.deleteOp(this.qiniuConfig.bucket, key)
+ })
+ this.bucketManager.batch(
+ moveOperations,
+ (err2, respBody2, respInfo2) => {
+ if (err2) {
+ reject(err2)
+ return
+ }
+ if (respInfo2.statusCode === 200) {
+ if (isEmpty(respBody.marker))
+ hasFile = false
+ else
+ marker = respBody.marker
+
+ resolve()
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo2.statusCode}, Info: ${respInfo2.statusMessage}`,
+ ),
+ )
+ }
+ },
+ )
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ },
+ )
+ })
+ }
+ }
+ }
+ }
+
+ /**
+ * 复制文件,含文件夹
+ */
+ async copyMultiFileOrDir(
+ fileList: FileOpItem[],
+ dir: string,
+ toDir: string,
+ ): Promise {
+ const files = fileList.filter(item => item.type === 'file')
+ const op = {
+ force: true,
+ }
+ if (files.length > 0) {
+ // 批处理文件
+ const copyOperations = files.map((item) => {
+ const fileName = `${dir}${item.name}`
+ // 拼接文件名
+ const ext = extname(item.name)
+ const bn = basename(item.name, ext)
+ const toFileName = `${toDir}${bn}${NETDISK_COPY_SUFFIX}${ext}`
+ return qiniu.rs.copyOp(
+ this.qiniuConfig.bucket,
+ fileName,
+ this.qiniuConfig.bucket,
+ toFileName,
+ op,
+ )
+ })
+ await new Promise((resolve, reject) => {
+ this.bucketManager.batch(copyOperations, (err, respBody, respInfo) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ if (respInfo.statusCode === 200) {
+ resolve()
+ }
+ else if (respInfo.statusCode === 298) {
+ reject(new Error('操作异常,但部分文件夹删除成功'))
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ })
+ })
+ }
+ // 处理文件夹
+ const dirs = fileList.filter(item => item.type === 'dir')
+ if (dirs.length > 0) {
+ // 处理文件夹的复制
+ for (let i = 0; i < dirs.length; i++) {
+ const dirName = `${dir}${dirs[i].name}/`
+ const copyDirName = `${toDir}${dirs[i].name}${NETDISK_COPY_SUFFIX}/`
+ let hasFile = true
+ let marker = ''
+ while (hasFile) {
+ await new Promise((resolve, reject) => {
+ // 列举当前目录下的所有文件
+ this.bucketManager.listPrefix(
+ this.qiniuConfig.bucket,
+ {
+ prefix: dirName,
+ limit: NETDISK_HANDLE_MAX_ITEM,
+ marker,
+ },
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ if (respInfo.statusCode === 200) {
+ const moveOperations = respBody.items.map((item) => {
+ const { key } = item
+ const destKey = key.replace(dirName, copyDirName)
+ return qiniu.rs.copyOp(
+ this.qiniuConfig.bucket,
+ key,
+ this.qiniuConfig.bucket,
+ destKey,
+ op,
+ )
+ })
+ this.bucketManager.batch(
+ moveOperations,
+ (err2, respBody2, respInfo2) => {
+ if (err2) {
+ reject(err2)
+ return
+ }
+ if (respInfo2.statusCode === 200) {
+ if (isEmpty(respBody.marker))
+ hasFile = false
+ else
+ marker = respBody.marker
+
+ resolve()
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo2.statusCode}, Info: ${respInfo2.statusMessage}`,
+ ),
+ )
+ }
+ },
+ )
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ },
+ )
+ })
+ }
+ }
+ }
+ }
+
+ /**
+ * 移动文件,含文件夹
+ */
+ async moveMultiFileOrDir(
+ fileList: FileOpItem[],
+ dir: string,
+ toDir: string,
+ ): Promise {
+ const files = fileList.filter(item => item.type === 'file')
+ const op = {
+ force: true,
+ }
+ if (files.length > 0) {
+ // 批处理文件
+ const copyOperations = files.map((item) => {
+ const fileName = `${dir}${item.name}`
+ const toFileName = `${toDir}${item.name}`
+ return qiniu.rs.moveOp(
+ this.qiniuConfig.bucket,
+ fileName,
+ this.qiniuConfig.bucket,
+ toFileName,
+ op,
+ )
+ })
+ await new Promise((resolve, reject) => {
+ this.bucketManager.batch(copyOperations, (err, respBody, respInfo) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ if (respInfo.statusCode === 200) {
+ resolve()
+ }
+ else if (respInfo.statusCode === 298) {
+ reject(new Error('操作异常,但部分文件夹删除成功'))
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ })
+ })
+ }
+ // 处理文件夹
+ const dirs = fileList.filter(item => item.type === 'dir')
+ if (dirs.length > 0) {
+ // 处理文件夹的复制
+ for (let i = 0; i < dirs.length; i++) {
+ const dirName = `${dir}${dirs[i].name}/`
+ const toDirName = `${toDir}${dirs[i].name}/`
+ // 移动的目录不是是自己
+ if (toDirName.startsWith(dirName))
+ continue
+
+ let hasFile = true
+ let marker = ''
+ while (hasFile) {
+ await new Promise((resolve, reject) => {
+ // 列举当前目录下的所有文件
+ this.bucketManager.listPrefix(
+ this.qiniuConfig.bucket,
+ {
+ prefix: dirName,
+ limit: NETDISK_HANDLE_MAX_ITEM,
+ marker,
+ },
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ if (respInfo.statusCode === 200) {
+ const moveOperations = respBody.items.map((item) => {
+ const { key } = item
+ const destKey = key.replace(dirName, toDirName)
+ return qiniu.rs.moveOp(
+ this.qiniuConfig.bucket,
+ key,
+ this.qiniuConfig.bucket,
+ destKey,
+ op,
+ )
+ })
+ this.bucketManager.batch(
+ moveOperations,
+ (err2, respBody2, respInfo2) => {
+ if (err2) {
+ reject(err2)
+ return
+ }
+ if (respInfo2.statusCode === 200) {
+ if (isEmpty(respBody.marker))
+ hasFile = false
+ else
+ marker = respBody.marker
+
+ resolve()
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo2.statusCode}, Info: ${respInfo2.statusMessage}`,
+ ),
+ )
+ }
+ },
+ )
+ }
+ else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`,
+ ),
+ )
+ }
+ },
+ )
+ })
+ }
+ }
+ }
+ }
+}
diff --git a/src/modules/netdisk/netdisk.module.ts b/src/modules/netdisk/netdisk.module.ts
new file mode 100644
index 0000000..aa5a3f7
--- /dev/null
+++ b/src/modules/netdisk/netdisk.module.ts
@@ -0,0 +1,22 @@
+import { Module } from '@nestjs/common'
+
+import { RouterModule } from '@nestjs/core'
+
+import { UserModule } from '../user/user.module'
+
+import { NetDiskManageController } from './manager/manage.controller'
+import { NetDiskManageService } from './manager/manage.service'
+import { NetDiskOverviewController } from './overview/overview.controller'
+import { NetDiskOverviewService } from './overview/overview.service'
+
+@Module({
+ imports: [UserModule, RouterModule.register([
+ {
+ path: 'netdisk',
+ module: NetdiskModule,
+ },
+ ])],
+ controllers: [NetDiskManageController, NetDiskOverviewController],
+ providers: [NetDiskManageService, NetDiskOverviewService],
+})
+export class NetdiskModule {}
diff --git a/src/modules/netdisk/overview/overview.controller.ts b/src/modules/netdisk/overview/overview.controller.ts
new file mode 100644
index 0000000..a7b7df9
--- /dev/null
+++ b/src/modules/netdisk/overview/overview.controller.ts
@@ -0,0 +1,49 @@
+import { CacheInterceptor, CacheKey, CacheTTL } from '@nestjs/cache-manager'
+import {
+ Controller,
+ Get,
+ UseInterceptors,
+} from '@nestjs/common'
+import {
+ ApiOkResponse,
+ ApiOperation,
+ ApiTags,
+} from '@nestjs/swagger'
+
+import { Perm, definePermission } from '~/modules/auth/decorators/permission.decorator'
+
+import { OverviewSpaceInfo } from './overview.dto'
+import { NetDiskOverviewService } from './overview.service'
+
+export const permissions = definePermission('netdisk:overview', {
+ DESC: 'desc',
+} as const)
+
+@ApiTags('NetDiskOverview - 网盘概览模块')
+@Controller('overview')
+export class NetDiskOverviewController {
+ constructor(private overviewService: NetDiskOverviewService) {}
+
+ @Get('desc')
+ @CacheKey('netdisk_overview_desc')
+ @CacheTTL(3600)
+ @UseInterceptors(CacheInterceptor)
+ @ApiOperation({ summary: '获取网盘空间数据统计' })
+ @ApiOkResponse({ type: OverviewSpaceInfo })
+ @Perm(permissions.DESC)
+ async space(): Promise {
+ const date = this.overviewService.getZeroHourAnd1Day(new Date())
+ const hit = await this.overviewService.getHit(date)
+ const flow = await this.overviewService.getFlow(date)
+ const space = await this.overviewService.getSpace(date)
+ const count = await this.overviewService.getCount(date)
+ return {
+ fileSize: count.datas[count.datas.length - 1],
+ flowSize: flow.datas[flow.datas.length - 1],
+ hitSize: hit.datas[hit.datas.length - 1],
+ spaceSize: space.datas[space.datas.length - 1],
+ flowTrend: flow,
+ sizeTrend: space,
+ }
+ }
+}
diff --git a/src/modules/netdisk/overview/overview.dto.ts b/src/modules/netdisk/overview/overview.dto.ts
new file mode 100644
index 0000000..9d77aff
--- /dev/null
+++ b/src/modules/netdisk/overview/overview.dto.ts
@@ -0,0 +1,53 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+export class SpaceInfo {
+ @ApiProperty({ description: '当月的X号', type: [Number] })
+ times: number[];
+
+ @ApiProperty({ description: '对应天数的容量, byte单位', type: [Number] })
+ datas: number[];
+}
+
+export class CountInfo {
+ @ApiProperty({ description: '当月的X号', type: [Number] })
+ times: number[];
+
+ @ApiProperty({ description: '对应天数的文件数量', type: [Number] })
+ datas: number[];
+}
+
+export class FlowInfo {
+ @ApiProperty({ description: '当月的X号', type: [Number] })
+ times: number[];
+
+ @ApiProperty({ description: '对应天数的耗费流量', type: [Number] })
+ datas: number[];
+}
+
+export class HitInfo {
+ @ApiProperty({ description: '当月的X号', type: [Number] })
+ times: number[];
+
+ @ApiProperty({ description: '对应天数的Get请求次数', type: [Number] })
+ datas: number[];
+}
+
+export class OverviewSpaceInfo {
+ @ApiProperty({ description: '当前使用容量' })
+ spaceSize: number;
+
+ @ApiProperty({ description: '当前文件数量' })
+ fileSize: number;
+
+ @ApiProperty({ description: '当天使用流量' })
+ flowSize: number;
+
+ @ApiProperty({ description: '当天请求次数' })
+ hitSize: number;
+
+ @ApiProperty({ description: '流量趋势,从当月1号开始计算', type: FlowInfo })
+ flowTrend: FlowInfo;
+
+ @ApiProperty({ description: '容量趋势,从当月1号开始计算', type: SpaceInfo })
+ sizeTrend: SpaceInfo;
+}
diff --git a/src/modules/netdisk/overview/overview.service.ts b/src/modules/netdisk/overview/overview.service.ts
new file mode 100644
index 0000000..40ff410
--- /dev/null
+++ b/src/modules/netdisk/overview/overview.service.ts
@@ -0,0 +1,156 @@
+import { HttpService } from '@nestjs/axios'
+import { Injectable } from '@nestjs/common'
+import { ConfigService } from '@nestjs/config'
+import dayjs from 'dayjs'
+import * as qiniu from 'qiniu'
+
+import { ConfigKeyPaths } from '~/config'
+import { OSS_API } from '~/constants/oss.constant'
+
+import { CountInfo, FlowInfo, HitInfo, SpaceInfo } from './overview.dto'
+
+@Injectable()
+export class NetDiskOverviewService {
+ private mac: qiniu.auth.digest.Mac
+ private readonly FORMAT = 'YYYYMMDDHHmmss'
+ private get qiniuConfig() {
+ return this.configService.get('oss', { infer: true })
+ }
+
+ constructor(
+ private configService: ConfigService,
+ private readonly httpService: HttpService,
+ ) {
+ this.mac = new qiniu.auth.digest.Mac(
+ this.qiniuConfig.accessKey,
+ this.qiniuConfig.secretKey,
+ )
+ }
+
+ /** 获取格式化后的起始和结束时间 */
+ getStartAndEndDate(start: Date, end = new Date()) {
+ return [dayjs(start).format(this.FORMAT), dayjs(end).format(this.FORMAT)]
+ }
+
+ /**
+ * 获取数据统计接口路径
+ * @see: https://developer.qiniu.com/kodo/3906/statistic-interface
+ */
+ getStatisticUrl(type: string, queryParams = {}) {
+ const defaultParams = {
+ $bucket: this.qiniuConfig.bucket,
+ g: 'day',
+ }
+ const searchParams = new URLSearchParams({ ...defaultParams, ...queryParams })
+ return decodeURIComponent(`${OSS_API}/v6/${type}?${searchParams}`)
+ }
+
+ /** 获取统计数据 */
+ getStatisticData(url: string) {
+ const accessToken = qiniu.util.generateAccessTokenV2(
+ this.mac,
+ url,
+ 'GET',
+ 'application/x-www-form-urlencoded',
+ )
+ return this.httpService.axiosRef.get(url, {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Authorization': `${accessToken}`,
+ },
+ })
+ }
+
+ /**
+ * 获取当天零时
+ */
+ getZeroHourToDay(current: Date): Date {
+ const year = dayjs(current).year()
+ const month = dayjs(current).month()
+ const date = dayjs(current).date()
+ return new Date(year, month, date, 0)
+ }
+
+ /**
+ * 获取当月1号零时
+ */
+ getZeroHourAnd1Day(current: Date): Date {
+ const year = dayjs(current).year()
+ const month = dayjs(current).month()
+ return new Date(year, month, 1, 0)
+ }
+
+ /**
+ * 该接口可以获取标准存储的当前存储量。可查询当天计量,统计延迟大概 5 分钟。
+ * https://developer.qiniu.com/kodo/3908/statistic-space
+ */
+ async getSpace(beginDate: Date, endDate = new Date()): Promise {
+ const [begin, end] = this.getStartAndEndDate(beginDate, endDate)
+ const url = this.getStatisticUrl('space', { begin, end })
+ const { data } = await this.getStatisticData(url)
+ return {
+ datas: data.datas,
+ times: data.times.map((e) => {
+ return dayjs.unix(e).date()
+ }),
+ }
+ }
+
+ /**
+ * 该接口可以获取标准存储的文件数量。可查询当天计量,统计延迟大概 5 分钟。
+ * https://developer.qiniu.com/kodo/3914/count
+ */
+ async getCount(beginDate: Date, endDate = new Date()): Promise {
+ const [begin, end] = this.getStartAndEndDate(beginDate, endDate)
+ const url = this.getStatisticUrl('count', { begin, end })
+ const { data } = await this.getStatisticData(url)
+ return {
+ times: data.times.map((e) => {
+ return dayjs.unix(e).date()
+ }),
+ datas: data.datas,
+ }
+ }
+
+ /**
+ * 外网流出流量统计
+ * 该接口可以获取外网流出流量、CDN回源流量统计和 GET 请求次数。可查询当天计量,统计延迟大概 5 分钟。
+ * https://developer.qiniu.com/kodo/3820/blob-io
+ */
+ async getFlow(beginDate: Date, endDate = new Date()): Promise {
+ const [begin, end] = this.getStartAndEndDate(beginDate, endDate)
+ const url = this.getStatisticUrl('blob_io', { begin, end, $ftype: 0, $src: 'origin', select: 'flow' })
+ const { data } = await this.getStatisticData(url)
+ const times = []
+ const datas = []
+ data.forEach((e) => {
+ times.push(dayjs(e.time).date())
+ datas.push(e.values.flow)
+ })
+ return {
+ times,
+ datas,
+ }
+ }
+
+ /**
+ * GET 请求次数统计
+ * 该接口可以获取外网流出流量、CDN回源流量统计和 GET 请求次数。可查询当天计量,统计延迟大概 5 分钟。
+ * https://developer.qiniu.com/kodo/3820/blob-io
+ */
+ async getHit(beginDate: Date, endDate = new Date()): Promise {
+ const [begin, end] = this.getStartAndEndDate(beginDate, endDate)
+ const url = this.getStatisticUrl('blob_io', { begin, end, $ftype: 0, $src: 'inner', select: 'hit' })
+ const { data } = await this.getStatisticData(url)
+ const times = []
+ const datas = []
+ data.forEach((e) => {
+ times.push(dayjs(e.time).date())
+ datas.push(e.values.hit)
+ })
+ return {
+ times,
+ datas,
+ }
+ }
+}
diff --git a/src/modules/sse/sse.controller.ts b/src/modules/sse/sse.controller.ts
new file mode 100644
index 0000000..1e475b7
--- /dev/null
+++ b/src/modules/sse/sse.controller.ts
@@ -0,0 +1,54 @@
+import { BeforeApplicationShutdown, Controller, Param, ParseIntPipe, Req, Res, Sse } from '@nestjs/common'
+import { ApiTags } from '@nestjs/swagger'
+import { FastifyReply, FastifyRequest } from 'fastify'
+import { Observable, interval } from 'rxjs'
+
+import { ApiSecurityAuth } from '~/common/decorators/swagger.decorator'
+
+import { MessageEvent, SseService } from './sse.service'
+
+@ApiTags('System - sse模块')
+@ApiSecurityAuth()
+@Controller('sse')
+export class SseController implements BeforeApplicationShutdown {
+ private replyMap: Map = new Map()
+
+ constructor(private readonly sseService: SseService) {}
+
+ private closeAllConnect() {
+ this.sseService.sendToAll({
+ type: 'close',
+ data: 'bye~',
+ })
+ this.replyMap.forEach((reply) => {
+ reply.raw.end().destroy()
+ })
+ }
+
+ // 通过控制台关闭程序时触发
+ beforeApplicationShutdown() {
+ // console.log('beforeApplicationShutdown')
+ this.closeAllConnect()
+ }
+
+ @Sse(':uid')
+ sse(@Param('uid', ParseIntPipe) uid: number, @Req() req: FastifyRequest, @Res() res: FastifyReply): Observable {
+ this.replyMap.set(uid, res)
+
+ const subscription = interval(10000).subscribe(() => {
+ this.sseService.sendToClient(uid, { type: 'ping' })
+ })
+
+ // 当客户端断开连接时
+ req.raw.on('close', () => {
+ subscription.unsubscribe()
+ this.sseService.removeClient(uid)
+ this.replyMap.delete(uid)
+ // console.log(`user-${uid}已关闭`)
+ })
+
+ return new Observable((subscriber) => {
+ this.sseService.addClient(uid, subscriber)
+ })
+ }
+}
diff --git a/src/modules/sse/sse.module.ts b/src/modules/sse/sse.module.ts
new file mode 100644
index 0000000..e6af2b1
--- /dev/null
+++ b/src/modules/sse/sse.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common'
+
+import { SseController } from './sse.controller'
+import { SseService } from './sse.service'
+
+@Module({
+ imports: [],
+ controllers: [SseController],
+ providers: [SseService],
+ exports: [SseService],
+})
+export class SseModule {}
diff --git a/src/modules/sse/sse.service.ts b/src/modules/sse/sse.service.ts
new file mode 100644
index 0000000..076e415
--- /dev/null
+++ b/src/modules/sse/sse.service.ts
@@ -0,0 +1,85 @@
+import { Injectable } from '@nestjs/common'
+import { Subscriber } from 'rxjs'
+import { In } from 'typeorm'
+
+import { ROOT_ROLE_ID } from '~/constants/system.constant'
+
+import { RoleEntity } from '~/modules/system/role/role.entity'
+import { UserEntity } from '~/modules/user/user.entity'
+
+export interface MessageEvent {
+ data?: string | object
+ id?: string
+ type?: 'ping' | 'close' | 'updatePermsAndMenus'
+ retry?: number
+}
+
+const clientMap: Map> = new Map()
+
+@Injectable()
+export class SseService {
+ addClient(uid: number, subscriber: Subscriber) {
+ clientMap.set(uid, subscriber)
+ }
+
+ removeClient(uid: number): void {
+ const client = clientMap.get(uid)
+ client?.complete()
+ clientMap.delete(uid)
+ }
+
+ sendToClient(uid: number, data: MessageEvent): void {
+ const client = clientMap.get(uid)
+ client?.next?.(data)
+ }
+
+ sendToAll(data: MessageEvent): void {
+ clientMap.forEach((client) => {
+ client.next(data)
+ })
+ }
+
+ /**
+ * 通知前端重新获取权限菜单
+ * @param uid
+ * @constructor
+ */
+ async noticeClientToUpdateMenusByUserIds(uid: number | number[]) {
+ const userIds = [].concat(uid) as number[]
+ userIds.forEach((uid) => {
+ this.sendToClient(uid, { type: 'updatePermsAndMenus' })
+ })
+ }
+
+ /**
+ * 通过menuIds通知用户更新权限菜单
+ */
+ async noticeClientToUpdateMenusByMenuIds(menuIds: number[]): Promise {
+ const roleMenus = await RoleEntity.find({
+ where: {
+ menus: {
+ id: In(menuIds),
+ },
+ },
+ })
+ const roleIds = roleMenus.map(n => n.id).concat(ROOT_ROLE_ID)
+ await this.noticeClientToUpdateMenusByRoleIds(roleIds)
+ }
+
+ /**
+ * 通过roleIds通知用户更新权限菜单
+ */
+ async noticeClientToUpdateMenusByRoleIds(roleIds: number[]): Promise {
+ const users = await UserEntity.find({
+ where: {
+ roles: {
+ id: In(roleIds),
+ },
+ },
+ })
+ if (users) {
+ const userIds = users.map(n => n.id)
+ await this.noticeClientToUpdateMenusByUserIds(userIds)
+ }
+ }
+}
diff --git a/src/modules/system/dept/dept.controller.ts b/src/modules/system/dept/dept.controller.ts
new file mode 100644
index 0000000..81b6ef9
--- /dev/null
+++ b/src/modules/system/dept/dept.controller.ts
@@ -0,0 +1,83 @@
+import { Body, Controller, Delete, Get, Post, Put, Query } from '@nestjs/common'
+import { ApiOperation, ApiTags } from '@nestjs/swagger'
+
+import { ApiResult } from '~/common/decorators/api-result.decorator'
+import { IdParam } from '~/common/decorators/id-param.decorator'
+import { ApiSecurityAuth } from '~/common/decorators/swagger.decorator'
+import { BusinessException } from '~/common/exceptions/biz.exception'
+import { ErrorEnum } from '~/constants/error-code.constant'
+import { AuthUser } from '~/modules/auth/decorators/auth-user.decorator'
+import { Perm, definePermission } from '~/modules/auth/decorators/permission.decorator'
+import { DeptEntity } from '~/modules/system/dept/dept.entity'
+
+import { DeptDto, DeptQueryDto } from './dept.dto'
+import { DeptService } from './dept.service'
+
+export const permissions = definePermission('system:dept', {
+ LIST: 'list',
+ CREATE: 'create',
+ READ: 'read',
+ UPDATE: 'update',
+ DELETE: 'delete',
+} as const)
+
+@ApiSecurityAuth()
+@ApiTags('System - 部门模块')
+@Controller('depts')
+export class DeptController {
+ constructor(private deptService: DeptService) {}
+
+ @Get()
+ @ApiOperation({ summary: '获取部门列表' })
+ @ApiResult({ type: [DeptEntity] })
+ @Perm(permissions.LIST)
+ async list(
+ @Query() dto: DeptQueryDto, @AuthUser('uid') uid: number): Promise {
+ return this.deptService.getDeptTree(uid, dto)
+ }
+
+ @Post()
+ @ApiOperation({ summary: '创建部门' })
+ @Perm(permissions.CREATE)
+ async create(@Body() dto: DeptDto): Promise {
+ await this.deptService.create(dto)
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: '查询部门信息' })
+ @Perm(permissions.READ)
+ async info(@IdParam() id: number) {
+ return this.deptService.info(id)
+ }
+
+ @Put(':id')
+ @ApiOperation({ summary: '更新部门' })
+ @Perm(permissions.UPDATE)
+ async update(
+ @IdParam() id: number, @Body() updateDeptDto: DeptDto): Promise {
+ await this.deptService.update(id, updateDeptDto)
+ }
+
+ @Delete(':id')
+ @ApiOperation({ summary: '删除部门' })
+ @Perm(permissions.DELETE)
+ async delete(@IdParam() id: number): Promise {
+ // 查询是否有关联用户或者部门,如果含有则无法删除
+ const count = await this.deptService.countUserByDeptId(id)
+ if (count > 0)
+ throw new BusinessException(ErrorEnum.DEPARTMENT_HAS_ASSOCIATED_USERS)
+
+ const count2 = await this.deptService.countChildDept(id)
+ console.log('count2', count2)
+ if (count2 > 0)
+ throw new BusinessException(ErrorEnum.DEPARTMENT_HAS_CHILD_DEPARTMENTS)
+
+ await this.deptService.delete(id)
+ }
+
+ // @Post('move')
+ // @ApiOperation({ summary: '部门移动排序' })
+ // async move(@Body() dto: MoveDeptDto): Promise {
+ // await this.deptService.move(dto.depts);
+ // }
+}
diff --git a/src/modules/system/dept/dept.dto.ts b/src/modules/system/dept/dept.dto.ts
new file mode 100644
index 0000000..5d79782
--- /dev/null
+++ b/src/modules/system/dept/dept.dto.ts
@@ -0,0 +1,70 @@
+import { ApiProperty } from '@nestjs/swagger'
+import { Type } from 'class-transformer'
+import {
+ ArrayNotEmpty,
+ IsArray,
+ IsInt,
+ IsOptional,
+ IsString,
+ Min,
+ MinLength,
+ ValidateNested,
+} from 'class-validator'
+
+export class DeptDto {
+ @ApiProperty({ description: '部门名称' })
+ @IsString()
+ @MinLength(1)
+ name: string
+
+ @ApiProperty({ description: '父级部门id' })
+ @Type(() => Number)
+ @IsInt()
+ @IsOptional()
+ parentId: number
+
+ @ApiProperty({ description: '排序编号', required: false })
+ @IsInt()
+ @Min(0)
+ @IsOptional()
+ orderNo: number
+}
+
+export class TransferDeptDto {
+ @ApiProperty({ description: '需要转移的管理员列表编号', type: [Number] })
+ @IsArray()
+ @ArrayNotEmpty()
+ userIds: number[]
+
+ @ApiProperty({ description: '需要转移过去的系统部门ID' })
+ @IsInt()
+ @Min(0)
+ deptId: number
+}
+
+export class MoveDept {
+ @ApiProperty({ description: '当前部门ID' })
+ @IsInt()
+ @Min(0)
+ id: number
+
+ @ApiProperty({ description: '移动到指定父级部门的ID' })
+ @IsInt()
+ @Min(0)
+ @IsOptional()
+ parentId: number
+}
+
+export class MoveDeptDto {
+ @ApiProperty({ description: '部门列表', type: [MoveDept] })
+ @ValidateNested({ each: true })
+ @Type(() => MoveDept)
+ depts: MoveDept[]
+}
+
+export class DeptQueryDto {
+ @ApiProperty({ description: '部门名称' })
+ @IsString()
+ @IsOptional()
+ name?: string
+}
diff --git a/src/modules/system/dept/dept.entity.ts b/src/modules/system/dept/dept.entity.ts
new file mode 100644
index 0000000..3a69396
--- /dev/null
+++ b/src/modules/system/dept/dept.entity.ts
@@ -0,0 +1,36 @@
+import { ApiHideProperty, ApiProperty } from '@nestjs/swagger'
+import {
+ Column,
+ Entity,
+ OneToMany,
+ Relation,
+ Tree,
+ TreeChildren,
+ TreeParent,
+} from 'typeorm'
+
+import { CommonEntity } from '~/common/entity/common.entity'
+
+import { UserEntity } from '../../user/user.entity'
+
+@Entity({ name: 'sys_dept' })
+@Tree('materialized-path')
+export class DeptEntity extends CommonEntity {
+ @Column()
+ @ApiProperty({ description: '部门名称' })
+ name: string
+
+ @Column({ nullable: true, default: 0 })
+ @ApiProperty({ description: '排序' })
+ orderNo: number
+
+ @TreeChildren({ cascade: true })
+ children: DeptEntity[]
+
+ @TreeParent({ onDelete: 'SET NULL' })
+ parent?: DeptEntity
+
+ @ApiHideProperty()
+ @OneToMany(() => UserEntity, user => user.dept)
+ users: Relation
+}
diff --git a/src/modules/system/dept/dept.module.ts b/src/modules/system/dept/dept.module.ts
new file mode 100644
index 0000000..d936ed9
--- /dev/null
+++ b/src/modules/system/dept/dept.module.ts
@@ -0,0 +1,19 @@
+import { Module } from '@nestjs/common'
+import { TypeOrmModule } from '@nestjs/typeorm'
+
+import { UserModule } from '../../user/user.module'
+import { RoleModule } from '../role/role.module'
+
+import { DeptController } from './dept.controller'
+import { DeptEntity } from './dept.entity'
+import { DeptService } from './dept.service'
+
+const services = [DeptService]
+
+@Module({
+ imports: [TypeOrmModule.forFeature([DeptEntity]), UserModule, RoleModule],
+ controllers: [DeptController],
+ providers: [...services],
+ exports: [TypeOrmModule, ...services],
+})
+export class DeptModule {}
diff --git a/src/modules/system/dept/dept.service.ts b/src/modules/system/dept/dept.service.ts
new file mode 100644
index 0000000..1eae39b
--- /dev/null
+++ b/src/modules/system/dept/dept.service.ts
@@ -0,0 +1,134 @@
+import { Injectable } from '@nestjs/common'
+import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm'
+import { isEmpty } from 'lodash'
+import { EntityManager, Repository, TreeRepository } from 'typeorm'
+
+import { BusinessException } from '~/common/exceptions/biz.exception'
+import { ErrorEnum } from '~/constants/error-code.constant'
+import { DeptEntity } from '~/modules/system/dept/dept.entity'
+import { UserEntity } from '~/modules/user/user.entity'
+
+import { deleteEmptyChildren } from '~/utils/list2tree.util'
+
+import { DeptDto, DeptQueryDto, MoveDept } from './dept.dto'
+
+@Injectable()
+export class DeptService {
+ constructor(
+ @InjectRepository(UserEntity)
+ private userRepository: Repository,
+ @InjectRepository(DeptEntity)
+ private deptRepository: TreeRepository,
+ @InjectEntityManager() private entityManager: EntityManager,
+ ) {}
+
+ async list(): Promise {
+ return this.deptRepository.find({ order: { orderNo: 'DESC' } })
+ }
+
+ async info(id: number): Promise {
+ const dept = await this.deptRepository
+ .createQueryBuilder('dept')
+ .leftJoinAndSelect('dept.parent', 'parent')
+ .where({ id })
+ .getOne()
+
+ if (isEmpty(dept))
+ throw new BusinessException(ErrorEnum.DEPARTMENT_NOT_FOUND)
+
+ return dept
+ }
+
+ async create({ parentId, ...data }: DeptDto): Promise {
+ const parent = await this.deptRepository
+ .createQueryBuilder('dept')
+ .where({ id: parentId })
+ .getOne()
+
+ await this.deptRepository.save({
+ ...data,
+ parent,
+ })
+ }
+
+ async update(id: number, { parentId, ...data }: DeptDto): Promise {
+ const item = await this.deptRepository
+ .createQueryBuilder('dept')
+ .where({ id })
+ .getOne()
+
+ const parent = await this.deptRepository
+ .createQueryBuilder('dept')
+ .where({ id: parentId })
+ .getOne()
+
+ await this.deptRepository.save({
+ ...item,
+ ...data,
+ parent,
+ })
+ }
+
+ async delete(id: number): Promise {
+ await this.deptRepository.delete(id)
+ }
+
+ /**
+ * 移动排序
+ */
+ async move(depts: MoveDept[]): Promise {
+ await this.entityManager.transaction(async (manager) => {
+ await manager.save(depts)
+ })
+ }
+
+ /**
+ * 根据部门查询关联的用户数量
+ */
+ async countUserByDeptId(id: number): Promise {
+ return this.userRepository.countBy({ dept: { id } })
+ }
+
+ /**
+ * 查找当前部门下的子部门数量
+ */
+ async countChildDept(id: number): Promise {
+ const item = await this.deptRepository.findOneBy({ id })
+ return (await this.deptRepository.countDescendants(item)) - 1
+ }
+
+ /**
+ * 获取部门列表树结构
+ */
+ async getDeptTree(
+ uid: number,
+ { name }: DeptQueryDto,
+ ): Promise {
+ const tree: DeptEntity[] = []
+
+ if (name) {
+ const deptList = await this.deptRepository
+ .createQueryBuilder('dept')
+ .where('dept.name like :name', { name: `%${name}%` })
+ .getMany()
+
+ for (const dept of deptList) {
+ const deptTree = await this.deptRepository.findDescendantsTree(dept)
+ tree.push(deptTree)
+ }
+
+ deleteEmptyChildren(tree)
+
+ return tree
+ }
+
+ const deptTree = await this.deptRepository.findTrees({
+ depth: 2,
+ relations: ['parent'],
+ })
+
+ deleteEmptyChildren(deptTree)
+
+ return deptTree
+ }
+}
diff --git a/src/modules/system/dict-item/dict-item.controller.ts b/src/modules/system/dict-item/dict-item.controller.ts
new file mode 100644
index 0000000..6906aa6
--- /dev/null
+++ b/src/modules/system/dict-item/dict-item.controller.ts
@@ -0,0 +1,68 @@
+import { Body, Controller, Delete, Get, Post, Query } from '@nestjs/common'
+import { ApiOperation, ApiTags } from '@nestjs/swagger'
+
+import { ApiResult } from '~/common/decorators/api-result.decorator'
+import { IdParam } from '~/common/decorators/id-param.decorator'
+import { ApiSecurityAuth } from '~/common/decorators/swagger.decorator'
+import { Pagination } from '~/helper/paginate/pagination'
+import { AuthUser } from '~/modules/auth/decorators/auth-user.decorator'
+import { Perm, definePermission } from '~/modules/auth/decorators/permission.decorator'
+import { DictItemEntity } from '~/modules/system/dict-item/dict-item.entity'
+
+import { DictItemDto, DictItemQueryDto } from './dict-item.dto'
+import { DictItemService } from './dict-item.service'
+
+export const permissions = definePermission('system:dict-item', {
+ LIST: 'list',
+ CREATE: 'create',
+ READ: 'read',
+ UPDATE: 'update',
+ DELETE: 'delete',
+} as const)
+
+@ApiTags('System - 字典项模块')
+@ApiSecurityAuth()
+@Controller('dict-item')
+export class DictItemController {
+ constructor(private dictItemService: DictItemService) {}
+
+ @Get()
+ @ApiOperation({ summary: '获取字典项列表' })
+ @ApiResult({ type: [DictItemEntity], isPage: true })
+ @Perm(permissions.LIST)
+ async list(@Query() dto: DictItemQueryDto): Promise> {
+ return this.dictItemService.page(dto)
+ }
+
+ @Post()
+ @ApiOperation({ summary: '新增字典项' })
+ @Perm(permissions.CREATE)
+ async create(@Body() dto: DictItemDto, @AuthUser() user: IAuthUser): Promise {
+ await this.dictItemService.isExistKey(dto)
+ dto.createBy = dto.updateBy = user.uid
+ await this.dictItemService.create(dto)
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: '查询字典项信息' })
+ @ApiResult({ type: DictItemEntity })
+ @Perm(permissions.READ)
+ async info(@IdParam() id: number): Promise {
+ return this.dictItemService.findOne(id)
+ }
+
+ @Post(':id')
+ @ApiOperation({ summary: '更新字典项' })
+ @Perm(permissions.UPDATE)
+ async update(@IdParam() id: number, @Body() dto: DictItemDto, @AuthUser() user: IAuthUser): Promise {
+ dto.updateBy = user.uid
+ await this.dictItemService.update(id, dto)
+ }
+
+ @Delete(':id')
+ @ApiOperation({ summary: '删除指定的字典项' })
+ @Perm(permissions.DELETE)
+ async delete(@IdParam() id: number): Promise {
+ await this.dictItemService.delete(id)
+ }
+}
diff --git a/src/modules/system/dict-item/dict-item.dto.ts b/src/modules/system/dict-item/dict-item.dto.ts
new file mode 100644
index 0000000..fa5cf65
--- /dev/null
+++ b/src/modules/system/dict-item/dict-item.dto.ts
@@ -0,0 +1,48 @@
+import { ApiProperty, PartialType } from '@nestjs/swagger'
+import { IsInt, IsOptional, IsString, MinLength } from 'class-validator'
+
+import { PagerDto } from '~/common/dto/pager.dto'
+
+import { DictItemEntity } from './dict-item.entity'
+
+export class DictItemDto extends PartialType(DictItemEntity) {
+ @ApiProperty({ description: '字典类型 ID' })
+ @IsInt()
+ typeId: number
+
+ @ApiProperty({ description: '字典项键名' })
+ @IsString()
+ @MinLength(1)
+ label: string
+
+ @ApiProperty({ description: '字典项值' })
+ @IsString()
+ @MinLength(1)
+ value: string
+
+ @ApiProperty({ description: '状态' })
+ @IsOptional()
+ @IsInt()
+ status?: number
+
+ @ApiProperty({ description: '备注' })
+ @IsOptional()
+ @IsString()
+ remark?: string
+}
+
+export class DictItemQueryDto extends PagerDto {
+ @ApiProperty({ description: '字典类型 ID', required: true })
+ @IsInt()
+ typeId: number
+
+ @ApiProperty({ description: '字典项键名' })
+ @IsString()
+ @IsOptional()
+ label?: string
+
+ @ApiProperty({ description: '字典项值' })
+ @IsString()
+ @IsOptional()
+ value?: string
+}
diff --git a/src/modules/system/dict-item/dict-item.entity.ts b/src/modules/system/dict-item/dict-item.entity.ts
new file mode 100644
index 0000000..d4885a8
--- /dev/null
+++ b/src/modules/system/dict-item/dict-item.entity.ts
@@ -0,0 +1,32 @@
+import { ApiProperty } from '@nestjs/swagger'
+import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'
+
+import { CompleteEntity } from '~/common/entity/common.entity'
+
+import { DictTypeEntity } from '../dict-type/dict-type.entity'
+
+@Entity({ name: 'sys_dict_item' })
+export class DictItemEntity extends CompleteEntity {
+ @ManyToOne(() => DictTypeEntity, { onDelete: 'CASCADE' })
+ @JoinColumn({ name: 'type_id' })
+ type: DictTypeEntity
+
+ @Column({ type: 'varchar', length: 50 })
+ @ApiProperty({ description: '字典项键名' })
+ label: string
+
+ @Column({ type: 'varchar', length: 50 })
+ @ApiProperty({ description: '字典项值' })
+ value: string
+
+ @Column({ nullable: true, comment: '字典项排序' })
+ orderNo: number
+
+ @Column({ type: 'tinyint', default: 1 })
+ @ApiProperty({ description: ' 状态' })
+ status: number
+
+ @Column({ type: 'varchar', nullable: true })
+ @ApiProperty({ description: '备注' })
+ remark: string
+}
diff --git a/src/modules/system/dict-item/dict-item.module.ts b/src/modules/system/dict-item/dict-item.module.ts
new file mode 100644
index 0000000..e5ee832
--- /dev/null
+++ b/src/modules/system/dict-item/dict-item.module.ts
@@ -0,0 +1,16 @@
+import { Module } from '@nestjs/common'
+import { TypeOrmModule } from '@nestjs/typeorm'
+
+import { DictItemController } from './dict-item.controller'
+import { DictItemEntity } from './dict-item.entity'
+import { DictItemService } from './dict-item.service'
+
+const services = [DictItemService]
+
+@Module({
+ imports: [TypeOrmModule.forFeature([DictItemEntity])],
+ controllers: [DictItemController],
+ providers: [...services],
+ exports: [TypeOrmModule, ...services],
+})
+export class DictItemModule {}
diff --git a/src/modules/system/dict-item/dict-item.service.ts b/src/modules/system/dict-item/dict-item.service.ts
new file mode 100644
index 0000000..ae5f0a0
--- /dev/null
+++ b/src/modules/system/dict-item/dict-item.service.ts
@@ -0,0 +1,97 @@
+import { Injectable } from '@nestjs/common'
+import { InjectRepository } from '@nestjs/typeorm'
+
+import { Like, Repository } from 'typeorm'
+
+import { BusinessException } from '~/common/exceptions/biz.exception'
+import { ErrorEnum } from '~/constants/error-code.constant'
+import { paginate } from '~/helper/paginate'
+import { Pagination } from '~/helper/paginate/pagination'
+import { DictItemEntity } from '~/modules/system/dict-item/dict-item.entity'
+
+import { DictItemDto, DictItemQueryDto } from './dict-item.dto'
+
+@Injectable()
+export class DictItemService {
+ constructor(
+ @InjectRepository(DictItemEntity)
+ private dictItemRepository: Repository,
+ ) {}
+
+ /**
+ * 罗列所有配置
+ */
+ async page({
+ page,
+ pageSize,
+ label,
+ value,
+ typeId,
+ }: DictItemQueryDto): Promise> {
+ const queryBuilder = this.dictItemRepository.createQueryBuilder('dict_item')
+ .orderBy({ orderNo: 'ASC' })
+ .where({
+ ...(label && { label: Like(`%${label}%`) }),
+ ...(value && { value: Like(`%${value}%`) }),
+ type: {
+ id: typeId,
+ },
+ })
+
+ return paginate(queryBuilder, { page, pageSize })
+ }
+
+ /**
+ * 获取参数总数
+ */
+ async countConfigList(): Promise {
+ return this.dictItemRepository.count()
+ }
+
+ /**
+ * 新增
+ */
+ async create(dto: DictItemDto): Promise {
+ const { typeId, ...rest } = dto
+ await this.dictItemRepository.insert({
+ ...rest,
+ type: {
+ id: typeId,
+ },
+ })
+ }
+
+ /**
+ * 更新
+ */
+ async update(id: number, dto: Partial): Promise {
+ const { typeId, ...rest } = dto
+ await this.dictItemRepository.update(id, {
+ ...rest,
+ type: {
+ id: typeId,
+ },
+ })
+ }
+
+ /**
+ * 删除
+ */
+ async delete(id: number): Promise {
+ await this.dictItemRepository.delete(id)
+ }
+
+ /**
+ * 查询单个
+ */
+ async findOne(id: number): Promise {
+ return this.dictItemRepository.findOneBy({ id })
+ }
+
+ async isExistKey(dto: DictItemDto): Promise {
+ const { value, typeId } = dto
+ const result = await this.dictItemRepository.findOneBy({ value, type: { id: typeId } })
+ if (result)
+ throw new BusinessException(ErrorEnum.DICT_NAME_EXISTS)
+ }
+}
diff --git a/src/modules/system/dict-type/dict-type.controller.ts b/src/modules/system/dict-type/dict-type.controller.ts
new file mode 100644
index 0000000..652a94b
--- /dev/null
+++ b/src/modules/system/dict-type/dict-type.controller.ts
@@ -0,0 +1,76 @@
+import { Body, Controller, Delete, Get, Post, Query } from '@nestjs/common'
+import { ApiOperation, ApiTags } from '@nestjs/swagger'
+
+import { ApiResult } from '~/common/decorators/api-result.decorator'
+import { IdParam } from '~/common/decorators/id-param.decorator'
+import { ApiSecurityAuth } from '~/common/decorators/swagger.decorator'
+import { Pagination } from '~/helper/paginate/pagination'
+import { AuthUser } from '~/modules/auth/decorators/auth-user.decorator'
+import { Perm, definePermission } from '~/modules/auth/decorators/permission.decorator'
+import { DictTypeEntity } from '~/modules/system/dict-type/dict-type.entity'
+
+import { DictTypeDto, DictTypeQueryDto } from './dict-type.dto'
+import { DictTypeService } from './dict-type.service'
+
+export const permissions = definePermission('system:dict-type', {
+ LIST: 'list',
+ CREATE: 'create',
+ READ: 'read',
+ UPDATE: 'update',
+ DELETE: 'delete',
+} as const)
+
+@ApiTags('System - 字典类型模块')
+@ApiSecurityAuth()
+@Controller('dict-type')
+export class DictTypeController {
+ constructor(private dictTypeService: DictTypeService) {}
+
+ @Get()
+ @ApiOperation({ summary: '获取字典类型列表' })
+ @ApiResult({ type: [DictTypeEntity], isPage: true })
+ @Perm(permissions.LIST)
+ async list(@Query() dto: DictTypeQueryDto): Promise> {
+ return this.dictTypeService.page(dto)
+ }
+
+ @Get('select-options')
+ @ApiOperation({ summary: '一次性获取所有的字典类型(不分页)' })
+ @ApiResult({ type: [DictTypeEntity] })
+ @Perm(permissions.LIST)
+ async getAll(): Promise {
+ return this.dictTypeService.getAll()
+ }
+
+ @Post()
+ @ApiOperation({ summary: '新增字典类型' })
+ @Perm(permissions.CREATE)
+ async create(@Body() dto: DictTypeDto, @AuthUser() user: IAuthUser): Promise {
+ await this.dictTypeService.isExistKey(dto.name)
+ dto.createBy = dto.updateBy = user.uid
+ await this.dictTypeService.create(dto)
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: '查询字典类型信息' })
+ @ApiResult({ type: DictTypeEntity })
+ @Perm(permissions.READ)
+ async info(@IdParam() id: number): Promise {
+ return this.dictTypeService.findOne(id)
+ }
+
+ @Post(':id')
+ @ApiOperation({ summary: '更新字典类型' })
+ @Perm(permissions.UPDATE)
+ async update(@IdParam() id: number, @Body() dto: DictTypeDto, @AuthUser() user: IAuthUser): Promise {
+ dto.updateBy = user.uid
+ await this.dictTypeService.update(id, dto)
+ }
+
+ @Delete(':id')
+ @ApiOperation({ summary: '删除指定的字典类型' })
+ @Perm(permissions.DELETE)
+ async delete(@IdParam() id: number): Promise {
+ await this.dictTypeService.delete(id)
+ }
+}
diff --git a/src/modules/system/dict-type/dict-type.dto.ts b/src/modules/system/dict-type/dict-type.dto.ts
new file mode 100644
index 0000000..c3809b4
--- /dev/null
+++ b/src/modules/system/dict-type/dict-type.dto.ts
@@ -0,0 +1,40 @@
+import { ApiProperty, PartialType } from '@nestjs/swagger'
+import { IsInt, IsOptional, IsString, MinLength } from 'class-validator'
+
+import { PagerDto } from '~/common/dto/pager.dto'
+
+import { DictTypeEntity } from './dict-type.entity'
+
+export class DictTypeDto extends PartialType(DictTypeEntity) {
+ @ApiProperty({ description: '字典类型名称' })
+ @IsString()
+ @MinLength(1)
+ name: string
+
+ @ApiProperty({ description: '字典类型code' })
+ @IsString()
+ @MinLength(3)
+ code: string
+
+ @ApiProperty({ description: '状态' })
+ @IsOptional()
+ @IsInt()
+ status?: number
+
+ @ApiProperty({ description: '备注' })
+ @IsOptional()
+ @IsString()
+ remark?: string
+}
+
+export class DictTypeQueryDto extends PagerDto {
+ @ApiProperty({ description: '字典类型名称' })
+ @IsString()
+ @IsOptional()
+ name: string
+
+ @ApiProperty({ description: '字典类型code' })
+ @IsString()
+ @IsOptional()
+ code: string
+}
diff --git a/src/modules/system/dict-type/dict-type.entity.ts b/src/modules/system/dict-type/dict-type.entity.ts
new file mode 100644
index 0000000..8d2608f
--- /dev/null
+++ b/src/modules/system/dict-type/dict-type.entity.ts
@@ -0,0 +1,23 @@
+import { ApiProperty } from '@nestjs/swagger'
+import { Column, Entity } from 'typeorm'
+
+import { CompleteEntity } from '~/common/entity/common.entity'
+
+@Entity({ name: 'sys_dict_type' })
+export class DictTypeEntity extends CompleteEntity {
+ @Column({ type: 'varchar', length: 50 })
+ @ApiProperty({ description: '字典名称' })
+ name: string
+
+ @Column({ type: 'varchar', length: 50, unique: true })
+ @ApiProperty({ description: '字典类型' })
+ code: string
+
+ @Column({ type: 'tinyint', default: 1 })
+ @ApiProperty({ description: ' 状态' })
+ status: number
+
+ @Column({ type: 'varchar', nullable: true })
+ @ApiProperty({ description: '备注' })
+ remark: string
+}
diff --git a/src/modules/system/dict-type/dict-type.module.ts b/src/modules/system/dict-type/dict-type.module.ts
new file mode 100644
index 0000000..5f8f238
--- /dev/null
+++ b/src/modules/system/dict-type/dict-type.module.ts
@@ -0,0 +1,16 @@
+import { Module } from '@nestjs/common'
+import { TypeOrmModule } from '@nestjs/typeorm'
+
+import { DictTypeController } from './dict-type.controller'
+import { DictTypeEntity } from './dict-type.entity'
+import { DictTypeService } from './dict-type.service'
+
+const services = [DictTypeService]
+
+@Module({
+ imports: [TypeOrmModule.forFeature([DictTypeEntity])],
+ controllers: [DictTypeController],
+ providers: [...services],
+ exports: [TypeOrmModule, ...services],
+})
+export class DictTypeModule {}
diff --git a/src/modules/system/dict-type/dict-type.service.ts b/src/modules/system/dict-type/dict-type.service.ts
new file mode 100644
index 0000000..04e6c47
--- /dev/null
+++ b/src/modules/system/dict-type/dict-type.service.ts
@@ -0,0 +1,84 @@
+import { Injectable } from '@nestjs/common'
+import { InjectRepository } from '@nestjs/typeorm'
+
+import { Like, Repository } from 'typeorm'
+
+import { BusinessException } from '~/common/exceptions/biz.exception'
+import { ErrorEnum } from '~/constants/error-code.constant'
+import { paginate } from '~/helper/paginate'
+import { Pagination } from '~/helper/paginate/pagination'
+import { DictTypeEntity } from '~/modules/system/dict-type/dict-type.entity'
+
+import { DictTypeDto, DictTypeQueryDto } from './dict-type.dto'
+
+@Injectable()
+export class DictTypeService {
+ constructor(
+ @InjectRepository(DictTypeEntity)
+ private dictTypeRepository: Repository,
+ ) {}
+
+ /**
+ * 罗列所有配置
+ */
+ async page({
+ page,
+ pageSize,
+ name,
+ code,
+ }: DictTypeQueryDto): Promise> {
+ const queryBuilder = this.dictTypeRepository.createQueryBuilder('dict_type')
+ .where({
+ ...(name && { name: Like(`%${name}%`) }),
+ ...(code && { code: Like(`%${code}%`) }),
+ })
+
+ return paginate(queryBuilder, { page, pageSize })
+ }
+
+ /** 一次性获取所有的字典类型 */
+ async getAll() {
+ return this.dictTypeRepository.find()
+ }
+
+ /**
+ * 获取参数总数
+ */
+ async countConfigList(): Promise {
+ return this.dictTypeRepository.count()
+ }
+
+ /**
+ * 新增
+ */
+ async create(dto: DictTypeDto): Promise {
+ await this.dictTypeRepository.insert(dto)
+ }
+
+ /**
+ * 更新
+ */
+ async update(id: number, dto: Partial): Promise {
+ await this.dictTypeRepository.update(id, dto)
+ }
+
+ /**
+ * 删除
+ */
+ async delete(id: number): Promise {
+ await this.dictTypeRepository.delete(id)
+ }
+
+ /**
+ * 查询单个
+ */
+ async findOne(id: number): Promise {
+ return this.dictTypeRepository.findOneBy({ id })
+ }
+
+ async isExistKey(name: string): Promise {
+ const result = await this.dictTypeRepository.findOneBy({ name })
+ if (result)
+ throw new BusinessException(ErrorEnum.DICT_NAME_EXISTS)
+ }
+}
diff --git a/src/modules/system/log/dto/log.dto.ts b/src/modules/system/log/dto/log.dto.ts
new file mode 100644
index 0000000..5cf0ff3
--- /dev/null
+++ b/src/modules/system/log/dto/log.dto.ts
@@ -0,0 +1,57 @@
+import { ApiProperty } from '@nestjs/swagger'
+import { IsOptional, IsString } from 'class-validator'
+
+import { PagerDto } from '~/common/dto/pager.dto'
+
+export class LoginLogQueryDto extends PagerDto {
+ @ApiProperty({ description: '用户名' })
+ @IsString()
+ @IsOptional()
+ username: string
+
+ @ApiProperty({ description: '登录IP' })
+ @IsOptional()
+ @IsString()
+ ip?: string
+
+ @ApiProperty({ description: '登录地点' })
+ @IsOptional()
+ @IsString()
+ address?: string
+
+ @ApiProperty({ description: '登录时间' })
+ @IsOptional()
+ time?: string[]
+}
+
+export class TaskLogQueryDto extends PagerDto {
+ @ApiProperty({ description: '用户名' })
+ @IsOptional()
+ @IsString()
+ username: string
+
+ @ApiProperty({ description: '登录IP' })
+ @IsString()
+ @IsOptional()
+ ip?: string
+
+ @ApiProperty({ description: '登录时间' })
+ @IsOptional()
+ time?: string[]
+}
+
+export class CaptchaLogQueryDto extends PagerDto {
+ @ApiProperty({ description: '用户名' })
+ @IsOptional()
+ @IsString()
+ username: string
+
+ @ApiProperty({ description: '验证码' })
+ @IsString()
+ @IsOptional()
+ code?: string
+
+ @ApiProperty({ description: '发送时间' })
+ @IsOptional()
+ time?: string[]
+}
diff --git a/src/modules/system/log/entities/captcha-log.entity.ts b/src/modules/system/log/entities/captcha-log.entity.ts
new file mode 100644
index 0000000..048bec8
--- /dev/null
+++ b/src/modules/system/log/entities/captcha-log.entity.ts
@@ -0,0 +1,23 @@
+import { ApiProperty } from '@nestjs/swagger'
+import { Column, Entity } from 'typeorm'
+
+import { CommonEntity } from '~/common/entity/common.entity'
+
+@Entity({ name: 'sys_captcha_log' })
+export class CaptchaLogEntity extends CommonEntity {
+ @Column({ name: 'user_id', nullable: true })
+ @ApiProperty({ description: '用户ID' })
+ userId: number
+
+ @Column({ nullable: true })
+ @ApiProperty({ description: '账号' })
+ account: string
+
+ @Column({ nullable: true })
+ @ApiProperty({ description: '验证码' })
+ code: string
+
+ @Column({ nullable: true })
+ @ApiProperty({ description: '验证码提供方' })
+ provider: 'sms' | 'email'
+}
diff --git a/src/modules/system/log/entities/index.ts b/src/modules/system/log/entities/index.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/system/log/entities/login-log.entity.ts b/src/modules/system/log/entities/login-log.entity.ts
new file mode 100644
index 0000000..7a56568
--- /dev/null
+++ b/src/modules/system/log/entities/login-log.entity.ts
@@ -0,0 +1,29 @@
+import { ApiProperty } from '@nestjs/swagger'
+import { Column, Entity, JoinColumn, ManyToOne, Relation } from 'typeorm'
+
+import { CommonEntity } from '~/common/entity/common.entity'
+
+import { UserEntity } from '../../../user/user.entity'
+
+@Entity({ name: 'sys_login_log' })
+export class LoginLogEntity extends CommonEntity {
+ @Column({ nullable: true })
+ @ApiProperty({ description: 'IP' })
+ ip: string
+
+ @Column({ nullable: true })
+ @ApiProperty({ description: '地址' })
+ address: string
+
+ @Column({ nullable: true })
+ @ApiProperty({ description: '登录方式' })
+ provider: string
+
+ @Column({ length: 500, nullable: true })
+ @ApiProperty({ description: '浏览器ua' })
+ ua: string
+
+ @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
+ @JoinColumn({ name: 'user_id' })
+ user: Relation
+}
diff --git a/src/modules/system/log/entities/task-log.entity.ts b/src/modules/system/log/entities/task-log.entity.ts
new file mode 100644
index 0000000..1721d68
--- /dev/null
+++ b/src/modules/system/log/entities/task-log.entity.ts
@@ -0,0 +1,25 @@
+import { ApiProperty } from '@nestjs/swagger'
+import { Column, Entity, JoinColumn, ManyToOne, Relation } from 'typeorm'
+
+import { CommonEntity } from '~/common/entity/common.entity'
+
+import { TaskEntity } from '../../task/task.entity'
+
+@Entity({ name: 'sys_task_log' })
+export class TaskLogEntity extends CommonEntity {
+ @Column({ type: 'tinyint', default: 0 })
+ @ApiProperty({ description: '任务状态:0失败,1成功' })
+ status: number
+
+ @Column({ type: 'text', nullable: true })
+ @ApiProperty({ description: '任务日志信息' })
+ detail: string
+
+ @Column({ type: 'int', nullable: true, name: 'consume_time', default: 0 })
+ @ApiProperty({ description: '任务耗时' })
+ consumeTime: number
+
+ @ManyToOne(() => TaskEntity)
+ @JoinColumn({ name: 'task_id' })
+ task: Relation
+}
diff --git a/src/modules/system/log/log.controller.ts b/src/modules/system/log/log.controller.ts
new file mode 100644
index 0000000..489f3cb
--- /dev/null
+++ b/src/modules/system/log/log.controller.ts
@@ -0,0 +1,64 @@
+import { Controller, Get, Query } from '@nestjs/common'
+import { ApiOperation, ApiTags } from '@nestjs/swagger'
+
+import { ApiResult } from '~/common/decorators/api-result.decorator'
+import { ApiSecurityAuth } from '~/common/decorators/swagger.decorator'
+import { Pagination } from '~/helper/paginate/pagination'
+import { Perm, definePermission } from '~/modules/auth/decorators/permission.decorator'
+
+import {
+ CaptchaLogQueryDto,
+ LoginLogQueryDto,
+ TaskLogQueryDto,
+} from './dto/log.dto'
+import { CaptchaLogEntity } from './entities/captcha-log.entity'
+import { TaskLogEntity } from './entities/task-log.entity'
+import { LoginLogInfo } from './models/log.model'
+import { CaptchaLogService } from './services/captcha-log.service'
+import { LoginLogService } from './services/login-log.service'
+import { TaskLogService } from './services/task-log.service'
+
+export const permissions = definePermission('system:log', {
+ TaskList: 'task:list',
+ LogList: 'login:list',
+ CaptchaList: 'captcha:list',
+} as const)
+
+@ApiSecurityAuth()
+@ApiTags('System - 日志模块')
+@Controller('log')
+export class LogController {
+ constructor(
+ private loginLogService: LoginLogService,
+ private taskService: TaskLogService,
+ private captchaLogService: CaptchaLogService,
+ ) {}
+
+ @Get('login/list')
+ @ApiOperation({ summary: '查询登录日志列表' })
+ @ApiResult({ type: [LoginLogInfo], isPage: true })
+ @Perm(permissions.TaskList)
+ async loginLogPage(
+ @Query() dto: LoginLogQueryDto,
+ ): Promise> {
+ return this.loginLogService.list(dto)
+ }
+
+ @Get('task/list')
+ @ApiOperation({ summary: '查询任务日志列表' })
+ @ApiResult({ type: [TaskLogEntity], isPage: true })
+ @Perm(permissions.LogList)
+ async taskList(@Query() dto: TaskLogQueryDto) {
+ return this.taskService.list(dto)
+ }
+
+ @Get('captcha/list')
+ @ApiOperation({ summary: '查询验证码日志列表' })
+ @ApiResult({ type: [CaptchaLogEntity], isPage: true })
+ @Perm(permissions.CaptchaList)
+ async captchaList(
+ @Query() dto: CaptchaLogQueryDto,
+ ): Promise> {
+ return this.captchaLogService.paginate(dto)
+ }
+}
diff --git a/src/modules/system/log/log.module.ts b/src/modules/system/log/log.module.ts
new file mode 100644
index 0000000..27f9f04
--- /dev/null
+++ b/src/modules/system/log/log.module.ts
@@ -0,0 +1,25 @@
+import { Module } from '@nestjs/common'
+import { TypeOrmModule } from '@nestjs/typeorm'
+
+import { UserModule } from '../../user/user.module'
+
+import { CaptchaLogEntity } from './entities/captcha-log.entity'
+import { LoginLogEntity } from './entities/login-log.entity'
+import { TaskLogEntity } from './entities/task-log.entity'
+import { LogController } from './log.controller'
+import { CaptchaLogService } from './services/captcha-log.service'
+import { LoginLogService } from './services/login-log.service'
+import { TaskLogService } from './services/task-log.service'
+
+const providers = [LoginLogService, TaskLogService, CaptchaLogService]
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([LoginLogEntity, CaptchaLogEntity, TaskLogEntity]),
+ UserModule,
+ ],
+ controllers: [LogController],
+ providers: [...providers],
+ exports: [TypeOrmModule, ...providers],
+})
+export class LogModule {}
diff --git a/src/modules/system/log/models/log.model.ts b/src/modules/system/log/models/log.model.ts
new file mode 100644
index 0000000..f128be0
--- /dev/null
+++ b/src/modules/system/log/models/log.model.ts
@@ -0,0 +1,47 @@
+import { ApiProperty } from '@nestjs/swagger'
+
+export class LoginLogInfo {
+ @ApiProperty({ description: '日志编号' })
+ id: number
+
+ @ApiProperty({ description: '登录ip', example: '1.1.1.1' })
+ ip: string
+
+ @ApiProperty({ description: '登录地址' })
+ address: string
+
+ @ApiProperty({ description: '系统', example: 'Windows 10' })
+ os: string
+
+ @ApiProperty({ description: '浏览器', example: 'Chrome' })
+ browser: string
+
+ @ApiProperty({ description: '登录用户名', example: 'admin' })
+ username: string
+
+ @ApiProperty({ description: '登录时间', example: '2023-12-22 16:46:20.333843' })
+ time: string
+}
+
+export class TaskLogInfo {
+ @ApiProperty({ description: '日志编号' })
+ id: number
+
+ @ApiProperty({ description: '任务编号' })
+ taskId: number
+
+ @ApiProperty({ description: '任务名称' })
+ name: string
+
+ @ApiProperty({ description: '创建时间' })
+ createdAt: string
+
+ @ApiProperty({ description: '耗时' })
+ consumeTime: number
+
+ @ApiProperty({ description: '执行信息' })
+ detail: string
+
+ @ApiProperty({ description: '任务执行状态' })
+ status: number
+}
diff --git a/src/modules/system/log/services/captcha-log.service.ts b/src/modules/system/log/services/captcha-log.service.ts
new file mode 100644
index 0000000..5bc01e1
--- /dev/null
+++ b/src/modules/system/log/services/captcha-log.service.ts
@@ -0,0 +1,50 @@
+import { Injectable } from '@nestjs/common'
+import { InjectRepository } from '@nestjs/typeorm'
+
+import { LessThan, Repository } from 'typeorm'
+
+import { paginate } from '~/helper/paginate'
+
+import { CaptchaLogQueryDto } from '../dto/log.dto'
+import { CaptchaLogEntity } from '../entities/captcha-log.entity'
+
+@Injectable()
+export class CaptchaLogService {
+ constructor(
+ @InjectRepository(CaptchaLogEntity)
+ private captchaLogRepository: Repository,
+ ) {}
+
+ async create(
+ account: string,
+ code: string,
+ provider: 'sms' | 'email',
+ uid?: number,
+ ): Promise {
+ await this.captchaLogRepository.save({
+ account,
+ code,
+ provider,
+ userId: uid,
+ })
+ }
+
+ async paginate({ page, pageSize }: CaptchaLogQueryDto) {
+ const queryBuilder = await this.captchaLogRepository
+ .createQueryBuilder('captcha_log')
+ .orderBy('captcha_log.id', 'DESC')
+
+ return paginate(queryBuilder, {
+ page,
+ pageSize,
+ })
+ }
+
+ async clearLog(): Promise {
+ await this.captchaLogRepository.clear()
+ }
+
+ async clearLogBeforeTime(time: Date): Promise {
+ await this.captchaLogRepository.delete({ createdAt: LessThan(time) })
+ }
+}
diff --git a/src/modules/system/log/services/login-log.service.ts b/src/modules/system/log/services/login-log.service.ts
new file mode 100644
index 0000000..b3420de
--- /dev/null
+++ b/src/modules/system/log/services/login-log.service.ts
@@ -0,0 +1,100 @@
+import { Injectable } from '@nestjs/common'
+import { InjectRepository } from '@nestjs/typeorm'
+
+import { Between, LessThan, Like, Repository } from 'typeorm'
+
+import UAParser from 'ua-parser-js'
+
+import { paginateRaw } from '~/helper/paginate'
+
+import { getIpAddress } from '~/utils/ip.util'
+
+import { LoginLogQueryDto } from '../dto/log.dto'
+import { LoginLogEntity } from '../entities/login-log.entity'
+import { LoginLogInfo } from '../models/log.model'
+
+async function parseLoginLog(e: any, parser: UAParser): Promise {
+ const uaResult = parser.setUA(e.login_log_ua).getResult()
+
+ return {
+ id: e.login_log_id,
+ ip: e.login_log_ip,
+ address: e.login_log_address,
+ os: `${`${uaResult.os.name ?? ''} `}${uaResult.os.version}`,
+ browser: `${`${uaResult.browser.name ?? ''} `}${uaResult.browser.version}`,
+ username: e.user_username,
+ time: e.login_log_created_at,
+ }
+}
+
+@Injectable()
+export class LoginLogService {
+ constructor(
+ @InjectRepository(LoginLogEntity)
+ private loginLogRepository: Repository,
+
+ ) {}
+
+ async create(uid: number, ip: string, ua: string): Promise {
+ try {
+ const address = await getIpAddress(ip)
+
+ await this.loginLogRepository.save({
+ ip,
+ ua,
+ address,
+ user: { id: uid },
+ })
+ }
+ catch (e) {
+ console.error(e)
+ }
+ }
+
+ async list({
+ page,
+ pageSize,
+ username,
+ ip,
+ address,
+ time,
+ }: LoginLogQueryDto) {
+ const queryBuilder = await this.loginLogRepository
+ .createQueryBuilder('login_log')
+ .innerJoinAndSelect('login_log.user', 'user')
+ .where({
+ ...(ip && { ip: Like(`%${ip}%`) }),
+ ...(address && { address: Like(`%${address}%`) }),
+ ...(time && { createdAt: Between(time[0], time[1]) }),
+ ...(username && {
+ user: {
+ username: Like(`%${username}%`),
+ },
+ }),
+ })
+ .orderBy('login_log.created_at', 'DESC')
+
+ const { items, ...rest } = await paginateRaw(queryBuilder, {
+ page,
+ pageSize,
+ })
+
+ const parser = new UAParser()
+ const loginLogInfos = await Promise.all(
+ items.map(item => parseLoginLog(item, parser)),
+ )
+
+ return {
+ items: loginLogInfos,
+ ...rest,
+ }
+ }
+
+ async clearLog(): Promise {
+ await this.loginLogRepository.clear()
+ }
+
+ async clearLogBeforeTime(time: Date): Promise {
+ await this.loginLogRepository.delete({ createdAt: LessThan(time) })
+ }
+}
diff --git a/src/modules/system/log/services/task-log.service.ts b/src/modules/system/log/services/task-log.service.ts
new file mode 100644
index 0000000..b57410f
--- /dev/null
+++ b/src/modules/system/log/services/task-log.service.ts
@@ -0,0 +1,52 @@
+import { Injectable } from '@nestjs/common'
+import { InjectRepository } from '@nestjs/typeorm'
+
+import { LessThan, Repository } from 'typeorm'
+
+import { paginate } from '~/helper/paginate'
+
+import { TaskLogQueryDto } from '../dto/log.dto'
+import { TaskLogEntity } from '../entities/task-log.entity'
+
+@Injectable()
+export class TaskLogService {
+ constructor(
+ @InjectRepository(TaskLogEntity)
+ private taskLogRepository: Repository