Selaa lähdekoodia

添加链接信息管理功能,包括新的控制器、服务、DTO和路由配置,支持创建、更新和查询链接信息。

wuyi 1 kuukausi sitten
vanhempi
commit
a345e184cb

+ 1 - 0
.gitignore

@@ -13,6 +13,7 @@ dist
 dist-ssr
 coverage
 *.local
+docs
 
 /cypress/videos/
 /cypress/screenshots/

+ 2 - 0
src/app.ts

@@ -15,6 +15,7 @@ import qrCodeRoutes from './routes/qr-code.routes'
 import personInfoRoutes from './routes/person-info.routes'
 import petInfoRoutes from './routes/pet-info.routes'
 import goodsInfoRoutes from './routes/goods-info.routes'
+import linkInfoRoutes from './routes/link-info.routes'
 import scanRecordRoutes from './routes/scan-record.routes'
 
 const options: FastifyEnvOptions = {
@@ -86,6 +87,7 @@ export const createApp = async () => {
   app.register(personInfoRoutes, { prefix: '/api/person' })
   app.register(petInfoRoutes, { prefix: '/api/pet' })
   app.register(goodsInfoRoutes, { prefix: '/api/goods' })
+  app.register(linkInfoRoutes, { prefix: '/api/link' })
   app.register(scanRecordRoutes, { prefix: '/api/scan' })
 
   const dataSource = createDataSource(app)

+ 126 - 0
src/controllers/link-info.controller.ts

@@ -0,0 +1,126 @@
+import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
+import { LinkInfoService } from '../services/link-info.service'
+import { CreateLinkInfoDto, UpdateLinkInfoDto, QueryLinkInfoDto, AdminUpdateLinkInfoDto } from '../dto/link-info.dto'
+
+export class LinkInfoController {
+  private linkInfoService: LinkInfoService
+
+  constructor(app: FastifyInstance) {
+    this.linkInfoService = new LinkInfoService(app)
+  }
+
+  async create(request: FastifyRequest<{ Body: CreateLinkInfoDto }>, reply: FastifyReply) {
+    try {
+      const { qrCode, ...data } = request.body
+
+      const linkInfo = await this.linkInfoService.create(qrCode, data)
+
+      return reply.code(201).send({
+        message: '链接信息创建成功',
+        data: linkInfo
+      })
+    } catch (error) {
+      const message = error instanceof Error ? error.message : '创建失败'
+      return reply.code(400).send({ message })
+    }
+  }
+
+  async update(request: FastifyRequest<{ Body: UpdateLinkInfoDto }>, reply: FastifyReply) {
+    try {
+      const { qrCode, maintenanceCode, ...data } = request.body
+
+      const linkInfo = await this.linkInfoService.update(qrCode, maintenanceCode, data)
+
+      return reply.send({
+        message: '链接信息更新成功',
+        data: linkInfo
+      })
+    } catch (error) {
+      const message = error instanceof Error ? error.message : '更新失败'
+      return reply.code(400).send({ message })
+    }
+  }
+
+  async get(request: FastifyRequest<{ Querystring: { qrCode: string } }>, reply: FastifyReply) {
+    try {
+      const { qrCode } = request.query
+
+      if (!qrCode) {
+        return reply.code(400).send({ message: '请提供二维码参数' })
+      }
+
+      const linkInfo = await this.linkInfoService.findByQrCode(qrCode)
+
+      if (!linkInfo) {
+        return reply.code(404).send({ message: '链接信息不存在' })
+      }
+
+      return reply.send(linkInfo)
+    } catch (error) {
+      const message = error instanceof Error ? error.message : '获取失败'
+      return reply.code(500).send({ message })
+    }
+  }
+
+  async adminUpdate(request: FastifyRequest<{ Body: AdminUpdateLinkInfoDto }>, reply: FastifyReply) {
+    try {
+      const { qrCodeId, ...data } = request.body
+
+      if (!qrCodeId) {
+        return reply.code(400).send({ message: '请提供二维码ID' })
+      }
+
+      const linkInfo = await this.linkInfoService.adminUpdate(qrCodeId, data)
+
+      return reply.send({
+        message: '链接信息更新成功',
+        data: linkInfo
+      })
+    } catch (error) {
+      const message = error instanceof Error ? error.message : '更新失败'
+      return reply.code(400).send({ message })
+    }
+  }
+
+  async list(request: FastifyRequest<{ Querystring: QueryLinkInfoDto }>, reply: FastifyReply) {
+    try {
+      const { jumpUrl, remark, startDate, endDate, page, pageSize } = request.query
+
+      const result = await this.linkInfoService.query(
+        jumpUrl,
+        remark,
+        startDate,
+        endDate,
+        page,
+        pageSize
+      )
+
+      return reply.send(result)
+    } catch (error) {
+      const message = error instanceof Error ? error.message : '查询失败'
+      return reply.code(500).send({ message })
+    }
+  }
+
+  async adminGetDetail(request: FastifyRequest<{ Querystring: { qrCodeId: string } }>, reply: FastifyReply) {
+    try {
+      const { qrCodeId } = request.query
+
+      if (!qrCodeId) {
+        return reply.code(400).send({ message: '请提供二维码ID' })
+      }
+
+      const linkInfo = await this.linkInfoService.adminGetDetail(Number(qrCodeId))
+
+      if (!linkInfo) {
+        return reply.code(404).send({ message: '链接信息不存在' })
+      }
+
+      return reply.send(linkInfo)
+    } catch (error) {
+      const message = error instanceof Error ? error.message : '获取失败'
+      return reply.code(500).send({ message })
+    }
+  }
+}
+

+ 93 - 0
src/dto/link-info.dto.ts

@@ -0,0 +1,93 @@
+import { IsString, IsOptional, IsBoolean, MaxLength, IsNumber, IsDateString, Min, IsUrl } from 'class-validator'
+
+export class CreateLinkInfoDto {
+  @IsString()
+  qrCode: string
+
+  @IsOptional()
+  @IsString()
+  @IsUrl()
+  @MaxLength(2000)
+  jumpUrl?: string
+
+  @IsOptional()
+  @IsBoolean()
+  isVisible?: boolean
+
+  @IsOptional()
+  @IsString()
+  @MaxLength(500)
+  remark?: string
+}
+
+export class UpdateLinkInfoDto {
+  @IsString()
+  qrCode: string
+
+  @IsString()
+  maintenanceCode: string
+
+  @IsOptional()
+  @IsString()
+  @IsUrl()
+  @MaxLength(2000)
+  jumpUrl?: string
+
+  @IsOptional()
+  @IsBoolean()
+  isVisible?: boolean
+
+  @IsOptional()
+  @IsString()
+  @MaxLength(500)
+  remark?: string
+}
+
+export class QueryLinkInfoDto {
+  @IsOptional()
+  @IsString()
+  jumpUrl?: string
+
+  @IsOptional()
+  @IsString()
+  remark?: string
+
+  @IsOptional()
+  @IsDateString()
+  startDate?: string
+
+  @IsOptional()
+  @IsDateString()
+  endDate?: string
+
+  @IsOptional()
+  @IsNumber()
+  @Min(1)
+  page?: number = 1
+
+  @IsOptional()
+  @IsNumber()
+  @Min(1)
+  pageSize?: number = 20
+}
+
+export class AdminUpdateLinkInfoDto {
+  @IsNumber()
+  qrCodeId: number
+
+  @IsOptional()
+  @IsString()
+  @IsUrl()
+  @MaxLength(2000)
+  jumpUrl?: string
+
+  @IsOptional()
+  @IsBoolean()
+  isVisible?: boolean
+
+  @IsOptional()
+  @IsString()
+  @MaxLength(500)
+  remark?: string
+}
+

+ 39 - 0
src/entities/link-info.entity.ts

@@ -0,0 +1,39 @@
+import {
+  Entity,
+  Column,
+  CreateDateColumn,
+  PrimaryGeneratedColumn,
+  UpdateDateColumn,
+  OneToOne,
+  JoinColumn,
+  Index
+} from 'typeorm'
+import { QrCode } from './qr-code.entity'
+
+@Entity()
+export class LinkInfo {
+  @PrimaryGeneratedColumn()
+  id: number
+
+  @Column({ unique: true })
+  qrCodeId: number
+
+  @Column({ type: 'text', nullable: true })
+  jumpUrl: string
+
+  @Column({ default: true })
+  isVisible: boolean
+
+  @Column({ type: 'text', nullable: true })
+  remark: string
+
+  @CreateDateColumn()
+  createdAt: Date
+
+  @UpdateDateColumn()
+  updatedAt: Date
+
+  @OneToOne(() => QrCode, qrCode => qrCode.linkInfo)
+  @JoinColumn({ name: 'qrCodeId' })
+  qrCode: QrCode
+}

+ 6 - 1
src/entities/qr-code.entity.ts

@@ -12,11 +12,13 @@ import { PersonInfo } from './person-info.entity'
 import { PetInfo } from './pet-info.entity'
 import { GoodsInfo } from './goods-info.entity'
 import { ScanRecord } from './scan-record.entity'
+import { LinkInfo } from './link-info.entity'
 
 export enum QrType {
   PERSON = 'person',
   PET = 'pet',
-  GOODS = 'goods'
+  GOODS = 'goods',
+  LINK = 'link'
 }
 
 @Entity()
@@ -59,6 +61,9 @@ export class QrCode {
   @OneToOne(() => GoodsInfo, goodsInfo => goodsInfo.qrCode)
   goodsInfo: GoodsInfo
 
+  @OneToOne(() => LinkInfo, linkInfo => linkInfo.qrCode)
+  linkInfo: LinkInfo
+
   @OneToMany(() => ScanRecord, scanRecord => scanRecord.qrCode)
   scanRecords: ScanRecord[]
 }

+ 34 - 0
src/routes/link-info.routes.ts

@@ -0,0 +1,34 @@
+import { FastifyInstance } from 'fastify'
+import { LinkInfoController } from '../controllers/link-info.controller'
+import { hasRole } from '../middlewares/auth.middleware'
+import { UserRole } from '../entities/user.entity'
+import { CreateLinkInfoDto, UpdateLinkInfoDto, QueryLinkInfoDto, AdminUpdateLinkInfoDto } from '../dto/link-info.dto'
+
+export default async function linkInfoRoutes(fastify: FastifyInstance) {
+  const linkInfoController = new LinkInfoController(fastify)
+
+  fastify.post<{ Body: CreateLinkInfoDto }>('/create', linkInfoController.create.bind(linkInfoController))
+
+  fastify.put<{ Body: UpdateLinkInfoDto }>('/update', linkInfoController.update.bind(linkInfoController))
+
+  fastify.get<{ Querystring: { qrCode: string } }>('/get', linkInfoController.get.bind(linkInfoController))
+
+  fastify.get<{ Querystring: QueryLinkInfoDto }>(
+    '/admin/list',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    linkInfoController.list.bind(linkInfoController)
+  )
+
+  fastify.get<{ Querystring: { qrCodeId: string } }>(
+    '/admin/detail',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    linkInfoController.adminGetDetail.bind(linkInfoController)
+  )
+
+  fastify.post<{ Body: AdminUpdateLinkInfoDto }>(
+    '/admin/update',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    linkInfoController.adminUpdate.bind(linkInfoController)
+  )
+}
+

+ 161 - 0
src/services/link-info.service.ts

@@ -0,0 +1,161 @@
+import { Repository } from 'typeorm'
+import { FastifyInstance } from 'fastify'
+import { LinkInfo } from '../entities/link-info.entity'
+import { QrCodeService } from './qr-code.service'
+import { QrType } from '../entities/qr-code.entity'
+import { PaginationResponse } from '../dto/common.dto'
+import { AdminUpdateLinkInfoDto } from '../dto/link-info.dto'
+
+export class LinkInfoService {
+  private linkInfoRepository: Repository<LinkInfo>
+  private qrCodeService: QrCodeService
+
+  constructor(app: FastifyInstance) {
+    this.linkInfoRepository = app.dataSource.getRepository(LinkInfo)
+    this.qrCodeService = new QrCodeService(app)
+  }
+
+  async create(qrCode: string, data: Partial<LinkInfo>): Promise<LinkInfo> {
+    const qrCodeEntity = await this.qrCodeService.findByQrCode(qrCode)
+    if (!qrCodeEntity) {
+      throw new Error('二维码不存在')
+    }
+
+    if (qrCodeEntity.qrType !== QrType.LINK) {
+      throw new Error('二维码类型不匹配')
+    }
+
+    if (qrCodeEntity.isActivated) {
+      throw new Error('二维码已激活,无法重复填写')
+    }
+
+    const linkInfo = this.linkInfoRepository.create({
+      ...data,
+      qrCodeId: qrCodeEntity.id
+    })
+
+    const result = await this.linkInfoRepository.save(linkInfo)
+
+    await this.qrCodeService.activateQrCode(qrCodeEntity.id)
+
+    return result
+  }
+
+  async update(qrCode: string, maintenanceCode: string, data: Partial<LinkInfo>): Promise<LinkInfo | null> {
+    const isValid = await this.qrCodeService.verifyMaintenanceCode(qrCode, maintenanceCode)
+    if (!isValid) {
+      throw new Error('维护码错误')
+    }
+
+    const qrCodeEntity = await this.qrCodeService.findByQrCode(qrCode)
+    if (!qrCodeEntity) {
+      throw new Error('二维码不存在')
+    }
+
+    const linkInfo = await this.linkInfoRepository.findOne({
+      where: { qrCodeId: qrCodeEntity.id }
+    })
+
+    if (!linkInfo) {
+      const created = this.linkInfoRepository.create({
+        ...data,
+        qrCodeId: qrCodeEntity.id
+      })
+      const result = await this.linkInfoRepository.save(created)
+      await this.qrCodeService.activateQrCode(qrCodeEntity.id)
+      return result
+    }
+
+    await this.linkInfoRepository.update(linkInfo.id, data)
+
+    const updated = await this.linkInfoRepository.findOne({ where: { id: linkInfo.id } })
+    if (!updated) {
+      throw new Error('更新后无法找到链接信息')
+    }
+    return updated
+  }
+
+  async findByQrCodeId(qrCodeId: number): Promise<LinkInfo | null> {
+    return this.linkInfoRepository.findOne({ where: { qrCodeId } })
+  }
+
+  async findByQrCode(qrCode: string): Promise<LinkInfo | null> {
+    const qrCodeEntity = await this.qrCodeService.findByQrCode(qrCode)
+    if (!qrCodeEntity) {
+      return null
+    }
+    return this.findByQrCodeId(qrCodeEntity.id)
+  }
+
+  async adminUpdate(qrCodeId: number, data: Omit<AdminUpdateLinkInfoDto, 'qrCodeId'>): Promise<LinkInfo> {
+    const linkInfo = await this.linkInfoRepository.findOne({
+      where: { qrCodeId }
+    })
+
+    if (!linkInfo) {
+      throw new Error('链接信息不存在')
+    }
+
+    await this.linkInfoRepository.update(linkInfo.id, data)
+    const updated = await this.linkInfoRepository.findOne({ where: { id: linkInfo.id } })
+    if (!updated) {
+      throw new Error('更新后无法找到链接信息')
+    }
+    return updated
+  }
+
+  async query(
+    jumpUrl?: string,
+    remark?: string,
+    startDate?: string,
+    endDate?: string,
+    page: number = 0,
+    pageSize: number = 20
+  ): Promise<PaginationResponse<LinkInfo>> {
+    const queryBuilder = this.linkInfoRepository.createQueryBuilder('linkInfo')
+
+    if (jumpUrl) {
+      queryBuilder.andWhere('linkInfo.jumpUrl LIKE :jumpUrl', { jumpUrl: `%${jumpUrl}%` })
+    }
+
+    if (remark) {
+      queryBuilder.andWhere('linkInfo.remark LIKE :remark', { remark: `%${remark}%` })
+    }
+
+    if (startDate && endDate) {
+      queryBuilder.andWhere('DATE(linkInfo.createdAt) BETWEEN :startDate AND :endDate', { startDate, endDate })
+    } else if (startDate) {
+      queryBuilder.andWhere('DATE(linkInfo.createdAt) >= :startDate', { startDate })
+    } else if (endDate) {
+      queryBuilder.andWhere('DATE(linkInfo.createdAt) <= :endDate', { endDate })
+    }
+
+    const [content, total] = await queryBuilder
+      .skip(page * pageSize)
+      .take(pageSize)
+      .orderBy('linkInfo.createdAt', 'DESC')
+      .getManyAndCount()
+
+    return {
+      content,
+      metadata: {
+        total: Number(total),
+        page: Number(page),
+        size: Number(pageSize)
+      }
+    }
+  }
+
+  async adminGetDetail(qrCodeId: number): Promise<LinkInfo | null> {
+    const linkInfo = await this.linkInfoRepository.findOne({
+      where: { qrCodeId }
+    })
+
+    if (!linkInfo) {
+      return null
+    }
+
+    return linkInfo
+  }
+}
+

+ 6 - 1
src/services/qr-code.service.ts

@@ -4,6 +4,7 @@ import { QrCode, QrType } from '../entities/qr-code.entity'
 import { PersonInfo } from '../entities/person-info.entity'
 import { PetInfo } from '../entities/pet-info.entity'
 import { GoodsInfo } from '../entities/goods-info.entity'
+import { LinkInfo } from '../entities/link-info.entity'
 import { PaginationResponse } from '../dto/common.dto'
 import { FileService } from './file.service'
 
@@ -12,6 +13,7 @@ export class QrCodeService {
   private personInfoRepository: Repository<PersonInfo>
   private petInfoRepository: Repository<PetInfo>
   private goodsInfoRepository: Repository<GoodsInfo>
+  private linkInfoRepository: Repository<LinkInfo>
   private fileService: FileService
   private app: FastifyInstance
 
@@ -20,6 +22,7 @@ export class QrCodeService {
     this.personInfoRepository = app.dataSource.getRepository(PersonInfo)
     this.petInfoRepository = app.dataSource.getRepository(PetInfo)
     this.goodsInfoRepository = app.dataSource.getRepository(GoodsInfo)
+    this.linkInfoRepository = app.dataSource.getRepository(LinkInfo)
     this.fileService = new FileService(app)
     this.app = app
   }
@@ -152,6 +155,8 @@ export class QrCodeService {
       info = await this.petInfoRepository.findOne({ where: { qrCodeId: entity.id } })
     } else if (entity.qrType === QrType.GOODS) {
       info = await this.goodsInfoRepository.findOne({ where: { qrCodeId: entity.id } })
+    } else if (entity.qrType === QrType.LINK) {
+      info = await this.linkInfoRepository.findOne({ where: { qrCodeId: entity.id } })
     }
 
     if (!isAdmin && info && info.isVisible === false) {
@@ -163,7 +168,7 @@ export class QrCodeService {
     }
 
     // 处理图片签名URL
-    if (info && info.photoUrl) {
+    if (info && 'photoUrl' in info && info.photoUrl) {
       try {
         if (!info.photoUrl.startsWith('http')) {
           info.photoUrl = await this.fileService.getSignedUrl(info.photoUrl, 3600)