Просмотр исходного кода

新增redis和相关统计共鞥

wilhelm wong 2 месяцев назад
Родитель
Сommit
faa58396e1

+ 2 - 0
.env

@@ -8,6 +8,8 @@ DB_PORT=3306
 DB_USERNAME=zouma
 DB_PASSWORD='2wsx@WSX#EDC'
 DB_DATABASE=junma_test
+REDIS_HOST=localhost
+REDIS_PORT=6379
 
 # JWT
 JWT_SECRET='G5HXsfhW!gKr&4W8'

+ 2 - 0
.env.production

@@ -8,6 +8,8 @@ DB_PORT=3306
 DB_USERNAME=root
 DB_PASSWORD='tYYf%BC7pqQJ8$v!'
 DB_DATABASE=junma
+REDIS_HOST=localhost
+REDIS_PORT=6379
 
 # JWT
 JWT_SECRET='^ZRn6CxYp%tnJinR'

+ 4 - 0
package.json

@@ -18,6 +18,7 @@
     "@fastify/swagger": "^9.4.2",
     "@fastify/swagger-ui": "^5.2.2",
     "@types/ali-oss": "^6.16.11",
+    "@types/ioredis": "^5.0.0",
     "ali-oss": "^6.23.0",
     "bcryptjs": "^3.0.2",
     "class-transformer": "^0.5.1",
@@ -25,7 +26,9 @@
     "decimal.js": "^10.6.0",
     "dotenv": "^16.4.7",
     "fastify": "^5.2.2",
+    "ioredis": "^5.8.2",
     "mysql2": "^3.14.0",
+    "node-cron": "^4.2.1",
     "randomstring": "^1.3.1",
     "reflect-metadata": "^0.2.2",
     "tronweb": "^5.3.3",
@@ -36,6 +39,7 @@
   "devDependencies": {
     "@types/bcryptjs": "^3.0.0",
     "@types/node": "^22.13.14",
+    "@types/node-cron": "^3.0.11",
     "@types/randomstring": "^1.3.0",
     "pino-pretty": "^13.0.0",
     "ts-node-dev": "^2.0.0",

+ 37 - 0
src/app.ts

@@ -22,6 +22,8 @@ import userShareRoutes from './routes/user-invite.routes'
 import teamDomainRoutes from './routes/team-domain.routes'
 import bannerRoutes from './routes/banner.routes'
 import { authenticate } from './middlewares/auth.middleware'
+import { createRedisClient, closeRedisClient } from './config/redis'
+import { BannerStatisticsScheduler } from './scheduler/banner-statistics.scheduler'
 
 const options: FastifyEnvOptions = {
   schema: schema,
@@ -109,7 +111,42 @@ export const createApp = async () => {
   await dataSource.initialize()
   app.decorate('dataSource', dataSource)
 
+  // 初始化Redis(如果未配置则使用默认值 localhost:6379)
+  try {
+    const redis = createRedisClient({
+      REDIS_HOST: app.config.REDIS_HOST || 'localhost',
+      REDIS_PORT: app.config.REDIS_PORT || 6379,
+      REDIS_PASSWORD: app.config.REDIS_PASSWORD,
+      REDIS_DB: app.config.REDIS_DB || 0
+    })
+    app.decorate('redis', redis)
+    if (app.config.REDIS_HOST) {
+      app.log.info(`Redis connected successfully to ${app.config.REDIS_HOST}:${app.config.REDIS_PORT || 6379}`)
+    } else {
+      app.log.info('Redis connected successfully using default configuration (localhost:6379)')
+    }
+  } catch (error) {
+    app.log.warn(`Redis connection failed, continuing without Redis: ${error instanceof Error ? error.message : String(error)}`)
+  }
+
+  // 初始化定时任务(需要Redis支持)
+  let bannerStatisticsScheduler: BannerStatisticsScheduler | null = null
+  if (app.redis) {
+    try {
+      bannerStatisticsScheduler = new BannerStatisticsScheduler(app)
+      bannerStatisticsScheduler.start()
+      app.decorate('bannerStatisticsScheduler', bannerStatisticsScheduler)
+    } catch (error) {
+      app.log.warn(`定时任务初始化失败: ${error instanceof Error ? error.message : String(error)}`)
+    }
+  }
+
   app.addHook('onClose', async () => {
+    // 停止定时任务
+    if (bannerStatisticsScheduler) {
+      bannerStatisticsScheduler.stop()
+    }
+    await closeRedisClient()
     await dataSource.destroy()
     process.exit(0)
   })

+ 13 - 0
src/config/env.ts

@@ -79,6 +79,19 @@ export const schema = {
     },
     PAYMENT_PID: {
       type: 'string'
+    },
+    // Redis配置(可选)
+    REDIS_HOST: {
+      type: 'string'
+    },
+    REDIS_PORT: {
+      type: 'number'
+    },
+    REDIS_PASSWORD: {
+      type: 'string'
+    },
+    REDIS_DB: {
+      type: 'number'
     }
   }
 }

+ 48 - 0
src/config/redis.ts

@@ -0,0 +1,48 @@
+import Redis from 'ioredis'
+
+let redisClient: Redis | null = null
+
+export function createRedisClient(config: {
+  REDIS_HOST?: string
+  REDIS_PORT?: number
+  REDIS_PASSWORD?: string
+  REDIS_DB?: number
+}): Redis {
+  if (redisClient) {
+    return redisClient
+  }
+
+  redisClient = new Redis({
+    host: config.REDIS_HOST || 'localhost',
+    port: config.REDIS_PORT || 6379,
+    password: config.REDIS_PASSWORD,
+    db: config.REDIS_DB || 0,
+    retryStrategy: (times) => {
+      const delay = Math.min(times * 50, 2000)
+      return delay
+    },
+    maxRetriesPerRequest: 3
+  })
+
+  redisClient.on('error', (err) => {
+    console.error('Redis Client Error:', err)
+  })
+
+  redisClient.on('connect', () => {
+    console.log('Redis Client Connected')
+  })
+
+  return redisClient
+}
+
+export function getRedisClient(): Redis | null {
+  return redisClient
+}
+
+export async function closeRedisClient(): Promise<void> {
+  if (redisClient) {
+    await redisClient.quit()
+    redisClient = null
+  }
+}
+

+ 208 - 1
src/controllers/banner.controller.ts

@@ -1,19 +1,28 @@
 import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
 import { BannerService } from '../services/banner.service'
+import { BannerClickRecordService } from '../services/banner-click-record.service'
 import {
   CreateBannerBody,
   UpdateBannerBody,
   ListBannerQuery,
   BannerParams
 } from '../dto/banner.dto'
+import {
+  GetClickCountByIpQuery,
+  GetIpsByBannerQuery,
+  GetBannersByIpQuery,
+  GetDailyStatisticsQuery
+} from '../dto/banner-click-record.dto'
 import { BannerPosition } from '../entities/banner.entity'
 import { UserRole } from '../entities/user.entity'
 
 export class BannerController {
   private bannerService: BannerService
+  private clickRecordService: BannerClickRecordService
 
   constructor(app: FastifyInstance) {
     this.bannerService = new BannerService(app)
+    this.clickRecordService = new BannerClickRecordService(app)
   }
 
   async create(request: FastifyRequest<{ Body: CreateBannerBody }>, reply: FastifyReply) {
@@ -90,7 +99,15 @@ export class BannerController {
   async recordClick(request: FastifyRequest<{ Params: BannerParams }>, reply: FastifyReply) {
     try {
       const { id } = request.params
-      const banner = await this.bannerService.incrementClickCount(id)
+      
+      // 获取当前访问IP
+      const ip =
+        request.ip ||
+        (request.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
+        (request.headers['x-real-ip'] as string) ||
+        'unknown'
+
+      const banner = await this.bannerService.incrementClickCount(id, ip)
       return reply.send({
         message: '点击记录成功',
         clickCount: banner.clickCount,
@@ -101,5 +118,195 @@ export class BannerController {
       return reply.code(500).send({ message: errorMessage })
     }
   }
+
+  /**
+   * 检查某个IP是否点击过某个广告
+   */
+  async hasClicked(request: FastifyRequest<{ Querystring: GetClickCountByIpQuery }>, reply: FastifyReply) {
+    try {
+      const { bannerId, ip, date } = request.query
+      
+      if (!bannerId || !ip) {
+        return reply.code(400).send({ message: 'bannerId和ip参数必填' })
+      }
+
+      const targetDate = date ? new Date(date) : undefined
+      const hasClicked = await this.clickRecordService.hasClicked(bannerId, ip, targetDate)
+      
+      return reply.send({
+        bannerId,
+        ip,
+        date: date || new Date().toISOString().split('T')[0],
+        hasClicked
+      })
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '查询失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  /**
+   * 获取某个广告被哪些IP点击
+   */
+  async getIpsByBanner(request: FastifyRequest<{ Querystring: GetIpsByBannerQuery }>, reply: FastifyReply) {
+    try {
+      const { bannerId, date } = request.query
+      
+      if (!bannerId) {
+        return reply.code(400).send({ message: 'bannerId参数必填' })
+      }
+
+      const targetDate = date ? new Date(date) : undefined
+      const ips = await this.clickRecordService.getIpsByBanner(bannerId, targetDate)
+      const uniqueIpCount = await this.clickRecordService.getUniqueIpCount(bannerId, targetDate)
+      
+      return reply.send({
+        bannerId,
+        date: date || new Date().toISOString().split('T')[0],
+        uniqueIpCount,
+        ips
+      })
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '查询失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  /**
+   * 获取某个IP点击了哪些广告
+   */
+  async getBannersByIp(request: FastifyRequest<{ Querystring: GetBannersByIpQuery }>, reply: FastifyReply) {
+    try {
+      const { ip, date } = request.query
+      
+      if (!ip) {
+        return reply.code(400).send({ message: 'ip参数必填' })
+      }
+
+      const targetDate = date ? new Date(date) : undefined
+      const banners = await this.clickRecordService.getBannersByIp(ip, targetDate)
+      
+      return reply.send({
+        ip,
+        date: date || new Date().toISOString().split('T')[0],
+        bannerCount: banners.length,
+        banners
+      })
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '查询失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  /**
+   * 获取某个广告的每日点击统计(独立IP数量)
+   */
+  async getDailyStatistics(request: FastifyRequest<{ Querystring: GetDailyStatisticsQuery }>, reply: FastifyReply) {
+    try {
+      const { bannerId, startDate, endDate } = request.query
+      
+      if (!bannerId || !startDate || !endDate) {
+        return reply.code(400).send({ message: 'bannerId、startDate和endDate参数必填' })
+      }
+
+      const start = new Date(startDate)
+      const end = new Date(endDate)
+      
+      if (isNaN(start.getTime()) || isNaN(end.getTime())) {
+        return reply.code(400).send({ message: '日期格式错误,请使用YYYY-MM-DD格式' })
+      }
+
+      const result = await this.clickRecordService.getDailyStatistics(bannerId, start, end)
+      
+      return reply.send({
+        bannerId,
+        startDate,
+        endDate,
+        daily: result.daily,
+        total: result.total
+      })
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '查询失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  /**
+   * 手动触发每日点击统计任务(管理员)
+   * 用于定时任务或手动执行
+   */
+  async updateDailyClickCount(request: FastifyRequest<{ Querystring: { date?: string } }>, reply: FastifyReply) {
+    try {
+      const { date } = request.query
+      const targetDate = date ? new Date(date) : undefined
+      
+      await this.clickRecordService.updateDailyClickCount(targetDate)
+      
+      return reply.send({
+        message: '每日点击统计更新成功',
+        date: date || new Date().toISOString().split('T')[0]
+      })
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '更新失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  /**
+   * 获取某个广告今日的Redis IP点击情况(管理员)
+   */
+  async getTodayIpsFromRedis(request: FastifyRequest<{ Params: BannerParams }>, reply: FastifyReply) {
+    try {
+      const { id } = request.params
+      const ips = await this.clickRecordService.getTodayIpsFromRedis(id)
+      
+      return reply.send({
+        bannerId: id,
+        date: new Date().toISOString().split('T')[0],
+        ipCount: ips.length,
+        ips
+      })
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '查询失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  /**
+   * 获取所有banner的今日和全期点击数据汇总(管理员)
+   * 访问此接口时会自动触发每日统计任务(统计昨天的数据)
+   */
+  async getSummary(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      // 触发每日统计任务(统计昨天的数据,如果重复统计会用最新数据覆盖)
+      // 使用try-catch包裹,避免统计失败影响接口返回
+      try {
+        await this.clickRecordService.updateDailyClickCount()
+      } catch (error) {
+        // 记录错误但不影响接口返回
+        console.warn('每日统计任务执行失败:', error instanceof Error ? error.message : String(error))
+      }
+
+      // 获取汇总数据
+      const summary = await this.clickRecordService.getAllBannersSummary()
+      
+      // 计算总计数据
+      const totalTodayUniqueIps = summary.reduce((sum, item) => sum + item.todayUniqueIps, 0)
+      const totalUniqueIps = summary.reduce((sum, item) => sum + item.totalUniqueIps, 0)
+      
+      return reply.send({
+        date: new Date().toISOString().split('T')[0],
+        banners: summary,
+        summary: {
+          totalBanners: summary.length,
+          totalTodayUniqueIps,
+          totalUniqueIps
+        }
+      })
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '获取统计汇总失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
 }
 

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

@@ -93,7 +93,7 @@ export class MemberController {
 
   async register(request: FastifyRequest<{ Body: RegisterBody }>, reply: FastifyReply) {
     try {
-      const { name, password, email, phone, code } = request.body
+      const { name, password, email, phone, code, memberCode } = request.body
 
       // 验证必填字段
       if (!name || !password) {
@@ -128,7 +128,7 @@ export class MemberController {
         'unknown'
 
       // 调用注册服务
-      const { user, member } = await this.memberService.register(name, password, email, phone, code, ip)
+      const { user, member } = await this.memberService.register(name, password, email, phone, code, ip, memberCode)
 
       // 生成JWT token
       const token = await reply.jwtSign({ id: user.id, name: user.name, role: user.role })

+ 382 - 1
src/controllers/team-members.controller.ts

@@ -9,7 +9,9 @@ import {
   TeamMemberStatsQuery,
   TeamMemberStatsResponse,
   TeamLeaderStatsQuery,
-  TeamLeaderStatsResponse
+  TeamLeaderStatsResponse,
+  PromotionLinkResponse,
+  GeneratePromoCodeResponse
 } from '../dto/team-members.dto'
 import { UserRole } from '../entities/user.entity'
 import { TeamService } from '../services/team.service'
@@ -267,4 +269,383 @@ export class TeamMembersController {
       })
     }
   }
+
+  /**
+   * 获取团队成员的推广链接
+   */
+  async getPromotionLink(request: FastifyRequest<{ Params: TeamMembersParams }>, reply: FastifyReply) {
+    try {
+      const user = request.user
+      if (!user) {
+        return reply.code(403).send({ message: '用户未登录' })
+      }
+
+      const { id } = request.params
+
+      // 权限检查:只有团队成员本人、团队管理员或系统管理员可以获取推广链接
+      if (user.role === UserRole.PROMOTER) {
+        // 推广员只能获取自己的推广链接
+        try {
+          const teamMember = await this.teamMembersService.findByUserId(user.id)
+          if (teamMember.id !== Number(id)) {
+            return reply.code(403).send({ message: '无权限获取其他团队成员的推广链接' })
+          }
+        } catch (error) {
+          // 如果找不到团队成员记录,返回友好错误
+          return reply.code(404).send({ message: '未找到您的团队成员信息,请联系管理员' })
+        }
+      } else if (user.role === UserRole.TEAM) {
+        // 团队管理员可以获取自己团队成员的推广链接
+        const teamMember = await this.teamMembersService.findById(Number(id))
+        const team = await this.teamService.findByUserId(user.id)
+        if (teamMember.teamId !== team.id) {
+          return reply.code(403).send({ message: '无权限获取其他团队的成员推广链接' })
+        }
+      } else if (user.role !== UserRole.ADMIN) {
+        return reply.code(403).send({ message: '无权限获取推广链接' })
+      }
+
+      const teamMember = await this.teamMembersService.findById(Number(id))
+      if (!teamMember.promoCode) {
+        return reply.code(400).send({ message: '团队成员没有推广码' })
+      }
+
+      const promotionLink = await this.teamMembersService.generatePromotionLink(Number(id))
+
+      const response: PromotionLinkResponse = {
+        teamMemberId: teamMember.id,
+        promoCode: teamMember.promoCode,
+        promotionLink
+      }
+
+      return reply.send(response)
+    } catch (error) {
+      console.error('获取推广链接失败:', error)
+      const errorMessage = error instanceof Error ? error.message : '未知错误'
+      
+      // 如果是找不到记录的错误,返回404
+      if (errorMessage.includes('Could not find any entity') || errorMessage.includes('未找到')) {
+        return reply.code(404).send({ 
+          message: '团队成员不存在',
+          error: errorMessage
+        })
+      }
+      
+      if (errorMessage.includes('未配置 super_domain')) {
+        return reply.code(400).send({ 
+          message: errorMessage,
+          error: 'CONFIG_ERROR'
+        })
+      }
+      
+      return reply.code(500).send({ 
+        message: '获取推广链接失败',
+        error: errorMessage
+      })
+    }
+  }
+
+  /**
+   * 为团队成员生成或重新生成推广码
+   */
+  async generatePromoCode(request: FastifyRequest<{ Params: TeamMembersParams }>, reply: FastifyReply) {
+    try {
+      const user = request.user
+      if (!user) {
+        return reply.code(403).send({ message: '用户未登录' })
+      }
+
+      const { id } = request.params
+
+      // 权限检查:只有团队成员本人、团队管理员或系统管理员可以生成推广码
+      if (user.role === UserRole.PROMOTER) {
+        // 推广员只能生成自己的推广码
+        try {
+          const teamMember = await this.teamMembersService.findByUserId(user.id)
+          if (teamMember.id !== Number(id)) {
+            return reply.code(403).send({ message: '无权限为其他团队成员生成推广码' })
+          }
+        } catch (error) {
+          // 如果找不到团队成员记录,返回友好错误
+          return reply.code(404).send({ message: '未找到您的团队成员信息,请联系管理员' })
+        }
+      } else if (user.role === UserRole.TEAM) {
+        // 团队管理员可以为自己的团队成员生成推广码
+        const teamMember = await this.teamMembersService.findById(Number(id))
+        const team = await this.teamService.findByUserId(user.id)
+        if (teamMember.teamId !== team.id) {
+          return reply.code(403).send({ message: '无权限为其他团队的成员生成推广码' })
+        }
+      } else if (user.role !== UserRole.ADMIN) {
+        return reply.code(403).send({ message: '无权限生成推广码' })
+      }
+
+      // 生成或重新生成推广码
+      const updatedTeamMember = await this.teamMembersService.generatePromoCode(Number(id))
+
+      const response: GeneratePromoCodeResponse = {
+        teamMemberId: updatedTeamMember.id,
+        promoCode: updatedTeamMember.promoCode!,
+        message: '推广码生成成功'
+      }
+
+      return reply.send(response)
+    } catch (error) {
+      console.error('生成推广码失败:', error)
+      const errorMessage = error instanceof Error ? error.message : '未知错误'
+      
+      // 如果是找不到记录的错误,返回404
+      if (errorMessage.includes('Could not find any entity') || errorMessage.includes('未找到')) {
+        return reply.code(404).send({ 
+          message: '团队成员不存在',
+          error: errorMessage
+        })
+      }
+      
+      return reply.code(500).send({ 
+        message: '生成推广码失败',
+        error: errorMessage
+      })
+    }
+  }
+
+  /**
+   * 推广员获取自己的推广码和推广链接(无需传递ID)
+   */
+  async getMyPromotionInfo(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const user = request.user
+      if (!user) {
+        return reply.code(403).send({ message: '用户未登录' })
+      }
+
+      // 只有推广员可以使用此接口
+      if (user.role !== UserRole.PROMOTER) {
+        return reply.code(403).send({ message: '此接口仅限推广员使用' })
+      }
+
+      // 查找当前用户的团队成员信息
+      let teamMember
+      try {
+        teamMember = await this.teamMembersService.findByUserId(user.id)
+      } catch (error) {
+        return reply.code(404).send({ message: '未找到您的团队成员信息,请联系管理员' })
+      }
+
+      // 如果没有推广码,自动生成一个
+      if (!teamMember.promoCode) {
+        teamMember = await this.teamMembersService.generatePromoCode(teamMember.id)
+      }
+
+      // 生成推广链接
+      let promotionLink = ''
+      try {
+        promotionLink = await this.teamMembersService.generatePromotionLink(teamMember.id)
+      } catch (error) {
+        const errorMessage = error instanceof Error ? error.message : '未知错误'
+        if (errorMessage.includes('未配置 super_domain')) {
+          return reply.code(400).send({ 
+            message: '系统未配置 super_domain,无法生成推广链接',
+            error: 'CONFIG_ERROR',
+            promoCode: teamMember.promoCode
+          })
+        }
+      }
+
+      const response: PromotionLinkResponse = {
+        teamMemberId: teamMember.id,
+        promoCode: teamMember.promoCode!,
+        promotionLink
+      }
+
+      return reply.send(response)
+    } catch (error) {
+      console.error('获取推广信息失败:', error)
+      const errorMessage = error instanceof Error ? error.message : '未知错误'
+      
+      return reply.code(500).send({ 
+        message: '获取推广信息失败',
+        error: errorMessage
+      })
+    }
+  }
+
+  /**
+   * 推广员生成自己的推广码(无需传递ID)
+   */
+  async generateMyPromoCode(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const user = request.user
+      if (!user) {
+        return reply.code(403).send({ message: '用户未登录' })
+      }
+
+      // 只有推广员可以使用此接口
+      if (user.role !== UserRole.PROMOTER) {
+        return reply.code(403).send({ message: '此接口仅限推广员使用' })
+      }
+
+      // 查找当前用户的团队成员信息
+      let teamMember
+      try {
+        teamMember = await this.teamMembersService.findByUserId(user.id)
+      } catch (error) {
+        return reply.code(404).send({ message: '未找到您的团队成员信息,请联系管理员' })
+      }
+
+      // 生成或重新生成推广码
+      const updatedTeamMember = await this.teamMembersService.generatePromoCode(teamMember.id)
+
+      const response: GeneratePromoCodeResponse = {
+        teamMemberId: updatedTeamMember.id,
+        promoCode: updatedTeamMember.promoCode!,
+        message: '推广码生成成功'
+      }
+
+      return reply.send(response)
+    } catch (error) {
+      console.error('生成推广码失败:', error)
+      const errorMessage = error instanceof Error ? error.message : '未知错误'
+      
+      return reply.code(500).send({ 
+        message: '生成推广码失败',
+        error: errorMessage
+      })
+    }
+  }
+
+  /**
+   * 基于personalAgentId获取IP成交率统计
+   */
+  async getIpConversionRateByPersonalAgentId(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const user = request.user
+      if (!user) {
+        return reply.code(403).send({ message: '用户未登录' })
+      }
+
+      // 允许团队用户和推广员使用此接口
+      if (user.role !== UserRole.TEAM && user.role !== UserRole.PROMOTER) {
+        return reply.code(403).send({ message: '只有团队用户和推广员可以使用此接口' })
+      }
+
+      // 推广员只能查看自己的统计
+      let targetUserId = user.id
+      if (user.role === UserRole.PROMOTER) {
+        targetUserId = user.id
+      } else if (user.role === UserRole.TEAM) {
+        // 团队用户查看自己的统计(如果有团队成员记录)
+        try {
+          const teamMember = await this.teamMembersService.findByUserId(user.id)
+          targetUserId = teamMember.userId
+        } catch (error) {
+          // 如果没有团队成员记录,返回0数据
+          return reply.send({
+            todayIpConversionRate: 0,
+            totalIpConversionRate: 0,
+            todayPaidUsers: 0,
+            todayLoginUsers: 0,
+            totalPaidUsers: 0,
+            totalUsers: 0
+          })
+        }
+      }
+
+      const statistics = await this.teamMembersService.getIpConversionRateByPersonalAgentId(targetUserId)
+      return reply.send(statistics)
+    } catch (error) {
+      console.error('获取IP成交率失败:', error)
+      return reply.code(500).send({ message: '获取IP成交率失败' })
+    }
+  }
+
+  /**
+   * 基于personalAgentId获取全部统计
+   */
+  async getAllStatisticsByPersonalAgentId(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const user = request.user
+      if (!user) {
+        return reply.code(403).send({ message: '用户未登录' })
+      }
+
+      // 允许团队用户和推广员使用此接口
+      if (user.role !== UserRole.TEAM && user.role !== UserRole.PROMOTER) {
+        return reply.code(403).send({ message: '只有团队用户和推广员可以使用此接口' })
+      }
+
+      // 推广员只能查看自己的统计
+      let targetUserId = user.id
+      if (user.role === UserRole.PROMOTER) {
+        targetUserId = user.id
+      } else if (user.role === UserRole.TEAM) {
+        // 团队用户查看自己的统计(如果有团队成员记录)
+        try {
+          const teamMember = await this.teamMembersService.findByUserId(user.id)
+          targetUserId = teamMember.userId
+        } catch (error) {
+          // 如果没有团队成员记录,返回0数据
+          return reply.send({
+            totalNewUsers: 0,
+            totalIncome: 0,
+            todayActiveUsers: 0,
+            todayIncome: 0,
+            totalSales: 0,
+            todaySales: 0
+          })
+        }
+      }
+
+      const statistics = await this.teamMembersService.getAllStatisticsByPersonalAgentId(targetUserId)
+      return reply.send(statistics)
+    } catch (error) {
+      console.error('获取全部统计数据失败:', error)
+      return reply.code(500).send({ message: '获取全部统计数据失败' })
+    }
+  }
+
+  /**
+   * 基于personalAgentId获取每日统计
+   */
+  async getDailyStatisticsByPersonalAgentId(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const user = request.user
+      if (!user) {
+        return reply.code(403).send({ message: '用户未登录' })
+      }
+
+      // 允许团队用户和推广员使用此接口
+      if (user.role !== UserRole.TEAM && user.role !== UserRole.PROMOTER) {
+        return reply.code(403).send({ message: '只有团队用户和推广员可以使用此接口' })
+      }
+
+      // 推广员只能查看自己的统计
+      let targetUserId = user.id
+      if (user.role === UserRole.PROMOTER) {
+        targetUserId = user.id
+      } else if (user.role === UserRole.TEAM) {
+        // 团队用户查看自己的统计(如果有团队成员记录)
+        try {
+          const teamMember = await this.teamMembersService.findByUserId(user.id)
+          targetUserId = teamMember.userId
+        } catch (error) {
+          // 如果没有团队成员记录,返回0数据
+          return reply.send({
+            todayNewUsers: 0,
+            todayIncome: 0,
+            todayActiveUsers: 0,
+            totalIncome: 0,
+            totalSales: 0,
+            todaySales: 0
+          })
+        }
+      }
+
+      const statistics = await this.teamMembersService.getDailyStatisticsByPersonalAgentId(targetUserId)
+      return reply.send(statistics)
+    } catch (error) {
+      console.error('获取每日统计数据失败:', error)
+      return reply.code(500).send({ message: '获取每日统计数据失败' })
+    }
+  }
 }

+ 22 - 0
src/dto/banner-click-record.dto.ts

@@ -0,0 +1,22 @@
+export interface GetClickCountByIpQuery {
+  bannerId: number
+  ip: string
+  date?: string // YYYY-MM-DD格式
+}
+
+export interface GetIpsByBannerQuery {
+  bannerId: number
+  date?: string // YYYY-MM-DD格式
+}
+
+export interface GetBannersByIpQuery {
+  ip: string
+  date?: string // YYYY-MM-DD格式
+}
+
+export interface GetDailyStatisticsQuery {
+  bannerId: number
+  startDate: string // YYYY-MM-DD格式
+  endDate: string // YYYY-MM-DD格式
+}
+

+ 3 - 0
src/dto/banner.dto.ts

@@ -6,6 +6,7 @@ export interface CreateBannerBody {
   title: string
   link: string
   position: BannerPosition
+  enabled?: boolean
 }
 
 export interface UpdateBannerBody {
@@ -14,11 +15,13 @@ export interface UpdateBannerBody {
   title?: string
   link?: string
   position?: BannerPosition
+  enabled?: boolean
 }
 
 export interface ListBannerQuery extends Pagination {
   position?: BannerPosition
   title?: string
+  enabled?: boolean
 }
 
 export interface BannerParams {

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

@@ -66,6 +66,7 @@ export interface RegisterBody {
   email?: string
   phone?: string
   code?: string
+  memberCode?: string
 }
 
 export interface UpdateProfileBody {

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

@@ -85,4 +85,16 @@ export interface TeamLeaderStatsResponse {
     teamLeaderTotalIncome: number
     teamLeaderTodayIncome: number
   }>
+}
+
+export interface PromotionLinkResponse {
+  teamMemberId: number
+  promoCode: string
+  promotionLink: string
+}
+
+export interface GeneratePromoCodeResponse {
+  teamMemberId: number
+  promoCode: string
+  message: string
 }

+ 25 - 0
src/entities/banner-daily-statistics.entity.ts

@@ -0,0 +1,25 @@
+import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, Index } from 'typeorm'
+
+@Entity()
+@Index('idx_banner_statistics_banner_date', ['bannerId', 'statDate'], { unique: true })
+@Index('idx_banner_statistics_date', ['statDate'])
+export class BannerDailyStatistics {
+  @PrimaryGeneratedColumn()
+  id: number
+
+  @Column()
+  bannerId: number
+
+  @Column({ type: 'date' })
+  statDate: Date
+
+  @Column({ default: 0 })
+  uniqueIpCount: number
+
+  @CreateDateColumn()
+  createdAt: Date
+
+  @UpdateDateColumn()
+  updatedAt: Date
+}
+

+ 4 - 0
src/entities/banner.entity.ts

@@ -9,6 +9,7 @@ export enum BannerPosition {
 @Entity()
 @Index('idx_banner_position', ['position'])
 @Index('idx_banner_created_at', ['createdAt'])
+@Index('idx_banner_position_enabled', ['position', 'enabled'])
 export class Banner {
   @PrimaryGeneratedColumn()
   id: number
@@ -35,6 +36,9 @@ export class Banner {
   })
   position: BannerPosition
 
+  @Column({ default: false })
+  enabled: boolean
+
   @CreateDateColumn()
   createdAt: Date
 

+ 3 - 0
src/entities/team-members.entity.ts

@@ -23,6 +23,9 @@ export class TeamMembers {
   @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 })
   commissionRate: number
 
+  @Column({ unique: true, length: 10, nullable: true })
+  promoCode: string
+
   @CreateDateColumn()
   createdAt: Date
 

+ 55 - 0
src/routes/banner.routes.ts

@@ -8,6 +8,12 @@ import {
   ListBannerQuery,
   BannerParams
 } from '../dto/banner.dto'
+import {
+  GetClickCountByIpQuery,
+  GetIpsByBannerQuery,
+  GetBannersByIpQuery,
+  GetDailyStatisticsQuery
+} from '../dto/banner-click-record.dto'
 import { BannerPosition } from '../entities/banner.entity'
 
 export default async function bannerRoutes(fastify: FastifyInstance) {
@@ -61,5 +67,54 @@ export default async function bannerRoutes(fastify: FastifyInstance) {
     { onRequest: [] },
     bannerController.recordClick.bind(bannerController)
   )
+
+  // 检查某个IP是否点击过某个广告(管理员)
+  fastify.get<{ Querystring: GetClickCountByIpQuery }>(
+    '/has-clicked',
+    { onRequest: [authenticate, hasRole(UserRole.ADMIN)] },
+    bannerController.hasClicked.bind(bannerController)
+  )
+
+  // 获取某个广告被哪些IP点击(管理员)
+  fastify.get<{ Querystring: GetIpsByBannerQuery }>(
+    '/ips',
+    { onRequest: [authenticate, hasRole(UserRole.ADMIN)] },
+    bannerController.getIpsByBanner.bind(bannerController)
+  )
+
+  // 获取某个IP点击了哪些广告(管理员)
+  fastify.get<{ Querystring: GetBannersByIpQuery }>(
+    '/banners-by-ip',
+    { onRequest: [authenticate, hasRole(UserRole.ADMIN)] },
+    bannerController.getBannersByIp.bind(bannerController)
+  )
+
+  // 获取某个广告的每日点击统计(管理员)
+  fastify.get<{ Querystring: GetDailyStatisticsQuery }>(
+    '/statistics/daily',
+    { onRequest: [authenticate, hasRole(UserRole.ADMIN)] },
+    bannerController.getDailyStatistics.bind(bannerController)
+  )
+
+  // 获取所有banner的今日和全期点击数据汇总(管理员)
+  fastify.get(
+    '/statistics/summary',
+    { onRequest: [authenticate, hasRole(UserRole.ADMIN)] },
+    bannerController.getSummary.bind(bannerController)
+  )
+
+  // 获取某个广告今日的Redis IP点击情况(管理员)
+  fastify.get<{ Params: BannerParams }>(
+    '/:id/today-ips',
+    { onRequest: [authenticate, hasRole(UserRole.ADMIN)] },
+    bannerController.getTodayIpsFromRedis.bind(bannerController)
+  )
+
+  // 手动触发每日点击统计任务(管理员)
+  fastify.post<{ Querystring: { date?: string } }>(
+    '/update-daily-count',
+    { onRequest: [authenticate, hasRole(UserRole.ADMIN)] },
+    bannerController.updateDailyClickCount.bind(bannerController)
+  )
 }
 

+ 49 - 0
src/routes/team-members.routes.ts

@@ -84,4 +84,53 @@ export default async function teamMembersRoutes(fastify: FastifyInstance) {
     { onRequest: [authenticate, hasRole(UserRole.TEAM)] },
     teamMembersController.getTeamLeaderStats.bind(teamMembersController)
   )
+
+  // 获取团队成员的推广链接
+  fastify.get<{ Params: TeamMembersParams }>(
+    '/:id/promotion-link',
+    { onRequest: [authenticate, hasAnyRole(UserRole.ADMIN, UserRole.TEAM, UserRole.PROMOTER)] },
+    teamMembersController.getPromotionLink.bind(teamMembersController)
+  )
+
+  // 为团队成员生成或重新生成推广码
+  fastify.post<{ Params: TeamMembersParams }>(
+    '/:id/generate-promo-code',
+    { onRequest: [authenticate, hasAnyRole(UserRole.ADMIN, UserRole.TEAM, UserRole.PROMOTER)] },
+    teamMembersController.generatePromoCode.bind(teamMembersController)
+  )
+
+  // 推广员获取自己的推广信息(无需传递ID)
+  fastify.get(
+    '/my/promotion-info',
+    { onRequest: [authenticate, hasRole(UserRole.PROMOTER)] },
+    teamMembersController.getMyPromotionInfo.bind(teamMembersController)
+  )
+
+  // 推广员生成自己的推广码(无需传递ID)
+  fastify.post(
+    '/my/generate-promo-code',
+    { onRequest: [authenticate, hasRole(UserRole.PROMOTER)] },
+    teamMembersController.generateMyPromoCode.bind(teamMembersController)
+  )
+
+  // 基于personalAgentId获取IP成交率统计
+  fastify.get(
+    '/statistics/ip-conversion-rate',
+    { onRequest: [authenticate, hasAnyRole(UserRole.TEAM, UserRole.PROMOTER)] },
+    teamMembersController.getIpConversionRateByPersonalAgentId.bind(teamMembersController)
+  )
+
+  // 基于personalAgentId获取全部统计
+  fastify.get(
+    '/statistics/all',
+    { onRequest: [authenticate, hasAnyRole(UserRole.TEAM, UserRole.PROMOTER)] },
+    teamMembersController.getAllStatisticsByPersonalAgentId.bind(teamMembersController)
+  )
+
+  // 基于personalAgentId获取每日统计
+  fastify.get(
+    '/statistics/daily',
+    { onRequest: [authenticate, hasAnyRole(UserRole.TEAM, UserRole.PROMOTER)] },
+    teamMembersController.getDailyStatisticsByPersonalAgentId.bind(teamMembersController)
+  )
 }

+ 87 - 0
src/scheduler/banner-statistics.scheduler.ts

@@ -0,0 +1,87 @@
+import * as cron from 'node-cron'
+import { FastifyInstance } from 'fastify'
+import { BannerClickRecordService } from '../services/banner-click-record.service'
+
+/**
+ * 广告点击统计定时任务
+ * 每天0点执行,统计前一天的点击总数到数据库
+ */
+export class BannerStatisticsScheduler {
+  private app: FastifyInstance
+  private clickRecordService: BannerClickRecordService
+  private cronJob: cron.ScheduledTask | null = null
+
+  constructor(app: FastifyInstance) {
+    this.app = app
+    this.clickRecordService = new BannerClickRecordService(app)
+  }
+
+  /**
+   * 启动定时任务
+   * 每天0点执行统计任务
+   */
+  start(): void {
+    // cron表达式: 0 0 * * * 表示每天0点执行
+    // 格式: 秒 分 时 日 月 周
+    this.cronJob = cron.schedule('0 0 * * *', async () => {
+      await this.executeDailyStatistics()
+    }, {
+      timezone: 'Asia/Shanghai' // 使用中国时区
+    })
+
+    this.app.log.info('广告点击统计定时任务已启动,将在每天0点执行')
+  }
+
+  /**
+   * 停止定时任务
+   */
+  stop(): void {
+    if (this.cronJob) {
+      this.cronJob.stop()
+      this.cronJob = null
+      this.app.log.info('广告点击统计定时任务已停止')
+    }
+  }
+
+  /**
+   * 执行每日统计任务
+   * 统计前一天的点击总数并保存到数据库
+   */
+  private async executeDailyStatistics(): Promise<void> {
+    const startTime = Date.now()
+    this.app.log.info('开始执行每日广告点击统计任务...')
+
+    try {
+      // 统计前一天的点击数据
+      await this.clickRecordService.updateDailyClickCount()
+      
+      const duration = Date.now() - startTime
+      this.app.log.info(`每日广告点击统计任务执行成功,耗时: ${duration}ms`)
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : String(error)
+      this.app.log.error({ err: error }, `每日广告点击统计任务执行失败: ${errorMessage}`)
+    }
+  }
+
+  /**
+   * 手动触发统计任务(用于测试或手动执行)
+   * @param date 可选,指定要统计的日期(默认统计昨天)
+   */
+  async manualTrigger(date?: Date): Promise<void> {
+    const startTime = Date.now()
+    this.app.log.info('手动触发广告点击统计任务...')
+
+    try {
+      // 统计指定日期或前一天的点击数据
+      await this.clickRecordService.updateDailyClickCount(date)
+      
+      const duration = Date.now() - startTime
+      this.app.log.info(`手动触发广告点击统计任务执行成功,耗时: ${duration}ms`)
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : String(error)
+      this.app.log.error({ err: error }, `手动触发广告点击统计任务执行失败: ${errorMessage}`)
+      throw error
+    }
+  }
+}
+

+ 415 - 0
src/services/banner-click-record.service.ts

@@ -0,0 +1,415 @@
+import { Repository, LessThan, Between } from 'typeorm'
+import { FastifyInstance } from 'fastify'
+import Redis from 'ioredis'
+import { BannerDailyStatistics } from '../entities/banner-daily-statistics.entity'
+import { Banner } from '../entities/banner.entity'
+
+export class BannerClickRecordService {
+  private statisticsRepository: Repository<BannerDailyStatistics>
+  private bannerRepository: Repository<Banner>
+  private redis: Redis | null
+
+  constructor(app: FastifyInstance) {
+    this.statisticsRepository = app.dataSource.getRepository(BannerDailyStatistics)
+    this.bannerRepository = app.dataSource.getRepository(Banner)
+    this.redis = app.redis || null
+    // 注意:不在构造函数中强制检查Redis,允许服务在Redis未配置时也能创建
+    // 在使用Redis的方法中会进行检查
+  }
+
+  /**
+   * 记录广告点击(每个IP每天只记录一次)
+   * 只记录到Redis,不写入MySQL
+   * @param bannerId 广告ID
+   * @param ip 访问IP
+   * @returns 是否是新记录(true表示首次记录,false表示已存在)
+   */
+  async recordClick(bannerId: number, ip: string): Promise<boolean> {
+    if (!this.redis) {
+      throw new Error('Redis未配置')
+    }
+
+    const today = new Date()
+    today.setHours(0, 0, 0, 0)
+    const dateStr = today.toISOString().split('T')[0] // YYYY-MM-DD
+
+    const redisKey = `banner:ip:${bannerId}:${dateStr}`
+    
+    // 检查IP是否已存在
+    const exists = await this.redis.sismember(redisKey, ip)
+    if (exists === 1) {
+      return false // 已存在,不重复记录
+    }
+
+    // Redis记录:单个IP单日访问标记
+    // Key格式: banner:ip:{bannerId}:{date}
+    // Value: Set结构,存储所有点击过的IP
+    await this.redis.sadd(redisKey, ip)
+    // 设置7天过期(单日数据保留7天)
+    await this.redis.expire(redisKey, 7 * 24 * 3600)
+
+    return true // 返回true表示是新记录
+  }
+
+  /**
+   * 检查某个IP是否点击过某个广告(从Redis查询)
+   * @param bannerId 广告ID
+   * @param ip IP地址
+   * @param date 日期(可选,默认今天)
+   */
+  async hasClicked(bannerId: number, ip: string, date?: Date): Promise<boolean> {
+    if (!this.redis) {
+      return false
+    }
+
+    const targetDate = date || new Date()
+    targetDate.setHours(0, 0, 0, 0)
+    const dateStr = targetDate.toISOString().split('T')[0]
+
+    const redisKey = `banner:ip:${bannerId}:${dateStr}`
+    const exists = await this.redis.sismember(redisKey, ip)
+    
+    return exists === 1
+  }
+
+  /**
+   * 获取某个广告被哪些IP点击(从Redis查询)
+   * @param bannerId 广告ID
+   * @param date 日期(可选,默认今天)
+   */
+  async getIpsByBanner(bannerId: number, date?: Date): Promise<Array<{ ip: string }>> {
+    if (!this.redis) {
+      return []
+    }
+
+    const targetDate = date || new Date()
+    targetDate.setHours(0, 0, 0, 0)
+    const dateStr = targetDate.toISOString().split('T')[0]
+
+    const redisKey = `banner:ip:${bannerId}:${dateStr}`
+    const ips = await this.redis.smembers(redisKey)
+
+    return ips.map(ip => ({ ip }))
+  }
+
+  /**
+   * 获取某个IP点击了哪些广告(从Redis查询)
+   * 注意:需要遍历所有广告的Redis Key,性能可能较差
+   * @param ip IP地址
+   * @param date 日期(可选,默认今天)
+   */
+  async getBannersByIp(ip: string, date?: Date): Promise<Array<{ bannerId: number }>> {
+    if (!this.redis) {
+      return []
+    }
+
+    const targetDate = date || new Date()
+    targetDate.setHours(0, 0, 0, 0)
+    const dateStr = targetDate.toISOString().split('T')[0]
+
+    // 获取所有banner
+    const banners = await this.bannerRepository.find({ select: ['id'] })
+    const result: Array<{ bannerId: number }> = []
+
+    // 遍历检查每个广告的Redis Set
+    for (const banner of banners) {
+      const redisKey = `banner:ip:${banner.id}:${dateStr}`
+      const exists = await this.redis.sismember(redisKey, ip)
+      if (exists === 1) {
+        result.push({ bannerId: banner.id })
+      }
+    }
+
+    return result
+  }
+
+  /**
+   * 获取某个广告的全期统计数据(所有时间的汇总)
+   * 全期 = MySQL中所有历史日期(不包括今天)的总和 + 今天Redis中的数量
+   * @param bannerId 广告ID
+   * @param skipTodayUpdate 是否跳过今天的更新(避免重复更新)
+   */
+  async getTotalStatistics(bannerId: number, skipTodayUpdate: boolean = false): Promise<{ totalUniqueIps: number }> {
+    const today = new Date()
+    today.setHours(0, 0, 0, 0)
+
+    // 从MySQL统计表查询所有历史日期(不包括今天)的总和
+    const yesterday = new Date(today)
+    yesterday.setDate(yesterday.getDate() - 1)
+    yesterday.setHours(23, 59, 59, 999)
+
+    const historyResult = await this.statisticsRepository
+      .createQueryBuilder('stat')
+      .select('SUM(stat.uniqueIpCount)', 'totalUniqueIps')
+      .where('stat.bannerId = :bannerId', { bannerId })
+      .andWhere('stat.statDate < :today', { today })
+      .getRawOne()
+
+    const historyTotal = historyResult?.totalUniqueIps ? parseInt(historyResult.totalUniqueIps, 10) : 0
+
+    // 获取今天Redis中的数量
+    let todayCount = 0
+    if (this.redis) {
+      const dateStr = today.toISOString().split('T')[0]
+      const redisKey = `banner:ip:${bannerId}:${dateStr}`
+      todayCount = await this.redis.scard(redisKey)
+    }
+
+    // 如果今天还没有统计到MySQL,且需要更新,则更新今天的统计数据
+    // 注意:这里不更新MySQL,因为我们只需要全期统计,不需要持久化今天的数据
+    // 全期统计 = 历史总和 + 今天Redis实时数据
+
+    return {
+      totalUniqueIps: historyTotal + todayCount
+    }
+  }
+
+  /**
+   * 获取某个广告的每日点击统计
+   * 如果查询范围包含今天,会先实时统计今天的数据并更新MySQL(用于每日列表显示)
+   * @param bannerId 广告ID
+   * @param startDate 开始日期
+   * @param endDate 结束日期
+   */
+  async getDailyStatistics(
+    bannerId: number,
+    startDate: Date,
+    endDate: Date
+  ): Promise<{
+    daily: Array<{ date: string; uniqueIps: number }>
+    total: { totalUniqueIps: number }
+  }> {
+    startDate.setHours(0, 0, 0, 0)
+    endDate.setHours(23, 59, 59, 999)
+    
+    const today = new Date()
+    today.setHours(0, 0, 0, 0)
+
+    // 判断是否包含今天
+    const includesToday = today.getTime() >= startDate.getTime() && today.getTime() <= endDate.getTime()
+    
+    // 如果查询范围包含今天,从Redis获取今天的实时数量(今天的数据不写入MySQL)
+    let todayStatCount = 0
+    if (includesToday && this.redis) {
+      const dateStr = today.toISOString().split('T')[0]
+      const redisKey = `banner:ip:${bannerId}:${dateStr}`
+      todayStatCount = await this.redis.scard(redisKey)
+    }
+
+    // 从MySQL统计表查询每日数据(只查询历史日期,不包括今天)
+    const yesterday = new Date(today)
+    yesterday.setDate(yesterday.getDate() - 1)
+    yesterday.setHours(23, 59, 59, 999)
+    
+    // 查询结束日期:如果包含今天,则只查询到今天之前;否则查询到endDate
+    const endDateForQuery = includesToday ? yesterday : endDate
+
+    const statistics = await this.statisticsRepository.find({
+      where: {
+        bannerId,
+        statDate: Between(startDate.getTime() <= endDateForQuery.getTime() ? startDate : endDateForQuery, endDateForQuery)
+      },
+      order: {
+        statDate: 'ASC'
+      }
+    })
+
+    // 构建每日统计数据
+    const dailyStats = statistics.map(stat => ({
+      date: stat.statDate.toISOString().split('T')[0],
+      uniqueIps: stat.uniqueIpCount
+    }))
+
+    // 如果查询范围包含今天,添加今天的数据(从Redis获取)
+    if (includesToday) {
+      dailyStats.push({
+        date: today.toISOString().split('T')[0],
+        uniqueIps: todayStatCount
+      })
+    }
+
+    // 获取全期统计数据(历史MySQL + 今天Redis)
+    const totalStats = await this.getTotalStatistics(bannerId, true)
+
+    return {
+      daily: dailyStats,
+      total: totalStats
+    }
+  }
+
+  /**
+   * 更新单个广告指定日期的统计数据到MySQL
+   * 从Redis读取点击数据,统计后保存到MySQL
+   * 注意:
+   * - 只保存历史日期,今天的统计数据不写入MySQL(今天的数据只存在Redis)
+   * - 如果该日期已存在统计数据,会用最新数据覆盖旧数据
+   * @param bannerId 广告ID
+   * @param date 日期(可选,默认今天)
+   */
+  async updateBannerStatistics(bannerId: number, date?: Date): Promise<number> {
+    if (!this.redis) {
+      throw new Error('Redis未配置')
+    }
+
+    const targetDate = date || new Date()
+    targetDate.setHours(0, 0, 0, 0)
+    const dateStr = targetDate.toISOString().split('T')[0]
+    const today = new Date()
+    today.setHours(0, 0, 0, 0)
+
+    const redisKey = `banner:ip:${bannerId}:${dateStr}`
+    
+    // 获取Redis Set的大小(独立IP数量)
+    const uniqueIpCount = await this.redis.scard(redisKey)
+
+    // 如果是今天,不写入MySQL,只返回Redis中的数据
+    // 今天的数据只存在Redis中,用于实时查询
+    if (targetDate.getTime() === today.getTime()) {
+      return uniqueIpCount
+    }
+
+    // 历史日期:查找或创建统计记录并保存到MySQL
+    let statistics = await this.statisticsRepository.findOne({
+      where: {
+        bannerId,
+        statDate: targetDate
+      }
+    })
+
+    if (statistics) {
+      // 更新统计
+      statistics.uniqueIpCount = uniqueIpCount
+      await this.statisticsRepository.save(statistics)
+    } else {
+      // 创建新统计记录
+      statistics = this.statisticsRepository.create({
+        bannerId,
+        statDate: targetDate,
+        uniqueIpCount
+      })
+      await this.statisticsRepository.save(statistics)
+    }
+
+    return uniqueIpCount
+  }
+
+  /**
+   * 统计指定日期的点击数并保存到MySQL统计表
+   * 用于定时任务,每天统计一次
+   * 从Redis读取指定日期的点击数据,统计后保存到MySQL
+   * 注意:如果重复统计同一日期,会用最新数据覆盖之前的统计数据
+   * @param date 基准日期(可选,默认今天,实际统计的是基准日期的前一天)
+   */
+  async updateDailyClickCount(date?: Date): Promise<void> {
+    if (!this.redis) {
+      throw new Error('Redis未配置')
+    }
+
+    const targetDate = date || new Date()
+    targetDate.setDate(targetDate.getDate() - 1) // 默认统计昨天
+    targetDate.setHours(0, 0, 0, 0)
+
+    // 获取所有banner
+    const banners = await this.bannerRepository.find({ select: ['id'] })
+
+    // 遍历每个banner,从Redis统计并保存到MySQL
+    for (const banner of banners) {
+      await this.updateBannerStatistics(banner.id, targetDate)
+    }
+  }
+
+  // 注意:每日统计数据会永久保存在MySQL中,不需要定期清理
+
+  /**
+   * 从Redis获取某个广告今日的所有IP点击情况
+   * @param bannerId 广告ID
+   */
+  async getTodayIpsFromRedis(bannerId: number): Promise<string[]> {
+    if (!this.redis) {
+      return []
+    }
+
+    const today = new Date().toISOString().split('T')[0]
+    const redisKey = `banner:ip:${bannerId}:${today}`
+    const ips = await this.redis.smembers(redisKey)
+
+    return ips
+  }
+
+  /**
+   * 获取某个广告指定日期的独立IP数量
+   * 如果是今天,会先实时统计并更新MySQL后再返回
+   * 如果是历史日期,直接从MySQL统计表查询
+   * @param bannerId 广告ID
+   * @param date 日期(可选,默认今天)
+   */
+  async getUniqueIpCount(bannerId: number, date?: Date): Promise<number> {
+    const targetDate = date || new Date()
+    targetDate.setHours(0, 0, 0, 0)
+    const today = new Date()
+    today.setHours(0, 0, 0, 0)
+
+    // 如果是今天,先更新统计数据
+    if (targetDate.getTime() === today.getTime()) {
+      return await this.updateBannerStatistics(bannerId, today)
+    }
+
+    // 历史数据从MySQL统计表查询
+    const statistics = await this.statisticsRepository.findOne({
+      where: {
+        bannerId,
+        statDate: targetDate
+      }
+    })
+
+    return statistics ? statistics.uniqueIpCount : 0
+  }
+
+  /**
+   * 获取所有banner的今日和全期点击统计数据汇总
+   * @returns 所有banner的统计汇总数据
+   */
+  async getAllBannersSummary(): Promise<Array<{
+    bannerId: number
+    title: string
+    position: string
+    enabled: boolean
+    todayUniqueIps: number
+    totalUniqueIps: number
+  }>> {
+    // 获取所有banner的基本信息
+    const banners = await this.bannerRepository.find({
+      select: ['id', 'title', 'position', 'enabled'],
+      order: { id: 'ASC' }
+    })
+
+    const today = new Date()
+    today.setHours(0, 0, 0, 0)
+    const dateStr = today.toISOString().split('T')[0]
+
+    // 并行获取每个banner的统计数据
+    const summaryPromises = banners.map(async (banner) => {
+      // 获取今日独立IP数(从Redis)
+      let todayUniqueIps = 0
+      if (this.redis) {
+        const redisKey = `banner:ip:${banner.id}:${dateStr}`
+        todayUniqueIps = await this.redis.scard(redisKey)
+      }
+
+      // 获取全期独立IP数(历史MySQL总和 + 今日Redis)
+      const totalStats = await this.getTotalStatistics(banner.id, true)
+
+      return {
+        bannerId: banner.id,
+        title: banner.title,
+        position: banner.position,
+        enabled: banner.enabled,
+        todayUniqueIps,
+        totalUniqueIps: totalStats.totalUniqueIps
+      }
+    })
+
+    return Promise.all(summaryPromises)
+  }
+}
+

+ 68 - 19
src/services/banner.service.ts

@@ -1,28 +1,54 @@
-import { Repository, Like } from 'typeorm'
+import { Repository, Like, Not } from 'typeorm'
 import { FastifyInstance } from 'fastify'
 import { Banner, BannerPosition } from '../entities/banner.entity'
 import { PaginationResponse } from '../dto/common.dto'
 import { CreateBannerBody, UpdateBannerBody, ListBannerQuery } from '../dto/banner.dto'
 import { FileService } from './file.service'
+import { BannerClickRecordService } from './banner-click-record.service'
 
 export class BannerService {
   private bannerRepository: Repository<Banner>
   private fileService: FileService
+  private clickRecordService: BannerClickRecordService
 
   constructor(app: FastifyInstance) {
     this.bannerRepository = app.dataSource.getRepository(Banner)
     this.fileService = new FileService(app)
+    this.clickRecordService = new BannerClickRecordService(app)
   }
 
   async create(data: CreateBannerBody): Promise<Banner> {
-    const banner = this.bannerRepository.create(data)
+    // 如果启用,需要禁用同位置的其他广告
+    if (data.enabled === true) {
+      await this.disableOtherBannersInPosition(data.position)
+    }
+    
+    const banner = this.bannerRepository.create({
+      ...data,
+      enabled: data.enabled ?? false
+    })
     return this.bannerRepository.save(banner)
   }
 
+  /**
+   * 禁用指定位置的所有其他广告
+   * @param position 广告位置
+   */
+  private async disableOtherBannersInPosition(position: BannerPosition): Promise<void> {
+    await this.bannerRepository.update(
+      { position, enabled: true },
+      { enabled: false }
+    )
+  }
+
   async findById(id: number): Promise<Banner> {
     const banner = await this.bannerRepository.findOneOrFail({ where: { id } })
     // 处理图片链接
     banner.image = await this.processImageUrl(banner.image)
+    // 更新todayClickCount为实时值(从Redis读取今天的数量)
+    const today = new Date()
+    today.setHours(0, 0, 0, 0)
+    banner.todayClickCount = await this.clickRecordService.getUniqueIpCount(id, today)
     return banner
   }
 
@@ -59,12 +85,18 @@ export class BannerService {
    * @returns 处理后的广告页数组
    */
   private async processImageUrls(banners: Banner[]): Promise<Banner[]> {
+    const today = new Date()
+    today.setHours(0, 0, 0, 0)
+    
     const processedBanners = await Promise.all(
       banners.map(async (banner) => {
         const processedImage = await this.processImageUrl(banner.image)
+        // 更新todayClickCount为实时值(从Redis读取今天的数量)
+        const todayCount = await this.clickRecordService.getUniqueIpCount(banner.id, today)
         return {
           ...banner,
-          image: processedImage
+          image: processedImage,
+          todayClickCount: todayCount
         }
       })
     )
@@ -106,12 +138,13 @@ export class BannerService {
 
   /**
    * 根据位置获取广告栏列表(不分页,用于前端展示)
+   * 只返回启用的广告
    * @param position 广告位置
    * @returns 广告栏列表
    */
   async findByPosition(position: BannerPosition): Promise<Banner[]> {
     const banners = await this.bannerRepository.find({
-      where: { position },
+      where: { position, enabled: true },
       order: { createdAt: 'DESC' }
     })
 
@@ -121,6 +154,23 @@ export class BannerService {
 
   async update(data: UpdateBannerBody): Promise<Banner> {
     const { id, ...updateData } = data
+    
+    // 获取当前广告信息
+    const currentBanner = await this.bannerRepository.findOne({ where: { id } })
+    if (!currentBanner) {
+      throw new Error('广告栏不存在')
+    }
+
+    // 如果启用,需要禁用同位置的其他广告
+    const targetPosition = updateData.position || currentBanner.position
+    if (updateData.enabled === true) {
+      // 禁用同位置的其他广告(排除当前广告)
+      await this.bannerRepository.update(
+        { position: targetPosition, enabled: true, id: Not(id) },
+        { enabled: false }
+      )
+    }
+
     await this.bannerRepository.update(id, updateData)
     return this.findById(id)
   }
@@ -131,31 +181,30 @@ export class BannerService {
 
   /**
    * 增加广告栏点击次数
+   * 注意:todayClickCount由定时任务每天统计一次,不在这里更新
    * @param id 广告栏ID
+   * @param ip 访问IP(可选,如果提供则记录IP点击)
    * @returns 更新后的广告栏
    */
-  async incrementClickCount(id: number): Promise<Banner> {
+  async incrementClickCount(id: number, ip?: string): Promise<Banner> {
     const banner = await this.bannerRepository.findOne({ where: { id } })
     if (!banner) {
       throw new Error('广告栏不存在')
     }
 
-    const today = new Date()
-    today.setHours(0, 0, 0, 0)
-    
-    // 获取更新日期的日期部分
-    const lastUpdateDate = new Date(banner.updatedAt)
-    lastUpdateDate.setHours(0, 0, 0, 0)
-
-    // 如果最后更新日期不是今天,重置今日点击数
-    if (lastUpdateDate.getTime() !== today.getTime()) {
-      banner.todayClickCount = 0
+    // 如果提供了IP,记录IP点击(Redis + MySQL)
+    // 每个IP每天只记录一次
+    if (ip) {
+      const isNewRecord = await this.clickRecordService.recordClick(id, ip)
+      // 如果是新记录,增加总点击数
+      if (isNewRecord) {
+        banner.clickCount += 1
+      }
+    } else {
+      // 如果没有IP,直接增加总点击数
+      banner.clickCount += 1
     }
 
-    // 增加总点击数和今日点击数
-    banner.clickCount += 1
-    banner.todayClickCount += 1
-
     await this.bannerRepository.save(banner)
     return this.findById(id)
   }

+ 66 - 13
src/services/member.service.ts

@@ -6,6 +6,7 @@ import { User, UserRole } from '../entities/user.entity'
 import * as randomstring from 'randomstring'
 import { Team } from '../entities/team.entity'
 import { TeamDomain } from '../entities/team-domain.entity'
+import { TeamMembers } from '../entities/team-members.entity'
 import bcrypt from 'bcryptjs'
 
 export class MemberService {
@@ -49,11 +50,19 @@ export class MemberService {
       let domainId = 0
 
       if (code && code.trim() !== '') {
-        // 使用 code 查找团队
-        const team = await manager.findOne(Team, { where: { affCode: code } })
-        if (team) {
-          parentId = team.userId
-          teamId = team.id
+        // 先尝试查找团队成员的推广码
+        const teamMember = await manager.findOne(TeamMembers, { where: { promoCode: code } })
+        if (teamMember) {
+          // 如果找到团队成员,使用团队成员的 userId 作为 parentId
+          parentId = teamMember.userId
+          teamId = teamMember.teamId
+        } else {
+          // 如果没有找到团队成员,尝试查找团队的推广码
+          const team = await manager.findOne(Team, { where: { affCode: code } })
+          if (team) {
+            parentId = team.userId
+            teamId = team.id
+          }
         }
       } else if (domain) {
         let domainName = domain
@@ -332,7 +341,8 @@ export class MemberService {
     email?: string,
     phone?: string,
     code?: string,
-    ip?: string
+    ip?: string,
+    memberCode?: string
   ): Promise<{ user: User; member: Member }> {
     return await this.dataSource.transaction(async manager => {
       // 检查用户名是否已存在
@@ -357,14 +367,57 @@ export class MemberService {
         }
       }
 
-      // 获取推荐团队
+      // 获取推荐团队或根据IP查找历史注册信息
       let team = null
-      if (code && code.trim() !== '') {
-        team = await manager.findOne(Team, { where: { affCode: code } })
+      let parentId = 1
+      let teamId = 0
+      let domainId = 0
+
+      // 优先使用 memberCode(团队成员推广码),如果没有则使用 code(团队推广码)
+      const promoCode = memberCode || code
+
+      if (promoCode && promoCode.trim() !== '') {
+        // 如果提供了 memberCode,优先查找团队成员的推广码
+        if (memberCode && memberCode.trim() !== '') {
+          const teamMember = await manager.findOne(TeamMembers, { where: { promoCode: memberCode } })
+          if (teamMember) {
+            // 如果找到团队成员,使用团队成员的 userId 作为 parentId
+            parentId = teamMember.userId
+            teamId = teamMember.teamId
+            domainId = 0
+          }
+        }
+        
+        // 如果没有找到团队成员(或者使用的是 code),尝试查找团队的推广码
+        if (parentId === 1 && teamId === 0) {
+          team = await manager.findOne(Team, { where: { affCode: promoCode } })
+          if (team) {
+            parentId = team.userId
+            teamId = team.id
+            domainId = 0
+          }
+        }
+      } else if (ip && ip !== 'unknown') {
+        // 如果没有推广参数,检查注册IP是否之前注册过账号
+        const existingMemberByIp = await manager.findOne(Member, {
+          where: { ip },
+          order: { createdAt: 'DESC' }
+        })
+
+        if (existingMemberByIp) {
+          // 如果该IP之前注册过账号,使用之前注册的teamId和domainId
+          teamId = existingMemberByIp.teamId || 0
+          domainId = existingMemberByIp.domainId || 0
+
+          // 根据teamId查找team,获取userId作为parentId(agentId)
+          if (teamId > 0) {
+            const existingTeam = await manager.findOne(Team, { where: { id: teamId } })
+            if (existingTeam) {
+              parentId = existingTeam.userId
+            }
+          }
+        }
       }
-      const parentId = team ? team.userId : 1
-      const teamId = team ? team.id : 0
-      const domainId = 0
 
       // 创建用户
       const hashedPassword = await bcrypt.hash(password, 10)
@@ -450,6 +503,6 @@ export class MemberService {
     })
     
     // 检查当前IP是否匹配任何被封禁member的ip
-    return bannedMembers.some(member => member.ip === ip)
+    return bannedMembers.some((member: Member) => member.ip === ip)
   }
 }

+ 439 - 2
src/services/team-members.service.ts

@@ -1,4 +1,4 @@
-import { Repository, Like } from 'typeorm'
+import { Repository, Like, Not } from 'typeorm'
 import { FastifyInstance } from 'fastify'
 import { TeamMembers } from '../entities/team-members.entity'
 import { IncomeRecords } from '../entities/income-records.entity'
@@ -9,6 +9,10 @@ import { PaginationResponse } from '../dto/common.dto'
 import { CreateTeamMembersBody, UpdateTeamMembersBody, ListTeamMembersQuery, TeamMemberStatsQuery, TeamMemberStatsResponse, TeamLeaderStatsQuery, TeamLeaderStatsResponse } from '../dto/team-members.dto'
 import { UserService } from './user.service'
 import { UserRole } from '../entities/user.entity'
+import * as randomstring from 'randomstring'
+import { SysConfigService } from './sys-config.service'
+import { PromotionLinkService } from './promotion-link.service'
+import { LinkType } from '../entities/promotion-link.entity'
 
 export class TeamMembersService {
   private teamMembersRepository: Repository<TeamMembers>
@@ -17,14 +21,20 @@ export class TeamMembersService {
   private userService: UserService
   private teamDomainRepository: Repository<TeamDomain>
   private teamRepository: Repository<Team>
+  private sysConfigService: SysConfigService
+  private promotionLinkService: PromotionLinkService
+  private app: FastifyInstance
 
   constructor(app: FastifyInstance) {
+    this.app = app
     this.teamMembersRepository = app.dataSource.getRepository(TeamMembers)
     this.incomeRecordsRepository = app.dataSource.getRepository(IncomeRecords)
     this.memberRepository = app.dataSource.getRepository(Member)
     this.userService = new UserService(app)
     this.teamDomainRepository = app.dataSource.getRepository(TeamDomain)
     this.teamRepository = app.dataSource.getRepository(Team)
+    this.sysConfigService = new SysConfigService(app)
+    this.promotionLinkService = new PromotionLinkService(app)
   }
 
   /**
@@ -72,9 +82,22 @@ export class TeamMembersService {
     const parentId = teamUserId || creatorId
     const createdUser = await this.userService.create(userPassword, teamMemberData.name, UserRole.PROMOTER, parentId)
 
+    // 生成推广码
+    const randomSuffix = randomstring.generate({
+      length: 10,
+      charset: 'alphanumeric'
+    })
+    let finalPromoCode = randomSuffix
+    let counter = 0
+    while (await this.teamMembersRepository.findOne({ where: { promoCode: finalPromoCode } })) {
+      counter++
+      finalPromoCode = `${randomSuffix}${counter}`
+    }
+
     const teamMember = this.teamMembersRepository.create({
       ...teamMemberData,
-      userId: createdUser.id
+      userId: createdUser.id,
+      promoCode: finalPromoCode
     })
     return this.teamMembersRepository.save(teamMember)
   }
@@ -616,4 +639,418 @@ export class TeamMembersService {
       membersStats
     }
   }
+
+  /**
+   * 生成团队成员的推广链接
+   * @param teamMemberId 团队成员ID
+   * @returns 推广链接
+   */
+  async generatePromotionLink(teamMemberId: number): Promise<string> {
+    const teamMember = await this.findById(teamMemberId)
+    
+    if (!teamMember.promoCode) {
+      throw new Error('团队成员没有推广码')
+    }
+
+    // 从系统配置获取 super_domain
+    let superDomain = ''
+    try {
+      const config = await this.sysConfigService.getSysConfig('super_domain')
+      superDomain = config.value
+    } catch (error) {
+      this.app.log.warn('未找到 super_domain 配置,使用默认值')
+      superDomain = ''
+    }
+
+    // 如果配置为空,返回空字符串或抛出错误
+    if (!superDomain || superDomain.trim() === '') {
+      throw new Error('系统未配置 super_domain,无法生成推广链接')
+    }
+
+    // 确保 super_domain 以 http:// 或 https:// 开头
+    let domain = superDomain.trim()
+    if (!domain.startsWith('http://') && !domain.startsWith('https://')) {
+      domain = `https://${domain}`
+    }
+
+    // 生成推广链接:{super_domain}?code={推广码}
+    const promotionLink = `${domain}?code=${teamMember.promoCode}`
+    
+    // 创建或更新 PromotionLink 记录,带上当前 member 的 id
+    // 如果已存在相同 memberId 的记录,则覆盖旧的
+    try {
+      await this.promotionLinkService.createOrUpdateByMemberId({
+        teamId: teamMember.teamId,
+        memberId: teamMember.id,
+        name: `${teamMember.name}的推广链接`,
+        image: '',
+        link: promotionLink,
+        type: LinkType.GENERAL
+      })
+    } catch (error) {
+      // 如果创建或更新记录失败,记录日志但不影响返回链接
+      this.app.log.warn('创建或更新推广链接记录失败:', error)
+    }
+    
+    return promotionLink
+  }
+
+  /**
+   * 根据 userId 生成推广链接
+   * @param userId 用户ID
+   * @returns 推广链接
+   */
+  async generatePromotionLinkByUserId(userId: number): Promise<string> {
+    const teamMember = await this.findByUserId(userId)
+    return this.generatePromotionLink(teamMember.id)
+  }
+
+  /**
+   * 为团队成员生成或重新生成推广码
+   * @param teamMemberId 团队成员ID
+   * @returns 更新后的团队成员信息
+   */
+  async generatePromoCode(teamMemberId: number): Promise<TeamMembers> {
+    const teamMember = await this.findById(teamMemberId)
+
+    // 生成推广码
+    const randomSuffix = randomstring.generate({
+      length: 10,
+      charset: 'alphanumeric'
+    })
+    let finalPromoCode = randomSuffix
+    let counter = 0
+    
+    // 确保生成的推广码是唯一的(排除当前成员的推广码)
+    // 使用查询构建器来排除当前成员
+    while (await this.teamMembersRepository
+      .createQueryBuilder('tm')
+      .where('tm.promoCode = :promoCode', { promoCode: finalPromoCode })
+      .andWhere('tm.id != :teamMemberId', { teamMemberId })
+      .getOne()) {
+      counter++
+      finalPromoCode = `${randomSuffix}${counter}`
+    }
+
+    // 更新推广码
+    await this.teamMembersRepository.update(teamMemberId, {
+      promoCode: finalPromoCode
+    })
+
+    // 返回更新后的团队成员信息
+    return this.findById(teamMemberId)
+  }
+
+  /**
+   * 基于personalAgentId获取IP成交率统计
+   * @param userId 团队成员的用户ID(personalAgentId)
+   * @returns IP成交率统计数据
+   */
+  async getIpConversionRateByPersonalAgentId(userId: number): Promise<{
+    todayIpConversionRate: number
+    totalIpConversionRate: number
+    todayPaidUsers: number
+    todayLoginUsers: number
+    totalPaidUsers: number
+    totalUsers: number
+  }> {
+    // 获取今天的开始时间(使用本地时区),并使用半开区间 [today, tomorrow)
+    const today = new Date()
+    today.setHours(0, 0, 0, 0)
+    const tomorrow = new Date(today)
+    tomorrow.setDate(tomorrow.getDate() + 1)
+
+    // 获取所有通过该团队成员(personalAgentId)产生的订单的用户ID
+    const orderUserIds = await this.incomeRecordsRepository
+      .createQueryBuilder('record')
+      .select('DISTINCT record.userId', 'userId')
+      .where('record.personalAgentId = :personalAgentId', { personalAgentId: userId })
+      .andWhere('record.delFlag = :delFlag', { delFlag: false })
+      .andWhere('record.status = :status', { status: true })
+      .getRawMany()
+
+    const userIds = orderUserIds.map(item => item.userId).filter(id => id !== null && id !== undefined)
+
+    if (userIds.length === 0) {
+      return {
+        todayIpConversionRate: 0,
+        totalIpConversionRate: 0,
+        todayPaidUsers: 0,
+        todayLoginUsers: 0,
+        totalPaidUsers: 0,
+        totalUsers: 0
+      }
+    }
+
+    // 统计今日登录用户数(基于会员的lastLoginAt字段)
+    const todayLoginUsers = await this.memberRepository
+      .createQueryBuilder('member')
+      .where('member.userId IN (:...userIds)', { userIds })
+      .andWhere('member.lastLoginAt >= :today', { today })
+      .andWhere('member.lastLoginAt < :tomorrow', { tomorrow })
+      .getCount()
+
+    // 统计总用户数
+    const totalUsers = await this.memberRepository
+      .createQueryBuilder('member')
+      .where('member.userId IN (:...userIds)', { userIds })
+      .getCount()
+
+    // 统计今日付费用户数(通过IncomeRecords表,查询今日有付费记录的用户,去重)
+    const todayPaidUsersResult = await this.incomeRecordsRepository
+      .createQueryBuilder('record')
+      .select('COUNT(DISTINCT record.userId)', 'count')
+      .where('record.personalAgentId = :personalAgentId', { personalAgentId: userId })
+      .andWhere('record.createdAt >= :today', { today })
+      .andWhere('record.createdAt < :tomorrow', { tomorrow })
+      .andWhere('record.delFlag = :delFlag', { delFlag: false })
+      .andWhere('record.status = :status', { status: true })
+      .getRawOne()
+
+    const todayPaidUsers = Number(todayPaidUsersResult?.count) || 0
+
+    // 统计总付费用户数(通过IncomeRecords表,查询有付费记录的用户,去重)
+    const totalPaidUsersResult = await this.incomeRecordsRepository
+      .createQueryBuilder('record')
+      .select('COUNT(DISTINCT record.userId)', 'count')
+      .where('record.personalAgentId = :personalAgentId', { personalAgentId: userId })
+      .andWhere('record.delFlag = :delFlag', { delFlag: false })
+      .andWhere('record.status = :status', { status: true })
+      .getRawOne()
+
+    const totalPaidUsers = Number(totalPaidUsersResult?.count) || 0
+
+    // 计算成交率(避免除零错误)
+    const todayIpConversionRate = todayLoginUsers > 0 
+      ? Number((todayPaidUsers / todayLoginUsers).toFixed(4)) 
+      : 0
+
+    const totalIpConversionRate = totalUsers > 0 
+      ? Number((totalPaidUsers / totalUsers).toFixed(4)) 
+      : 0
+
+    return {
+      todayIpConversionRate,
+      totalIpConversionRate,
+      todayPaidUsers,
+      todayLoginUsers,
+      totalPaidUsers,
+      totalUsers
+    }
+  }
+
+  /**
+   * 基于personalAgentId获取全部统计
+   * @param userId 团队成员的用户ID(personalAgentId)
+   * @returns 全部统计数据
+   */
+  async getAllStatisticsByPersonalAgentId(userId: number): Promise<{
+    totalNewUsers: number
+    totalIncome: number
+    todayActiveUsers: number
+    todayIncome: number
+    totalSales: number
+    todaySales: number
+  }> {
+    // 获取今天的开始时间(使用本地时区),并使用半开区间 [today, tomorrow)
+    const today = new Date()
+    today.setHours(0, 0, 0, 0)
+    const tomorrow = new Date(today)
+    tomorrow.setDate(tomorrow.getDate() + 1)
+
+    // 获取所有通过该团队成员(personalAgentId)产生的订单的用户ID
+    const orderUserIds = await this.incomeRecordsRepository
+      .createQueryBuilder('record')
+      .select('DISTINCT record.userId', 'userId')
+      .where('record.personalAgentId = :personalAgentId', { personalAgentId: userId })
+      .andWhere('record.delFlag = :delFlag', { delFlag: false })
+      .andWhere('record.status = :status', { status: true })
+      .getRawMany()
+
+    const userIds = orderUserIds.map(item => item.userId).filter(id => id !== null && id !== undefined)
+
+    // 重新计算总新增用户数(去重)
+    const totalNewUsersDistinct = userIds.length > 0
+      ? await this.memberRepository
+          .createQueryBuilder('member')
+          .select('COUNT(DISTINCT member.userId)', 'count')
+          .where('member.userId IN (:...userIds)', { userIds })
+          .getRawOne()
+      : { count: 0 }
+
+    // 统计今日活跃用户数(基于lastLoginAt字段)
+    const todayActiveUsers = userIds.length > 0
+      ? await this.memberRepository
+          .createQueryBuilder('member')
+          .where('member.userId IN (:...userIds)', { userIds })
+          .andWhere('member.lastLoginAt >= :today', { today })
+          .andWhere('member.lastLoginAt < :tomorrow', { tomorrow })
+          .getCount()
+      : 0
+
+    // 统计总收入(只统计personalIncomeAmount)
+    const totalIncomeRecords = await this.incomeRecordsRepository
+      .createQueryBuilder('record')
+      .select('SUM(CAST(record.personalIncomeAmount AS DECIMAL(10,5)))', 'totalIncome')
+      .where('record.personalAgentId = :personalAgentId', { personalAgentId: userId })
+      .andWhere('record.delFlag = :delFlag', { delFlag: false })
+      .andWhere('record.status = :status', { status: true })
+      .getRawOne()
+
+    const totalIncome = totalIncomeRecords?.totalIncome ? parseFloat(totalIncomeRecords.totalIncome) : 0
+
+    // 统计今日收入(只统计personalIncomeAmount)
+    const todayIncomeRecords = await this.incomeRecordsRepository
+      .createQueryBuilder('record')
+      .select('SUM(CAST(record.personalIncomeAmount AS DECIMAL(10,5)))', 'totalIncome')
+      .where('record.personalAgentId = :personalAgentId', { personalAgentId: userId })
+      .andWhere('record.createdAt >= :startDate', { startDate: today })
+      .andWhere('record.createdAt < :endDate', { endDate: tomorrow })
+      .andWhere('record.delFlag = :delFlag', { delFlag: false })
+      .andWhere('record.status = :status', { status: true })
+      .getRawOne()
+
+    const todayIncome = todayIncomeRecords?.totalIncome ? parseFloat(todayIncomeRecords.totalIncome) : 0
+
+    // 统计历史总销售额
+    const totalSalesRecords = await this.incomeRecordsRepository
+      .createQueryBuilder('record')
+      .select('SUM(CAST(record.orderPrice AS DECIMAL(10,5)))', 'totalSales')
+      .where('record.personalAgentId = :personalAgentId', { personalAgentId: userId })
+      .andWhere('record.delFlag = :delFlag', { delFlag: false })
+      .andWhere('record.status = :status', { status: true })
+      .getRawOne()
+
+    const totalSales = totalSalesRecords?.totalSales ? parseFloat(totalSalesRecords.totalSales) : 0
+
+    // 统计今日销售额
+    const todaySalesRecords = await this.incomeRecordsRepository
+      .createQueryBuilder('record')
+      .select('SUM(CAST(record.orderPrice AS DECIMAL(10,5)))', 'totalSales')
+      .where('record.personalAgentId = :personalAgentId', { personalAgentId: userId })
+      .andWhere('record.createdAt >= :startDate', { startDate: today })
+      .andWhere('record.createdAt < :endDate', { endDate: tomorrow })
+      .andWhere('record.delFlag = :delFlag', { delFlag: false })
+      .andWhere('record.status = :status', { status: true })
+      .getRawOne()
+
+    const todaySales = todaySalesRecords?.totalSales ? parseFloat(todaySalesRecords.totalSales) : 0
+
+    return {
+      totalNewUsers: Number(totalNewUsersDistinct?.count) || 0,
+      totalIncome,
+      todayActiveUsers,
+      todayIncome,
+      totalSales,
+      todaySales
+    }
+  }
+
+  /**
+   * 基于personalAgentId获取每日统计
+   * @param userId 团队成员的用户ID(personalAgentId)
+   * @returns 每日统计数据
+   */
+  async getDailyStatisticsByPersonalAgentId(userId: number): Promise<{
+    todayNewUsers: number
+    todayIncome: number
+    todayActiveUsers: number
+    totalIncome: number
+    totalSales: number
+    todaySales: number
+  }> {
+    // 获取今天的开始时间(使用本地时区),并使用半开区间 [today, tomorrow)
+    const today = new Date()
+    today.setHours(0, 0, 0, 0)
+    const tomorrow = new Date(today)
+    tomorrow.setDate(tomorrow.getDate() + 1)
+
+    // 获取所有通过该团队成员(personalAgentId)产生的订单的用户ID
+    const orderUserIds = await this.incomeRecordsRepository
+      .createQueryBuilder('record')
+      .select('DISTINCT record.userId', 'userId')
+      .where('record.personalAgentId = :personalAgentId', { personalAgentId: userId })
+      .andWhere('record.delFlag = :delFlag', { delFlag: false })
+      .andWhere('record.status = :status', { status: true })
+      .getRawMany()
+
+    const userIds = orderUserIds.map(item => item.userId).filter(id => id !== null && id !== undefined)
+
+    // 统计今日新增用户数
+    const todayNewUsers = userIds.length > 0
+      ? await this.memberRepository
+          .createQueryBuilder('member')
+          .where('member.userId IN (:...userIds)', { userIds })
+          .andWhere('member.createdAt >= :today', { today })
+          .andWhere('member.createdAt < :tomorrow', { tomorrow })
+          .getCount()
+      : 0
+
+    // 统计今日活跃用户数(基于lastLoginAt字段)
+    const todayActiveUsers = userIds.length > 0
+      ? await this.memberRepository
+          .createQueryBuilder('member')
+          .where('member.userId IN (:...userIds)', { userIds })
+          .andWhere('member.lastLoginAt >= :today', { today })
+          .andWhere('member.lastLoginAt < :tomorrow', { tomorrow })
+          .getCount()
+      : 0
+
+    // 统计今日收入(只统计personalIncomeAmount)
+    const todayIncomeRecords = await this.incomeRecordsRepository
+      .createQueryBuilder('record')
+      .select('SUM(CAST(record.personalIncomeAmount AS DECIMAL(10,5)))', 'totalIncome')
+      .where('record.personalAgentId = :personalAgentId', { personalAgentId: userId })
+      .andWhere('record.createdAt >= :startDate', { startDate: today })
+      .andWhere('record.createdAt < :endDate', { endDate: tomorrow })
+      .andWhere('record.delFlag = :delFlag', { delFlag: false })
+      .andWhere('record.status = :status', { status: true })
+      .getRawOne()
+
+    const todayIncome = todayIncomeRecords?.totalIncome ? parseFloat(todayIncomeRecords.totalIncome) : 0
+
+    // 统计历史总收入(只统计personalIncomeAmount)
+    const totalIncomeRecords = await this.incomeRecordsRepository
+      .createQueryBuilder('record')
+      .select('SUM(CAST(record.personalIncomeAmount AS DECIMAL(10,5)))', 'totalIncome')
+      .where('record.personalAgentId = :personalAgentId', { personalAgentId: userId })
+      .andWhere('record.delFlag = :delFlag', { delFlag: false })
+      .andWhere('record.status = :status', { status: true })
+      .getRawOne()
+
+    const totalIncome = totalIncomeRecords?.totalIncome ? parseFloat(totalIncomeRecords.totalIncome) : 0
+
+    // 统计历史总销售额
+    const totalSalesRecords = await this.incomeRecordsRepository
+      .createQueryBuilder('record')
+      .select('SUM(CAST(record.orderPrice AS DECIMAL(10,5)))', 'totalSales')
+      .where('record.personalAgentId = :personalAgentId', { personalAgentId: userId })
+      .andWhere('record.delFlag = :delFlag', { delFlag: false })
+      .andWhere('record.status = :status', { status: true })
+      .getRawOne()
+
+    const totalSales = totalSalesRecords?.totalSales ? parseFloat(totalSalesRecords.totalSales) : 0
+
+    // 统计今日销售额
+    const todaySalesRecords = await this.incomeRecordsRepository
+      .createQueryBuilder('record')
+      .select('SUM(CAST(record.orderPrice AS DECIMAL(10,5)))', 'totalSales')
+      .where('record.personalAgentId = :personalAgentId', { personalAgentId: userId })
+      .andWhere('record.createdAt >= :startDate', { startDate: today })
+      .andWhere('record.createdAt < :endDate', { endDate: tomorrow })
+      .andWhere('record.delFlag = :delFlag', { delFlag: false })
+      .andWhere('record.status = :status', { status: true })
+      .getRawOne()
+
+    const todaySales = todaySalesRecords?.totalSales ? parseFloat(todaySalesRecords.totalSales) : 0
+
+    return {
+      todayNewUsers,
+      todayIncome,
+      todayActiveUsers,
+      totalIncome,
+      totalSales,
+      todaySales
+    }
+  }
 }

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

@@ -1,5 +1,7 @@
 import 'fastify'
 import { DataSource } from 'typeorm'
+import Redis from 'ioredis'
+import { BannerStatisticsScheduler } from '../scheduler/banner-statistics.scheduler'
 
 declare module 'fastify' {
   interface FastifyInstance {
@@ -30,8 +32,16 @@ declare module 'fastify' {
       PAYMENT_URL: string
       PAYMENT_KEY: string
       PAYMENT_PID: string
+
+      // Redis配置(可选)
+      REDIS_HOST?: string
+      REDIS_PORT?: number
+      REDIS_PASSWORD?: string
+      REDIS_DB?: number
     }
     dataSource: DataSource
+    redis?: Redis
+    bannerStatisticsScheduler?: BannerStatisticsScheduler
   }
 
   interface FastifyRequest {