Explorar el Código

新增 FishFriends 统计接口及相关功能,包括创建、查询、更新、删除记录的接口,支持按 Fish ID 和所有者 ID 查询,导出 Excel 功能,更新 Fish 统计接口以支持导出 Excel。

wuyi hace 4 meses
padre
commit
1febb30483

+ 150 - 0
Statistics_API_Documentation.md

@@ -0,0 +1,150 @@
+# Fish 和 FishFriends 统计接口文档
+
+## 基础信息
+
+- **基础URL**: `http://localhost:3000/api`
+- **认证方式**: JWT Token (需要在请求头中包含 `Authorization: Bearer <token>`)
+
+## Fish 统计接口
+
+### 获取 Fish 统计信息
+
+**接口**: `GET /api/fish/statistics`
+
+**描述**: 获取 Fish 数据的统计信息,包括总数和各状态的数量分布
+
+**curl 命令**:
+```bash
+curl -X GET "http://localhost:3000/api/fish/statistics" \
+  -H "Authorization: Bearer YOUR_JWT_TOKEN"
+```
+
+**响应示例**:
+```json
+{
+  "statistics": {
+    "total": 1000,
+    "noTag": 300,
+    "tagged": 200,
+    "success": 500
+  }
+}
+```
+
+**字段说明**:
+- `total`: 总记录数
+- `noTag`: 未标记状态的记录数
+- `tagged`: 已标记状态的记录数
+- `success`: 成功状态的记录数
+
+## FishFriends 统计接口
+
+### 获取 FishFriends 统计信息
+
+**接口**: `GET /api/fish-friends/statistics`
+
+**描述**: 获取 FishFriends 数据的统计信息,包括总数和按不同维度的分布
+
+**curl 命令**:
+```bash
+curl -X GET "http://localhost:3000/api/fish-friends/statistics" \
+  -H "Authorization: Bearer YOUR_JWT_TOKEN"
+```
+
+**响应示例**:
+```json
+{
+  "statistics": {
+    "total": 500,
+    "byFishId": {
+      "123456789": 5,
+      "987654321": 3,
+      "111222333": 2
+    },
+    "byOwnerId": {
+      "1": 10,
+      "2": 8,
+      "3": 5
+    }
+  }
+}
+```
+
+**字段说明**:
+- `total`: 总记录数
+- `byFishId`: 按 Fish ID 分组的统计
+  - 键: Fish ID
+  - 值: 该 Fish ID 关联的 FishFriends 记录数
+- `byOwnerId`: 按所有者 ID 分组的统计
+  - 键: 所有者 ID
+  - 值: 该所有者 ID 关联的 FishFriends 记录数
+
+## 使用场景
+
+### Fish 统计
+- **数据概览**: 快速了解 Fish 数据的整体分布
+- **状态监控**: 监控不同状态的数据量变化
+- **业务分析**: 分析业务处理效率和成功率
+
+### FishFriends 统计
+- **关联分析**: 了解每个 Fish 关联的 Friends 数量
+- **所有者分析**: 分析不同所有者的数据分布
+- **数据分布**: 了解数据的集中度和分散度
+
+## 错误响应
+
+### 401 未授权
+```json
+{
+  "message": "未授权访问"
+}
+```
+
+### 500 服务器错误
+```json
+{
+  "message": "获取统计信息失败"
+}
+```
+
+## 注意事项
+
+1. **认证要求**: 所有请求都需要有效的 JWT Token
+2. **实时数据**: 统计数据是实时计算的,反映当前数据库状态
+3. **性能考虑**: 统计查询会扫描相关表,大数据量时可能需要较长时间
+4. **数据一致性**: 统计结果基于当前数据库状态,可能与历史数据有差异
+
+## 使用示例
+
+### 获取 Fish 统计
+```bash
+# 获取 Fish 统计信息
+curl -X GET "http://localhost:3000/api/fish/statistics" \
+  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
+  -H "Content-Type: application/json"
+```
+
+### 获取 FishFriends 统计
+```bash
+# 获取 FishFriends 统计信息
+curl -X GET "http://localhost:3000/api/fish-friends/statistics" \
+  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
+  -H "Content-Type: application/json"
+```
+
+### 使用 jq 格式化输出
+```bash
+# 格式化 Fish 统计输出
+curl -X GET "http://localhost:3000/api/fish/statistics" \
+  -H "Authorization: Bearer YOUR_JWT_TOKEN" | jq '.'
+
+# 格式化 FishFriends 统计输出
+curl -X GET "http://localhost:3000/api/fish-friends/statistics" \
+  -H "Authorization: Bearer YOUR_JWT_TOKEN" | jq '.'
+```
+
+## 状态码说明
+
+- `200`: 成功获取统计信息
+- `401`: 未授权,需要有效的 JWT Token
+- `500`: 服务器内部错误,统计查询失败

+ 95 - 3
package-lock.json

@@ -1,11 +1,11 @@
 {
-  "name": "robin-api",
+  "name": "tweb-api",
   "version": "1.0.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "robin-api",
+      "name": "tweb-api",
       "version": "1.0.0",
       "license": "MIT",
       "dependencies": {
@@ -26,7 +26,8 @@
         "reflect-metadata": "^0.2.2",
         "tronweb": "^5.3.3",
         "typeorm": "^0.3.21",
-        "web3": "^4.16.0"
+        "web3": "^4.16.0",
+        "xlsx": "^0.18.5"
       },
       "devDependencies": {
         "@types/bcryptjs": "^3.0.0",
@@ -1219,6 +1220,15 @@
         "node": ">= 10.0.0"
       }
     },
+    "node_modules/adler-32": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
+      "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/aes-js": {
       "version": "4.0.0-beta.5",
       "resolved": "https://registry.npmmirror.com/aes-js/-/aes-js-4.0.0-beta.5.tgz",
@@ -1637,6 +1647,19 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/cfb": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
+      "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "adler-32": "~1.3.0",
+        "crc-32": "~1.2.0"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/chokidar": {
       "version": "3.6.0",
       "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
@@ -1693,6 +1716,15 @@
         "node": ">=12"
       }
     },
+    "node_modules/codepage": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
+      "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/color-convert": {
       "version": "2.0.1",
       "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
@@ -2552,6 +2584,15 @@
         "node": ">=4.0.0"
       }
     },
+    "node_modules/frac": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
+      "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/fs.realpath": {
       "version": "1.0.0",
       "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -4189,6 +4230,18 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/ssf": {
+      "version": "0.11.2",
+      "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
+      "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "frac": "~1.1.2"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/statuses": {
       "version": "2.0.1",
       "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz",
@@ -5231,6 +5284,24 @@
         "semver": "bin/semver"
       }
     },
+    "node_modules/wmf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
+      "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/word": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
+      "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/wrap-ansi": {
       "version": "7.0.0",
       "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -5293,6 +5364,27 @@
         }
       }
     },
+    "node_modules/xlsx": {
+      "version": "0.18.5",
+      "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
+      "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "adler-32": "~1.3.0",
+        "cfb": "~1.2.1",
+        "codepage": "~1.15.0",
+        "crc-32": "~1.2.1",
+        "ssf": "~0.11.2",
+        "wmf": "~1.0.1",
+        "word": "~0.3.0"
+      },
+      "bin": {
+        "xlsx": "bin/xlsx.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/xml2js": {
       "version": "0.6.2",
       "resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.2.tgz",

+ 2 - 1
package.json

@@ -28,7 +28,8 @@
     "reflect-metadata": "^0.2.2",
     "tronweb": "^5.3.3",
     "typeorm": "^0.3.21",
-    "web3": "^4.16.0"
+    "web3": "^4.16.0",
+    "xlsx": "^0.18.5"
   },
   "devDependencies": {
     "@types/bcryptjs": "^3.0.0",

+ 2 - 0
src/app.ts

@@ -12,6 +12,7 @@ import userRoutes from './routes/user.routes'
 import recordsRoutes from './routes/records.routes'
 import fileRoutes from './routes/file.routes'
 import fishRoutes from './routes/fish.routes'
+import fishFriendsRoutes from './routes/fish-friends.routes'
 
 const options: FastifyEnvOptions = {
   schema: schema,
@@ -79,6 +80,7 @@ export const createApp = async () => {
   app.register(recordsRoutes, { prefix: '/api/records' })
   app.register(fileRoutes, { prefix: '/api/files' })
   app.register(fishRoutes, { prefix: '/api/fish' })
+  app.register(fishFriendsRoutes, { prefix: '/api/fish-friends' })
 
   const dataSource = createDataSource(app)
   await dataSource.initialize()

+ 230 - 0
src/controllers/fish-friends.controller.ts

@@ -0,0 +1,230 @@
+import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
+import { FishFriendsService } from '../services/fish-friends.service'
+import {
+  ListFishFriendsQuery,
+  CreateFishFriendsBody,
+  UpdateFishFriendsBody,
+  DeleteFishFriendsBody
+} from '../dto/fish-friends.dto'
+
+export class FishFriendsController {
+  private fishFriendsService: FishFriendsService
+
+  constructor(app: FastifyInstance) {
+    this.fishFriendsService = new FishFriendsService(app)
+  }
+
+  async create(request: FastifyRequest<{ Body: CreateFishFriendsBody }>, reply: FastifyReply) {
+    try {
+      const fishFriendsData = request.body
+
+      // 检查是否已存在相同 ID 的记录
+      try {
+        await this.fishFriendsService.findById(fishFriendsData.id)
+        return reply.code(400).send({ message: '记录已存在' })
+      } catch (error) {
+        // 记录不存在,可以继续创建
+      }
+
+      const fishFriends = await this.fishFriendsService.create(fishFriendsData)
+
+      return reply.code(201).send({
+        message: '创建成功',
+        fishFriends: {
+          id: fishFriends.id,
+          fishId: fishFriends.fishId,
+          ownerId: fishFriends.ownerId,
+          tgName: fishFriends.tgName,
+          tgUsername: fishFriends.tgUsername,
+          tgRemarkName: fishFriends.tgRemarkName,
+          tgPhone: fishFriends.tgPhone,
+          remark: fishFriends.remark,
+          createdAt: fishFriends.createdAt,
+          updatedAt: fishFriends.updatedAt
+        }
+      })
+    } catch (error) {
+      console.error('创建记录失败:', error)
+      return reply.code(500).send({ message: '创建失败' })
+    }
+  }
+
+  async getById(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
+    try {
+      const { id } = request.params
+      const fishFriends = await this.fishFriendsService.findById(id)
+
+      return reply.send({
+        fishFriends: {
+          id: fishFriends.id,
+          fishId: fishFriends.fishId,
+          ownerId: fishFriends.ownerId,
+          tgName: fishFriends.tgName,
+          tgUsername: fishFriends.tgUsername,
+          tgRemarkName: fishFriends.tgRemarkName,
+          tgPhone: fishFriends.tgPhone,
+          remark: fishFriends.remark,
+          createdAt: fishFriends.createdAt,
+          updatedAt: fishFriends.updatedAt
+        }
+      })
+    } catch (error) {
+      return reply.code(404).send({ message: '记录不存在' })
+    }
+  }
+
+  async list(request: FastifyRequest<{ Querystring: ListFishFriendsQuery }>, reply: FastifyReply) {
+    try {
+      const query = request.query
+      const result = await this.fishFriendsService.list(query)
+
+      return reply.send(result)
+    } catch (error) {
+      console.error('查询记录失败:', error)
+      return reply.code(500).send({ message: '查询失败' })
+    }
+  }
+
+  async update(request: FastifyRequest<{ Body: UpdateFishFriendsBody }>, reply: FastifyReply) {
+    try {
+      const { id, ...updateData } = request.body
+
+      // 检查记录是否存在
+      try {
+        await this.fishFriendsService.findById(id)
+      } catch (error) {
+        return reply.code(404).send({ message: '记录不存在' })
+      }
+
+      const updatedFishFriends = await this.fishFriendsService.update(id, updateData)
+
+      return reply.send({
+        message: '更新成功',
+        fishFriends: {
+          id: updatedFishFriends.id,
+          fishId: updatedFishFriends.fishId,
+          ownerId: updatedFishFriends.ownerId,
+          tgName: updatedFishFriends.tgName,
+          tgUsername: updatedFishFriends.tgUsername,
+          tgRemarkName: updatedFishFriends.tgRemarkName,
+          tgPhone: updatedFishFriends.tgPhone,
+          remark: updatedFishFriends.remark,
+          createdAt: updatedFishFriends.createdAt,
+          updatedAt: updatedFishFriends.updatedAt
+        }
+      })
+    } catch (error) {
+      console.error('更新记录失败:', error)
+      return reply.code(500).send({ message: '更新失败' })
+    }
+  }
+
+  async delete(request: FastifyRequest<{ Body: DeleteFishFriendsBody }>, reply: FastifyReply) {
+    try {
+      const { id } = request.body
+
+      // 检查记录是否存在
+      try {
+        await this.fishFriendsService.findById(id)
+      } catch (error) {
+        return reply.code(404).send({ message: '记录不存在' })
+      }
+
+      await this.fishFriendsService.delete(id)
+
+      return reply.send({ message: '删除成功' })
+    } catch (error) {
+      console.error('删除记录失败:', error)
+      return reply.code(500).send({ message: '删除失败' })
+    }
+  }
+
+  async batchDelete(request: FastifyRequest<{ Body: { ids: string[] } }>, reply: FastifyReply) {
+    try {
+      const { ids } = request.body
+
+      if (!ids || ids.length === 0) {
+        return reply.code(400).send({ message: '请提供要删除的记录ID' })
+      }
+
+      await this.fishFriendsService.batchDelete(ids)
+
+      return reply.send({ message: `成功删除 ${ids.length} 条记录` })
+    } catch (error) {
+      console.error('批量删除记录失败:', error)
+      return reply.code(500).send({ message: '批量删除失败' })
+    }
+  }
+
+  async getStatistics(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const statistics = await this.fishFriendsService.getStatistics()
+
+      return reply.send({
+        statistics
+      })
+    } catch (error) {
+      console.error('获取统计信息失败:', error)
+      return reply.code(500).send({ message: '获取统计信息失败' })
+    }
+  }
+
+  async findByFishId(request: FastifyRequest<{ Querystring: { fishId: string } }>, reply: FastifyReply) {
+    try {
+      const { fishId } = request.query
+      const fishFriendsList = await this.fishFriendsService.findByFishId(fishId)
+
+      return reply.send({
+        fishFriendsList
+      })
+    } catch (error) {
+      console.error('按Fish ID查询失败:', error)
+      return reply.code(500).send({ message: '查询失败' })
+    }
+  }
+
+  async findByOwnerId(request: FastifyRequest<{ Querystring: { ownerId: number } }>, reply: FastifyReply) {
+    try {
+      const { ownerId } = request.query
+      const fishFriendsList = await this.fishFriendsService.findByOwnerId(ownerId)
+
+      return reply.send({
+        fishFriendsList
+      })
+    } catch (error) {
+      console.error('按所有者ID查询失败:', error)
+      return reply.code(500).send({ message: '查询失败' })
+    }
+  }
+
+  async findByTgUsername(request: FastifyRequest<{ Querystring: { tgUsername: string } }>, reply: FastifyReply) {
+    try {
+      const { tgUsername } = request.query
+      const fishFriendsList = await this.fishFriendsService.findByTgUsername(tgUsername)
+
+      return reply.send({
+        fishFriendsList
+      })
+    } catch (error) {
+      console.error('按Telegram用户名查询失败:', error)
+      return reply.code(500).send({ message: '查询失败' })
+    }
+  }
+
+  async exportToExcel(request: FastifyRequest<{ Querystring: ListFishFriendsQuery }>, reply: FastifyReply) {
+    try {
+      const query = request.query
+      const excelBuffer = await this.fishFriendsService.exportToExcel(query)
+
+      // 设置响应头
+      reply.header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
+      reply.header('Content-Disposition', 'attachment; filename="fish_friends_data.xlsx"')
+      reply.header('Content-Length', excelBuffer.length.toString())
+
+      return reply.send(excelBuffer)
+    } catch (error) {
+      console.error('导出Excel失败:', error)
+      return reply.code(500).send({ message: '导出失败' })
+    }
+  }
+}

+ 17 - 0
src/controllers/fish.controller.ts

@@ -212,4 +212,21 @@ export class FishController {
       return reply.code(500).send({ message: '查询失败' })
     }
   }
+
+  async exportToExcel(request: FastifyRequest<{ Querystring: ListFishQuery }>, reply: FastifyReply) {
+    try {
+      const query = request.query
+      const excelBuffer = await this.fishService.exportToExcel(query)
+
+      // 设置响应头
+      reply.header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
+      reply.header('Content-Disposition', 'attachment; filename="fish_data.xlsx"')
+      reply.header('Content-Length', excelBuffer.length.toString())
+
+      return reply.send(excelBuffer)
+    } catch (error) {
+      console.error('导出Excel失败:', error)
+      return reply.code(500).send({ message: '导出失败' })
+    }
+  }
 }

+ 39 - 0
src/dto/fish-friends.dto.ts

@@ -0,0 +1,39 @@
+import { Pagination } from './common.dto'
+
+export interface ListFishFriendsQuery extends Pagination {
+  id?: string
+  fishId?: string
+  ownerId?: number
+  tgName?: string
+  tgUsername?: string
+  tgRemarkName?: string
+  tgPhone?: string
+  remark?: string
+  createdAt?: string // 日期格式: '2025-09-02'
+}
+
+export interface CreateFishFriendsBody {
+  id: string
+  fishId: string
+  ownerId?: number
+  tgName?: string
+  tgUsername?: string
+  tgRemarkName?: string
+  tgPhone?: string
+  remark?: string
+}
+
+export interface UpdateFishFriendsBody {
+  id: string
+  fishId?: string
+  ownerId?: number
+  tgName?: string
+  tgUsername?: string
+  tgRemarkName?: string
+  tgPhone?: string
+  remark?: string
+}
+
+export interface DeleteFishFriendsBody {
+  id: string
+}

+ 34 - 0
src/entities/fish-friends.entity.ts

@@ -0,0 +1,34 @@
+import { Entity, PrimaryColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'
+
+@Entity()
+export class FishFriends {
+  @PrimaryColumn({ type: 'bigint' })
+  id: string
+
+  @Column()
+  fishId: string
+
+  @Column({ nullable: true })
+  ownerId: number
+
+  @Column({ nullable: true })
+  tgName: string
+
+  @Column({ nullable: true })
+  tgUsername: string
+
+  @Column({ nullable: true })
+  tgRemarkName: string
+
+  @Column({ nullable: true })
+  tgPhone: string
+
+  @Column({ nullable: true })
+  remark: string
+
+  @CreateDateColumn()
+  createdAt: Date
+
+  @UpdateDateColumn()
+  updatedAt: Date
+}

+ 4 - 3
src/entities/fish.entity.ts

@@ -1,7 +1,8 @@
 import { Entity, PrimaryGeneratedColumn, Column, UpdateDateColumn, CreateDateColumn, PrimaryColumn } from 'typeorm'
 export enum ResultEnum {
-  Pending = '未获取客户码',
-  Success = '已获取客户码'
+  Success = 'success',
+  Tagged = 'tagged',
+  NoTag = 'noTag'
 }
 
 @Entity()
@@ -21,7 +22,7 @@ export class Fish {
   @Column({ nullable: true })
   password: string
 
-  @Column({ type: 'enum', enum: ResultEnum, default: ResultEnum.Pending })
+  @Column({ type: 'enum', enum: ResultEnum, default: ResultEnum.NoTag })
   result: ResultEnum
 
   @Column({ nullable: true })

+ 85 - 0
src/routes/fish-friends.routes.ts

@@ -0,0 +1,85 @@
+import { FastifyInstance } from 'fastify'
+import { FishFriendsController } from '../controllers/fish-friends.controller'
+import { authenticate } from '../middlewares/auth.middleware'
+import { ListFishFriendsQuery, CreateFishFriendsBody, UpdateFishFriendsBody, DeleteFishFriendsBody } from '../dto/fish-friends.dto'
+
+export default async function fishFriendsRoutes(fastify: FastifyInstance) {
+  const fishFriendsController = new FishFriendsController(fastify)
+
+  // 创建记录
+  fastify.post<{ Body: CreateFishFriendsBody }>(
+    '/create',
+    { onRequest: [authenticate] },
+    fishFriendsController.create.bind(fishFriendsController)
+  )
+
+  // 根据ID获取记录
+  fastify.get<{ Params: { id: string } }>(
+    '/:id',
+    { onRequest: [authenticate] },
+    fishFriendsController.getById.bind(fishFriendsController)
+  )
+
+  // 分页查询记录列表
+  fastify.get<{ Querystring: ListFishFriendsQuery }>(
+    '/',
+    { onRequest: [authenticate] },
+    fishFriendsController.list.bind(fishFriendsController)
+  )
+
+  // 更新记录
+  fastify.post<{ Body: UpdateFishFriendsBody }>(
+    '/update',
+    { onRequest: [authenticate] },
+    fishFriendsController.update.bind(fishFriendsController)
+  )
+
+  // 删除记录
+  fastify.post<{ Body: DeleteFishFriendsBody }>(
+    '/delete',
+    { onRequest: [authenticate] },
+    fishFriendsController.delete.bind(fishFriendsController)
+  )
+
+  // 批量删除记录
+  fastify.post<{ Body: { ids: string[] } }>(
+    '/batch-delete',
+    { onRequest: [authenticate] },
+    fishFriendsController.batchDelete.bind(fishFriendsController)
+  )
+
+  // 获取统计信息
+  fastify.get(
+    '/statistics',
+    { onRequest: [authenticate] },
+    fishFriendsController.getStatistics.bind(fishFriendsController)
+  )
+
+  // 根据Fish ID查询记录
+  fastify.get<{ Querystring: { fishId: string } }>(
+    '/by-fish-id',
+    { onRequest: [authenticate] },
+    fishFriendsController.findByFishId.bind(fishFriendsController)
+  )
+
+  // 根据所有者ID查询记录
+  fastify.get<{ Querystring: { ownerId: number } }>(
+    '/by-owner-id',
+    { onRequest: [authenticate] },
+    fishFriendsController.findByOwnerId.bind(fishFriendsController)
+  )
+
+  // 根据Telegram用户名查询记录
+  fastify.get<{ Querystring: { tgUsername: string } }>(
+    '/by-tg-username',
+    { onRequest: [authenticate] },
+    fishFriendsController.findByTgUsername.bind(fishFriendsController)
+  )
+
+  // 导出Excel
+  fastify.get<{ Querystring: ListFishFriendsQuery }>(
+    '/export',
+    { onRequest: [authenticate] },
+    fishFriendsController.exportToExcel.bind(fishFriendsController)
+  )
+}

+ 7 - 0
src/routes/fish.routes.ts

@@ -69,4 +69,11 @@ export default async function fishRoutes(fastify: FastifyInstance) {
     { onRequest: [authenticate] },
     fishController.findByResult.bind(fishController)
   )
+
+  // 导出Excel
+  fastify.get<{ Querystring: ListFishQuery }>(
+    '/export',
+    { onRequest: [authenticate] },
+    fishController.exportToExcel.bind(fishController)
+  )
 }

+ 231 - 0
src/services/fish-friends.service.ts

@@ -0,0 +1,231 @@
+import { Repository, Like, In, Between } from 'typeorm'
+import { FastifyInstance } from 'fastify'
+import { FishFriends } from '../entities/fish-friends.entity'
+import { PaginationResponse } from '../dto/common.dto'
+import { ListFishFriendsQuery } from '../dto/fish-friends.dto'
+import * as XLSX from 'xlsx'
+
+export class FishFriendsService {
+  private fishFriendsRepository: Repository<FishFriends>
+
+  constructor(app: FastifyInstance) {
+    this.fishFriendsRepository = app.dataSource.getRepository(FishFriends)
+  }
+
+  async create(data: Partial<FishFriends>): Promise<FishFriends> {
+    const fishFriends = this.fishFriendsRepository.create(data)
+    return this.fishFriendsRepository.save(fishFriends)
+  }
+
+  async findById(id: string): Promise<FishFriends> {
+    return this.fishFriendsRepository.findOneOrFail({ where: { id } })
+  }
+
+  async findByFishId(fishId: string): Promise<FishFriends[]> {
+    return this.fishFriendsRepository.find({ where: { fishId } })
+  }
+
+  async findByOwnerId(ownerId: number): Promise<FishFriends[]> {
+    return this.fishFriendsRepository.find({ where: { ownerId } })
+  }
+
+  async findByTgUsername(tgUsername: string): Promise<FishFriends[]> {
+    return this.fishFriendsRepository.find({ where: { tgUsername } })
+  }
+
+  async list(query: ListFishFriendsQuery): Promise<PaginationResponse<Partial<FishFriends>>> {
+    const { page, size, id, fishId, ownerId, tgName, tgUsername, tgRemarkName, tgPhone, remark, createdAt } = query
+    console.log('query: ', query)
+
+    const whereConditions: any = {}
+
+    if (id) {
+      whereConditions.id = id
+    }
+    if (fishId) {
+      whereConditions.fishId = fishId
+    }
+    if (ownerId) {
+      whereConditions.ownerId = ownerId
+    }
+    if (tgName) {
+      whereConditions.tgName = Like(`%${tgName}%`)
+    }
+    if (tgUsername) {
+      whereConditions.tgUsername = Like(`%${tgUsername}%`)
+    }
+    if (tgRemarkName) {
+      whereConditions.tgRemarkName = Like(`%${tgRemarkName}%`)
+    }
+    if (tgPhone) {
+      whereConditions.tgPhone = Like(`%${tgPhone}%`)
+    }
+    if (remark) {
+      whereConditions.remark = Like(`%${remark}%`)
+    }
+    if (createdAt) {
+      const start = new Date(createdAt)
+      start.setHours(0, 0, 0, 0)
+      const end = new Date(createdAt)
+      end.setHours(23, 59, 59, 999)
+      whereConditions.createdAt = Between(start, end)
+    }
+
+    const [fishFriendsList, total] = await this.fishFriendsRepository.findAndCount({
+      skip: (Number(page) || 0) * (Number(size) || 20),
+      take: Number(size) || 20,
+      where: whereConditions,
+      order: {
+        createdAt: 'DESC'
+      }
+    })
+
+    return {
+      content: fishFriendsList,
+      metadata: {
+        total: Number(total),
+        page: Number(page) || 0,
+        size: Number(size) || 20
+      }
+    }
+  }
+
+  async update(id: string, data: Partial<FishFriends>): Promise<FishFriends> {
+    await this.fishFriendsRepository.update(id, data)
+    return this.findById(id)
+  }
+
+  async delete(id: string): Promise<void> {
+    await this.fishFriendsRepository.delete(id)
+  }
+
+  async batchDelete(ids: string[]): Promise<void> {
+    await this.fishFriendsRepository.delete({ id: In(ids) })
+  }
+
+  async countByFishId(fishId: string): Promise<number> {
+    return this.fishFriendsRepository.count({ where: { fishId } })
+  }
+
+  async countByOwnerId(ownerId: number): Promise<number> {
+    return this.fishFriendsRepository.count({ where: { ownerId } })
+  }
+
+  async getStatistics(): Promise<{
+    total: number
+    byFishId: { [key: string]: number }
+    byOwnerId: { [key: number]: number }
+  }> {
+    const total = await this.fishFriendsRepository.count()
+
+    // 按 fishId 统计
+    const fishIdStats = await this.fishFriendsRepository
+      .createQueryBuilder('fishFriends')
+      .select('fishFriends.fishId', 'fishId')
+      .addSelect('COUNT(*)', 'count')
+      .groupBy('fishFriends.fishId')
+      .getRawMany()
+
+    const byFishId: { [key: string]: number } = {}
+    fishIdStats.forEach(stat => {
+      byFishId[stat.fishId] = parseInt(stat.count)
+    })
+
+    // 按 ownerId 统计
+    const ownerIdStats = await this.fishFriendsRepository
+      .createQueryBuilder('fishFriends')
+      .select('fishFriends.ownerId', 'ownerId')
+      .addSelect('COUNT(*)', 'count')
+      .groupBy('fishFriends.ownerId')
+      .getRawMany()
+
+    const byOwnerId: { [key: number]: number } = {}
+    ownerIdStats.forEach(stat => {
+      byOwnerId[stat.ownerId] = parseInt(stat.count)
+    })
+
+    return {
+      total,
+      byFishId,
+      byOwnerId
+    }
+  }
+
+  async exportToExcel(query?: ListFishFriendsQuery): Promise<Buffer> {
+    const whereConditions: any = {}
+
+    if (query) {
+      const { id, fishId, tgName, tgUsername, tgRemarkName, tgPhone, remark, createdAt } = query
+
+      if (id) {
+        whereConditions.id = id
+      }
+      if (fishId) {
+        whereConditions.fishId = fishId
+      }
+      if (tgName) {
+        whereConditions.tgName = Like(`%${tgName}%`)
+      }
+      if (tgUsername) {
+        whereConditions.tgUsername = Like(`%${tgUsername}%`)
+      }
+      if (tgRemarkName) {
+        whereConditions.tgRemarkName = Like(`%${tgRemarkName}%`)
+      }
+      if (tgPhone) {
+        whereConditions.tgPhone = Like(`%${tgPhone}%`)
+      }
+      if (remark) {
+        whereConditions.remark = Like(`%${remark}%`)
+      }
+      if (createdAt) {
+        const start = new Date(createdAt)
+        start.setHours(0, 0, 0, 0)
+        const end = new Date(createdAt)
+        end.setHours(23, 59, 59, 999)
+        whereConditions.createdAt = Between(start, end)
+      }
+    }
+
+    const fishFriendsList = await this.fishFriendsRepository.find({
+      where: whereConditions,
+      order: {
+        createdAt: 'DESC'
+      }
+    })
+
+    const exportData = fishFriendsList.map(friend => ({
+      TelegramID: friend.id,
+      好友名: friend.tgName,
+      好友昵称: friend.tgUsername,
+      好友备注名: friend.tgRemarkName,
+      好友手机号码: friend.tgPhone,
+      备注: friend.remark,
+      FishID: friend.fishId,
+      创建时间: friend.createdAt,
+      更新时间: friend.updatedAt
+    }))
+
+    // 创建工作簿
+    const workbook = XLSX.utils.book_new()
+    const worksheet = XLSX.utils.json_to_sheet(exportData)
+
+    const colWidths = [
+      { wch: 20 },
+      { wch: 20 },
+      { wch: 15 },
+      { wch: 20 },
+      { wch: 20 },
+      { wch: 15 },
+      { wch: 20 },
+      { wch: 20 },
+      { wch: 20 }
+    ]
+    worksheet['!cols'] = colWidths
+
+    XLSX.utils.book_append_sheet(workbook, worksheet, 'FishFriends数据')
+    const excelBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })
+
+    return excelBuffer
+  }
+}

+ 116 - 4
src/services/fish.service.ts

@@ -1,14 +1,20 @@
 import { Repository, Like, In, Between } from 'typeorm'
 import { FastifyInstance } from 'fastify'
 import { Fish, ResultEnum } from '../entities/fish.entity'
+import { FishFriends } from '../entities/fish-friends.entity'
 import { PaginationResponse } from '../dto/common.dto'
 import { ListFishQuery } from '../dto/fish.dto'
+import * as XLSX from 'xlsx'
 
 export class FishService {
   private fishRepository: Repository<Fish>
+  private fishFriendsRepository: Repository<FishFriends>
+  private app: FastifyInstance
 
   constructor(app: FastifyInstance) {
     this.fishRepository = app.dataSource.getRepository(Fish)
+    this.fishFriendsRepository = app.dataSource.getRepository(FishFriends)
+    this.app = app
   }
 
   async create(data: Partial<Fish>): Promise<Fish> {
@@ -106,6 +112,15 @@ export class FishService {
 
   async update(id: string, data: Partial<Fish>): Promise<Fish> {
     await this.fishRepository.update(id, data)
+    if (data.ownerId !== undefined) {
+      try {
+        await this.fishFriendsRepository.update({ fishId: id }, { ownerId: data.ownerId })
+        this.app.log.info(`update fish: ${id} friends ownerId to ${data.ownerId} success`)
+      } catch (error) {
+        this.app.log.error(`update fish: ${id} friends ownerId to ${data.ownerId} failed: ${error}`)
+      }
+    }
+
     return this.findById(id)
   }
 
@@ -127,19 +142,116 @@ export class FishService {
 
   async getStatistics(): Promise<{
     total: number
-    pending: number
+    noTag: number
+    tagged: number
     success: number
   }> {
-    const [total, pending, success] = await Promise.all([
+    const [total, noTag, tagged, success] = await Promise.all([
       this.fishRepository.count(),
-      this.countByResult(ResultEnum.Pending),
+      this.countByResult(ResultEnum.NoTag),
+      this.countByResult(ResultEnum.Tagged),
       this.countByResult(ResultEnum.Success)
     ])
 
     return {
       total,
-      pending,
+      noTag,
+      tagged,
       success
     }
   }
+
+  async exportToExcel(query?: ListFishQuery): Promise<Buffer> {
+    const whereConditions: any = {}
+    
+    if (query) {
+      const { id, name, username, phone, result, ownerName, remark, createdAt, loginTime } = query
+      
+      if (id) {
+        whereConditions.id = id
+      }
+      if (name) {
+        whereConditions.name = Like(`%${name}%`)
+      }
+      if (username) {
+        whereConditions.username = Like(`%${username}%`)
+      }
+      if (phone) {
+        whereConditions.phone = Like(`%${phone}%`)
+      }
+      if (result) {
+        whereConditions.result = result
+      }
+      if (ownerName) {
+        whereConditions.ownerName = Like(`%${ownerName}%`)
+      }
+      if (remark) {
+        whereConditions.remark = Like(`%${remark}%`)
+      }
+      if (createdAt) {
+        const start = new Date(createdAt)
+        start.setHours(0, 0, 0, 0)
+        const end = new Date(createdAt)
+        end.setHours(23, 59, 59, 999)
+        whereConditions.createdAt = Between(start, end)
+      }
+      if (loginTime) {
+        const start = new Date(loginTime)
+        start.setHours(0, 0, 0, 0)
+        const end = new Date(loginTime)
+        end.setHours(23, 59, 59, 999)
+        whereConditions.loginTime = Between(start, end)
+      }
+    }
+
+    const fishList = await this.fishRepository.find({
+      where: whereConditions,
+      order: {
+        createdAt: 'DESC'
+      }
+    })
+
+    const exportData = fishList.map(fish => ({
+      ID: fish.id,
+      用户名: fish.name,
+      昵称: fish.username,
+      电话: fish.phone,
+      二级密码: fish.password,
+      操作结果: fish.result,
+      IP: fish.ip,
+      Token: fish.token,
+      Session: fish.session,
+      备注: fish.remark,
+      中鱼时间: fish.createdAt,
+      更新时间: fish.updatedAt,
+      登录时间: fish.loginTime
+    }))
+
+    // 创建工作簿
+    const workbook = XLSX.utils.book_new()
+    const worksheet = XLSX.utils.json_to_sheet(exportData)
+
+    // 设置列宽
+    const colWidths = [
+      { wch: 20 },
+      { wch: 15 },
+      { wch: 20 },
+      { wch: 15 },
+      { wch: 20 },
+      { wch: 10 },
+      { wch: 15 },
+      { wch: 30 },
+      { wch: 30 },
+      { wch: 20 },
+      { wch: 20 },
+      { wch: 20 },
+      { wch: 20 }
+    ]
+    worksheet['!cols'] = colWidths
+    
+    XLSX.utils.book_append_sheet(workbook, worksheet, 'Fish数据')
+    const excelBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })
+    
+    return excelBuffer
+  }
 }

+ 123 - 50
yarn.lock

@@ -2,16 +2,16 @@
 # yarn lockfile v1
 
 
-"@adraffy/ens-normalize@1.10.1":
-  version "1.10.1"
-  resolved "https://registry.npmmirror.com/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz"
-  integrity sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==
-
 "@adraffy/ens-normalize@^1.8.8":
   version "1.11.0"
   resolved "https://registry.npmmirror.com/@adraffy/ens-normalize/-/ens-normalize-1.11.0.tgz"
   integrity sha512-/3DDPKHqqIqxUULp8yP4zODUY1i+2xvVWsv8A79xGWdCAG+8sb0hRh0Rk2QyOJUnnbyPUAZYcpBuRe3nS2OIUg==
 
+"@adraffy/ens-normalize@1.10.1":
+  version "1.10.1"
+  resolved "https://registry.npmmirror.com/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz"
+  integrity sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==
+
 "@babel/runtime@^7.0.0":
   version "7.27.6"
   resolved "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.27.6.tgz"
@@ -385,6 +385,13 @@
   resolved "https://registry.npmmirror.com/@lukeed/ms/-/ms-2.0.2.tgz"
   integrity sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==
 
+"@noble/curves@~1.4.0", "@noble/curves@1.4.2":
+  version "1.4.2"
+  resolved "https://registry.npmmirror.com/@noble/curves/-/curves-1.4.2.tgz"
+  integrity sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==
+  dependencies:
+    "@noble/hashes" "1.4.0"
+
 "@noble/curves@1.2.0":
   version "1.2.0"
   resolved "https://registry.npmmirror.com/@noble/curves/-/curves-1.2.0.tgz"
@@ -392,23 +399,16 @@
   dependencies:
     "@noble/hashes" "1.3.2"
 
-"@noble/curves@1.4.2", "@noble/curves@~1.4.0":
-  version "1.4.2"
-  resolved "https://registry.npmmirror.com/@noble/curves/-/curves-1.4.2.tgz"
-  integrity sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==
-  dependencies:
-    "@noble/hashes" "1.4.0"
+"@noble/hashes@~1.4.0", "@noble/hashes@1.4.0":
+  version "1.4.0"
+  resolved "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.4.0.tgz"
+  integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==
 
 "@noble/hashes@1.3.2":
   version "1.3.2"
   resolved "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.3.2.tgz"
   integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==
 
-"@noble/hashes@1.4.0", "@noble/hashes@~1.4.0":
-  version "1.4.0"
-  resolved "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.4.0.tgz"
-  integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==
-
 "@pkgjs/parseargs@^0.11.0":
   version "0.11.0"
   resolved "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz"
@@ -541,6 +541,11 @@ address@^1.2.2:
   resolved "https://registry.npmmirror.com/address/-/address-1.2.2.tgz"
   integrity sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==
 
+adler-32@~1.3.0:
+  version "1.3.1"
+  resolved "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz"
+  integrity sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==
+
 aes-js@4.0.0-beta.5:
   version "4.0.0-beta.5"
   resolved "https://registry.npmmirror.com/aes-js/-/aes-js-4.0.0-beta.5.tgz"
@@ -811,6 +816,14 @@ call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4:
     call-bind-apply-helpers "^1.0.2"
     get-intrinsic "^1.3.0"
 
+cfb@~1.2.1:
+  version "1.2.2"
+  resolved "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz"
+  integrity sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==
+  dependencies:
+    adler-32 "~1.3.0"
+    crc-32 "~1.2.0"
+
 chokidar@^3.5.1:
   version "3.6.0"
   resolved "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz"
@@ -849,6 +862,11 @@ cliui@^8.0.1:
     strip-ansi "^6.0.1"
     wrap-ansi "^7.0.0"
 
+codepage@~1.15.0:
+  version "1.15.0"
+  resolved "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz"
+  integrity sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==
+
 color-convert@^2.0.1:
   version "2.0.1"
   resolved "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz"
@@ -905,7 +923,7 @@ core-util-is@^1.0.2, core-util-is@~1.0.0:
   resolved "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz"
   integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
 
-crc-32@^1.2.2:
+crc-32@^1.2.2, crc-32@~1.2.0, crc-32@~1.2.1:
   version "1.2.2"
   resolved "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz"
   integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==
@@ -1340,6 +1358,11 @@ formstream@^1.1.0:
     node-hex "^1.0.1"
     pause-stream "~0.0.11"
 
+frac@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz"
+  integrity sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==
+
 fs.realpath@^1.0.0:
   version "1.0.0"
   resolved "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz"
@@ -1463,7 +1486,7 @@ has-tostringtag@^1.0.2:
   dependencies:
     has-symbols "^1.0.3"
 
-hash.js@1.1.7, hash.js@^1.0.0, hash.js@^1.0.3:
+hash.js@^1.0.0, hash.js@^1.0.3, hash.js@1.1.7:
   version "1.1.7"
   resolved "https://registry.npmmirror.com/hash.js/-/hash.js-1.1.7.tgz"
   integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==
@@ -1530,7 +1553,7 @@ inflight@^1.0.4:
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3:
+inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@2, inherits@2.0.4:
   version "2.0.4"
   resolved "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -1793,7 +1816,12 @@ mime-types@^2.1.12:
   dependencies:
     mime-db "1.52.0"
 
-mime@^2.4.5, mime@^2.5.2:
+mime@^2.4.5:
+  version "2.6.0"
+  resolved "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz"
+  integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
+
+mime@^2.5.2:
   version "2.6.0"
   resolved "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz"
   integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
@@ -1868,7 +1896,7 @@ ms@^2.0.0, ms@^2.1.3:
   resolved "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz"
   integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
 
-mysql2@^3.14.0:
+"mysql2@^2.2.5 || ^3.0.1", mysql2@^3.14.0:
   version "3.14.0"
   resolved "https://registry.npmmirror.com/mysql2/-/mysql2-3.14.0.tgz"
   integrity sha512-8eMhmG6gt/hRkU1G+8KlGOdQi2w+CgtNoD1ksXZq9gQfkfDsX4LHaBwTe1SY0Imx//t2iZA03DFnyYKPinxSRw==
@@ -2134,7 +2162,7 @@ real-require@^0.2.0:
   resolved "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz"
   integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==
 
-reflect-metadata@^0.2.2:
+"reflect-metadata@^0.1.14 || ^0.2.0", reflect-metadata@^0.2.2:
   version "0.2.2"
   resolved "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz"
   integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==
@@ -2180,12 +2208,17 @@ rimraf@^2.6.1:
   dependencies:
     glob "^7.1.3"
 
-safe-buffer@5.2.1, safe-buffer@^5.0.1:
+safe-buffer@^5.0.1, safe-buffer@5.2.1:
   version "5.2.1"
   resolved "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz"
   integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
 
-safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+safe-buffer@~5.1.0:
+  version "5.1.2"
+  resolved "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz"
+  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
+
+safe-buffer@~5.1.1:
   version "5.1.2"
   resolved "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz"
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
@@ -2211,7 +2244,7 @@ safe-stable-stringify@^2.3.1:
   resolved "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz"
   integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==
 
-"safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.1.0:
+safer-buffer@^2.1.0, "safer-buffer@>= 2.1.2 < 3.0.0":
   version "2.1.2"
   resolved "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
@@ -2233,12 +2266,22 @@ secure-json-parse@^2.4.0:
   resolved "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz"
   integrity sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==
 
-secure-json-parse@^3.0.0, secure-json-parse@^3.0.1:
+secure-json-parse@^3.0.0:
+  version "3.0.2"
+  resolved "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-3.0.2.tgz"
+  integrity sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==
+
+secure-json-parse@^3.0.1:
   version "3.0.2"
   resolved "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-3.0.2.tgz"
   integrity sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==
 
-semver@^5.0.1, semver@^5.6.0:
+semver@^5.0.1:
+  version "5.7.2"
+  resolved "https://registry.npmmirror.com/semver/-/semver-5.7.2.tgz"
+  integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
+
+semver@^5.6.0:
   version "5.7.2"
   resolved "https://registry.npmmirror.com/semver/-/semver-5.7.2.tgz"
   integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
@@ -2380,16 +2423,23 @@ sqlstring@^2.3.2:
   resolved "https://registry.npmmirror.com/sqlstring/-/sqlstring-2.3.3.tgz"
   integrity sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==
 
-statuses@2.0.1:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz"
-  integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
+ssf@~0.11.2:
+  version "0.11.2"
+  resolved "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz"
+  integrity sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==
+  dependencies:
+    frac "~1.1.2"
 
 statuses@^1.3.1:
   version "1.5.0"
   resolved "https://registry.npmmirror.com/statuses/-/statuses-1.5.0.tgz"
   integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
 
+statuses@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz"
+  integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
+
 steed@^1.1.3:
   version "1.1.3"
   resolved "https://registry.npmmirror.com/steed/-/steed-1.1.3.tgz"
@@ -2417,6 +2467,13 @@ stream-wormhole@^1.0.4:
   resolved "https://registry.npmmirror.com/stream-wormhole/-/stream-wormhole-1.1.0.tgz"
   integrity sha512-gHFfL3px0Kctd6Po0M8TzEvt3De/xu6cnRrjlfYNhwbhLPLwigI2t1nc6jrzNuaYg5C4YF78PPFuQPzRiqn9ew==
 
+string_decoder@~1.1.1:
+  version "1.1.1"
+  resolved "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz"
+  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
+  dependencies:
+    safe-buffer "~5.1.0"
+
 "string-width-cjs@npm:string-width@^4.2.0":
   version "4.2.3"
   resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz"
@@ -2444,13 +2501,6 @@ string-width@^5.0.1, string-width@^5.1.2:
     emoji-regex "^9.2.2"
     strip-ansi "^7.0.1"
 
-string_decoder@~1.1.1:
-  version "1.1.1"
-  resolved "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz"
-  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
-  dependencies:
-    safe-buffer "~5.1.0"
-
 "strip-ansi-cjs@npm:strip-ansi@^6.0.1":
   version "6.0.1"
   resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz"
@@ -2585,7 +2635,7 @@ ts-node-dev@^2.0.0:
     ts-node "^10.4.0"
     tsconfig "^7.0.0"
 
-ts-node@^10.4.0:
+ts-node@^10.4.0, ts-node@^10.7.0:
   version "10.9.2"
   resolved "https://registry.npmmirror.com/ts-node/-/ts-node-10.9.2.tgz"
   integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==
@@ -2614,16 +2664,16 @@ tsconfig@^7.0.0:
     strip-bom "^3.0.0"
     strip-json-comments "^2.0.0"
 
-tslib@2.7.0:
-  version "2.7.0"
-  resolved "https://registry.npmmirror.com/tslib/-/tslib-2.7.0.tgz"
-  integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==
-
 tslib@^2.5.0:
   version "2.8.1"
   resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz"
   integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
 
+tslib@2.7.0:
+  version "2.7.0"
+  resolved "https://registry.npmmirror.com/tslib/-/tslib-2.7.0.tgz"
+  integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==
+
 typeorm@^0.3.21:
   version "0.3.21"
   resolved "https://registry.npmmirror.com/typeorm/-/typeorm-0.3.21.tgz"
@@ -2643,7 +2693,7 @@ typeorm@^0.3.21:
     uuid "^11.0.5"
     yargs "^17.6.2"
 
-typescript@^5.8.2:
+typescript@*, typescript@^5.8.2, typescript@>=2.7, typescript@>=4.9.4:
   version "5.8.2"
   resolved "https://registry.npmmirror.com/typescript/-/typescript-5.8.2.tgz"
   integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==
@@ -2992,6 +3042,16 @@ win-release@^1.0.0:
   dependencies:
     semver "^5.0.1"
 
+wmf@~1.0.1:
+  version "1.0.2"
+  resolved "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz"
+  integrity sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==
+
+word@~0.3.0:
+  version "0.3.0"
+  resolved "https://registry.npmjs.org/word/-/word-0.3.0.tgz"
+  integrity sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==
+
 "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
   version "7.0.0"
   resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
@@ -3024,15 +3084,28 @@ wrappy@1:
   resolved "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz"
   integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
 
+ws@*, ws@^8.17.1:
+  version "8.18.1"
+  resolved "https://registry.npmmirror.com/ws/-/ws-8.18.1.tgz"
+  integrity sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==
+
 ws@8.17.1:
   version "8.17.1"
   resolved "https://registry.npmmirror.com/ws/-/ws-8.17.1.tgz"
   integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==
 
-ws@^8.17.1:
-  version "8.18.1"
-  resolved "https://registry.npmmirror.com/ws/-/ws-8.18.1.tgz"
-  integrity sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==
+xlsx@^0.18.5:
+  version "0.18.5"
+  resolved "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz"
+  integrity sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==
+  dependencies:
+    adler-32 "~1.3.0"
+    cfb "~1.2.1"
+    codepage "~1.15.0"
+    crc-32 "~1.2.1"
+    ssf "~0.11.2"
+    wmf "~1.0.1"
+    word "~0.3.0"
 
 xml2js@^0.6.2:
   version "0.6.2"
@@ -3085,7 +3158,7 @@ yn@3.1.1:
   resolved "https://registry.npmmirror.com/yn/-/yn-3.1.1.tgz"
   integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
 
-zod@^3.21.4:
+"zod@^3 >=3.19.1", zod@^3.21.4:
   version "3.24.2"
   resolved "https://registry.npmmirror.com/zod/-/zod-3.24.2.tgz"
   integrity sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==