Browse Source

添加物品信息管理功能,包括创建、更新、查询和管理员更新接口,新增相关 DTO、实体和服务,更新二维码实体以支持物品信息。

wuyi 1 month ago
parent
commit
b230228f5d

+ 2 - 0
src/app.ts

@@ -14,6 +14,7 @@ import sysConfigRoutes from './routes/sys-config.routes'
 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 scanRecordRoutes from './routes/scan-record.routes'
 
 const options: FastifyEnvOptions = {
@@ -84,6 +85,7 @@ export const createApp = async () => {
   app.register(qrCodeRoutes, { prefix: '/api/qr' })
   app.register(personInfoRoutes, { prefix: '/api/person' })
   app.register(petInfoRoutes, { prefix: '/api/pet' })
+  app.register(goodsInfoRoutes, { prefix: '/api/goods' })
   app.register(scanRecordRoutes, { prefix: '/api/scan' })
 
   const dataSource = createDataSource(app)

+ 134 - 0
src/controllers/goods-info.controller.ts

@@ -0,0 +1,134 @@
+import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
+import { GoodsInfoService } from '../services/goods-info.service'
+import { ScanRecordService } from '../services/scan-record.service'
+import {
+  CreateGoodsInfoDto,
+  UpdateGoodsInfoDto,
+  QueryGoodsInfoDto,
+  AdminUpdateGoodsInfoDto
+} from '../dto/goods-info.dto'
+
+export class GoodsInfoController {
+  private goodsInfoService: GoodsInfoService
+  private scanRecordService: ScanRecordService
+
+  constructor(app: FastifyInstance) {
+    this.goodsInfoService = new GoodsInfoService(app)
+    this.scanRecordService = new ScanRecordService(app)
+  }
+
+  async create(request: FastifyRequest<{ Body: CreateGoodsInfoDto }>, reply: FastifyReply) {
+    try {
+      const { qrCode, ...data } = request.body
+
+      if (data.photoUrl) {
+        try {
+          const urlObj = new URL(data.photoUrl)
+          data.photoUrl = urlObj.pathname.substring(1)
+        } catch (error) {}
+      }
+
+      const goodsInfo = await this.goodsInfoService.create(qrCode, data)
+
+      return reply.code(201).send({
+        message: '物品信息创建成功',
+        data: goodsInfo
+      })
+    } catch (error) {
+      const message = error instanceof Error ? error.message : '创建失败'
+      return reply.code(400).send({ message })
+    }
+  }
+
+  async update(request: FastifyRequest<{ Body: UpdateGoodsInfoDto }>, reply: FastifyReply) {
+    try {
+      const { qrCode, maintenanceCode, ...data } = request.body
+
+      if (data.photoUrl) {
+        try {
+          const urlObj = new URL(data.photoUrl)
+          data.photoUrl = urlObj.pathname.substring(1)
+        } catch (error) {}
+      }
+
+      const goodsInfo = await this.goodsInfoService.update(qrCode, maintenanceCode, data)
+
+      return reply.send({
+        message: '物品信息更新成功',
+        data: goodsInfo
+      })
+    } 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 goodsInfo = await this.goodsInfoService.findByQrCode(qrCode)
+
+      if (!goodsInfo) {
+        return reply.code(404).send({ message: '物品信息不存在' })
+      }
+
+      return reply.send(goodsInfo)
+    } catch (error) {
+      const message = error instanceof Error ? error.message : '获取失败'
+      return reply.code(500).send({ message })
+    }
+  }
+
+  async adminUpdate(request: FastifyRequest<{ Body: AdminUpdateGoodsInfoDto }>, reply: FastifyReply) {
+    try {
+      const { qrCodeId, ...data } = request.body
+
+      if (!qrCodeId) {
+        return reply.code(400).send({ message: '请提供二维码ID' })
+      }
+
+      if (data.photoUrl) {
+        try {
+          const urlObj = new URL(data.photoUrl)
+          data.photoUrl = urlObj.pathname.substring(1)
+        } catch (error) {}
+      }
+
+      const goodsInfo = await this.goodsInfoService.adminUpdate(qrCodeId, data)
+
+      return reply.send({
+        message: '物品信息更新成功',
+        data: goodsInfo
+      })
+    } catch (error) {
+      const message = error instanceof Error ? error.message : '更新失败'
+      return reply.code(400).send({ message })
+    }
+  }
+
+  async list(request: FastifyRequest<{ Querystring: QueryGoodsInfoDto }>, reply: FastifyReply) {
+    try {
+      const { name, contactName, contactPhone, startDate, endDate, page, pageSize } = request.query
+
+      const result = await this.goodsInfoService.query(
+        name,
+        contactName,
+        contactPhone,
+        startDate,
+        endDate,
+        page,
+        pageSize
+      )
+
+      return reply.send(result)
+    } catch (error) {
+      const message = error instanceof Error ? error.message : '查询失败'
+      return reply.code(500).send({ message })
+    }
+  }
+}

+ 124 - 0
src/dto/goods-info.dto.ts

@@ -0,0 +1,124 @@
+import { IsString, IsOptional, IsEmail, Length, MaxLength, IsNumber, IsDateString, Min } from 'class-validator'
+
+export class CreateGoodsInfoDto {
+  @IsString()
+  qrCode: string
+
+  @IsOptional()
+  @IsString()
+  @MaxLength(500)
+  photoUrl?: string
+
+  @IsString()
+  @Length(1, 100)
+  name: string
+
+  @IsString()
+  @Length(1, 100)
+  contactName: string
+
+  @IsString()
+  @Length(1, 20)
+  contactPhone: string
+
+  @IsOptional()
+  @IsEmail()
+  @MaxLength(100)
+  contactEmail?: string
+}
+
+export class UpdateGoodsInfoDto {
+  @IsString()
+  qrCode: string
+
+  @IsString()
+  maintenanceCode: string
+
+  @IsOptional()
+  @IsString()
+  @MaxLength(500)
+  photoUrl?: string
+
+  @IsOptional()
+  @IsString()
+  @Length(1, 100)
+  name?: string
+
+  @IsOptional()
+  @IsString()
+  @Length(1, 100)
+  contactName?: string
+
+  @IsOptional()
+  @IsString()
+  @Length(1, 20)
+  contactPhone?: string
+
+  @IsOptional()
+  @IsEmail()
+  @MaxLength(100)
+  contactEmail?: string
+}
+
+export class QueryGoodsInfoDto {
+  @IsOptional()
+  @IsString()
+  name?: string
+
+  @IsOptional()
+  @IsString()
+  contactName?: string
+
+  @IsOptional()
+  @IsString()
+  contactPhone?: 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 AdminUpdateGoodsInfoDto {
+  @IsNumber()
+  qrCodeId: number
+
+  @IsOptional()
+  @IsString()
+  @MaxLength(500)
+  photoUrl?: string
+
+  @IsOptional()
+  @IsString()
+  @Length(1, 100)
+  name?: string
+
+  @IsOptional()
+  @IsString()
+  @Length(1, 100)
+  contactName?: string
+
+  @IsOptional()
+  @IsString()
+  @Length(1, 20)
+  contactPhone?: string
+
+  @IsOptional()
+  @IsEmail()
+  @MaxLength(100)
+  contactEmail?: string
+}
+

+ 48 - 0
src/entities/goods-info.entity.ts

@@ -0,0 +1,48 @@
+import {
+  Entity,
+  PrimaryGeneratedColumn,
+  Column,
+  CreateDateColumn,
+  UpdateDateColumn,
+  OneToOne,
+  JoinColumn
+} from 'typeorm'
+import { QrCode } from './qr-code.entity'
+
+@Entity()
+export class GoodsInfo {
+  @PrimaryGeneratedColumn()
+  id: number
+
+  @Column({ unique: true })
+  qrCodeId: number
+
+  @Column({ nullable: true, length: 500 })
+  photoUrl: string
+
+  @Column({ length: 100 })
+  name: string
+
+  @Column({ length: 100 })
+  contactName: string
+
+  @Column({ length: 20 })
+  contactPhone: string
+
+  @Column({ length: 100, nullable: true })
+  contactEmail: string
+
+  @Column({ length: 500, nullable: true })
+  remark: string
+
+  @CreateDateColumn()
+  createdAt: Date
+
+  @UpdateDateColumn()
+  updatedAt: Date
+
+  @OneToOne(() => QrCode, qrCode => qrCode.goodsInfo)
+  @JoinColumn({ name: 'qrCodeId' })
+  qrCode: QrCode
+}
+

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

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

+ 32 - 0
src/routes/goods-info.routes.ts

@@ -0,0 +1,32 @@
+import { FastifyInstance } from 'fastify'
+import { GoodsInfoController } from '../controllers/goods-info.controller'
+import { hasRole } from '../middlewares/auth.middleware'
+import { UserRole } from '../entities/user.entity'
+import {
+  CreateGoodsInfoDto,
+  UpdateGoodsInfoDto,
+  QueryGoodsInfoDto,
+  AdminUpdateGoodsInfoDto
+} from '../dto/goods-info.dto'
+
+export default async function goodsInfoRoutes(fastify: FastifyInstance) {
+  const goodsInfoController = new GoodsInfoController(fastify)
+
+  fastify.post<{ Body: CreateGoodsInfoDto }>('/create', goodsInfoController.create.bind(goodsInfoController))
+
+  fastify.put<{ Body: UpdateGoodsInfoDto }>('/update', goodsInfoController.update.bind(goodsInfoController))
+
+  fastify.get<{ Querystring: { qrCode: string } }>('/get', goodsInfoController.get.bind(goodsInfoController))
+
+  fastify.get<{ Querystring: QueryGoodsInfoDto }>(
+    '/list',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    goodsInfoController.list.bind(goodsInfoController)
+  )
+
+  fastify.post<{ Body: AdminUpdateGoodsInfoDto }>(
+    '/admin/update',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    goodsInfoController.adminUpdate.bind(goodsInfoController)
+  )
+}

+ 153 - 0
src/services/goods-info.service.ts

@@ -0,0 +1,153 @@
+import { Repository } from 'typeorm'
+import { FastifyInstance } from 'fastify'
+import { GoodsInfo } from '../entities/goods-info.entity'
+import { QrCodeService } from './qr-code.service'
+import { QrType } from '../entities/qr-code.entity'
+import { PaginationResponse } from '../dto/common.dto'
+import { AdminUpdateGoodsInfoDto } from '../dto/goods-info.dto'
+
+export class GoodsInfoService {
+  private goodsInfoRepository: Repository<GoodsInfo>
+  private qrCodeService: QrCodeService
+
+  constructor(app: FastifyInstance) {
+    this.goodsInfoRepository = app.dataSource.getRepository(GoodsInfo)
+    this.qrCodeService = new QrCodeService(app)
+  }
+
+  async create(qrCode: string, data: Partial<GoodsInfo>): Promise<GoodsInfo> {
+    const qrCodeEntity = await this.qrCodeService.findByQrCode(qrCode)
+    if (!qrCodeEntity) {
+      throw new Error('二维码不存在')
+    }
+
+    if (qrCodeEntity.qrType !== QrType.GOODS) {
+      throw new Error('二维码类型不匹配')
+    }
+
+    if (qrCodeEntity.isActivated) {
+      throw new Error('二维码已激活,无法重复填写')
+    }
+
+    const goodsInfo = this.goodsInfoRepository.create({
+      ...data,
+      qrCodeId: qrCodeEntity.id
+    })
+
+    const result = await this.goodsInfoRepository.save(goodsInfo)
+
+    await this.qrCodeService.activateQrCode(qrCodeEntity.id)
+
+    return result
+  }
+
+  async update(qrCode: string, maintenanceCode: string, data: Partial<GoodsInfo>): Promise<GoodsInfo | 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 goodsInfo = await this.goodsInfoRepository.findOne({
+      where: { qrCodeId: qrCodeEntity.id }
+    })
+
+    if (!goodsInfo) {
+      const created = this.goodsInfoRepository.create({
+        ...data,
+        qrCodeId: qrCodeEntity.id
+      })
+      const result = await this.goodsInfoRepository.save(created)
+      await this.qrCodeService.activateQrCode(qrCodeEntity.id)
+      return result
+    }
+
+    await this.goodsInfoRepository.update(goodsInfo.id, data)
+
+    const updated = await this.goodsInfoRepository.findOne({ where: { id: goodsInfo.id } })
+    if (!updated) {
+      throw new Error('更新后无法找到物品信息')
+    }
+    return updated
+  }
+
+  async findByQrCodeId(qrCodeId: number): Promise<GoodsInfo | null> {
+    return this.goodsInfoRepository.findOne({ where: { qrCodeId } })
+  }
+
+  async findByQrCode(qrCode: string): Promise<GoodsInfo | null> {
+    const qrCodeEntity = await this.qrCodeService.findByQrCode(qrCode)
+    if (!qrCodeEntity) {
+      return null
+    }
+    return this.findByQrCodeId(qrCodeEntity.id)
+  }
+
+  async adminUpdate(qrCodeId: number, data: Omit<AdminUpdateGoodsInfoDto, 'qrCodeId'>): Promise<GoodsInfo> {
+    const goodsInfo = await this.goodsInfoRepository.findOne({
+      where: { qrCodeId }
+    })
+
+    if (!goodsInfo) {
+      throw new Error('物品信息不存在')
+    }
+
+    await this.goodsInfoRepository.update(goodsInfo.id, data)
+    const updated = await this.goodsInfoRepository.findOne({ where: { id: goodsInfo.id } })
+    if (!updated) {
+      throw new Error('更新后无法找到物品信息')
+    }
+    return updated
+  }
+
+  async query(
+    name?: string,
+    contactName?: string,
+    contactPhone?: string,
+    startDate?: string,
+    endDate?: string,
+    page: number = 0,
+    pageSize: number = 20
+  ): Promise<PaginationResponse<GoodsInfo>> {
+    const queryBuilder = this.goodsInfoRepository.createQueryBuilder('goodsInfo')
+
+    if (name) {
+      queryBuilder.andWhere('goodsInfo.name LIKE :name', { name: `%${name}%` })
+    }
+
+    if (contactName) {
+      queryBuilder.andWhere('goodsInfo.contactName LIKE :contactName', { contactName: `%${contactName}%` })
+    }
+
+    if (contactPhone) {
+      queryBuilder.andWhere('goodsInfo.contactPhone LIKE :contactPhone', { contactPhone: `%${contactPhone}%` })
+    }
+
+    if (startDate && endDate) {
+      queryBuilder.andWhere('DATE(goodsInfo.createdAt) BETWEEN :startDate AND :endDate', { startDate, endDate })
+    } else if (startDate) {
+      queryBuilder.andWhere('DATE(goodsInfo.createdAt) >= :startDate', { startDate })
+    } else if (endDate) {
+      queryBuilder.andWhere('DATE(goodsInfo.createdAt) <= :endDate', { endDate })
+    }
+
+    const [content, total] = await queryBuilder
+      .skip(page * pageSize)
+      .take(pageSize)
+      .orderBy('goodsInfo.createdAt', 'DESC')
+      .getManyAndCount()
+
+    return {
+      content,
+      metadata: {
+        total: Number(total),
+        page: Number(page),
+        size: Number(pageSize)
+      }
+    }
+  }
+}

+ 5 - 0
src/services/qr-code.service.ts

@@ -3,6 +3,7 @@ import { FastifyInstance } from 'fastify'
 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 { PaginationResponse } from '../dto/common.dto'
 import { randomBytes } from 'crypto'
 import { FileService } from './file.service'
@@ -11,6 +12,7 @@ export class QrCodeService {
   private qrCodeRepository: Repository<QrCode>
   private personInfoRepository: Repository<PersonInfo>
   private petInfoRepository: Repository<PetInfo>
+  private goodsInfoRepository: Repository<GoodsInfo>
   private fileService: FileService
   private app: FastifyInstance
 
@@ -18,6 +20,7 @@ export class QrCodeService {
     this.qrCodeRepository = app.dataSource.getRepository(QrCode)
     this.personInfoRepository = app.dataSource.getRepository(PersonInfo)
     this.petInfoRepository = app.dataSource.getRepository(PetInfo)
+    this.goodsInfoRepository = app.dataSource.getRepository(GoodsInfo)
     this.fileService = new FileService(app)
     this.app = app
   }
@@ -100,6 +103,8 @@ export class QrCodeService {
       info = await this.personInfoRepository.findOne({ where: { qrCodeId: entity.id } })
     } else if (entity.qrType === QrType.PET) {
       info = await this.petInfoRepository.findOne({ where: { qrCodeId: entity.id } })
+    } else if (entity.qrType === QrType.GOODS) {
+      info = await this.goodsInfoRepository.findOne({ where: { qrCodeId: entity.id } })
     }
 
     // 处理图片签名URL