Răsfoiți Sursa

添加视频源

wilhelm wong 1 lună în urmă
părinte
comite
4b85048d7e

+ 1 - 1
.cursor/mcp.json

@@ -8,7 +8,7 @@
         "--transport",
         "stdio",
         "--dsn",
-        "mysql://zouma:2wsx%40WSX%23EDC@rdsave1o67m1ido6gwp6public.mysql.rds.aliyuncs.com:3306/junma_test"
+        "mysql://root:tYYf%25BC7pqQJ8%24v%21@8.210.167.152:3306/junma"
       ],
       "env": {
         "READONLY": "true"

+ 2 - 0
package.json

@@ -14,6 +14,7 @@
   "dependencies": {
     "@fastify/cors": "^11.0.1",
     "@fastify/env": "^5.0.2",
+    "@fastify/http-proxy": "^11.4.1",
     "@fastify/jwt": "^9.1.0",
     "@fastify/multipart": "^9.0.3",
     "@fastify/swagger": "^9.4.2",
@@ -21,6 +22,7 @@
     "@types/ali-oss": "^6.16.11",
     "@types/ioredis": "^5.0.0",
     "ali-oss": "^6.23.0",
+    "axios": "^1.7.9",
     "bcryptjs": "^3.0.2",
     "class-transformer": "^0.5.1",
     "class-validator": "^0.14.1",

+ 6 - 0
src/app.ts

@@ -23,6 +23,9 @@ import teamDomainRoutes from './routes/team-domain.routes'
 import landingDomainPoolRoutes from './routes/landing-domain-pool.routes'
 import bannerRoutes from './routes/banner.routes'
 import pageClickRecordRoutes from './routes/page-click-record.routes'
+import videoRoutes from './routes/video.routes'
+import m3u8ProxyRoutes from './routes/m3u8-proxy.routes'
+import domainManagementRoutes from './routes/domain-management.routes'
 import { authenticate } from './middlewares/auth.middleware'
 import { createRedisClient, closeRedisClient } from './config/redis'
 import { BannerStatisticsScheduler } from './scheduler/banner-statistics.scheduler'
@@ -105,6 +108,9 @@ export const createApp = async () => {
   app.register(userShareRoutes, { prefix: '/api/user-share' })
   app.register(bannerRoutes, { prefix: '/api/banners' })
   app.register(pageClickRecordRoutes, { prefix: '/api/page-clicks' })
+  app.register(videoRoutes, { prefix: '/api/video' })
+  app.register(m3u8ProxyRoutes)
+  app.register(domainManagementRoutes, { prefix: '/api/domain-management' })
 
   // 添加 /account 路由重定向到用户资料
   app.get('/account', { onRequest: [authenticate] }, async (request, reply) => {

+ 4 - 0
src/config/env.ts

@@ -92,6 +92,10 @@ export const schema = {
     },
     REDIS_DB: {
       type: 'number'
+    },
+    // 视频API配置(可选)
+    VIDEO_API_URL: {
+      type: 'string'
     }
   }
 }

+ 82 - 0
src/controllers/domain-management.controller.ts

@@ -0,0 +1,82 @@
+import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
+import { DomainManagementService } from '../services/domain-management.service'
+import {
+  CreateDomainManagementBody,
+  UpdateDomainManagementBody,
+  ListDomainManagementQuery
+} from '../dto/domain-management.dto'
+
+export class DomainManagementController {
+  private domainManagementService: DomainManagementService
+
+  constructor(app: FastifyInstance) {
+    this.domainManagementService = new DomainManagementService(app)
+  }
+
+  async create(request: FastifyRequest<{ Body: CreateDomainManagementBody }>, reply: FastifyReply) {
+    try {
+      const domainManagement = await this.domainManagementService.create(request.body)
+      return reply.code(201).send(domainManagement)
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '操作失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  async update(
+    request: FastifyRequest<{ Params: { id: string }; Body: UpdateDomainManagementBody }>,
+    reply: FastifyReply
+  ) {
+    try {
+      const id = parseInt(request.params.id)
+      const domainManagement = await this.domainManagementService.update(id, request.body)
+      return reply.send(domainManagement)
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '操作失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  async delete(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
+    try {
+      const id = parseInt(request.params.id)
+      await this.domainManagementService.delete(id)
+      return reply.send({ success: true })
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '操作失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  async getById(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
+    try {
+      const id = parseInt(request.params.id)
+      const domainManagement = await this.domainManagementService.getById(id)
+      return reply.send(domainManagement)
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '操作失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  async list(request: FastifyRequest<{ Querystring: ListDomainManagementQuery }>, reply: FastifyReply) {
+    try {
+      const domainManagements = await this.domainManagementService.list(request.query)
+      return reply.send(domainManagements)
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '操作失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  async getDomainTypes(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const types = await this.domainManagementService.getDomainTypes()
+      return reply.send(types)
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '操作失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+}
+

+ 8 - 2
src/controllers/landing-domain-pool.controller.ts

@@ -189,8 +189,14 @@ export class LandingDomainPoolController {
         return reply.code(400).send({ message: '域名参数不能为空' })
       }
 
-      const landingDomainPools = await this.landingDomainPoolService.findByTeamDomain(domain)
-      return reply.send(landingDomainPools)
+      const result = await this.landingDomainPoolService.findByTeamDomain(domain)
+      
+      // 如果没有找到,返回 null
+      if (!result) {
+        return reply.send(null)
+      }
+      
+      return reply.send(result)
     } catch (error) {
       const errorMessage = error instanceof Error ? error.message : '获取落地域名池失败'
       if (errorMessage === '域名不存在') {

+ 80 - 2
src/controllers/member.controller.ts

@@ -7,6 +7,7 @@ import {
   MemberResponse,
   UpdateGuestBody,
   MemberLoginBody,
+  MemberLoginByCodeBody,
   ResetPasswordBody,
   RegisterBody,
   UpdateProfileBody
@@ -42,6 +43,9 @@ export class MemberController {
 
       const user = await this.memberService.createGuest(code, domain, ip, landingDomain)
       const token = await reply.jwtSign({ id: user.id, name: user.name, role: user.role })
+      
+      // 获取member信息以返回loginCode
+      const member = await this.memberService.findByUserId(user.id)
 
       return reply.code(201).send({
         user: {
@@ -49,7 +53,8 @@ export class MemberController {
           name: user.name,
           vipLevel: VipLevel.GUEST
         },
-        token
+        token,
+        loginCode: member?.loginCode || null
       })
     } catch (error) {
       return reply.code(500).send({ message: '创建游客失败' })
@@ -142,7 +147,8 @@ export class MemberController {
           role: user.role,
           vipLevel: member.vipLevel
         },
-        token
+        token,
+        loginCode: member.loginCode || null
       })
     } catch (error) {
       const errorMessage = error instanceof Error ? error.message : '注册失败'
@@ -182,6 +188,54 @@ export class MemberController {
     }
   }
 
+  /**
+   * 通过免登录code登录
+   * 用于guest用户通过注册时生成的loginCode快速登录
+   */
+  async loginByCode(request: FastifyRequest<{ Body: MemberLoginByCodeBody }>, reply: FastifyReply) {
+    try {
+      const { code } = request.body
+
+      // 验证必填字段
+      if (!code || !code.trim()) {
+        return reply.code(400).send({ message: '请输入登录码' })
+      }
+
+      // 验证code格式(32位字母数字组合)
+      if (code.trim().length !== 32 || !/^[a-zA-Z0-9]+$/.test(code.trim())) {
+        return reply.code(400).send({ message: '登录码格式不正确,应为32位字母数字组合' })
+      }
+
+      // 调用登录服务
+      const loginResult = await this.memberService.loginByCode(code.trim())
+      if (!loginResult) {
+        return reply.code(401).send({ message: '登录码无效或账户已被禁用' })
+      }
+
+      const { user, member } = loginResult
+
+      // 生成JWT token
+      const token = await reply.jwtSign({ id: user.id, name: user.name, role: user.role })
+
+      // 检查VIP过期时间
+      await this.memberService.checkVipExpireTime(member)
+
+      return reply.send({
+        message: '登录成功',
+        user: {
+          id: user.id,
+          name: user.name,
+          role: user.role,
+          vipLevel: member.vipLevel
+        },
+        token
+      })
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '登录失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
   async profile(request: FastifyRequest, reply: FastifyReply) {
     try {
       const user = await this.userService.findById(request.user.id)
@@ -205,6 +259,30 @@ export class MemberController {
     }
   }
 
+  /**
+   * 获取当前用户的免登录code
+   * 需要登录才能访问,返回当前登录用户的loginCode
+   */
+  async getLoginCode(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const userId = request.user.id
+      
+      // 获取当前用户的loginCode
+      const loginCode = await this.memberService.getLoginCodeByUserId(userId)
+      
+      if (!loginCode) {
+        return reply.code(404).send({ message: '未找到登录码,该账户可能不是guest用户或未生成登录码' })
+      }
+
+      return reply.send({
+        loginCode
+      })
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '获取登录码失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
   async resetPassword(request: FastifyRequest<{ Body: ResetPasswordBody }>, reply: FastifyReply) {
     try {
       const { password } = request.body

+ 9 - 4
src/controllers/team-members.controller.ts

@@ -310,12 +310,13 @@ export class TeamMembersController {
         return reply.code(400).send({ message: '团队成员没有推广码' })
       }
 
-      const promotionLink = await this.teamMembersService.generatePromotionLink(Number(id))
+      const links = await this.teamMembersService.generatePromotionLink(Number(id))
 
       const response: PromotionLinkResponse = {
         teamMemberId: teamMember.id,
         promoCode: teamMember.promoCode,
-        promotionLink
+        promotionLink: links.generalLink,
+        browserLink: links.browserLink
       }
 
       return reply.send(response)
@@ -439,8 +440,11 @@ export class TeamMembersController {
 
       // 生成推广链接
       let promotionLink = ''
+      let browserLink = ''
       try {
-        promotionLink = await this.teamMembersService.generatePromotionLink(teamMember.id)
+        const links = await this.teamMembersService.generatePromotionLink(teamMember.id)
+        promotionLink = links.generalLink
+        browserLink = links.browserLink
       } catch (error) {
         const errorMessage = error instanceof Error ? error.message : '未知错误'
         if (errorMessage.includes('未配置 super_domain')) {
@@ -455,7 +459,8 @@ export class TeamMembersController {
       const response: PromotionLinkResponse = {
         teamMemberId: teamMember.id,
         promoCode: teamMember.promoCode!,
-        promotionLink
+        promotionLink,
+        browserLink
       }
 
       return reply.send(response)

+ 153 - 0
src/controllers/video.controller.ts

@@ -0,0 +1,153 @@
+import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
+import { VideoService } from '../services/video.service'
+import { VideoListQuery, VideoDetailQuery } from '../dto/video.dto'
+
+export class VideoController {
+  private videoService: VideoService
+  private app: FastifyInstance
+
+  constructor(app: FastifyInstance) {
+    this.app = app
+    this.videoService = new VideoService(app)
+  }
+
+  /**
+   * 统一错误响应格式
+   */
+  private createErrorResponse(msg: string, data: any = null) {
+    return {
+      code: 0,
+      msg,
+      time: Math.floor(Date.now() / 1000).toString(),
+      data
+    }
+  }
+
+  /**
+   * 获取视频列表(中转接口,公开接口,无需认证)
+   */
+  async getVideoList(request: FastifyRequest<{ Querystring: VideoListQuery }>, reply: FastifyReply) {
+    try {
+      const query = request.query
+      
+      // 构建查询参数
+      const params: VideoListQuery = {
+        p: query.p || 1,
+        l: query.l || 100,
+        k: query.k
+      }
+
+      const result = await this.videoService.getVideoList(params)
+      
+      // 处理 m3u8 URL,转换为代理地址
+      if (result?.data?.list && Array.isArray(result.data.list)) {
+        result.data.list.forEach(item => {
+          if (item.m3u8) {
+            item.m3u8 = this.videoService.convertM3u8ToProxyUrl(item.m3u8, request)
+          }
+        })
+      }
+      
+      return reply.send(result)
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '获取视频列表失败'
+      this.app.log.error(`获取视频列表失败: ${errorMessage}`)
+      
+      return reply.code(500).send(
+        this.createErrorResponse(errorMessage, {
+          list: [],
+          total: 0,
+          page: request.query.p || 1,
+          limit: request.query.l || 100,
+          l: request.query.l || 100
+        })
+      )
+    }
+  }
+
+  /**
+   * 获取视频详情(中转接口,公开接口,无需认证)
+   */
+  async getVideoDetail(request: FastifyRequest<{ Querystring: VideoDetailQuery }>, reply: FastifyReply) {
+    try {
+      const { id } = request.query
+      
+      if (!id) {
+        return reply.code(400).send(
+          this.createErrorResponse('视频ID参数必填')
+        )
+      }
+
+      const result = await this.videoService.getVideoDetail({ id })
+      
+      // 处理 m3u8 URL,转换为代理地址
+      if (result?.data?.m3u8) {
+        result.data.m3u8 = this.videoService.convertM3u8ToProxyUrl(result.data.m3u8, request)
+      }
+      
+      return reply.send(result)
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '获取视频详情失败'
+      this.app.log.error(`获取视频详情失败: ${errorMessage}`)
+      
+      return reply.code(500).send(
+        this.createErrorResponse(errorMessage)
+      )
+    }
+  }
+
+  /**
+   * 获取视频封面图片(公开接口,无需认证)
+   * 将base64图片数据转换为图片响应返回
+   */
+  async getVideoImage(request: FastifyRequest<{ Querystring: { id: string | number } }>, reply: FastifyReply) {
+    try {
+      const { id } = request.query
+      
+      if (!id) {
+        return reply.code(400).send(
+          this.createErrorResponse('视频ID参数必填')
+        )
+      }
+
+      // 将id转换为数字
+      const videoId = typeof id === 'string' ? parseInt(id, 10) : id
+      
+      if (isNaN(videoId)) {
+        return reply.code(400).send(
+          this.createErrorResponse('视频ID参数格式错误')
+        )
+      }
+
+      this.app.log.info(`获取视频封面图片请求 (视频ID: ${videoId})`)
+
+      // 获取视频图片数据
+      const imageData = await this.videoService.getVideoImageData(videoId)
+      
+      if (!imageData) {
+        this.app.log.warn(`视频封面图片不存在 (视频ID: ${videoId})`)
+        return reply.code(404).send(
+          this.createErrorResponse('视频封面图片不存在')
+        )
+      }
+
+      // 设置响应头并返回图片数据
+      reply.type(imageData.mimeType)
+      reply.header('Cache-Control', 'public, max-age=31536000') // 缓存1年
+      reply.header('Content-Length', imageData.buffer.length.toString())
+      reply.header('Accept-Ranges', 'bytes')
+      this.app.log.info(`成功返回视频封面图片 (视频ID: ${videoId}, 类型: ${imageData.mimeType}, 大小: ${imageData.buffer.length} bytes)`)
+      
+      // 直接返回Buffer,Fastify会自动处理
+      return reply.send(imageData.buffer)
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '获取视频封面图片失败'
+      this.app.log.error(`获取视频封面图片失败: ${errorMessage}`)
+      
+      return reply.code(500).send(
+        this.createErrorResponse(errorMessage)
+      )
+    }
+  }
+}
+

+ 27 - 0
src/dto/domain-management.dto.ts

@@ -0,0 +1,27 @@
+import { DomainType } from '../entities/domain-management.entity'
+
+export interface CreateDomainManagementBody {
+  teamId: number
+  domainType: DomainType
+  remark?: string
+  domain: string
+  enabled?: boolean
+}
+
+export interface UpdateDomainManagementBody {
+  teamId?: number
+  domainType?: DomainType
+  remark?: string
+  domain?: string
+  enabled?: boolean
+}
+
+export interface ListDomainManagementQuery {
+  page?: number
+  size?: number
+  teamId?: number
+  domainType?: DomainType
+  domain?: string
+  enabled?: boolean
+}
+

+ 9 - 0
src/dto/member.dto.ts

@@ -56,6 +56,15 @@ export interface MemberLoginBody {
   password: string
 }
 
+/**
+ * 通过免登录code登录请求体
+ * code: 32位字母数字组合的登录码,由guest用户注册时自动生成
+ */
+export interface MemberLoginByCodeBody {
+  /** 免登录code,32位字母数字组合 */
+  code: string
+}
+
 export interface ResetPasswordBody {
   password: string
 }

+ 1 - 0
src/dto/team-members.dto.ts

@@ -101,6 +101,7 @@ export interface PromotionLinkResponse {
   teamMemberId: number
   promoCode: string
   promotionLink: string
+  browserLink: string
 }
 
 export interface GeneratePromoCodeResponse {

+ 73 - 0
src/dto/video.dto.ts

@@ -0,0 +1,73 @@
+/**
+ * 视频列表查询参数
+ */
+export interface VideoListQuery {
+  /** 页码,默认为1 */
+  p?: number
+  /** 每页数量,默认为100 */
+  l?: number
+  /** 关键词筛选(可选) */
+  k?: string
+}
+
+/**
+ * 视频详情查询参数
+ */
+export interface VideoDetailQuery {
+  /** 视频ID(必填) */
+  id: number
+}
+
+/**
+ * 视频项数据结构
+ */
+export interface VideoItem {
+  /** 视频ID */
+  id: number
+  /** 视频标题 */
+  title: string
+  /** 视频播放地址(m3u8格式) */
+  m3u8: string
+  /** 视频封面图片地址 */
+  image: string
+}
+
+/**
+ * 视频列表响应数据结构
+ */
+export interface VideoListResponse {
+  /** 响应码,0表示失败,1表示成功 */
+  code: number
+  /** 响应消息 */
+  msg: string
+  /** 时间戳(秒) */
+  time: string
+  /** 响应数据 */
+  data: {
+    /** 视频列表 */
+    list: VideoItem[]
+    /** 总记录数 */
+    total: number
+    /** 当前页码 */
+    page: number
+    /** 每页数量 */
+    limit: number
+    /** 每页数量(与limit相同) */
+    l: number
+  }
+}
+
+/**
+ * 视频详情响应数据结构
+ */
+export interface VideoDetailResponse {
+  /** 响应码,0表示失败,1表示成功 */
+  code: number
+  /** 响应消息 */
+  msg: string
+  /** 时间戳(秒) */
+  time: string
+  /** 响应数据 */
+  data: VideoItem | null
+}
+

+ 38 - 0
src/entities/domain-management.entity.ts

@@ -0,0 +1,38 @@
+import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'
+
+export enum DomainType {
+  PRIMARY = 'primary', // 一级域名
+  SECONDARY = 'secondary' // 二级域名
+}
+
+@Entity()
+export class DomainManagement {
+  @PrimaryGeneratedColumn()
+  id: number
+
+  @Column({ type: 'int' })
+  teamId: number
+
+  @Column({
+    type: 'enum',
+    enum: DomainType,
+    default: DomainType.PRIMARY
+  })
+  domainType: DomainType
+
+  @Column({ length: 500, nullable: true })
+  remark: string
+
+  @Column({ length: 255 })
+  domain: string
+
+  @Column({ type: 'boolean', default: true })
+  enabled: boolean
+
+  @CreateDateColumn()
+  createdAt: Date
+
+  @UpdateDateColumn()
+  updatedAt: Date
+}
+

+ 3 - 0
src/entities/member.entity.ts

@@ -46,6 +46,9 @@ export class Member {
   @Column({ nullable: true, length: 45 })
   ip: string
 
+  @Column({ unique: true, length: 32, nullable: true })
+  loginCode: string
+
   @Column({
     type: 'enum',
     enum: VipLevel,

+ 56 - 0
src/routes/domain-management.routes.ts

@@ -0,0 +1,56 @@
+import { FastifyInstance } from 'fastify'
+import { hasRole } from '../middlewares/auth.middleware'
+import { UserRole } from '../entities/user.entity'
+import { DomainManagementController } from '../controllers/domain-management.controller'
+import {
+  CreateDomainManagementBody,
+  UpdateDomainManagementBody,
+  ListDomainManagementQuery
+} from '../dto/domain-management.dto'
+
+export default async function domainManagementRoutes(fastify: FastifyInstance) {
+  const domainManagementController = new DomainManagementController(fastify)
+
+  // 创建域名
+  fastify.post<{ Body: CreateDomainManagementBody }>(
+    '/',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    domainManagementController.create.bind(domainManagementController)
+  )
+
+  // 更新域名
+  fastify.put<{ Params: { id: string }; Body: UpdateDomainManagementBody }>(
+    '/:id',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    domainManagementController.update.bind(domainManagementController)
+  )
+
+  // 删除域名
+  fastify.delete<{ Params: { id: string } }>(
+    '/:id',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    domainManagementController.delete.bind(domainManagementController)
+  )
+
+  // 获取单个域名
+  fastify.get<{ Params: { id: string } }>(
+    '/:id',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    domainManagementController.getById.bind(domainManagementController)
+  )
+
+  // 获取域名列表
+  fastify.get<{ Querystring: ListDomainManagementQuery }>(
+    '/',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    domainManagementController.list.bind(domainManagementController)
+  )
+
+  // 获取域名类型列表
+  fastify.get(
+    '/types/all',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    domainManagementController.getDomainTypes.bind(domainManagementController)
+  )
+}
+

+ 135 - 0
src/routes/m3u8-proxy.routes.ts

@@ -0,0 +1,135 @@
+import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
+import axios from 'axios'
+import { SysConfig } from '../entities/sys-config.entity'
+
+/**
+ * M3U8 代理路由
+ * 前缀: /api/proxy/m3u8
+ * 用于代理 HTTP 的 m3u8 视频链接,解决 HTTPS 网站的混合内容问题
+ */
+export default async function m3u8ProxyRoutes(fastify: FastifyInstance) {
+  const defaultTargetHost = 'http://cndbt02.jxcnjd.com'
+  
+  // 配置缓存
+  let videoSourceConfigCache: { value: string | null; timestamp: number } | null = null
+  const CONFIG_CACHE_EXPIRY = 5 * 60 * 1000 // 配置缓存过期时间:5分钟
+
+  /**
+   * 获取视频源配置(带缓存)
+   * @returns 视频源域名配置,如果不存在则返回null
+   */
+  async function getVideoSourceConfig(): Promise<string | null> {
+    // 检查缓存
+    if (videoSourceConfigCache && 
+        Date.now() - videoSourceConfigCache.timestamp < CONFIG_CACHE_EXPIRY) {
+      return videoSourceConfigCache.value
+    }
+
+    try {
+      const sysConfigRepository = fastify.dataSource.getRepository(SysConfig)
+      // 从数据库读取配置(teamId=0 表示全局配置)
+      const config = await sysConfigRepository.findOne({ 
+        where: { name: 'video_source', teamId: 0 } 
+      })
+      
+      const value = config?.value || null
+      
+      // 更新缓存
+      videoSourceConfigCache = {
+        value,
+        timestamp: Date.now()
+      }
+      
+      return value
+    } catch (error) {
+      fastify.log.warn(`读取视频源配置失败: ${error instanceof Error ? error.message : String(error)}`)
+      return null
+    }
+  }
+
+  /**
+   * 获取目标主机地址
+   * @returns 目标主机地址(完整URL或域名)
+   */
+  async function getTargetHost(): Promise<string> {
+    const videoSourceConfig = await getVideoSourceConfig()
+    if (videoSourceConfig) {
+      // 如果配置是完整URL,直接使用;如果是域名,添加http://前缀
+      if (videoSourceConfig.startsWith('http://') || videoSourceConfig.startsWith('https://')) {
+        return videoSourceConfig
+      } else {
+        return `http://${videoSourceConfig}`
+      }
+    }
+    return defaultTargetHost
+  }
+
+  // 处理 OPTIONS 预检请求
+  fastify.options('/api/proxy/m3u8/*', async (request: FastifyRequest, reply: FastifyReply) => {
+    reply.header('Access-Control-Allow-Origin', '*')
+    reply.header('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
+    reply.header('Access-Control-Allow-Headers', '*')
+    reply.code(204).send()
+  })
+
+  // 处理 GET 请求
+  fastify.get('/api/proxy/m3u8/*', async (request: FastifyRequest, reply: FastifyReply) => {
+    try {
+      // 获取请求路径(去掉 /api/proxy/m3u8 前缀)
+      const urlPath = request.url.split('?')[0] // 去掉查询参数部分
+      const path = urlPath.replace('/api/proxy/m3u8', '')
+      
+      // 获取目标主机(从配置读取,如果配置存在则使用配置的域名)
+      const targetHost = await getTargetHost()
+      
+      // 构建目标 URL
+      let targetUrl = `${targetHost}${path}`
+      
+      // 如果有查询参数,添加到目标 URL
+      const queryString = new URLSearchParams(request.query as Record<string, string>).toString()
+      const finalUrl = queryString ? `${targetUrl}?${queryString}` : targetUrl
+
+      fastify.log.info(`[M3U8 Proxy] Requesting: ${finalUrl}`)
+
+      // 构建请求头(移除 Range 头)
+      const headers: Record<string, string> = {}
+      for (const [key, value] of Object.entries(request.headers)) {
+        const lowerKey = key.toLowerCase()
+        // 跳过 Range 头和 Host 头
+        if (lowerKey !== 'range' && lowerKey !== 'host') {
+          headers[key] = Array.isArray(value) ? value[0] : value || ''
+        }
+      }
+
+      // 发送请求到目标服务器
+      const response = await axios.get(finalUrl, {
+        headers,
+        responseType: 'stream',
+        timeout: 30000, // 30秒超时
+        validateStatus: () => true // 接受所有状态码
+      })
+
+      // 设置响应头
+      reply.header('Access-Control-Allow-Origin', '*')
+      reply.header('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
+      reply.header('Access-Control-Allow-Headers', '*')
+      reply.header('Content-Type', 'application/vnd.apple.mpegurl')
+      
+      // 转发状态码
+      reply.code(response.status)
+
+      // 转发响应流
+      return reply.send(response.data)
+    } catch (error) {
+      fastify.log.error(`[M3U8 Proxy] Error: ${error instanceof Error ? error.message : String(error)}`)
+      if (!reply.sent) {
+        reply.code(500).send({
+          code: 0,
+          msg: '代理请求失败',
+          time: Math.floor(Date.now() / 1000).toString(),
+          data: null
+        })
+      }
+    }
+  })
+}

+ 9 - 1
src/routes/member.routes.ts

@@ -7,6 +7,7 @@ import {
   ListMemberQuery,
   UpdateGuestBody,
   MemberLoginBody,
+  MemberLoginByCodeBody,
   ResetPasswordBody,
   RegisterBody,
   UpdateProfileBody
@@ -33,12 +34,19 @@ export default async function memberRoutes(fastify: FastifyInstance) {
   // 注册
   fastify.post<{ Body: RegisterBody }>('/register', memberController.register.bind(memberController))
 
-  // 登录
+  // 登录(用户名密码)
   fastify.post<{ Body: MemberLoginBody }>('/login', memberController.memberLogin.bind(memberController))
 
+  // 通过免登录code登录(guest用户专用)
+  // 请求体: { code: string } - 32位字母数字组合的登录码
+  fastify.post<{ Body: MemberLoginByCodeBody }>('/login-by-code', memberController.loginByCode.bind(memberController))
+
   // 获取当前会员信息
   fastify.get('/profile', { onRequest: [authenticate] }, memberController.profile.bind(memberController))
 
+  // 获取当前用户的免登录code(需要登录)
+  fastify.get('/login-code', { onRequest: [authenticate] }, memberController.getLoginCode.bind(memberController))
+
   // 修改用户名和邮箱
   fastify.put<{ Body: UpdateProfileBody }>(
     '/profile',

+ 52 - 0
src/routes/video.routes.ts

@@ -0,0 +1,52 @@
+import { FastifyInstance } from 'fastify'
+import { VideoController } from '../controllers/video.controller'
+import { VideoListQuery, VideoDetailQuery } from '../dto/video.dto'
+
+/**
+ * 视频接口路由
+ * 前缀: /api/video
+ * 所有接口均为公开接口,无需认证
+ */
+export default async function videoRoutes(fastify: FastifyInstance) {
+  const videoController = new VideoController(fastify)
+
+  /**
+   * GET /api/video/list
+   * 获取视频列表(中转接口)
+   * 查询参数:
+   *   - p: 页码(可选,默认1)
+   *   - l: 每页数量(可选,默认100)
+   *   - k: 关键词筛选(可选)
+   */
+  fastify.get<{ Querystring: VideoListQuery }>(
+    '/list',
+    { onRequest: [] },
+    videoController.getVideoList.bind(videoController)
+  )
+
+  /**
+   * GET /api/video/detail
+   * 获取视频详情(中转接口)
+   * 查询参数:
+   *   - id: 视频ID(必填)
+   */
+  fastify.get<{ Querystring: VideoDetailQuery }>(
+    '/detail',
+    { onRequest: [] },
+    videoController.getVideoDetail.bind(videoController)
+  )
+
+  /**
+   * GET /api/video/image
+   * 获取视频封面图片(直接返回图片数据)
+   * 查询参数:
+   *   - id: 视频ID(必填)
+   * 返回: 图片二进制数据(Content-Type: image/jpeg, image/png等)
+   */
+  fastify.get<{ Querystring: { id: string | number } }>(
+    '/image',
+    { onRequest: [] },
+    videoController.getVideoImage.bind(videoController)
+  )
+}
+

+ 117 - 0
src/services/domain-management.service.ts

@@ -0,0 +1,117 @@
+import { FastifyInstance } from 'fastify'
+import { Repository, Like } from 'typeorm'
+import { DomainManagement, DomainType } from '../entities/domain-management.entity'
+import {
+  CreateDomainManagementBody,
+  UpdateDomainManagementBody,
+  ListDomainManagementQuery
+} from '../dto/domain-management.dto'
+
+export class DomainManagementService {
+  private app: FastifyInstance
+  private domainManagementRepository: Repository<DomainManagement>
+
+  constructor(app: FastifyInstance) {
+    this.app = app
+    this.domainManagementRepository = app.dataSource.getRepository(DomainManagement)
+  }
+
+  async create(data: CreateDomainManagementBody) {
+    // 检查域名是否已存在
+    const existingDomain = await this.domainManagementRepository.findOne({
+      where: { domain: data.domain }
+    })
+    if (existingDomain) {
+      throw new Error('域名已存在')
+    }
+
+    const domainManagement = this.domainManagementRepository.create({
+      teamId: data.teamId,
+      domainType: data.domainType,
+      remark: data.remark,
+      domain: data.domain,
+      enabled: data.enabled !== undefined ? data.enabled : true
+    })
+
+    return await this.domainManagementRepository.save(domainManagement)
+  }
+
+  async update(id: number, data: UpdateDomainManagementBody) {
+    const domainManagement = await this.domainManagementRepository.findOneOrFail({
+      where: { id }
+    })
+
+    // 如果更新域名,检查新域名是否已存在
+    if (data.domain && data.domain !== domainManagement.domain) {
+      const existingDomain = await this.domainManagementRepository.findOne({
+        where: { domain: data.domain }
+      })
+      if (existingDomain) {
+        throw new Error('域名已存在')
+      }
+    }
+
+    Object.assign(domainManagement, data)
+    return await this.domainManagementRepository.save(domainManagement)
+  }
+
+  async delete(id: number) {
+    const domainManagement = await this.domainManagementRepository.findOneOrFail({
+      where: { id }
+    })
+    return await this.domainManagementRepository.remove(domainManagement)
+  }
+
+  async getById(id: number) {
+    return await this.domainManagementRepository.findOneOrFail({
+      where: { id }
+    })
+  }
+
+  async list(query: ListDomainManagementQuery) {
+    const { page = 0, size = 20, teamId, domainType, domain, enabled } = query
+
+    const queryBuilder = this.domainManagementRepository.createQueryBuilder('dm')
+
+    if (teamId !== undefined) {
+      queryBuilder.andWhere('dm.teamId = :teamId', { teamId })
+    }
+
+    if (domainType) {
+      queryBuilder.andWhere('dm.domainType = :domainType', { domainType })
+    }
+
+    if (domain) {
+      queryBuilder.andWhere('dm.domain LIKE :domain', { domain: `%${domain}%` })
+    }
+
+    if (enabled !== undefined) {
+      queryBuilder.andWhere('dm.enabled = :enabled', { enabled })
+    }
+
+    queryBuilder
+      .orderBy('dm.createdAt', 'DESC')
+      .addOrderBy('dm.id', 'DESC')
+
+    const total = await queryBuilder.getCount()
+    const data = await queryBuilder
+      .skip(page * size)
+      .take(size)
+      .getMany()
+
+    return {
+      data,
+      meta: {
+        page,
+        size,
+        total,
+        totalPages: Math.ceil(total / size)
+      }
+    }
+  }
+
+  async getDomainTypes() {
+    return Object.values(DomainType)
+  }
+}
+

+ 116 - 25
src/services/landing-domain-pool.service.ts

@@ -1,17 +1,24 @@
-import { Repository, Like, In } from 'typeorm'
+import { Repository, Like, In, IsNull } from 'typeorm'
 import { FastifyInstance } from 'fastify'
 import { LandingDomainPool, DomainType } from '../entities/landing-domain-pool.entity'
 import { TeamDomain } from '../entities/team-domain.entity'
 import { TeamMembers } from '../entities/team-members.entity'
+import { DomainManagement, DomainType as DomainManagementType } from '../entities/domain-management.entity'
 import { PaginationResponse } from '../dto/common.dto'
 import { CreateLandingDomainPoolBody, UpdateLandingDomainPoolBody, ListLandingDomainPoolQuery } from '../dto/landing-domain-pool.dto'
 import { TeamService } from './team.service'
 import { UserService } from './user.service'
 
+export interface DomainResult {
+  domain: string
+  type: 'landing' | 'secondary' // 区分落地域名和2级域名
+}
+
 export class LandingDomainPoolService {
   private landingDomainPoolRepository: Repository<LandingDomainPool>
   private teamDomainRepository: Repository<TeamDomain>
   private teamMembersRepository: Repository<TeamMembers>
+  private domainManagementRepository: Repository<DomainManagement>
   private teamService: TeamService
   private userService: UserService
 
@@ -19,6 +26,7 @@ export class LandingDomainPoolService {
     this.landingDomainPoolRepository = app.dataSource.getRepository(LandingDomainPool)
     this.teamDomainRepository = app.dataSource.getRepository(TeamDomain)
     this.teamMembersRepository = app.dataSource.getRepository(TeamMembers)
+    this.domainManagementRepository = app.dataSource.getRepository(DomainManagement)
     this.teamService = new TeamService(app)
     this.userService = new UserService(app)
   }
@@ -290,42 +298,125 @@ export class LandingDomainPoolService {
   }
 
   /**
-   * 根据域名(team-domain)获取落地域名池列表
-   * 如果域名绑定到个人(teamMemberId不为空),返回该个人的所有落地域名
-   * 如果域名绑定到团队(teamMemberId为空),返回该团队的所有落地域名
-   * @param domain team-domain中的域名
-   * @returns 落地域名池列表
+   * 根据域名获取落地域名或二级域名(随机返回一个)
+   * 逻辑:
+   * 1. 先在一级域名池(landing-domain-pool)查找,如果能找到就按原逻辑返回对应的落地域名和类型
+   * 2. 如果找不到,去域名管理(domain-management)里查询
+   * 3. 根据查询到的域名团队和类型返回下级域名:
+   *    - 如果输入是一级域名,查询返回团队相同、类型为二级的对应域名
+   *    - 如果输入是二级域名,返回对应团队未绑定用户的落地域名
+   * 4. 如果有多个结果,随机返回一个;如果没有结果,返回 null
+   * @param domain 域名
+   * @returns 域名结果(包含类型字段区分2级域名和落地域名),如果不存在则返回 null
    */
-  async findByTeamDomain(domain: string): Promise<LandingDomainPool[]> {
-    // 根据域名查找team-domain记录
-    const teamDomain = await this.teamDomainRepository.findOne({
+  async findByTeamDomain(domain: string): Promise<DomainResult | null> {
+    // 第一步:先在一级域名池(landing-domain-pool)查找
+    const landingDomain = await this.landingDomainPoolRepository.findOne({
       where: { domain }
     })
 
-    if (!teamDomain) {
-      throw new Error('域名不存在')
+    if (landingDomain) {
+      // 如果找到,按原逻辑返回对应的落地域名
+      // 根据域名查找team-domain记录
+      const teamDomain = await this.teamDomainRepository.findOne({
+        where: { domain }
+      })
+
+      let landingDomains: LandingDomainPool[] = []
+
+      if (teamDomain) {
+        // 如果域名绑定到个人(teamMemberId不为空)
+        if (teamDomain.teamMemberId !== null && teamDomain.teamMemberId !== undefined) {
+          // 查找团队成员信息
+          const teamMember = await this.teamMembersRepository.findOne({
+            where: { id: teamDomain.teamMemberId }
+          })
+
+          if (teamMember) {
+            // 返回该个人(userId)的所有落地域名
+            landingDomains = await this.landingDomainPoolRepository.find({
+              where: { userId: teamMember.userId },
+              order: { createdAt: 'DESC' }
+            })
+          }
+        } else {
+          // 如果域名绑定到团队(teamMemberId为空),返回该团队的所有落地域名
+          landingDomains = await this.findByTeamId(teamDomain.teamId)
+        }
+      } else {
+        // 如果team-domain中找不到,返回该落地域名本身
+        landingDomains = [landingDomain]
+      }
+
+      // 转换为DomainResult格式,类型为'landing'
+      const results = landingDomains.map(ld => ({
+        domain: ld.domain,
+        type: 'landing' as const
+      }))
+      
+      // 如果有多个结果,随机返回一个
+      if (results.length === 0) {
+        return null
+      }
+      return results[Math.floor(Math.random() * results.length)]
     }
 
-    // 如果域名绑定到个人(teamMemberId不为空)
-    if (teamDomain.teamMemberId !== null && teamDomain.teamMemberId !== undefined) {
-      // 查找团队成员信息
-      const teamMember = await this.teamMembersRepository.findOne({
-        where: { id: teamDomain.teamMemberId }
+    // 第二步:如果一级域名池找不到,去域名管理(domain-management)里查询
+    const domainManagement = await this.domainManagementRepository.findOne({
+      where: { domain }
+    })
+
+    if (!domainManagement) {
+      // 如果域名管理中也没有,返回 null
+      return null
+    }
+
+    // 第三步:根据查询到的域名团队和类型返回下级域名
+    if (domainManagement.domainType === DomainManagementType.PRIMARY) {
+      // 如果输入是一级域名,查询返回团队相同、类型为二级的对应域名
+      const secondaryDomains = await this.domainManagementRepository.find({
+        where: {
+          teamId: domainManagement.teamId,
+          domainType: DomainManagementType.SECONDARY
+        },
+        order: { createdAt: 'DESC' }
       })
 
-      if (!teamMember) {
-        throw new Error('团队成员不存在')
+      // 转换为DomainResult格式,类型为'secondary'
+      const results = secondaryDomains.map(dm => ({
+        domain: dm.domain,
+        type: 'secondary' as const
+      }))
+      
+      // 如果有多个结果,随机返回一个
+      if (results.length === 0) {
+        return null
       }
-
-      // 返回该个人(userId)的所有落地域名
-      return await this.landingDomainPoolRepository.find({
-        where: { userId: teamMember.userId },
+      return results[Math.floor(Math.random() * results.length)]
+    } else if (domainManagement.domainType === DomainManagementType.SECONDARY) {
+      // 如果输入是二级域名,返回对应团队未绑定用户的落地域名
+      const landingDomains = await this.landingDomainPoolRepository.find({
+        where: {
+          teamId: domainManagement.teamId,
+          userId: IsNull() // 未绑定用户的落地域名
+        },
         order: { createdAt: 'DESC' }
       })
-    } else {
-      // 如果域名绑定到团队(teamMemberId为空),返回该团队的所有落地域名
-      return await this.findByTeamId(teamDomain.teamId)
+
+      // 转换为DomainResult格式,类型为'landing'
+      const results = landingDomains.map(ld => ({
+        domain: ld.domain,
+        type: 'landing' as const
+      }))
+      
+      // 如果有多个结果,随机返回一个
+      if (results.length === 0) {
+        return null
+      }
+      return results[Math.floor(Math.random() * results.length)]
     }
+
+    return null
   }
 
   /**

+ 71 - 0
src/services/member.service.ts

@@ -177,6 +177,19 @@ export class MemberService {
       })
 
       const savedUser = await manager.save(user)
+      
+      // 生成免登录code
+      let loginCode = randomstring.generate({
+        length: 32,
+        charset: 'alphanumeric'
+      })
+      while (await manager.findOne(Member, { where: { loginCode } })) {
+        loginCode = randomstring.generate({
+          length: 32,
+          charset: 'alphanumeric'
+        })
+      }
+      
       const member = manager.create(Member, {
         userId: savedUser.id,
         teamId,
@@ -184,6 +197,7 @@ export class MemberService {
         vipLevel: VipLevel.GUEST,
         status: MemberStatus.ACTIVE,
         ip: ip || 'unknown',
+        loginCode,
         lastLoginAt: new Date()
       })
       await manager.save(member)
@@ -271,6 +285,40 @@ export class MemberService {
     return { user, member }
   }
 
+  /**
+   * 通过免登录code登录
+   * @param loginCode 免登录code(32位字母数字组合)
+   * @returns 返回用户和会员信息,如果code无效或账户被禁用则返回null
+   */
+  async loginByCode(loginCode: string): Promise<{ user: User; member: Member } | null> {
+    // 验证code格式(32位字母数字组合)
+    if (!loginCode || loginCode.trim().length !== 32 || !/^[a-zA-Z0-9]+$/.test(loginCode)) {
+      return null
+    }
+
+    // 查找对应的会员记录
+    const member = await this.memberRepository.findOne({ where: { loginCode: loginCode.trim() } })
+    if (!member) {
+      return null
+    }
+
+    // 检查账户状态
+    if (member.status !== MemberStatus.ACTIVE) {
+      return null
+    }
+
+    // 查找对应的用户记录
+    const user = await this.dataSource.getRepository(User).findOne({ where: { id: member.userId } })
+    if (!user) {
+      return null
+    }
+
+    // 更新最后登录时间
+    await this.updateLastLogin(member.id)
+
+    return { user, member }
+  }
+
   async create(data: {
     userId: number
     teamId?: number
@@ -304,6 +352,16 @@ export class MemberService {
     return this.memberRepository.findOne({ where: { userId } })
   }
 
+  /**
+   * 获取用户的免登录code
+   * @param userId 用户ID
+   * @returns 返回loginCode,如果用户不存在或没有loginCode则返回null
+   */
+  async getLoginCodeByUserId(userId: number): Promise<string | null> {
+    const member = await this.findByUserId(userId)
+    return member?.loginCode || null
+  }
+
   async findByEmail(email: string): Promise<Member | null> {
     return this.memberRepository.findOne({ where: { email } })
   }
@@ -593,6 +651,18 @@ export class MemberService {
       })
       const savedUser = await manager.save(user)
 
+      // 生成免登录code
+      let loginCode = randomstring.generate({
+        length: 32,
+        charset: 'alphanumeric'
+      })
+      while (await manager.findOne(Member, { where: { loginCode } })) {
+        loginCode = randomstring.generate({
+          length: 32,
+          charset: 'alphanumeric'
+        })
+      }
+
       // 创建会员
       const member = manager.create(Member, {
         userId: savedUser.id,
@@ -603,6 +673,7 @@ export class MemberService {
         vipLevel: VipLevel.FREE,
         status: MemberStatus.ACTIVE,
         ip: ip || 'unknown',
+        loginCode,
         lastLoginAt: new Date()
       })
       const savedMember = await manager.save(member)

+ 229 - 15
src/services/multi-level-commission.service.ts

@@ -49,8 +49,14 @@ export class MultiLevelCommissionService {
   /**
    * 获取用户的所有上级代理(按层级排序,支持无限层级)
    * 逻辑:
-   * 1. 从 user 的 member 关联表里的 domainId 查询到 teamMember
-   * 2. 从 teamMember 的 parentId 开始向上查询其上级代理
+   * 1. 优先通过 domainId 查找:
+   *    - 从 user 的 member 关联表里的 domainId 查询到 teamDomain
+   *    - 如果 teamDomain.teamMemberId 为空,说明直接绑定团队,只有一层分润(从 team 表获取)
+   *    - 如果 teamDomain.teamMemberId 存在,从 teamMember 的 parentId 开始向上查询其上级代理
+   * 2. 如果 domainId 为空,通过 user.parentId 查找:
+   *    - 优先查找 team_members 表(userId = parentId)
+   *    - 如果找不到,查找 team 表(userId = parentId)
+   *    - 继续向上查找 parentId 的 parentId,直到没有 parentId 或达到最大层级
    * 3. 查到 parentId 为空为止
    * 4. 此时添加最后一个团队层级,从 team 表获取
    */
@@ -64,9 +70,184 @@ export class MultiLevelCommissionService {
 
     this.app.log.debug({ userId, domainId: member?.domainId, maxLevel }, 'getUserParentChain: 开始查找用户上级代理链')
 
-    // 如果没有提供 member 或 domainId 无效,返回空链
+    // 如果没有提供 member 或 domainId 无效,尝试通过 user.parentId 查找分润关系
     if (!member || !member.domainId || member.domainId <= 0) {
-      this.app.log.debug({ userId }, 'getUserParentChain: member 或 domainId 无效,返回空链')
+      this.app.log.debug({ userId }, 'getUserParentChain: member 或 domainId 无效,尝试通过 user.parentId 查找分润关系')
+      
+      // 获取用户信息
+      const user = await this.userRepository.findOne({
+        where: { id: userId }
+      })
+      
+      if (!user || !user.parentId || user.parentId <= 0) {
+        this.app.log.debug({ userId }, 'getUserParentChain: 用户不存在或没有 parentId,返回空链')
+        return chain
+      }
+      
+      // 通过 user.parentId 向上查找分润关系
+      let currentParentId = user.parentId
+      
+      while (currentParentId && currentParentId > 0) {
+        // 检查最大层级限制
+        if (maxLevel !== undefined && level > maxLevel) {
+          this.app.log.debug({ userId, level, maxLevel }, 'getUserParentChain: 达到最大层级限制,停止查找')
+          break
+        }
+        
+        // 优先查找 team_members 表
+        const teamMember = await this.teamMembersRepository.findOne({
+          where: { userId: currentParentId }
+        })
+        
+        if (teamMember) {
+          const commissionRate = typeof teamMember.commissionRate === 'string'
+            ? parseFloat(teamMember.commissionRate)
+            : Number(teamMember.commissionRate)
+          
+          if (commissionRate > 0) {
+            chain.push({
+              level,
+              userId: currentParentId,
+              commissionRate: commissionRate,
+              type: 'teamMember'
+            })
+            this.app.log.debug({ userId, level, parentId: currentParentId, commissionRate, type: 'teamMember' }, 'getUserParentChain: 通过 parentId 找到 teamMember')
+            
+            // 继续向上查找 teamMember 的 parentId(teamMember.parentId 是 teamMember 的 ID,需要查找对应的 teamMember)
+            if (teamMember.parentId) {
+              const parentTeamMember = await this.teamMembersRepository.findOne({
+                where: { id: teamMember.parentId }
+              })
+              
+              if (parentTeamMember) {
+                currentParentId = parentTeamMember.userId
+                level++
+                continue
+              } else {
+                // 如果找不到 parentTeamMember,查找对应的 team
+                if (teamMember.teamId) {
+                  const team = await this.teamRepository.findOne({
+                    where: { id: teamMember.teamId }
+                  })
+                  
+                  if (team) {
+                    const teamCommissionRate = typeof team.commissionRate === 'string'
+                      ? parseFloat(team.commissionRate)
+                      : Number(team.commissionRate)
+                    
+                    if (teamCommissionRate > 0) {
+                      chain.push({
+                        level: level + 1,
+                        userId: team.userId,
+                        commissionRate: teamCommissionRate,
+                        type: 'team'
+                      })
+                      this.app.log.debug({ userId, level: level + 1, teamUserId: team.userId, commissionRate: teamCommissionRate, type: 'team' }, 'getUserParentChain: 通过 parentId 找到 team')
+                    }
+                  }
+                }
+                break
+              }
+            } else {
+              // 如果 teamMember 没有 parentId,查找对应的 team
+              if (teamMember.teamId) {
+                const team = await this.teamRepository.findOne({
+                  where: { id: teamMember.teamId }
+                })
+                
+                if (team) {
+                  const teamCommissionRate = typeof team.commissionRate === 'string'
+                    ? parseFloat(team.commissionRate)
+                    : Number(team.commissionRate)
+                  
+                  if (teamCommissionRate > 0) {
+                    chain.push({
+                      level: level + 1,
+                      userId: team.userId,
+                      commissionRate: teamCommissionRate,
+                      type: 'team'
+                    })
+                    this.app.log.debug({ userId, level: level + 1, teamUserId: team.userId, commissionRate: teamCommissionRate, type: 'team' }, 'getUserParentChain: 通过 parentId 找到 team')
+                  }
+                }
+              }
+              break
+            }
+          } else {
+            // commissionRate 为 0,跳过该层级,继续向上查找
+            if (teamMember.parentId) {
+              const parentTeamMember = await this.teamMembersRepository.findOne({
+                where: { id: teamMember.parentId }
+              })
+              
+              if (parentTeamMember) {
+                currentParentId = parentTeamMember.userId
+                level++
+                continue
+              }
+            }
+            
+            // 如果找不到 parentTeamMember,查找对应的 team
+            if (teamMember.teamId) {
+              const team = await this.teamRepository.findOne({
+                where: { id: teamMember.teamId }
+              })
+              
+              if (team) {
+                const teamCommissionRate = typeof team.commissionRate === 'string'
+                  ? parseFloat(team.commissionRate)
+                  : Number(team.commissionRate)
+                
+                if (teamCommissionRate > 0) {
+                  chain.push({
+                    level,
+                    userId: team.userId,
+                    commissionRate: teamCommissionRate,
+                    type: 'team'
+                  })
+                  this.app.log.debug({ userId, level, teamUserId: team.userId, commissionRate: teamCommissionRate, type: 'team' }, 'getUserParentChain: 通过 parentId 找到 team')
+                }
+              }
+            }
+            break
+          }
+        } else {
+          // 没有找到 teamMember,查找 team 表
+          const team = await this.teamRepository.findOne({
+            where: { userId: currentParentId }
+          })
+          
+          if (team) {
+            const teamCommissionRate = typeof team.commissionRate === 'string'
+              ? parseFloat(team.commissionRate)
+              : Number(team.commissionRate)
+            
+            if (teamCommissionRate > 0) {
+              chain.push({
+                level,
+                userId: currentParentId,
+                commissionRate: teamCommissionRate,
+                type: 'team'
+              })
+              this.app.log.debug({ userId, level, parentId: currentParentId, commissionRate: teamCommissionRate, type: 'team' }, 'getUserParentChain: 通过 parentId 找到 team')
+            }
+          }
+          
+          // 继续向上查找 parentId 的 parentId
+          const parentUser = await this.userRepository.findOne({
+            where: { id: currentParentId }
+          })
+          
+          if (parentUser && parentUser.parentId && parentUser.parentId > 0) {
+            currentParentId = parentUser.parentId
+            level++
+          } else {
+            break
+          }
+        }
+      }
+      
+      this.app.log.info({ userId, chainLength: chain.length, chain: chain.map(item => ({ level: item.level, userId: item.userId, commissionRate: item.commissionRate, type: item.type })) }, 'getUserParentChain: 通过 parentId 查找完成,返回代理链')
       return chain
     }
 
@@ -75,14 +256,46 @@ export class MultiLevelCommissionService {
       where: { id: member.domainId }
     })
 
-    if (!teamDomain || !teamDomain.teamMemberId || teamDomain.teamMemberId <= 0) {
-      this.app.log.debug({ userId, domainId: member.domainId, teamDomain: teamDomain ? { id: teamDomain.id, teamMemberId: teamDomain.teamMemberId } : null }, 'getUserParentChain: teamDomain 无效,返回空链')
+    if (!teamDomain) {
+      this.app.log.debug({ userId, domainId: member.domainId }, 'getUserParentChain: teamDomain 不存在,返回空链')
       return chain
     }
 
-    this.app.log.debug({ userId, domainId: member.domainId, teamMemberId: teamDomain.teamMemberId }, 'getUserParentChain: 找到 teamDomain')
+    // 2. 如果 teamMemberId 为空,说明直接绑定团队,只有一层分润
+    if (!teamDomain.teamMemberId || teamDomain.teamMemberId <= 0) {
+      this.app.log.debug({ userId, domainId: member.domainId, teamId: teamDomain.teamId }, 'getUserParentChain: teamDomain.teamMemberId 为空,直接绑定团队,查找 team')
+      
+      // 直接查找对应的 team,只有一层分润
+      if (teamDomain.teamId) {
+        const team = await this.teamRepository.findOne({
+          where: { id: teamDomain.teamId }
+        })
 
-    // 2. 通过 teamDomain.teamMemberId 查询到初始 teamMember
+        if (team) {
+          // 确保 commissionRate 转换为数字
+          const teamCommissionRate = typeof team.commissionRate === 'string'
+            ? parseFloat(team.commissionRate)
+            : Number(team.commissionRate)
+          
+          chain.push({
+            level: 1,
+            userId: team.userId,
+            commissionRate: teamCommissionRate,
+            type: 'team'
+          })
+          this.app.log.debug({ userId, level: 1, teamId: team.id, teamUserId: team.userId, commissionRate: teamCommissionRate }, 'getUserParentChain: 直接绑定团队,添加 team 到链中(只有一层)')
+        } else {
+          this.app.log.debug({ userId, teamId: teamDomain.teamId }, 'getUserParentChain: 未找到对应的 team')
+        }
+      }
+      
+      this.app.log.info({ userId, chainLength: chain.length, chain: chain.map(item => ({ level: item.level, userId: item.userId, commissionRate: item.commissionRate, type: item.type })) }, 'getUserParentChain: 直接绑定团队,查找完成,返回代理链')
+      return chain
+    }
+
+    this.app.log.debug({ userId, domainId: member.domainId, teamMemberId: teamDomain.teamMemberId }, 'getUserParentChain: 找到 teamDomain,通过 teamMember 查找多级分润')
+
+    // 3. 通过 teamDomain.teamMemberId 查询到初始 teamMember
     let currentTeamMember = await this.teamMembersRepository.findOne({
       where: { id: teamDomain.teamMemberId }
     })
@@ -94,7 +307,7 @@ export class MultiLevelCommissionService {
 
     this.app.log.debug({ userId, teamMemberId: currentTeamMember.id, teamMemberUserId: currentTeamMember.userId, commissionRate: currentTeamMember.commissionRate }, 'getUserParentChain: 找到初始 teamMember,开始向上查找')
 
-    // 3. 从 teamMember 开始,通过 parentId 向上循环查找所有上级代理
+    // 4. 从 teamMember 开始,通过 parentId 向上循环查找所有上级代理
     while (currentTeamMember) {
       // 检查最大层级限制
       if (maxLevel !== undefined && level > maxLevel) {
@@ -130,7 +343,7 @@ export class MultiLevelCommissionService {
       // 如果 parentId 为空,说明已经到了最顶层,需要查找 team
       if (!currentTeamMember.parentId) {
         this.app.log.debug({ userId, teamMemberId: currentTeamMember.id, teamId: currentTeamMember.teamId }, 'getUserParentChain: teamMember parentId 为空,根据 teamId 查找对应的 team')
-        // 4. 根据 teamMember.teamId 查找对应的 team
+        // 5. 根据 teamMember.teamId 查找对应的 team
         const team = await this.teamRepository.findOne({
           where: { id: currentTeamMember.teamId }
         })
@@ -379,9 +592,10 @@ export class MultiLevelCommissionService {
     userId: number,
     orderPrice: number,
     status: boolean = false,
-    commissionDetails?: CommissionDetail[]
+    commissionDetails?: CommissionDetail[],
+    member?: Member | null
   ): Promise<void> {
-    this.app.log.debug({ incomeRecordId, orderNo, userId, orderPrice, status, hasStoredDetails: !!commissionDetails }, 'createCommissionIndex: 开始创建分润索引记录')
+    this.app.log.debug({ incomeRecordId, orderNo, userId, orderPrice, status, hasStoredDetails: !!commissionDetails, hasMember: !!member }, 'createCommissionIndex: 开始创建分润索引记录')
     
     // 如果提供了已存储的分润信息,直接使用;否则重新计算
     let details: CommissionDetail[]
@@ -389,9 +603,9 @@ export class MultiLevelCommissionService {
       details = commissionDetails
       this.app.log.debug({ orderNo, detailsCount: details.length }, 'createCommissionIndex: 使用已存储的分润信息')
     } else {
-      // 计算多级分润
-      this.app.log.debug({ orderNo, userId, orderPrice }, 'createCommissionIndex: 重新计算多级分润')
-      details = await this.calculateCommission(userId, orderPrice)
+      // 计算多级分润(传递 member 参数,用于查找分润关系)
+      this.app.log.debug({ orderNo, userId, orderPrice, hasMember: !!member }, 'createCommissionIndex: 重新计算多级分润')
+      details = await this.calculateCommission(userId, orderPrice, member || null)
     }
 
     // 如果没有分润,不创建索引记录

+ 14 - 4
src/services/payment.service.ts

@@ -354,13 +354,24 @@ export class PaymentService {
       })
       this.app.log.info('Income record status updated successfully')
 
+      // 获取 member 信息,用于分润计算
+      const member = await this.memberService.findByUserId(incomeRecord.userId)
+      if (!member) {
+        this.app.log.error('Failed to find corresponding member record')
+        throw new Error('Failed to find corresponding member record')
+      }
+
       // 支付成功后创建多级分润索引表(使用已存储的分润信息)
       try {
         // 从已存储的分润信息创建索引表
         let commissionDetails: any[] | undefined
         if (incomeRecord.commissionDetails) {
           try {
-            commissionDetails = JSON.parse(incomeRecord.commissionDetails)
+            const parsed = JSON.parse(incomeRecord.commissionDetails)
+            // 如果解析后是空数组,也视为需要重新计算
+            if (Array.isArray(parsed) && parsed.length > 0) {
+              commissionDetails = parsed
+            }
           } catch (parseError) {
             this.app.log.warn({ err: parseError }, 'Failed to parse stored commission details, will recalculate')
           }
@@ -372,7 +383,8 @@ export class PaymentService {
           incomeRecord.userId,
           incomeRecord.orderPrice,
           true, // 支付成功,状态为已支付
-          commissionDetails // 使用已存储的分润信息,如果解析失败则重新计算
+          commissionDetails, // 使用已存储的分润信息,如果为空或解析失败则重新计算
+          member // 传递 member 信息,用于重新计算分润
         )
         this.app.log.info('Commission index created successfully after payment')
       } catch (indexError) {
@@ -382,8 +394,6 @@ export class PaymentService {
 
       // 更新会员等级
       const vipLevel = this.getVipLevelByOrderType(incomeRecord.orderType)
-
-      const member = await this.memberService.findByUserId(incomeRecord.userId)
       if (!member) {
         this.app.log.error('Failed to find corresponding member record')
         throw new Error('Failed to find corresponding member record')

+ 34 - 3
src/services/promotion-link.service.ts

@@ -27,14 +27,19 @@ export class PromotionLinkService {
   }
 
   /**
-   * 根据 memberId 查找推广链接
-   * @param memberId 团队成员ID
-   * @returns 推广链接或 null
+   * 根据 memberId 查找推广链接(不区分类型,取一条)
    */
   async findByMemberId(memberId: number): Promise<PromotionLink | null> {
     return this.promotionLinkRepository.findOne({ where: { memberId } })
   }
 
+  /**
+   * 根据 memberId + type 查找推广链接
+   */
+  async findByMemberIdAndType(memberId: number, type: LinkType): Promise<PromotionLink | null> {
+    return this.promotionLinkRepository.findOne({ where: { memberId, type } })
+  }
+
   /**
    * 创建或更新推广链接(根据 memberId)
    * 如果已存在相同 memberId 的记录,则更新;否则创建新记录
@@ -67,6 +72,32 @@ export class PromotionLinkService {
     }
   }
 
+  /**
+   * 创建或更新推广链接(根据 memberId + type)
+   * 用于同一个成员生成多种类型的链接(如通用、浏览器)
+   */
+  async createOrUpdateByMemberIdAndType(data: CreatePromotionLinkBody): Promise<PromotionLink> {
+    if (!data.memberId) {
+      return this.create(data)
+    }
+
+    const existingLink = await this.findByMemberIdAndType(data.memberId, data.type ?? LinkType.GENERAL)
+
+    if (existingLink) {
+      const updateData: any = {}
+      if (data.teamId !== undefined) updateData.teamId = data.teamId
+      if (data.name !== undefined) updateData.name = data.name
+      if (data.image !== undefined) updateData.image = data.image
+      if (data.link !== undefined) updateData.link = data.link
+      if (data.type !== undefined) updateData.type = data.type
+
+      await this.promotionLinkRepository.update(existingLink.id, updateData)
+      return this.findById(existingLink.id)
+    }
+
+    return this.create(data)
+  }
+
   /**
    * 处理图片链接,如果是OSS链接则生成签名URL
    * @param imageUrl 原始图片URL

+ 57 - 20
src/services/team-members.service.ts

@@ -7,6 +7,7 @@ import { TeamDomain } from '../entities/team-domain.entity'
 import { Team } from '../entities/team.entity'
 import { User } from '../entities/user.entity'
 import { AgentCommissionIndex } from '../entities/agent-commission-index.entity'
+import { DomainManagement, DomainType as DomainManagementType } from '../entities/domain-management.entity'
 import { PaginationResponse } from '../dto/common.dto'
 import { CreateTeamMembersBody, UpdateTeamMembersBody, ListTeamMembersQuery, TeamMemberStatsQuery, TeamMemberStatsResponse, TeamLeaderStatsQuery, TeamLeaderStatsResponse, TeamMemberTreeNode, TeamMemberStatsTreeNode } from '../dto/team-members.dto'
 import { UserService } from './user.service'
@@ -26,6 +27,7 @@ export class TeamMembersService {
   private teamRepository: Repository<Team>
   private userRepository: Repository<User>
   private indexRepository: Repository<AgentCommissionIndex>
+  private domainManagementRepository: Repository<DomainManagement>
   private sysConfigService: SysConfigService
   private promotionLinkService: PromotionLinkService
   private multiLevelCommissionService: MultiLevelCommissionService
@@ -41,6 +43,7 @@ export class TeamMembersService {
     this.teamRepository = app.dataSource.getRepository(Team)
     this.userRepository = app.dataSource.getRepository(User)
     this.indexRepository = app.dataSource.getRepository(AgentCommissionIndex)
+    this.domainManagementRepository = app.dataSource.getRepository(DomainManagement)
     this.sysConfigService = new SysConfigService(app)
     this.promotionLinkService = new PromotionLinkService(app)
     this.multiLevelCommissionService = new MultiLevelCommissionService(app)
@@ -1170,57 +1173,91 @@ export class TeamMembersService {
 
   /**
    * 生成团队成员的推广链接
+   * 优先使用当前团队的一级域名,如果没有则使用公用的 super_domain 配置
    * @param teamMemberId 团队成员ID
    * @returns 推广链接
    */
-  async generatePromotionLink(teamMemberId: number): Promise<string> {
+  async generatePromotionLink(teamMemberId: number): Promise<{ generalLink: string; browserLink: string }> {
     const teamMember = await this.findById(teamMemberId)
     
     if (!teamMember.promoCode) {
       throw new Error('团队成员没有推广码')
     }
 
-    // 从系统配置获取 super_domain
-    let superDomain = ''
+    let domain = ''
+
+    // 第一步:尝试获取当前团队的一级域名
     try {
-      const config = await this.sysConfigService.getSysConfig('super_domain')
-      superDomain = config.value
+      const primaryDomain = await this.domainManagementRepository.findOne({
+        where: {
+          teamId: teamMember.teamId,
+          domainType: DomainManagementType.PRIMARY,
+          enabled: true
+        },
+        order: { createdAt: 'DESC' }
+      })
+
+      if (primaryDomain && primaryDomain.domain) {
+        domain = primaryDomain.domain.trim()
+        this.app.log.info(`使用团队一级域名: ${domain}`)
+      }
     } catch (error) {
-      this.app.log.warn('未找到 super_domain 配置,使用默认值')
-      superDomain = ''
+      this.app.log.warn({ err: error }, '查询团队一级域名失败,将使用公用配置')
     }
 
-    // 如果配置为空,返回空字符串或抛出错误
-    if (!superDomain || superDomain.trim() === '') {
-      throw new Error('系统未配置 super_domain,无法生成推广链接')
+    // 第二步:如果团队没有一级域名,使用公用的 super_domain 配置
+    if (!domain || domain.trim() === '') {
+      try {
+        const config = await this.sysConfigService.getSysConfig('super_domain')
+        domain = config.value
+        this.app.log.info(`使用公用配置 super_domain: ${domain}`)
+      } catch (error) {
+        this.app.log.warn({ err: error }, '未找到 super_domain 配置')
+        domain = ''
+      }
     }
 
-    // 确保 super_domain 以 http:// 或 https:// 开头
-    let domain = superDomain.trim()
-    if (!domain.startsWith('http://') && !domain.startsWith('https://')) {
-      domain = `https://${domain}`
+    // 如果都没有配置,抛出错误
+    if (!domain || domain.trim() === '') {
+      throw new Error('系统未配置域名(团队一级域名或 super_domain),无法生成推广链接')
     }
 
-    // 生成推广链接:{super_domain}?code={推广码}
-    const promotionLink = `${domain}?code=${teamMember.promoCode}`
+    // 确保域名以 http:// 或 https:// 开头
+    let finalDomain = domain.trim()
+    if (!finalDomain.startsWith('http://') && !finalDomain.startsWith('https://')) {
+      finalDomain = `https://${finalDomain}`
+    }
+
+    // 生成推广链接:{domain}?code={推广码}
+    const generalLink = `${finalDomain}?code=${teamMember.promoCode}`
+    // 浏览器链接增加 redirect=1 参数
+    const browserLink = `${finalDomain}?code=${teamMember.promoCode}&redirect=1`
     
     // 创建或更新 PromotionLink 记录,带上当前 member 的 id
     // 如果已存在相同 memberId 的记录,则覆盖旧的
     try {
-      await this.promotionLinkService.createOrUpdateByMemberId({
+      await this.promotionLinkService.createOrUpdateByMemberIdAndType({
         teamId: teamMember.teamId,
         memberId: teamMember.id,
         name: `${teamMember.name}的推广链接`,
         image: '',
-        link: promotionLink,
+        link: generalLink,
         type: LinkType.GENERAL
       })
+      await this.promotionLinkService.createOrUpdateByMemberIdAndType({
+        teamId: teamMember.teamId,
+        memberId: teamMember.id,
+        name: `${teamMember.name}的浏览器推广链接`,
+        image: '',
+        link: browserLink,
+        type: LinkType.BROWSER
+      })
     } catch (error) {
       // 如果创建或更新记录失败,记录日志但不影响返回链接
       this.app.log.warn({ err: error }, '创建或更新推广链接记录失败')
     }
     
-    return promotionLink
+    return { generalLink, browserLink }
   }
 
   /**
@@ -1228,7 +1265,7 @@ export class TeamMembersService {
    * @param userId 用户ID
    * @returns 推广链接
    */
-  async generatePromotionLinkByUserId(userId: number): Promise<string> {
+  async generatePromotionLinkByUserId(userId: number): Promise<{ generalLink: string; browserLink: string }> {
     const teamMember = await this.findByUserId(userId)
     return this.generatePromotionLink(teamMember.id)
   }

+ 507 - 0
src/services/video.service.ts

@@ -0,0 +1,507 @@
+import { FastifyInstance } from 'fastify'
+import axios, { AxiosError } from 'axios'
+import { VideoListQuery, VideoListResponse, VideoDetailQuery, VideoDetailResponse, VideoItem } from '../dto/video.dto'
+
+/**
+ * 内存缓存项:存储视频ID对应的base64图片数据
+ */
+interface ImageCacheItem {
+  base64: string
+  timestamp: number
+}
+
+export class VideoService {
+  private videoApiUrl: string
+  private app: FastifyInstance
+  private imageCache: Map<number, ImageCacheItem> = new Map()
+  private readonly CACHE_EXPIRY = 60 * 60 * 1000 // 缓存过期时间:1小时
+  private readonly M3U8_TARGET_HOST = 'cndbt02.jxcnjd.com' // M3U8 目标服务器域名
+
+  constructor(app: FastifyInstance) {
+    this.app = app
+    this.videoApiUrl = app.config.VIDEO_API_URL || 'http://154.89.195.242/api/video/list'
+    
+    // 定期清理过期缓存(每30分钟清理一次)
+    setInterval(() => {
+      this.cleanExpiredCache()
+    }, 30 * 60 * 1000)
+  }
+
+  /**
+   * 判断字符串是否为 IP 地址
+   * @param str 待判断的字符串
+   * @returns 是否为 IP 地址
+   */
+  private isIPAddress(str: string): boolean {
+    // IPv4 正则表达式
+    const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/
+    // IPv6 正则表达式(简化版)
+    const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$/
+    
+    return ipv4Regex.test(str) || ipv6Regex.test(str)
+  }
+
+  /**
+   * 将 m3u8 URL 转换为代理地址
+   * @param m3u8Url 原始 m3u8 URL
+   * @param request 请求对象(用于获取协议和主机)
+   * @returns 代理后的 m3u8 URL
+   */
+  convertM3u8ToProxyUrl(m3u8Url: string, request?: any): string {
+    if (!m3u8Url || typeof m3u8Url !== 'string') {
+      return m3u8Url
+    }
+
+    try {
+      // 解析原始 URL
+      const url = new URL(m3u8Url)
+      
+      // 只处理目标服务器的 HTTP 链接
+      if (url.hostname === this.M3U8_TARGET_HOST && url.protocol === 'http:') {
+        // 提取路径部分
+        const path = url.pathname + url.search
+        
+        // 构建代理 URL
+        // 如果有请求对象,使用请求的协议和主机;否则使用相对路径
+        if (request) {
+          // 获取主机信息
+          const hostHeader = request.headers?.host || ''
+          let hostname = (request as any).hostname || 
+                        hostHeader.split(':')[0] || 
+                        'localhost'
+          
+          // 根据 hostname 类型决定协议
+          // 域名 → https,IP 地址 → http
+          let protocol: string
+          if (this.isIPAddress(hostname)) {
+            // IP 地址:使用 http
+            protocol = 'http'
+          } else {
+            // 域名:使用 https
+            protocol = 'https'
+          }
+          
+          let host: string
+          
+          // 如果是 IP 地址,需要包含端口
+          if (this.isIPAddress(hostname)) {
+            // IP 地址:直接使用 host header(包含端口)
+            // 如果 host header 没有端口,添加默认端口
+            if (hostHeader.includes(':')) {
+              host = hostHeader
+            } else {
+              // 没有端口时,添加默认端口 80
+              host = `${hostname}:80`
+            }
+          } else {
+            // 域名:去掉端口(除非是非标准端口)
+            const hostParts = hostHeader.split(':')
+            if (hostParts.length > 1) {
+              const port = hostParts[1]
+              // 只保留非标准端口(80 和 443 除外)
+              if (port !== '80' && port !== '443') {
+                host = hostHeader
+              } else {
+                host = hostname
+              }
+            } else {
+              host = hostname
+            }
+          }
+          
+          return `${protocol}://${host}/api/proxy/m3u8${path}`
+        } else {
+          // 如果没有请求对象,返回相对路径(前端会自动使用当前域名)
+          return `/api/proxy/m3u8${path}`
+        }
+      }
+      
+      // 如果不是目标服务器的 HTTP 链接,直接返回原 URL
+      return m3u8Url
+    } catch (error) {
+      // URL 解析失败,返回原 URL
+      this.app.log.warn(`M3U8 URL 转换失败: ${m3u8Url}, 错误: ${error instanceof Error ? error.message : String(error)}`)
+      return m3u8Url
+    }
+  }
+
+  /**
+   * 将base64图片数据存入缓存
+   * @param videoId 视频ID
+   * @param base64Str base64图片字符串
+   */
+  private cacheImageData(videoId: number, base64Str: string): void {
+    this.imageCache.set(videoId, {
+      base64: base64Str,
+      timestamp: Date.now()
+    })
+  }
+
+  /**
+   * 从缓存获取base64图片数据
+   * @param videoId 视频ID
+   * @returns base64图片字符串,如果不存在或已过期则返回null
+   */
+  private getCachedImageData(videoId: number): string | null {
+    const cached = this.imageCache.get(videoId)
+    if (!cached) {
+      return null
+    }
+
+    // 检查是否过期
+    if (Date.now() - cached.timestamp > this.CACHE_EXPIRY) {
+      this.imageCache.delete(videoId)
+      return null
+    }
+
+    return cached.base64
+  }
+
+  /**
+   * 清理过期的缓存
+   */
+  private cleanExpiredCache(): void {
+    const now = Date.now()
+    for (const [videoId, item] of this.imageCache.entries()) {
+      if (now - item.timestamp > this.CACHE_EXPIRY) {
+        this.imageCache.delete(videoId)
+      }
+    }
+  }
+
+  /**
+   * 检测字符串是否为base64格式的图片
+   * @param str 待检测的字符串
+   * @returns 是否为base64格式
+   */
+  private isBase64Image(str: string): boolean {
+    if (!str || typeof str !== 'string') {
+      return false
+    }
+    
+    // 检查是否为data URI格式 (data:image/xxx;base64,xxxxx)
+    if (str.startsWith('data:image/')) {
+      return true
+    }
+    
+    // 检查是否为纯base64字符串(通常很长且只包含base64字符)
+    // base64字符集: A-Z, a-z, 0-9, +, /, =
+    const base64Regex = /^[A-Za-z0-9+/=]+$/
+    // base64字符串通常较长(至少几百个字符)
+    if (str.length > 100 && base64Regex.test(str)) {
+      return true
+    }
+    
+    return false
+  }
+
+
+  /**
+   * 解析base64图片数据
+   * @param base64Str base64图片字符串
+   * @returns 图片Buffer和MIME类型
+   */
+  parseBase64Image(base64Str: string): { buffer: Buffer; mimeType: string } {
+    let mimeType = 'image/jpeg'
+    let base64Data = base64Str
+
+    // 处理data URI格式 (data:image/png;base64,xxxxx)
+    if (base64Str.startsWith('data:image/')) {
+      const matches = base64Str.match(/^data:image\/(\w+);base64,(.+)$/)
+      if (matches && matches.length === 3) {
+        mimeType = `image/${matches[1]}`
+        base64Data = matches[2]
+      } else {
+        // 如果没有匹配到标准格式,尝试直接提取base64部分
+        const base64Index = base64Str.indexOf('base64,')
+        if (base64Index !== -1) {
+          base64Data = base64Str.substring(base64Index + 7)
+          // 尝试从data URI中提取MIME类型
+          const mimeMatch = base64Str.match(/data:image\/(\w+)/)
+          if (mimeMatch) {
+            mimeType = `image/${mimeMatch[1]}`
+          }
+        }
+      }
+    }
+
+    // 将base64字符串转换为Buffer
+    const buffer = Buffer.from(base64Data, 'base64')
+    return { buffer, mimeType }
+  }
+
+  /**
+   * 直接从外部API获取视频详情(不处理图片)
+   * @param videoId 视频ID
+   * @returns 原始视频详情响应
+   */
+  private async getRawVideoDetail(videoId: number): Promise<VideoDetailResponse | null> {
+    try {
+      // 直接调用外部API,不进行图片处理
+      const { data } = await axios.get<VideoDetailResponse>(this.videoApiUrl, {
+        params: {
+          id: videoId.toString()
+        },
+        headers: {
+          'Content-Type': 'application/json'
+        },
+        timeout: 10000 // 10秒超时
+      })
+
+      return data
+    } catch (error) {
+      const errorMsg = error instanceof Error ? error.message : String(error)
+      this.app.log.error(`获取原始视频详情失败 (视频ID: ${videoId}): ${errorMsg}`)
+      return null
+    }
+  }
+
+  /**
+   * 从图片URL获取图片数据(支持JSON响应格式)
+   * @param imageUrl 图片URL(可能是图片URL或返回JSON的API地址)
+   * @returns 图片Buffer和MIME类型,如果获取失败则返回null
+   */
+  private async fetchImageFromUrl(imageUrl: string): Promise<{ buffer: Buffer; mimeType: string } | null> {
+    try {
+      const response = await axios.get(imageUrl, {
+        responseType: 'arraybuffer',
+        timeout: 10000,
+        headers: {
+          'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+          'Accept': 'image/*,*/*'
+        }
+      })
+
+      // 检查响应是否是JSON格式(通过Content-Type判断)
+      const contentType = response.headers['content-type'] || ''
+      
+      if (contentType.includes('application/json') || contentType.includes('text/json')) {
+        // 如果是JSON响应,尝试解析base64数据
+        try {
+          const jsonData = JSON.parse(Buffer.from(response.data).toString('utf-8'))
+          
+          // 检查是否有base64字段
+          let base64Data: string | null = null
+          if (jsonData.data?.base64) {
+            base64Data = jsonData.data.base64
+          } else if (jsonData.base64) {
+            base64Data = jsonData.base64
+          } else if (typeof jsonData === 'string' && this.isBase64Image(jsonData)) {
+            base64Data = jsonData
+          }
+
+          if (base64Data && this.isBase64Image(base64Data)) {
+            this.app.log.info(`从JSON响应中提取base64图片数据`)
+            return this.parseBase64Image(base64Data)
+          } else {
+            this.app.log.warn(`JSON响应中未找到有效的base64图片数据`)
+            return null
+          }
+        } catch (parseError) {
+          this.app.log.error(`解析JSON响应失败: ${parseError instanceof Error ? parseError.message : String(parseError)}`)
+          return null
+        }
+      }
+
+      // 如果不是JSON,按图片处理
+      const buffer = Buffer.from(response.data)
+      
+      // 从Content-Type获取MIME类型,如果没有则从URL推断
+      let mimeType = contentType
+      
+      // 清理MIME类型(移除可能的参数,如 charset=utf-8)
+      if (mimeType.includes(';')) {
+        mimeType = mimeType.split(';')[0].trim()
+      }
+      
+      if (!mimeType || !mimeType.startsWith('image/')) {
+        // 从URL扩展名推断
+        const urlLower = imageUrl.toLowerCase()
+        if (urlLower.includes('.png')) mimeType = 'image/png'
+        else if (urlLower.includes('.gif')) mimeType = 'image/gif'
+        else if (urlLower.includes('.webp')) mimeType = 'image/webp'
+        else if (urlLower.includes('.jpg') || urlLower.includes('.jpeg')) mimeType = 'image/jpeg'
+        else mimeType = 'image/jpeg' // 默认使用jpeg
+      }
+      
+      this.app.log.info(`从URL获取图片成功,类型: ${mimeType}, 大小: ${buffer.length} bytes`)
+      return { buffer, mimeType }
+    } catch (error) {
+      const errorMsg = error instanceof Error ? error.message : String(error)
+      this.app.log.error(`从URL获取图片失败: ${errorMsg}`)
+      return null
+    }
+  }
+
+  /**
+   * 根据视频ID获取图片数据(优先从缓存获取,缓存未命中则调用外部API)
+   * @param videoId 视频ID
+   * @returns 图片Buffer和MIME类型,如果不存在则返回null
+   */
+  async getVideoImageData(videoId: number): Promise<{ buffer: Buffer; mimeType: string } | null> {
+    try {
+      // 首先尝试从缓存获取
+      let base64Data = this.getCachedImageData(videoId)
+      
+      // 如果缓存未命中,从外部API获取
+      if (!base64Data) {
+        this.app.log.info(`缓存未命中,从外部API获取视频图片数据 (视频ID: ${videoId})`)
+        const videoDetail = await this.getRawVideoDetail(videoId)
+        
+        if (!videoDetail) {
+          this.app.log.warn(`无法获取视频详情 (视频ID: ${videoId})`)
+          return null
+        }
+
+        if (!videoDetail.data) {
+          this.app.log.warn(`视频详情数据为空 (视频ID: ${videoId})`)
+          return null
+        }
+
+        if (!videoDetail.data.image) {
+          this.app.log.warn(`视频封面图片字段为空 (视频ID: ${videoId})`)
+          return null
+        }
+
+        const imageData = videoDetail.data.image
+        this.app.log.info(`获取到图片数据,类型: ${this.isBase64Image(imageData) ? 'base64' : 'URL'} (视频ID: ${videoId})`)
+
+        if (this.isBase64Image(imageData)) {
+          // 如果是base64,存入缓存并转换
+          this.cacheImageData(videoId, imageData)
+          return this.parseBase64Image(imageData)
+        } else {
+          // 如果是URL,直接从URL获取图片
+          this.app.log.info(`从URL获取图片: ${imageData} (视频ID: ${videoId})`)
+          return await this.fetchImageFromUrl(imageData)
+        }
+      } else {
+        this.app.log.info(`从缓存获取图片数据 (视频ID: ${videoId})`)
+        return this.parseBase64Image(base64Data)
+      }
+    } catch (error) {
+      const errorMsg = error instanceof Error ? error.message : String(error)
+      this.app.log.error(`获取视频图片数据失败 (视频ID: ${videoId}): ${errorMsg}`)
+      return null
+    }
+  }
+
+  /**
+   * 处理视频项的图片字段,缓存base64数据
+   * @param videoItem 视频项
+   */
+  private processVideoItemImage(videoItem: VideoItem): void {
+    if (!videoItem.image || !this.isBase64Image(videoItem.image)) {
+      return
+    }
+
+    try {
+      this.cacheImageData(videoItem.id, videoItem.image)
+    } catch (error) {
+      const errorMsg = error instanceof Error ? error.message : String(error)
+      this.app.log.error(`缓存视频封面失败 (视频ID: ${videoItem.id}): ${errorMsg}`)
+    }
+  }
+
+  /**
+   * 获取视频列表
+   * @param query 查询参数
+   * @returns 视频列表响应
+   */
+  async getVideoList(query: VideoListQuery): Promise<VideoListResponse> {
+    try {
+      // 构建查询参数
+      const params: Record<string, string> = {}
+      
+      if (query.p !== undefined) {
+        params.p = query.p.toString()
+      }
+      
+      if (query.l !== undefined) {
+        params.l = query.l.toString()
+      }
+      
+      if (query.k) {
+        params.k = query.k
+      }
+
+      // 发送HTTP请求
+      const { data } = await axios.get<VideoListResponse>(this.videoApiUrl, {
+        params,
+        headers: {
+          'Content-Type': 'application/json'
+        },
+        timeout: 10000 // 10秒超时
+      })
+
+      // 缓存base64图片数据
+      if (data?.data?.list && Array.isArray(data.data.list)) {
+        data.data.list.forEach(item => this.processVideoItemImage(item))
+      }
+
+      return data
+    } catch (error) {
+      const errorMsg = error instanceof Error ? error.message : String(error)
+      this.app.log.error(`获取视频列表失败: ${errorMsg}`)
+      
+      if (axios.isAxiosError(error)) {
+        const axiosError = error as AxiosError
+        if (axiosError.response) {
+          // 服务器返回了错误响应
+          throw new Error(`视频API返回错误: ${axiosError.response.status} ${axiosError.response.statusText}`)
+        } else if (axiosError.request) {
+          // 请求已发送但没有收到响应
+          throw new Error('无法连接到视频API服务器')
+        }
+      }
+      
+      const errorMessage = error instanceof Error ? error.message : '获取视频列表失败'
+      throw new Error(errorMessage)
+    }
+  }
+
+  /**
+   * 获取视频详情
+   * @param query 查询参数
+   * @returns 视频详情响应
+   */
+  async getVideoDetail(query: VideoDetailQuery): Promise<VideoDetailResponse> {
+    try {
+      // 发送HTTP请求,使用id参数
+      const { data } = await axios.get<VideoDetailResponse>(this.videoApiUrl, {
+        params: {
+          id: query.id.toString()
+        },
+        headers: {
+          'Content-Type': 'application/json'
+        },
+        timeout: 10000 // 10秒超时
+      })
+
+      // 缓存base64图片数据
+      if (data?.data) {
+        this.processVideoItemImage(data.data)
+      }
+
+      return data
+    } catch (error) {
+      const errorMsg = error instanceof Error ? error.message : String(error)
+      this.app.log.error(`获取视频详情失败: ${errorMsg}`)
+      
+      if (axios.isAxiosError(error)) {
+        const axiosError = error as AxiosError
+        if (axiosError.response) {
+          // 服务器返回了错误响应
+          throw new Error(`视频API返回错误: ${axiosError.response.status} ${axiosError.response.statusText}`)
+        } else if (axiosError.request) {
+          // 请求已发送但没有收到响应
+          throw new Error('无法连接到视频API服务器')
+        }
+      }
+      
+      const errorMessage = error instanceof Error ? error.message : '获取视频详情失败'
+      throw new Error(errorMessage)
+    }
+  }
+}
+

+ 3 - 0
src/types/fastify.d.ts

@@ -38,6 +38,9 @@ declare module 'fastify' {
       REDIS_PORT?: number
       REDIS_PASSWORD?: string
       REDIS_DB?: number
+
+      // 视频API配置(可选)
+      VIDEO_API_URL?: string
     }
     dataSource: DataSource
     redis?: Redis

+ 117 - 81
yarn.lock

@@ -231,9 +231,9 @@
   integrity sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==
 
 "@fastify/cors@^11.0.1":
-  version "11.1.0"
-  resolved "https://registry.yarnpkg.com/@fastify/cors/-/cors-11.1.0.tgz#09f79748f08f147d19cfc3f1807b59791bc77cf0"
-  integrity sha512-sUw8ed8wP2SouWZTIbA7V2OQtMNpLj2W6qJOYhNdcmINTu6gsxVYXjQiM9mdi8UUDlcoDDJ/W2syPo1WB2QjYA==
+  version "11.2.0"
+  resolved "https://registry.yarnpkg.com/@fastify/cors/-/cors-11.2.0.tgz#82c47aff9bd7dfd40ac0a66fcd87a034113dcdd8"
+  integrity sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==
   dependencies:
     fastify-plugin "^5.0.0"
     toad-cache "^3.7.0"
@@ -268,6 +268,16 @@
   resolved "https://registry.yarnpkg.com/@fastify/forwarded/-/forwarded-3.0.1.tgz#9662b7bd4a59f6d123cc3487494f75f635c32d23"
   integrity sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==
 
+"@fastify/http-proxy@^11.4.1":
+  version "11.4.1"
+  resolved "https://registry.yarnpkg.com/@fastify/http-proxy/-/http-proxy-11.4.1.tgz#21735d776f0ab04a9f69a3116b1a76e94b74eb1e"
+  integrity sha512-sMYEIB0c7qCYutpZyS12c8xnVgmEMSUUVU2XjcNq2JzHf6Hta1BWcpnG5FXxR3WEm48uZNCi0MxnIYtwjwd21Q==
+  dependencies:
+    "@fastify/reply-from" "^12.5.0"
+    fast-querystring "^1.1.2"
+    fastify-plugin "^5.1.0"
+    ws "^8.18.3"
+
 "@fastify/jwt@^9.1.0":
   version "9.1.0"
   resolved "https://registry.yarnpkg.com/@fastify/jwt/-/jwt-9.1.0.tgz#9a765586ffdc4581f7071d00a94736a178d788c9"
@@ -305,6 +315,19 @@
     "@fastify/forwarded" "^3.0.0"
     ipaddr.js "^2.1.0"
 
+"@fastify/reply-from@^12.5.0":
+  version "12.5.0"
+  resolved "https://registry.yarnpkg.com/@fastify/reply-from/-/reply-from-12.5.0.tgz#b7200545e0543eb773c43ffcbf44a2791f2f6dba"
+  integrity sha512-m7mTGjgtnpnZBk4I8r6eFJY8WB4kyvXJo2nAf5PBm5f3mj3P7G6H2D7mhmF25os/n6EGMWVyw/bpTUehvy0i8g==
+  dependencies:
+    "@fastify/error" "^4.0.0"
+    end-of-stream "^1.4.4"
+    fast-content-type-parse "^3.0.0"
+    fast-querystring "^1.1.2"
+    fastify-plugin "^5.0.1"
+    toad-cache "^3.7.0"
+    undici "^7.0.0"
+
 "@fastify/send@^4.0.0":
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/@fastify/send/-/send-4.1.0.tgz#d9c283b86e12080c0dcc160bbc16106debf1f0d3"
@@ -513,9 +536,9 @@
   integrity sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==
 
 "@types/node@*":
-  version "24.10.1"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.1.tgz#91e92182c93db8bd6224fca031e2370cef9a8f01"
-  integrity sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==
+  version "25.0.1"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.1.tgz#9c41c277a1b16491174497cd075f8de7c27a1ac4"
+  integrity sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==
   dependencies:
     undici-types "~7.16.0"
 
@@ -527,9 +550,9 @@
     undici-types "~6.19.2"
 
 "@types/node@^22.13.14":
-  version "22.19.1"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.1.tgz#1188f1ddc9f46b4cc3aec76749050b4e1f459b7b"
-  integrity sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==
+  version "22.19.2"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.2.tgz#2f0956fba46518aaf7578c84e37bddab55f85d01"
+  integrity sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==
   dependencies:
     undici-types "~6.21.0"
 
@@ -548,7 +571,7 @@
   resolved "https://registry.yarnpkg.com/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz#9aa30c04db212a9a0649d6ae6fd50accc40748a1"
   integrity sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==
 
-"@types/validator@^13.11.8":
+"@types/validator@^13.15.3":
   version "13.15.10"
   resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.15.10.tgz#742b77ec34d58554b94a76a14cef30d59e3c16b9"
   integrity sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==
@@ -674,10 +697,10 @@ ansi-styles@^6.1.0:
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041"
   integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==
 
-ansis@^3.17.0:
-  version "3.17.0"
-  resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
-  integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
+ansis@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/ansis/-/ansis-4.2.0.tgz#2e6e61c46b11726ac67f78785385618b9e658780"
+  integrity sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==
 
 any-promise@^1.0.0, any-promise@^1.3.0:
   version "1.3.0"
@@ -742,7 +765,7 @@ aws-ssl-profiles@^1.1.1:
   resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641"
   integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==
 
-axios@^1.8.4:
+axios@^1.7.9, axios@^1.8.4:
   version "1.13.2"
   resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.2.tgz#9ada120b7b5ab24509553ec3e40123521117f687"
   integrity sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==
@@ -891,13 +914,13 @@ class-transformer@^0.5.1:
   integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==
 
 class-validator@^0.14.1:
-  version "0.14.2"
-  resolved "https://registry.yarnpkg.com/class-validator/-/class-validator-0.14.2.tgz#a3de95edd26b703e89c151a2023d3c115030340d"
-  integrity sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==
+  version "0.14.3"
+  resolved "https://registry.yarnpkg.com/class-validator/-/class-validator-0.14.3.tgz#834a4caafa8359aed73d7708badb4cf271be50fe"
+  integrity sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==
   dependencies:
-    "@types/validator" "^13.11.8"
+    "@types/validator" "^13.15.3"
     libphonenumber-js "^1.11.1"
-    validator "^13.9.0"
+    validator "^13.15.20"
 
 cliui@^8.0.1:
   version "8.0.1"
@@ -960,9 +983,9 @@ content-type@^1.0.2:
   integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
 
 cookie@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.0.2.tgz#27360701532116bd3f1f9416929d176afe1e4610"
-  integrity sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c"
+  integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
 
 copy-to@^2.0.1:
   version "2.0.1"
@@ -1010,12 +1033,12 @@ dateformat@^4.6.3:
   resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-4.6.3.tgz#556fa6497e5217fedb78821424f8a1c22fa3f4b5"
   integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==
 
-dayjs@^1.11.13:
+dayjs@^1.11.19:
   version "1.11.19"
   resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938"
   integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==
 
-debug@^4.1.1, debug@^4.3.4, debug@^4.4.0:
+debug@^4.1.1, debug@^4.3.4, debug@^4.4.3:
   version "4.4.3"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
   integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -1027,7 +1050,7 @@ decimal.js@^10.6.0:
   resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a"
   integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==
 
-dedent@^1.6.0:
+dedent@^1.7.0:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.0.tgz#c1f9445335f0175a96587be245a282ff451446ca"
   integrity sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==
@@ -1088,7 +1111,7 @@ dotenv-expand@10.0.0:
   resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-10.0.0.tgz#12605d00fb0af6d0a592e6558585784032e4ef37"
   integrity sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==
 
-dotenv@^16.4.7:
+dotenv@^16.4.7, dotenv@^16.6.1:
   version "16.6.1"
   resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020"
   integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==
@@ -1154,7 +1177,7 @@ emoji-regex@^9.2.2:
   resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
   integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
 
-end-of-stream@^1.1.0:
+end-of-stream@^1.1.0, end-of-stream@^1.4.4:
   version "1.4.5"
   resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c"
   integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==
@@ -1223,9 +1246,9 @@ ethereum-cryptography@^2.0.0:
     "@scure/bip39" "1.3.0"
 
 ethers@^6.6.0:
-  version "6.15.0"
-  resolved "https://registry.yarnpkg.com/ethers/-/ethers-6.15.0.tgz#2980f2a3baf0509749b7e21f8692fa8a8349c0e3"
-  integrity sha512-Kf/3ZW54L4UT0pZtsY/rf+EkBU7Qi5nnhonjUb8yTXcxH3cdcWrV2cRyk0Xk/4jK6OoHhxxZHriyhje20If2hQ==
+  version "6.16.0"
+  resolved "https://registry.yarnpkg.com/ethers/-/ethers-6.16.0.tgz#fff9b4f05d7a359c774ad6e91085a800f7fccf65"
+  integrity sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==
   dependencies:
     "@adraffy/ens-normalize" "1.10.1"
     "@noble/curves" "1.2.0"
@@ -1252,10 +1275,15 @@ extend-shallow@^2.0.1:
   dependencies:
     is-extendable "^0.1.0"
 
-fast-copy@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/fast-copy/-/fast-copy-3.0.2.tgz#59c68f59ccbcac82050ba992e0d5c389097c9d35"
-  integrity sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==
+fast-content-type-parse@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz#5590b6c807cc598be125e6740a9fde589d2b7afb"
+  integrity sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==
+
+fast-copy@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/fast-copy/-/fast-copy-4.0.1.tgz#be5c74baede1a72adf8168df2dc56e842c77a00e"
+  integrity sha512-+uUOQlhsaswsizHFmEFAQhB3lSiQ+lisxl50N6ZP0wywlZeWsIESxSi9ftPEps8UGfiBzyYP7x27zA674WUvXw==
 
 fast-decode-uri-component@^1.0.1:
   version "1.0.1"
@@ -1289,7 +1317,7 @@ fast-jwt@^5.0.0:
     ecdsa-sig-formatter "^1.0.11"
     mnemonist "^0.40.0"
 
-fast-querystring@^1.0.0:
+fast-querystring@^1.0.0, fast-querystring@^1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/fast-querystring/-/fast-querystring-1.1.2.tgz#a6d24937b4fc6f791b4ee31dcb6f53aeafb89f53"
   integrity sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==
@@ -1313,7 +1341,7 @@ fastfall@^1.5.0:
   dependencies:
     reusify "^1.0.0"
 
-fastify-plugin@^5.0.0:
+fastify-plugin@^5.0.0, fastify-plugin@^5.0.1, fastify-plugin@^5.1.0:
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/fastify-plugin/-/fastify-plugin-5.1.0.tgz#7083e039d6418415f9a669f8c25e72fc5bf2d3e7"
   integrity sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==
@@ -1492,7 +1520,7 @@ glob-parent@~5.1.2:
   dependencies:
     is-glob "^4.0.1"
 
-glob@^10.4.5:
+glob@^10.5.0:
   version "10.5.0"
   resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c"
   integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==
@@ -1607,9 +1635,9 @@ iconv-lite@^0.6.3:
     safer-buffer ">= 2.1.2 < 3.0.0"
 
 iconv-lite@^0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.0.tgz#c50cd80e6746ca8115eb98743afa81aa0e147a3e"
-  integrity sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==
+  version "0.7.1"
+  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.1.tgz#d4af1d2092f2bb05aab6296e5e7cd286d2f15432"
+  integrity sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==
   dependencies:
     safer-buffer ">= 2.1.2 < 3.0.0"
 
@@ -1652,9 +1680,9 @@ ioredis@*, ioredis@^5.8.2:
     standard-as-callback "^2.1.0"
 
 ipaddr.js@^2.1.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8"
-  integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.3.0.tgz#71dce70e1398122208996d1c22f2ba46a24b1abc"
+  integrity sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==
 
 is-arguments@^1.0.4:
   version "1.2.0"
@@ -1840,9 +1868,9 @@ jstoxml@^2.0.0:
   integrity sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==
 
 libphonenumber-js@^1.11.1:
-  version "1.12.29"
-  resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.29.tgz#1de335eff8f7c8b9e7467bb510aab1e123c09ef5"
-  integrity sha512-P2aLrbeqHbmh8+9P35LXQfXOKc7XJ0ymUKl7tyeyQjdRNfzunXWxQXGc4yl3fUf28fqLRfPY+vIVvFXK7KEBTw==
+  version "1.12.31"
+  resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.31.tgz#3cdb45641c6b77228dd1238f3d810c3bb5d91199"
+  integrity sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ==
 
 light-my-request@^6.0.0:
   version "6.6.0"
@@ -1879,16 +1907,11 @@ lru-cache@^10.2.0:
   integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
 
 lru-cache@^11.0.0:
-  version "11.2.2"
-  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.2.tgz#40fd37edffcfae4b2940379c0722dc6eeaa75f24"
-  integrity sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==
+  version "11.2.4"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.4.tgz#ecb523ebb0e6f4d837c807ad1abaea8e0619770d"
+  integrity sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==
 
-lru-cache@^7.14.1:
-  version "7.18.3"
-  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89"
-  integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
-
-lru.min@^1.0.0:
+lru.min@^1.0.0, lru.min@^1.1.0:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.3.tgz#c8c3d001dfb4cbe5b8d1f4bea207d4a320e5d76f"
   integrity sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==
@@ -2020,11 +2043,11 @@ mz@^2.7.0:
     thenify-all "^1.0.0"
 
 named-placeholders@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/named-placeholders/-/named-placeholders-1.1.3.tgz#df595799a36654da55dda6152ba7a137ad1d9351"
-  integrity sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/named-placeholders/-/named-placeholders-1.1.4.tgz#92882114525181077e21aab96889f25e857e233a"
+  integrity sha512-/qfG0Kk/bLJIvej4FcPQ2KYUJP8iQdU1CTxysNb/U2wUNb+/4K485yeio8iNoiwfqJnsTInXoRPTza0dZWHVJQ==
   dependencies:
-    lru-cache "^7.14.1"
+    lru.min "^1.1.0"
 
 node-cron@^4.2.1:
   version "4.2.1"
@@ -2150,20 +2173,27 @@ pino-abstract-transport@^2.0.0:
   dependencies:
     split2 "^4.0.0"
 
+pino-abstract-transport@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz#b21e5f33a297e8c4c915c62b3ce5dd4a87a52c23"
+  integrity sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==
+  dependencies:
+    split2 "^4.0.0"
+
 pino-pretty@^13.0.0:
-  version "13.1.2"
-  resolved "https://registry.yarnpkg.com/pino-pretty/-/pino-pretty-13.1.2.tgz#4e7484f2c5d02cce03159b96aa04697bf9e84ff6"
-  integrity sha512-3cN0tCakkT4f3zo9RXDIhy6GTvtYD6bK4CRBLN9j3E/ePqN1tugAXD5rGVfoChW6s0hiek+eyYlLNqc/BG7vBQ==
+  version "13.1.3"
+  resolved "https://registry.yarnpkg.com/pino-pretty/-/pino-pretty-13.1.3.tgz#2274cccda925dd355c104079a5029f6598d0381b"
+  integrity sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==
   dependencies:
     colorette "^2.0.7"
     dateformat "^4.6.3"
-    fast-copy "^3.0.2"
+    fast-copy "^4.0.0"
     fast-safe-stringify "^2.1.1"
     help-me "^5.0.0"
     joycon "^3.1.1"
     minimist "^1.2.6"
     on-exit-leak-free "^2.1.0"
-    pino-abstract-transport "^2.0.0"
+    pino-abstract-transport "^3.0.0"
     pump "^3.0.0"
     secure-json-parse "^4.0.0"
     sonic-boom "^4.0.1"
@@ -2529,7 +2559,7 @@ split2@^4.0.0:
   resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4"
   integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==
 
-sql-highlight@^6.0.0:
+sql-highlight@^6.1.0:
   version "6.1.0"
   resolved "https://registry.yarnpkg.com/sql-highlight/-/sql-highlight-6.1.0.tgz#e34024b4c6eac2744648771edfe3c1f894153743"
   integrity sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==
@@ -2814,21 +2844,22 @@ typed-array-buffer@^1.0.3:
     is-typed-array "^1.1.14"
 
 typeorm@^0.3.21:
-  version "0.3.27"
-  resolved "https://registry.yarnpkg.com/typeorm/-/typeorm-0.3.27.tgz#f1e8f3cdc820225f168e901e7e1eaca3a3ec6f3c"
-  integrity sha512-pNV1bn+1n8qEe8tUNsNdD8ejuPcMAg47u2lUGnbsajiNUr3p2Js1XLKQjBMH0yMRMDfdX8T+fIRejFmIwy9x4A==
+  version "0.3.28"
+  resolved "https://registry.yarnpkg.com/typeorm/-/typeorm-0.3.28.tgz#a3aabed8ef64287ee68da278d8ffa1d3c6c6b8ca"
+  integrity sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==
   dependencies:
     "@sqltools/formatter" "^1.2.5"
-    ansis "^3.17.0"
+    ansis "^4.2.0"
     app-root-path "^3.1.0"
     buffer "^6.0.3"
-    dayjs "^1.11.13"
-    debug "^4.4.0"
-    dedent "^1.6.0"
-    dotenv "^16.4.7"
-    glob "^10.4.5"
+    dayjs "^1.11.19"
+    debug "^4.4.3"
+    dedent "^1.7.0"
+    dotenv "^16.6.1"
+    glob "^10.5.0"
+    reflect-metadata "^0.2.2"
     sha.js "^2.4.12"
-    sql-highlight "^6.0.0"
+    sql-highlight "^6.1.0"
     tslib "^2.8.1"
     uuid "^11.1.0"
     yargs "^17.7.2"
@@ -2853,6 +2884,11 @@ undici-types@~7.16.0:
   resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
   integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
 
+undici@^7.0.0:
+  version "7.16.0"
+  resolved "https://registry.yarnpkg.com/undici/-/undici-7.16.0.tgz#cb2a1e957726d458b536e3f076bf51f066901c1a"
+  integrity sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==
+
 unescape@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/unescape/-/unescape-1.0.1.tgz#956e430f61cad8a4d57d82c518f5e6cc5d0dda96"
@@ -2915,7 +2951,7 @@ v8-compile-cache-lib@^3.0.1:
   resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
   integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
 
-validator@^13.7.0, validator@^13.9.0:
+validator@^13.15.20, validator@^13.7.0:
   version "13.15.23"
   resolved "https://registry.yarnpkg.com/validator/-/validator-13.15.23.tgz#59a874f84e4594588e3409ab1edbe64e96d0c62d"
   integrity sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==
@@ -3234,7 +3270,7 @@ ws@8.17.1:
   resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"
   integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==
 
-ws@^8.17.1:
+ws@^8.17.1, ws@^8.18.3:
   version "8.18.3"
   resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472"
   integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
@@ -3276,9 +3312,9 @@ y18n@^5.0.5:
   integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
 
 yaml@^2.4.1, yaml@^2.4.2:
-  version "2.8.1"
-  resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.1.tgz#1870aa02b631f7e8328b93f8bc574fac5d6c4d79"
-  integrity sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==
+  version "2.8.2"
+  resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.2.tgz#5694f25eca0ce9c3e7a9d9e00ce0ddabbd9e35c5"
+  integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==
 
 yargs-parser@^21.1.1:
   version "21.1.1"