Ver Fonte

增加新功能,增加powershell更新命令

wilhelm wong há 2 meses atrás
pai
commit
49c1b5d338

+ 251 - 0
deploy-enhanced.ps1

@@ -0,0 +1,251 @@
+# Enhanced Deployment Script for junma-api
+# PowerShell版本部署脚本
+
+param(
+    [string]$Environment = "production"
+)
+
+# 设置错误处理
+$ErrorActionPreference = "Stop"
+
+# 颜色定义
+$Colors = @{
+    Red = "Red"
+    Green = "Green"
+    Yellow = "Yellow"
+    Cyan = "Cyan"
+    White = "White"
+}
+
+# 日志函数
+function Write-LogInfo {
+    param([string]$Message)
+    Write-Host $Message -ForegroundColor $Colors.Green
+}
+
+function Write-LogWarn {
+    param([string]$Message)
+    Write-Host $Message -ForegroundColor $Colors.Yellow
+}
+
+function Write-LogError {
+    param([string]$Message)
+    Write-Host $Message -ForegroundColor $Colors.Red
+}
+
+function Write-LogStep {
+    param([string]$Message)
+    Write-Host $Message -ForegroundColor $Colors.Cyan
+}
+
+# 错误处理函数
+function Handle-Error {
+    param([string]$ErrorMessage)
+    Write-LogError "Deployment failed: $ErrorMessage"
+    exit 1
+}
+
+# 主部署流程
+try {
+    Write-LogInfo "Starting junma-api deployment..."
+
+    # 拉取最新代码
+    Write-LogWarn "Pulling latest code..."
+    try {
+        # 检查Git是否可用
+        $gitPath = Get-Command git -ErrorAction SilentlyContinue
+        if (-not $gitPath) {
+            Write-LogWarn "Git not found in PATH, trying common locations..."
+            $commonGitPaths = @(
+                "C:\Program Files\Git\bin\git.exe",
+                "C:\Program Files (x86)\Git\bin\git.exe",
+                "C:\Users\$env:USERNAME\AppData\Local\Programs\Git\bin\git.exe"
+            )
+            
+            $gitFound = $false
+            foreach ($path in $commonGitPaths) {
+                if (Test-Path $path) {
+                    Write-LogInfo "Found Git at: $path"
+                    & $path pull origin main
+                    $gitFound = $true
+                    break
+                }
+            }
+            
+            if (-not $gitFound) {
+                Write-LogWarn "Git not found, skipping git pull step"
+                Write-LogWarn "Please ensure Git is installed and in PATH, or run 'git pull origin main' manually"
+            }
+        }
+        else {
+            git pull origin main
+        }
+        Write-LogInfo "Code pull completed"
+    }
+    catch {
+        Write-LogWarn "Git pull failed: $($_.Exception.Message)"
+        Write-LogWarn "Continuing with deployment..."
+    }
+
+    # 清理旧的构建文件
+    Write-LogWarn "Cleaning old build files..."
+    if (Test-Path "dist") {
+        Remove-Item -Recurse -Force "dist"
+        Write-LogInfo "dist directory cleaned"
+    }
+
+    # 清理node_modules(可选)
+    if (Test-Path "node_modules") {
+        Write-LogWarn "Cleaning node_modules..."
+        Remove-Item -Recurse -Force "node_modules"
+        Write-LogInfo "node_modules cleaned"
+    }
+
+    # 移除锁文件(可选)
+    if (Test-Path "package-lock.json") {
+        Remove-Item -Force "package-lock.json"
+    }
+    if (Test-Path "yarn.lock") {
+        Remove-Item -Force "yarn.lock"
+    }
+
+    # 安装依赖
+    Write-LogWarn "Installing dependencies..."
+    try {
+        yarn install
+        Write-LogInfo "Dependencies installed successfully"
+    }
+    catch {
+        Handle-Error "Dependency installation failed: $($_.Exception.Message)"
+    }
+
+    # 构建生产版本
+    Write-LogWarn "Building production version..."
+    try {
+        yarn build
+        Write-LogInfo "Build successful"
+    }
+    catch {
+        Handle-Error "Build failed: $($_.Exception.Message)"
+    }
+
+    # 检查构建结果
+    Write-LogWarn "Checking build results..."
+    if (-not (Test-Path "dist")) {
+        Handle-Error "Build failed, dist directory does not exist"
+    }
+
+    # 复制环境配置文件
+    Write-LogWarn "Copying environment configuration files..."
+    if (Test-Path ".env.production") {
+        Copy-Item ".env.production" "dist\.env"
+        Write-LogInfo "Environment configuration file copied"
+    }
+    else {
+        Write-LogWarn "Warning: .env.production file does not exist"
+    }
+
+    # 复制package.json
+    Copy-Item "package.json" "dist\package.json"
+
+    # 部署到服务器
+    Write-LogWarn "Deploying to server..."
+    
+    # SSH配置
+    $sshKeyPath = "D:\dev tools\key\id_huang.pem"
+    $serverUser = "root"
+    $serverHost = "8.210.167.152"
+    $serverPath = "/var/www/junma-api/"
+    
+    # 检查SSH密钥文件是否存在
+    if (-not (Test-Path $sshKeyPath)) {
+        Handle-Error "SSH key file does not exist: $sshKeyPath"
+    }
+    
+    # 尝试不同的上传方法
+    $uploadSuccess = $false
+    
+    # 尝试rsync
+    $rsyncPath = Get-Command rsync -ErrorAction SilentlyContinue
+    if ($rsyncPath) {
+        Write-LogStep "Using rsync to upload..."
+        try {
+            $rsyncArgs = @(
+                "--exclude=node_modules/"
+                "-ravzh"
+                "--delete"
+                "-e"
+                "ssh -i `"$sshKeyPath`" -o StrictHostKeyChecking=no"
+                "./dist/"
+                "$serverUser@$serverHost`:$serverPath"
+            )
+            & $rsyncPath.Source $rsyncArgs
+            $uploadSuccess = $true
+            Write-LogInfo "rsync upload successful"
+        }
+        catch {
+            Write-LogWarn "rsync upload failed, trying scp..."
+        }
+    }
+    else {
+        Write-LogWarn "rsync not available, trying scp..."
+    }
+    
+    # 尝试scp如果rsync失败
+    if (-not $uploadSuccess) {
+        $scpPath = Get-Command scp -ErrorAction SilentlyContinue
+        if ($scpPath) {
+            Write-LogStep "Using scp to upload..."
+            try {
+                $scpArgs = @(
+                    "-r"
+                    "-i"
+                    "`"$sshKeyPath`""
+                    "-o"
+                    "StrictHostKeyChecking=no"
+                    "./dist/*"
+                    "$serverUser@$serverHost`:$serverPath"
+                )
+                & $scpPath.Source $scpArgs
+                $uploadSuccess = $true
+                Write-LogInfo "scp upload successful"
+            }
+            catch {
+                Handle-Error "scp upload failed: $($_.Exception.Message)"
+            }
+        }
+        else {
+            Handle-Error "No available upload tools (rsync/scp)"
+        }
+    }
+    
+    if (-not $uploadSuccess) {
+        Handle-Error "File upload failed - no available upload method found"
+    }
+
+    # 在服务器上执行部署后操作
+    Write-LogWarn "Executing post-deployment operations on server..."
+    try {
+        $sshArgs = @(
+            "-i"
+            "`"$sshKeyPath`""
+            "-o"
+            "StrictHostKeyChecking=no"
+            "$serverUser@$serverHost"
+            "cd /var/www/junma-api && yarn install && pm2 restart junma-api"
+        )
+        & ssh $sshArgs
+        Write-LogInfo "Server deployment operations successful"
+    }
+    catch {
+        Handle-Error "Server deployment operations failed: $($_.Exception.Message)"
+    }
+
+    Write-LogInfo "Deployment completed!"
+    Write-LogInfo "API service restarted"
+    Write-LogInfo "Service address: http://8.210.167.152:3000"
+}
+catch {
+    Write-LogError "Error occurred during deployment: $($_.Exception.Message)"
+    exit 1
+}

+ 2 - 0
src/app.ts

@@ -20,6 +20,7 @@ import paymentRoutes from './routes/payment.routes'
 import memberRoutes from './routes/member.routes'
 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'
 
 const options: FastifyEnvOptions = {
@@ -97,6 +98,7 @@ export const createApp = async () => {
   app.register(paymentRoutes, { prefix: '/api/payment' })
   app.register(memberRoutes, { prefix: '/api/member' })
   app.register(userShareRoutes, { prefix: '/api/user-share' })
+  app.register(bannerRoutes, { prefix: '/api/banners' })
 
   // 添加 /account 路由重定向到用户资料
   app.get('/account', { onRequest: [authenticate] }, async (request, reply) => {

+ 105 - 0
src/controllers/banner.controller.ts

@@ -0,0 +1,105 @@
+import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
+import { BannerService } from '../services/banner.service'
+import {
+  CreateBannerBody,
+  UpdateBannerBody,
+  ListBannerQuery,
+  BannerParams
+} from '../dto/banner.dto'
+import { BannerPosition } from '../entities/banner.entity'
+import { UserRole } from '../entities/user.entity'
+
+export class BannerController {
+  private bannerService: BannerService
+
+  constructor(app: FastifyInstance) {
+    this.bannerService = new BannerService(app)
+  }
+
+  async create(request: FastifyRequest<{ Body: CreateBannerBody }>, reply: FastifyReply) {
+    try {
+      const banner = await this.bannerService.create(request.body)
+      return reply.code(201).send(banner)
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '创建广告栏失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  async findById(request: FastifyRequest<{ Params: BannerParams }>, reply: FastifyReply) {
+    try {
+      const { id } = request.params
+      const banner = await this.bannerService.findById(id)
+      return reply.send(banner)
+    } catch (error) {
+      return reply.code(404).send({ message: '广告栏不存在' })
+    }
+  }
+
+  async findAll(request: FastifyRequest<{ Querystring: ListBannerQuery }>, reply: FastifyReply) {
+    try {
+      const result = await this.bannerService.findAll(request.query)
+      return reply.send(result)
+    } catch (error) {
+      return reply.code(500).send({ message: '获取广告栏列表失败' })
+    }
+  }
+
+  /**
+   * 根据位置获取广告栏列表(公开接口,无需认证)
+   */
+  async findByPosition(request: FastifyRequest<{ Params: { position: BannerPosition } }>, reply: FastifyReply) {
+    try {
+      const { position } = request.params
+      const banners = await this.bannerService.findByPosition(position)
+      return reply.send(banners)
+    } catch (error) {
+      return reply.code(500).send({ message: '获取广告栏失败' })
+    }
+  }
+
+  async update(
+    request: FastifyRequest<{ Params: BannerParams; Body: UpdateBannerBody }>,
+    reply: FastifyReply
+  ) {
+    try {
+      const { id } = request.params
+      const updateData = { ...request.body, id }
+      const banner = await this.bannerService.update(updateData)
+      return reply.send(banner)
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '更新广告栏失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  async delete(request: FastifyRequest<{ Params: BannerParams }>, reply: FastifyReply) {
+    try {
+      const { id } = request.params
+      await this.bannerService.delete(id)
+      return reply.send({ message: '广告栏已删除' })
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '删除广告栏失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  /**
+   * 记录广告栏点击(公开接口,无需认证)
+   */
+  async recordClick(request: FastifyRequest<{ Params: BannerParams }>, reply: FastifyReply) {
+    try {
+      const { id } = request.params
+      const banner = await this.bannerService.incrementClickCount(id)
+      return reply.send({
+        message: '点击记录成功',
+        clickCount: banner.clickCount,
+        todayClickCount: banner.todayClickCount
+      })
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '记录点击失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+}
+

+ 49 - 0
src/controllers/member.controller.ts

@@ -516,4 +516,53 @@ export class MemberController {
       return reply.code(500).send({ message: '获取统计数据失败' })
     }
   }
+
+  /**
+   * 封禁当前用户的member
+   */
+  async ban(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const user = request.user
+      if (!user) {
+        return reply.code(403).send({ message: '用户未登录' })
+      }
+
+      const bannedMember = await this.memberService.banMember(user.id)
+      return reply.send({
+        message: '用户已被封禁',
+        member: {
+          id: bannedMember.id,
+          userId: bannedMember.userId,
+          status: bannedMember.status
+        }
+      })
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '封禁用户失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  /**
+   * 检查当前访问IP是否在被ban的member的ip中
+   */
+  async checkBan(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      // 获取当前访问IP
+      const ip =
+        request.ip ||
+        (request.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
+        (request.headers['x-real-ip'] as string) ||
+        'unknown'
+
+      const isBanned = await this.memberService.checkBan(ip)
+      
+      return reply.send({
+        ip,
+        isBanned
+      })
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '检查封禁状态失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
 }

+ 68 - 6
src/controllers/team-domain.controller.ts

@@ -30,6 +30,25 @@ export class TeamDomainController {
 
   async create(request: FastifyRequest<{ Body: CreateTeamDomainBody }>, reply: FastifyReply) {
     try {
+      const user = request.user
+      if (!user) {
+        return reply.code(403).send({ message: '用户未登录' })
+      }
+
+      // 如果是TEAM用户,自动设置teamId并验证权限
+      if (user.role === UserRole.TEAM) {
+        const team = await this.teamService.findByUserId(user.id)
+        request.body.teamId = team.id
+
+        // 如果指定了teamMemberId,验证该成员是否属于该团队
+        if (request.body.teamMemberId) {
+          const teamMember = await this.teamMembersService.findById(request.body.teamMemberId)
+          if (!teamMember || teamMember.teamId !== team.id) {
+            return reply.code(403).send({ message: '指定的团队成员不属于您的团队' })
+          }
+        }
+      }
+
       // 检查是否包含多个域名(通过中英文逗号、分号或换行分隔)
       const hasMultipleDomains = /[,;\n\r]/.test(request.body.domain)
 
@@ -47,7 +66,8 @@ export class TeamDomainController {
         return reply.code(201).send(teamDomain)
       }
     } catch (error) {
-      return reply.code(500).send({ message: '创建团队域名失败' })
+      const errorMessage = error instanceof Error ? error.message : '创建团队域名失败'
+      return reply.code(500).send({ message: errorMessage })
     }
   }
 
@@ -125,14 +145,44 @@ export class TeamDomainController {
         return reply.code(403).send({ message: '用户未登录' })
       }
 
-      if (user.role !== UserRole.ADMIN) {
-        return reply.code(403).send({ message: '无权限' })
+      // 获取要更新的域名信息
+      const existingDomain = await this.teamDomainService.findById(id)
+
+      // 如果是TEAM用户,只能更新自己团队的域名
+      if (user.role === UserRole.TEAM) {
+        const team = await this.teamService.findByUserId(user.id)
+        
+        // 验证域名属于该用户的团队
+        if (existingDomain.teamId !== team.id) {
+          return reply.code(403).send({ message: '无权限操作其他团队的域名' })
+        }
+
+        // TEAM用户不能修改teamId,保持原有的teamId
+        if (updateData.teamId !== undefined && updateData.teamId !== team.id) {
+          return reply.code(403).send({ message: '只能绑定到自己的团队' })
+        }
+        // 移除teamId字段,因为TEAM用户不能修改团队绑定
+        delete updateData.teamId
+
+        // 如果指定了teamMemberId,验证该成员是否属于该团队
+        if (updateData.teamMemberId !== undefined) {
+          if (updateData.teamMemberId === null) {
+            // 允许设置为null(取消绑定到个人)
+            updateData.teamMemberId = null
+          } else {
+            const teamMember = await this.teamMembersService.findById(updateData.teamMemberId)
+            if (!teamMember || teamMember.teamId !== team.id) {
+              return reply.code(403).send({ message: '指定的团队成员不属于您的团队' })
+            }
+          }
+        }
       }
 
       const updatedTeamDomain = await this.teamDomainService.update(updateData)
       return reply.send(updatedTeamDomain)
     } catch (error) {
-      return reply.code(500).send({ message: '更新团队域名失败' })
+      const errorMessage = error instanceof Error ? error.message : '更新团队域名失败'
+      return reply.code(500).send({ message: errorMessage })
     }
   }
 
@@ -145,14 +195,26 @@ export class TeamDomainController {
         return reply.code(403).send({ message: '用户未登录' })
       }
 
-      if (user.role !== UserRole.ADMIN) {
+      // 获取要删除的域名信息
+      const existingDomain = await this.teamDomainService.findById(id)
+
+      // 如果是TEAM用户,只能删除自己团队的域名
+      if (user.role === UserRole.TEAM) {
+        const team = await this.teamService.findByUserId(user.id)
+        
+        // 验证域名属于该用户的团队
+        if (existingDomain.teamId !== team.id) {
+          return reply.code(403).send({ message: '无权限删除其他团队的域名' })
+        }
+      } else if (user.role !== UserRole.ADMIN) {
         return reply.code(403).send({ message: '无权限' })
       }
 
       await this.teamDomainService.delete(id)
       return reply.send({ message: '团队域名已删除' })
     } catch (error) {
-      return reply.code(500).send({ message: '删除团队域名失败' })
+      const errorMessage = error instanceof Error ? error.message : '删除团队域名失败'
+      return reply.code(500).send({ message: errorMessage })
     }
   }
 

+ 43 - 4
src/controllers/team.controller.ts

@@ -1,6 +1,6 @@
 import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
 import { TeamService } from '../services/team.service'
-import { CreateTeamBody, UpdateTeamBody, ListTeamQuery, TeamParams, UpdateRevenueBody } from '../dto/team.dto'
+import { CreateTeamBody, UpdateTeamBody, ListTeamQuery, TeamParams, UpdateRevenueBody, UpdateThemeColorBody } from '../dto/team.dto'
 import { UserRole } from '../entities/user.entity'
 
 export class TeamController {
@@ -155,13 +155,52 @@ export class TeamController {
       if (!user) {
         return reply.code(403).send({ message: '用户未登录' })
       }
-      if (user.role !== UserRole.TEAM) {
-        return reply.code(403).send({ message: '只有团队用户可以使用此接口' })
+      // 允许团队用户和推广员使用此接口
+      if (user.role !== UserRole.TEAM && user.role !== UserRole.PROMOTER) {
+        return reply.code(403).send({ message: '只有团队用户和推广员可以使用此接口' })
       }
-      const statistics = await this.teamService.getIpConversionRate(user.id)
+      const statistics = await this.teamService.getIpConversionRate(user.id, user.role)
       return reply.send(statistics)
     } catch (error) {
       return reply.code(500).send({ message: '获取IP成交率失败' })
     }
   }
+
+  async updateThemeColor(request: FastifyRequest<{ Body: UpdateThemeColorBody }>, reply: FastifyReply) {
+    try {
+      const user = request.user
+      if (!user) {
+        return reply.code(403).send({ message: '用户未登录' })
+      }
+      // 只允许团队用户和管理员使用此接口
+      if (user.role !== UserRole.TEAM && user.role !== UserRole.ADMIN) {
+        return reply.code(403).send({ message: '只有团队用户和管理员可以使用此接口' })
+      }
+      const updatedTeam = await this.teamService.updateThemeColor(user.id, request.body.themeColor)
+      return reply.send(updatedTeam)
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '更新主题颜色失败'
+      return reply.code(500).send({ message: errorMessage })
+    }
+  }
+
+  async getMyTeamTheme(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const user = request.user
+      if (!user) {
+        return reply.code(403).send({ message: '用户未登录' })
+      }
+      
+      const themeColor = await this.teamService.getTeamThemeColorByUser(user.id, user.role as UserRole)
+      
+      if (!themeColor) {
+        // 如果没有找到团队,返回默认主题
+        return reply.send({ themeColor: 'dark' })
+      }
+      
+      return reply.send(themeColor)
+    } catch (error) {
+      return reply.code(500).send({ message: '获取团队主题颜色失败' })
+    }
+  }
 }

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

@@ -0,0 +1,27 @@
+import { BannerPosition } from '../entities/banner.entity'
+import { Pagination } from './common.dto'
+
+export interface CreateBannerBody {
+  image: string
+  title: string
+  link: string
+  position: BannerPosition
+}
+
+export interface UpdateBannerBody {
+  id: number
+  image?: string
+  title?: string
+  link?: string
+  position?: BannerPosition
+}
+
+export interface ListBannerQuery extends Pagination {
+  position?: BannerPosition
+  title?: string
+}
+
+export interface BannerParams {
+  id: number
+}
+

+ 1 - 1
src/dto/team-domain.dto.ts

@@ -11,7 +11,7 @@ export interface CreateTeamDomainBody {
 export interface UpdateTeamDomainBody {
   id: number
   teamId?: number
-  teamMemberId?: number // 可选的团队成员ID,如果指定则绑定到个人
+  teamMemberId?: number | null // 可选的团队成员ID,如果指定则绑定到个人,null表示取消绑定
   domain?: string
   description?: string
 }

+ 6 - 0
src/dto/team.dto.ts

@@ -11,6 +11,7 @@ export interface CreateTeamBody {
   totalUsers?: number
   commissionRate?: number
   password?: string
+  themeColor?: string
 }
 
 export interface UpdateTeamBody {
@@ -23,6 +24,7 @@ export interface UpdateTeamBody {
   todayDAU?: number
   totalUsers?: number
   commissionRate?: number
+  themeColor?: string
 }
 
 export interface ListTeamQuery extends Pagination {
@@ -40,3 +42,7 @@ export interface UpdateRevenueBody {
   amount: number
   type: 'total' | 'today'
 }
+
+export interface UpdateThemeColorBody {
+  themeColor: string
+}

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

@@ -0,0 +1,44 @@
+import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, Index } from 'typeorm'
+
+export enum BannerPosition {
+  TOP = 'top',      // 顶部
+  MIDDLE = 'middle', // 中部
+  BOTTOM = 'bottom' // 底部
+}
+
+@Entity()
+@Index('idx_banner_position', ['position'])
+@Index('idx_banner_created_at', ['createdAt'])
+export class Banner {
+  @PrimaryGeneratedColumn()
+  id: number
+
+  @Column({ length: 500 })
+  image: string
+
+  @Column({ length: 200 })
+  title: string
+
+  @Column({ length: 500 })
+  link: string
+
+  @Column({ default: 0 })
+  clickCount: number
+
+  @Column({ default: 0 })
+  todayClickCount: number
+
+  @Column({
+    type: 'enum',
+    enum: BannerPosition,
+    default: BannerPosition.TOP
+  })
+  position: BannerPosition
+
+  @CreateDateColumn()
+  createdAt: Date
+
+  @UpdateDateColumn()
+  updatedAt: Date
+}
+

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

@@ -35,6 +35,9 @@ export class Team {
   @Column({ unique: true, length: 10, nullable: true })
   affCode: string
 
+  @Column({ length: 20, nullable: true, default: 'dark' })
+  themeColor: string
+
   @CreateDateColumn()
   createdAt: Date
 

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

@@ -0,0 +1,65 @@
+import { FastifyInstance } from 'fastify'
+import { BannerController } from '../controllers/banner.controller'
+import { authenticate, hasRole } from '../middlewares/auth.middleware'
+import { UserRole } from '../entities/user.entity'
+import {
+  CreateBannerBody,
+  UpdateBannerBody,
+  ListBannerQuery,
+  BannerParams
+} from '../dto/banner.dto'
+import { BannerPosition } from '../entities/banner.entity'
+
+export default async function bannerRoutes(fastify: FastifyInstance) {
+  const bannerController = new BannerController(fastify)
+
+  // 创建广告栏(管理员)
+  fastify.post<{ Body: CreateBannerBody }>(
+    '/',
+    { onRequest: [authenticate, hasRole(UserRole.ADMIN)] },
+    bannerController.create.bind(bannerController)
+  )
+
+  // 获取广告栏列表(管理员,支持分页和筛选)
+  fastify.get<{ Querystring: ListBannerQuery }>(
+    '/',
+    { onRequest: [authenticate, hasRole(UserRole.ADMIN)] },
+    bannerController.findAll.bind(bannerController)
+  )
+
+  // 根据位置获取广告栏列表(公开接口,无需认证)
+  fastify.get<{ Params: { position: BannerPosition } }>(
+    '/position/:position',
+    { onRequest: [] },
+    bannerController.findByPosition.bind(bannerController)
+  )
+
+  // 获取单个广告栏(管理员)
+  fastify.get<{ Params: BannerParams }>(
+    '/:id',
+    { onRequest: [authenticate, hasRole(UserRole.ADMIN)] },
+    bannerController.findById.bind(bannerController)
+  )
+
+  // 更新广告栏(管理员)
+  fastify.put<{ Params: BannerParams; Body: UpdateBannerBody }>(
+    '/:id',
+    { onRequest: [authenticate, hasRole(UserRole.ADMIN)] },
+    bannerController.update.bind(bannerController)
+  )
+
+  // 删除广告栏(管理员)
+  fastify.delete<{ Params: BannerParams }>(
+    '/:id',
+    { onRequest: [authenticate, hasRole(UserRole.ADMIN)] },
+    bannerController.delete.bind(bannerController)
+  )
+
+  // 记录广告栏点击(公开接口,无需认证)
+  fastify.post<{ Params: BannerParams }>(
+    '/:id/click',
+    { onRequest: [] },
+    bannerController.recordClick.bind(bannerController)
+  )
+}
+

+ 14 - 0
src/routes/member.routes.ts

@@ -123,4 +123,18 @@ export default async function memberRoutes(fastify: FastifyInstance) {
     { onRequest: [hasRole(UserRole.ADMIN)] },
     memberController.getStatistics.bind(memberController)
   )
+
+  // 封禁当前用户的member
+  fastify.post(
+    '/ban',
+    { onRequest: [authenticate] },
+    memberController.ban.bind(memberController)
+  )
+
+  // 检查当前访问IP是否在被ban的member的ip中
+  fastify.get(
+    '/check-ban',
+    { onRequest: [] },
+    memberController.checkBan.bind(memberController)
+  )
 }

+ 1 - 1
src/routes/team-domain.routes.ts

@@ -15,7 +15,7 @@ export default async function teamDomainRoutes(fastify: FastifyInstance) {
   // 创建团队域名
   fastify.post<{ Body: CreateTeamDomainBody }>(
     '/',
-    { onRequest: [authenticate, hasRole(UserRole.ADMIN)] },
+    { onRequest: [authenticate, hasAnyRole(UserRole.ADMIN, UserRole.TEAM)] },
     teamDomainController.create.bind(teamDomainController)
   )
 

+ 16 - 2
src/routes/team.routes.ts

@@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify'
 import { TeamController } from '../controllers/team.controller'
 import { authenticate, hasAnyRole, hasRole } from '../middlewares/auth.middleware'
 import { UserRole } from '../entities/user.entity'
-import { CreateTeamBody, UpdateTeamBody, ListTeamQuery, TeamParams, UpdateRevenueBody } from '../dto/team.dto'
+import { CreateTeamBody, UpdateTeamBody, ListTeamQuery, TeamParams, UpdateRevenueBody, UpdateThemeColorBody } from '../dto/team.dto'
 
 export default async function teamRoutes(fastify: FastifyInstance) {
   const teamController = new TeamController(fastify)
@@ -79,7 +79,21 @@ export default async function teamRoutes(fastify: FastifyInstance) {
   // 获取团队IP成交率统计
   fastify.get(
     '/statistics/ip-conversion-rate',
-    { onRequest: [authenticate, hasRole(UserRole.TEAM)] },
+    { onRequest: [authenticate, hasAnyRole(UserRole.TEAM, UserRole.PROMOTER)] },
     teamController.getIpConversionRate.bind(teamController)
   )
+
+  // 更新团队主题颜色(允许团队用户更新自己的主题颜色)
+  fastify.put<{ Body: UpdateThemeColorBody }>(
+    '/theme-color',
+    { onRequest: [authenticate, hasAnyRole(UserRole.ADMIN, UserRole.TEAM)] },
+    teamController.updateThemeColor.bind(teamController)
+  )
+
+  // 获取当前用户的团队主题颜色(允许所有认证用户访问)
+  fastify.get(
+    '/my-theme',
+    { onRequest: [authenticate] },
+    teamController.getMyTeamTheme.bind(teamController)
+  )
 }

+ 163 - 0
src/services/banner.service.ts

@@ -0,0 +1,163 @@
+import { Repository, Like } 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'
+
+export class BannerService {
+  private bannerRepository: Repository<Banner>
+  private fileService: FileService
+
+  constructor(app: FastifyInstance) {
+    this.bannerRepository = app.dataSource.getRepository(Banner)
+    this.fileService = new FileService(app)
+  }
+
+  async create(data: CreateBannerBody): Promise<Banner> {
+    const banner = this.bannerRepository.create(data)
+    return this.bannerRepository.save(banner)
+  }
+
+  async findById(id: number): Promise<Banner> {
+    const banner = await this.bannerRepository.findOneOrFail({ where: { id } })
+    // 处理图片链接
+    banner.image = await this.processImageUrl(banner.image)
+    return banner
+  }
+
+  /**
+   * 处理图片链接,如果是OSS链接则生成签名URL
+   * @param imageUrl 原始图片URL
+   * @returns 处理后的图片URL
+   */
+  private async processImageUrl(imageUrl: string): Promise<string> {
+    if (!imageUrl) return imageUrl
+    
+    try {
+      // 检查是否为OSS链接
+      if (imageUrl.includes('.oss-') && imageUrl.includes('.aliyuncs.com')) {
+        // 从URL中提取key
+        const urlParts = imageUrl.split('.aliyuncs.com/')
+        if (urlParts.length > 1) {
+          const key = urlParts[1]
+          // 生成签名URL
+          return await this.fileService.getSignedUrl(key)
+        }
+      }
+      return imageUrl
+    } catch (error) {
+      // 如果签名失败,返回原始URL
+      console.warn('图片链接签名失败:', error)
+      return imageUrl
+    }
+  }
+
+  /**
+   * 批量处理图片链接
+   * @param banners 广告栏数组
+   * @returns 处理后的广告页数组
+   */
+  private async processImageUrls(banners: Banner[]): Promise<Banner[]> {
+    const processedBanners = await Promise.all(
+      banners.map(async (banner) => {
+        const processedImage = await this.processImageUrl(banner.image)
+        return {
+          ...banner,
+          image: processedImage
+        }
+      })
+    )
+    return processedBanners
+  }
+
+  async findAll(query: ListBannerQuery): Promise<PaginationResponse<Banner>> {
+    const { page, size, position, title } = query
+    
+    const where: any = {}
+    
+    if (title) {
+      where.title = Like(`%${title}%`)
+    }
+    
+    if (position) {
+      where.position = position
+    }
+
+    const [banners, total] = await this.bannerRepository.findAndCount({
+      where,
+      skip: (Number(page) || 0) * (Number(size) || 20),
+      take: Number(size) || 20,
+      order: { createdAt: 'DESC' }
+    })
+
+    // 处理图片链接,生成签名URL
+    const processedBanners = await this.processImageUrls(banners)
+
+    return {
+      content: processedBanners,
+      metadata: {
+        total: Number(total),
+        page: Number(page) || 0,
+        size: Number(size) || 20
+      }
+    }
+  }
+
+  /**
+   * 根据位置获取广告栏列表(不分页,用于前端展示)
+   * @param position 广告位置
+   * @returns 广告栏列表
+   */
+  async findByPosition(position: BannerPosition): Promise<Banner[]> {
+    const banners = await this.bannerRepository.find({
+      where: { position },
+      order: { createdAt: 'DESC' }
+    })
+
+    // 处理图片链接
+    return await this.processImageUrls(banners)
+  }
+
+  async update(data: UpdateBannerBody): Promise<Banner> {
+    const { id, ...updateData } = data
+    await this.bannerRepository.update(id, updateData)
+    return this.findById(id)
+  }
+
+  async delete(id: number): Promise<void> {
+    await this.bannerRepository.delete(id)
+  }
+
+  /**
+   * 增加广告栏点击次数
+   * @param id 广告栏ID
+   * @returns 更新后的广告栏
+   */
+  async incrementClickCount(id: number): 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
+    }
+
+    // 增加总点击数和今日点击数
+    banner.clickCount += 1
+    banner.todayClickCount += 1
+
+    await this.bannerRepository.save(banner)
+    return this.findById(id)
+  }
+}
+

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

@@ -422,4 +422,34 @@ export class MemberService {
       }
     })
   }
+
+  /**
+   * 封禁当前用户的member
+   * @param userId 用户ID
+   * @returns 封禁后的member
+   */
+  async banMember(userId: number): Promise<Member> {
+    const member = await this.findByUserId(userId)
+    if (!member) {
+      throw new Error('会员信息不存在')
+    }
+    
+    // 将status修改为INACTIVE
+    return await this.updateStatus(member.id, MemberStatus.INACTIVE)
+  }
+
+  /**
+   * 检查当前IP是否在被封禁的member的ip中
+   * @param ip 要检查的IP地址
+   * @returns 如果IP被封禁返回true,否则返回false
+   */
+  async checkBan(ip: string): Promise<boolean> {
+    // 查找所有被封禁的member(status为INACTIVE)
+    const bannedMembers = await this.memberRepository.find({
+      where: { status: MemberStatus.INACTIVE }
+    })
+    
+    // 检查当前IP是否匹配任何被封禁member的ip
+    return bannedMembers.some(member => member.ip === ip)
+  }
 }

+ 20 - 14
src/services/team-domain.service.ts

@@ -170,7 +170,24 @@ export class TeamDomainService {
       await this.teamService.findById(updateData.teamId)
     }
 
-    await this.teamDomainRepository.update(id, updateData)
+    // 构建符合 TypeORM 要求的更新对象
+    const updateEntity: Partial<TeamDomain> = {}
+    if (updateData.domain !== undefined) {
+      updateEntity.domain = updateData.domain
+    }
+    if (updateData.description !== undefined) {
+      updateEntity.description = updateData.description
+    }
+    if (updateData.teamId !== undefined) {
+      updateEntity.teamId = updateData.teamId
+    }
+    // 处理 teamMemberId,包括 null 值的情况
+    // 虽然实体类型定义不包含 null,但数据库允许 null,使用类型断言绕过类型检查
+    if (updateData.teamMemberId !== undefined) {
+      ;(updateEntity as any).teamMemberId = updateData.teamMemberId
+    }
+
+    await this.teamDomainRepository.update(id, updateEntity)
     return this.findById(id)
   }
 
@@ -186,24 +203,13 @@ export class TeamDomainService {
   }
 
   async findByTeamMemberId(teamMemberId: number): Promise<TeamDomain[]> {
-    // 首先通过teamMemberId查找绑定的域名
+    // 只返回直接绑定到该团队成员的域名,如果没有绑定则返回空列表
     const domainsByMember = await this.teamDomainRepository.find({
       where: { teamMemberId: teamMemberId },
       order: { createdAt: 'DESC' }
     })
 
-    // 如果直接绑定到个人,返回这些域名
-    if (domainsByMember.length > 0) {
-      return domainsByMember
-    }
-
-    // 如果没有直接绑定,查找该团队成员所属团队的域名
-    const teamMember = await this.teamMembersRepository.findOne({ where: { id: teamMemberId } })
-    if (teamMember && teamMember.teamId) {
-      return await this.findByTeamId(teamMember.teamId)
-    }
-
-    return []
+    return domainsByMember
   }
 
   async findAllGroupedByTeam(query?: ListTeamDomainQuery): Promise<Record<number, TeamDomain[]>> {

+ 154 - 23
src/services/team.service.ts

@@ -4,6 +4,8 @@ import { Team } from '../entities/team.entity'
 import { IncomeRecords, IncomeType } from '../entities/income-records.entity'
 import { Member } from '../entities/member.entity'
 import { User } from '../entities/user.entity'
+import { TeamMembers } from '../entities/team-members.entity'
+import { TeamDomain } from '../entities/team-domain.entity'
 import { PaginationResponse } from '../dto/common.dto'
 import { CreateTeamBody, UpdateTeamBody, ListTeamQuery } from '../dto/team.dto'
 import { UserService } from './user.service'
@@ -16,6 +18,8 @@ export class TeamService {
   private incomeRecordsRepository: Repository<IncomeRecords>
   private memberRepository: Repository<Member>
   private userRepository: Repository<User>
+  private teamMembersRepository: Repository<TeamMembers>
+  private teamDomainRepository: Repository<TeamDomain>
   private userService: UserService
   private sysConfigService: SysConfigService
 
@@ -24,6 +28,8 @@ export class TeamService {
     this.incomeRecordsRepository = app.dataSource.getRepository(IncomeRecords)
     this.memberRepository = app.dataSource.getRepository(Member)
     this.userRepository = app.dataSource.getRepository(User)
+    this.teamMembersRepository = app.dataSource.getRepository(TeamMembers)
+    this.teamDomainRepository = app.dataSource.getRepository(TeamDomain)
     this.userService = new UserService(app)
     this.sysConfigService = new SysConfigService(app)
   }
@@ -108,6 +114,12 @@ export class TeamService {
     return this.findById(id)
   }
 
+  async updateThemeColor(userId: number, themeColor: string): Promise<Team> {
+    const team = await this.findByUserId(userId)
+    team.themeColor = themeColor
+    return this.teamRepository.save(team)
+  }
+
   async delete(id: number): Promise<void> {
     await this.teamRepository.delete(id)
   }
@@ -454,12 +466,77 @@ export class TeamService {
     return this.teamRepository.findOneOrFail({ where: { userId } })
   }
 
+  /**
+   * 根据用户ID和角色获取团队主题颜色
+   * 支持所有用户角色:TEAM、PROMOTER、USER(会员)
+   */
+  async getTeamThemeColorByUser(userId: number, userRole: UserRole): Promise<{ themeColor: string } | null> {
+    try {
+      let teamId: number | null = null
+
+      if (userRole === UserRole.TEAM) {
+        // 团队用户:直接从team表查找
+        const team = await this.teamRepository.findOne({ where: { userId } })
+        if (team) {
+          teamId = team.id
+        }
+      } else if (userRole === UserRole.PROMOTER) {
+        // 推广员:从team_members表查找团队ID
+        const teamMember = await this.teamMembersRepository.findOne({ where: { userId } })
+        if (teamMember && teamMember.teamId) {
+          teamId = teamMember.teamId
+        }
+      } else if (userRole === UserRole.USER) {
+        // 会员:从member表查找,优先使用teamId,其次通过domainId查找
+        const member = await this.memberRepository.findOne({ where: { userId } })
+        if (member) {
+          if (member.teamId && member.teamId > 0) {
+            // 直接有团队ID
+            teamId = member.teamId
+          } else if (member.domainId && member.domainId > 0) {
+            // 通过域名查找团队ID
+            const teamDomain = await this.teamDomainRepository.findOne({ where: { id: member.domainId } })
+            if (teamDomain && teamDomain.teamId) {
+              teamId = teamDomain.teamId
+            }
+          }
+        }
+      }
+      // ADMIN角色和其他情况返回null
+
+      if (!teamId) {
+        return null
+      }
+
+      // 根据teamId获取团队主题颜色
+      const team = await this.teamRepository.findOne({
+        where: { id: teamId },
+        select: ['themeColor']
+      })
+
+      if (!team) {
+        return null
+      }
+
+      return {
+        themeColor: team.themeColor || 'dark' // 如果没有设置,返回默认值
+      }
+    } catch (error) {
+      // 发生错误时返回null
+      return null
+    }
+  }
+
   /**
    * 获取团队IP成交率统计
    * 今日IP成交率 = 今日付费用户数 / 今日登录用户数
    * 总IP成交率 = 总付费用户数 / 总用户数
+   * 
+   * 支持团队用户和推广员:
+   * - 团队用户:统计整个团队的数据(通过teamId)
+   * - 推广员:统计绑定域名的数据(通过domainId),如果没有绑定域名则返回0
    */
-  async getIpConversionRate(userId: number): Promise<{
+  async getIpConversionRate(userId: number, userRole: UserRole): Promise<{
     todayIpConversionRate: number
     totalIpConversionRate: number
     todayPaidUsers: number
@@ -467,37 +544,94 @@ export class TeamService {
     totalPaidUsers: number
     totalUsers: number
   }> {
-    // 获取团队信息
-    const team = await this.findByUserId(userId)
-    const teamId = team.id
-
     // 获取今天的开始时间(使用本地时区),并使用半开区间 [today, tomorrow)
     const today = new Date()
     today.setHours(0, 0, 0, 0)
     const tomorrow = new Date(today)
     tomorrow.setDate(tomorrow.getDate() + 1)
 
-    // 统计今日登录用户数(基于会员的lastLoginAt字段)
-    const todayLoginUsers = await this.memberRepository.count({
-      where: {
-        teamId: teamId,
-        lastLoginAt: Between(today, tomorrow)
+    let domainIds: number[] = []
+    let teamId: number | null = null
+
+    // 根据用户角色决定统计方式
+    if (userRole === UserRole.TEAM) {
+      // 团队用户:通过teamId统计
+      const team = await this.findByUserId(userId)
+      teamId = team.id
+    } else if (userRole === UserRole.PROMOTER) {
+      // 推广员:通过绑定的域名统计
+      const teamMember = await this.teamMembersRepository.findOne({ where: { userId } })
+      if (!teamMember) {
+        // 如果没有找到团队成员信息,返回0
+        return {
+          todayIpConversionRate: 0,
+          totalIpConversionRate: 0,
+          todayPaidUsers: 0,
+          todayLoginUsers: 0,
+          totalPaidUsers: 0,
+          totalUsers: 0
+        }
       }
-    })
 
-    // 统计总用户数(基于会员的teamId)
-    const totalUsers = await this.memberRepository.count({
-      where: {
-        teamId: teamId
+      // 查找绑定的域名
+      const teamDomains = await this.teamDomainRepository.find({
+        where: { teamMemberId: teamMember.id },
+        order: { createdAt: 'DESC' }
+      })
+
+      // 如果没有绑定域名,返回0
+      if (teamDomains.length === 0) {
+        return {
+          todayIpConversionRate: 0,
+          totalIpConversionRate: 0,
+          todayPaidUsers: 0,
+          todayLoginUsers: 0,
+          totalPaidUsers: 0,
+          totalUsers: 0
+        }
       }
-    })
 
-    // 统计今日付费用户数(通过IncomeRecords表,查询今日有付费记录的用户,去重)
-    const todayPaidUsersResult = await this.incomeRecordsRepository
+      domainIds = teamDomains.map(d => d.id)
+    } else {
+      throw new Error('不支持的用户角色')
+    }
+
+    // 构建基础查询条件
+    let todayLoginUsersQuery = this.memberRepository.createQueryBuilder('member')
+    let totalUsersQuery = this.memberRepository.createQueryBuilder('member')
+    let todayPaidUsersQuery = this.incomeRecordsRepository
+      .createQueryBuilder('record')
+      .innerJoin('member', 'm', 'm.userId = record.userId')
+    let totalPaidUsersQuery = this.incomeRecordsRepository
       .createQueryBuilder('record')
       .innerJoin('member', 'm', 'm.userId = record.userId')
+
+    if (userRole === UserRole.TEAM && teamId !== null) {
+      // 团队用户:通过teamId过滤
+      todayLoginUsersQuery = todayLoginUsersQuery.where('member.teamId = :teamId', { teamId })
+      totalUsersQuery = totalUsersQuery.where('member.teamId = :teamId', { teamId })
+      todayPaidUsersQuery = todayPaidUsersQuery.where('m.teamId = :teamId', { teamId })
+      totalPaidUsersQuery = totalPaidUsersQuery.where('m.teamId = :teamId', { teamId })
+    } else if (userRole === UserRole.PROMOTER && domainIds.length > 0) {
+      // 推广员:通过domainId过滤
+      todayLoginUsersQuery = todayLoginUsersQuery.where('member.domainId IN (:...domainIds)', { domainIds })
+      totalUsersQuery = totalUsersQuery.where('member.domainId IN (:...domainIds)', { domainIds })
+      todayPaidUsersQuery = todayPaidUsersQuery.where('m.domainId IN (:...domainIds)', { domainIds })
+      totalPaidUsersQuery = totalPaidUsersQuery.where('m.domainId IN (:...domainIds)', { domainIds })
+    }
+
+    // 统计今日登录用户数(基于会员的lastLoginAt字段)
+    const todayLoginUsers = await todayLoginUsersQuery
+      .andWhere('member.lastLoginAt >= :today', { today })
+      .andWhere('member.lastLoginAt < :tomorrow', { tomorrow })
+      .getCount()
+
+    // 统计总用户数
+    const totalUsers = await totalUsersQuery.getCount()
+
+    // 统计今日付费用户数(通过IncomeRecords表,查询今日有付费记录的用户,去重)
+    const todayPaidUsersResult = await todayPaidUsersQuery
       .select('COUNT(DISTINCT record.userId)', 'count')
-      .where('m.teamId = :teamId', { teamId })
       .andWhere('record.createdAt >= :today', { today })
       .andWhere('record.createdAt < :tomorrow', { tomorrow })
       .andWhere('record.delFlag = :delFlag', { delFlag: false })
@@ -507,11 +641,8 @@ export class TeamService {
     const todayPaidUsers = Number(todayPaidUsersResult?.count) || 0
 
     // 统计总付费用户数(通过IncomeRecords表,查询有付费记录的用户,去重)
-    const totalPaidUsersResult = await this.incomeRecordsRepository
-      .createQueryBuilder('record')
-      .innerJoin('member', 'm', 'm.userId = record.userId')
+    const totalPaidUsersResult = await totalPaidUsersQuery
       .select('COUNT(DISTINCT record.userId)', 'count')
-      .where('m.teamId = :teamId', { teamId })
       .andWhere('record.delFlag = :delFlag', { delFlag: false })
       .andWhere('record.status = :status', { status: true })
       .getRawOne()