1
0

2 Commits 202b709c9f ... 30602e7261

Autor SHA1 Nachricht Datum
  wuyi 30602e7261 短信任务优化 vor 6 Tagen
  wuyi d99ca85a1f sms-task vor 6 Tagen

+ 1 - 0
.gitignore

@@ -13,6 +13,7 @@ dist
 dist-ssr
 coverage
 *.local
+docs
 
 /cypress/videos/
 /cypress/screenshots/

+ 2 - 0
src/app.ts

@@ -11,6 +11,7 @@ import { createDataSource } from './config/database'
 import userRoutes from './routes/user.routes'
 import fileRoutes from './routes/file.routes'
 import sysConfigRoutes from './routes/sys-config.routes'
+import smsTaskRoutes from './routes/sms-task.routes'
 
 const options: FastifyEnvOptions = {
   schema: schema,
@@ -77,6 +78,7 @@ export const createApp = async () => {
   app.register(userRoutes, { prefix: '/api/users' })
   app.register(fileRoutes, { prefix: '/api/files' })
   app.register(sysConfigRoutes, { prefix: '/api/sys-config' })
+  app.register(smsTaskRoutes, { prefix: '/api/sms-tasks' })
 
   const dataSource = createDataSource(app)
   await dataSource.initialize()

+ 183 - 0
src/controllers/sms-task.controller.ts

@@ -0,0 +1,183 @@
+import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
+import { SmsTaskService } from '../services/sms-task.service'
+import { UpdateSmsTaskBody, ListSmsTaskQuery, GetSmsTaskParams, ListSmsTaskItemQuery } from '../dto/sms-task.dto'
+
+export class SmsTaskController {
+  private smsTaskService: SmsTaskService
+
+  constructor(app: FastifyInstance) {
+    this.smsTaskService = new SmsTaskService(app)
+  }
+
+  async create(request: FastifyRequest, reply: FastifyReply) {
+    try {
+      const userId = request.user.id
+
+      const data = await request.file()
+      if (!data) {
+        return reply.code(400).send({ message: '请选择要上传的文件' })
+      }
+
+      if (!data.filename || !data.filename.toLowerCase().endsWith('.txt')) {
+        return reply.code(400).send({ message: '必须上传 txt 格式的文件' })
+      }
+
+      const nameField = data.fields?.['name']
+      const messageField = data.fields?.['message']
+      const remarkField = data.fields?.['remark']
+
+      const name =
+        nameField && !Array.isArray(nameField) && 'value' in nameField ? (nameField.value as string) : undefined
+      const message =
+        messageField && !Array.isArray(messageField) && 'value' in messageField
+          ? (messageField.value as string)
+          : undefined
+      const remark =
+        remarkField && !Array.isArray(remarkField) && 'value' in remarkField ? (remarkField.value as string) : undefined
+
+      if (!name) {
+        return reply.code(400).send({ message: '任务名称不能为空' })
+      }
+      if (!message) {
+        return reply.code(400).send({ message: 'message 不能为空' })
+      }
+
+      const buffer = await data.toBuffer()
+
+      const task = await this.smsTaskService.create({
+        userId,
+        name,
+        message,
+        buffer,
+        remark
+      })
+
+      return reply.code(201).send({
+        message: '创建成功',
+        data: task
+      })
+    } catch (error) {
+      return reply.code(500).send({
+        message: '创建失败',
+        error: error instanceof Error ? error.message : String(error)
+      })
+    }
+  }
+
+  async list(request: FastifyRequest<{ Querystring: ListSmsTaskQuery }>, reply: FastifyReply) {
+    try {
+      const result = await this.smsTaskService.findAll(request.query)
+      return reply.send({
+        message: '查询成功',
+        ...result
+      })
+    } catch (error) {
+      return reply.code(500).send({
+        message: '查询失败',
+        error: error instanceof Error ? error.message : String(error)
+      })
+    }
+  }
+
+  async getById(request: FastifyRequest<{ Params: GetSmsTaskParams }>, reply: FastifyReply) {
+    try {
+      const { id } = request.params
+      const task = await this.smsTaskService.findById(id)
+      return reply.send({
+        message: '查询成功',
+        data: task
+      })
+    } catch (error) {
+      if (error instanceof Error && error.message.includes('Could not find')) {
+        return reply.code(404).send({
+          message: '任务不存在'
+        })
+      }
+      return reply.code(500).send({
+        message: '查询失败',
+        error: error instanceof Error ? error.message : String(error)
+      })
+    }
+  }
+
+  async update(request: FastifyRequest<{ Body: UpdateSmsTaskBody }>, reply: FastifyReply) {
+    try {
+      const { id, ...updateData } = request.body
+      const task = await this.smsTaskService.update(id, { id, ...updateData })
+      return reply.send({
+        message: '更新成功',
+        data: task
+      })
+    } catch (error) {
+      if (error instanceof Error && error.message.includes('Could not find')) {
+        return reply.code(404).send({
+          message: '任务不存在'
+        })
+      }
+      return reply.code(500).send({
+        message: '更新失败',
+        error: error instanceof Error ? error.message : String(error)
+      })
+    }
+  }
+
+  async delete(request: FastifyRequest<{ Params: GetSmsTaskParams }>, reply: FastifyReply) {
+    try {
+      const { id } = request.params
+      await this.smsTaskService.delete(id)
+      return reply.send({
+        message: '删除成功'
+      })
+    } catch (error) {
+      return reply.code(500).send({
+        message: '删除失败',
+        error: error instanceof Error ? error.message : String(error)
+      })
+    }
+  }
+
+  async listSmsTaskItems(request: FastifyRequest<{ Querystring: ListSmsTaskItemQuery }>, reply: FastifyReply) {
+    try {
+      const result = await this.smsTaskService.findAllTaskItems(request.query)
+      return reply.send({
+        message: '查询成功',
+        ...result
+      })
+    } catch (error) {
+      return reply.code(500).send({
+        message: '查询失败',
+        error: error instanceof Error ? error.message : String(error)
+      })
+    }
+  }
+
+  async start(request: FastifyRequest<{ Params: GetSmsTaskParams }>, reply: FastifyReply) {
+    try {
+      const { id } = request.params
+      await this.smsTaskService.start(id)
+      return reply.send({
+        message: '任务已开始'
+      })
+    } catch (error) {
+      return reply.code(500).send({
+        message: '开始任务失败',
+        error: error instanceof Error ? error.message : String(error)
+      })
+    }
+  }
+
+  async pause(request: FastifyRequest<{ Params: GetSmsTaskParams }>, reply: FastifyReply) {
+    try {
+      const { id } = request.params
+      await this.smsTaskService.pause(id)
+      return reply.send({
+        message: '任务已暂停'
+      })
+    } catch (error) {
+      return reply.code(500).send({
+        message: '暂停任务失败',
+        error: error instanceof Error ? error.message : String(error)
+      })
+    }
+  }
+}

+ 36 - 0
src/dto/sms-task.dto.ts

@@ -0,0 +1,36 @@
+import { TaskStatus } from '../enum/task.enum'
+import { Pagination } from './common.dto'
+
+export interface CreateSmsTaskBody {
+  userId: number
+  name: string
+  message: string
+  remark?: string
+}
+
+export interface UpdateSmsTaskBody {
+  id: number
+  name?: string
+  message?: string
+  status?: TaskStatus
+  processed?: number
+  successed?: number
+  total?: number
+  startedAt?: Date
+  remark?: string
+}
+
+export interface ListSmsTaskQuery extends Pagination {
+  userId?: number
+  status?: TaskStatus
+  name?: string
+}
+
+export interface GetSmsTaskParams {
+  id: number
+}
+
+export interface ListSmsTaskItemQuery extends Pagination {
+  taskId?: number
+  status?: string
+}

+ 35 - 0
src/entities/sms-task-item.entity.ts

@@ -0,0 +1,35 @@
+import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'
+import { TaskItemStatus } from '../enum/task.enum'
+
+@Entity()
+@Index('idx_taskId_status_id', ['taskId', 'status', 'id'])
+@Index('idx_taskId_createdAt', ['taskId', 'createdAt'])
+export class SmsTaskItem {
+  @PrimaryGeneratedColumn()
+  id: number
+
+  @Column()
+  taskId: number
+
+  @Column()
+  target: string
+
+  @Column({
+    type: 'enum',
+    enum: TaskItemStatus,
+    default: TaskItemStatus.IDLE
+  })
+  status: TaskItemStatus
+
+  @Column({ type: 'text', nullable: true })
+  errorMsg: string | null
+
+  @Column({ type: 'datetime', precision: 6, default: null })
+  operatingAt: Date
+
+  @CreateDateColumn()
+  createdAt: Date
+
+  @UpdateDateColumn()
+  updatedAt: Date
+}

+ 46 - 0
src/entities/sms-task.entity.ts

@@ -0,0 +1,46 @@
+import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'
+import { TaskStatus } from '../enum/task.enum'
+
+@Entity()
+@Index('idx_status_delFlag_startedAt', ['status', 'delFlag', 'startedAt'])
+@Index('idx_userId_delFlag_createdAt', ['userId', 'delFlag', 'createdAt'])
+export class SmsTask {
+  @PrimaryGeneratedColumn()
+  id: number
+
+  @Column()
+  userId: number
+
+  @Column()
+  name: string
+
+  @Column({ type: 'text', nullable: false })
+  message: string
+
+  @Column({ type: 'enum', enum: TaskStatus, nullable: false, default: TaskStatus.IDLE })
+  status: TaskStatus
+
+  @Column({ default: 0 })
+  processed: number
+
+  @Column({ default: 0 })
+  successed: number
+
+  @Column({ default: 0 })
+  total: number
+
+  @Column({ type: 'datetime', precision: 6, default: null })
+  startedAt: Date
+
+  @CreateDateColumn()
+  createdAt: Date
+
+  @UpdateDateColumn()
+  updatedAt: Date
+
+  @Column({ default: false })
+  delFlag: boolean
+
+  @Column({ nullable: true })
+  remark: string
+}

+ 22 - 0
src/enum/task.enum.ts

@@ -0,0 +1,22 @@
+export enum TaskStatus {
+  IDLE = 'idle',
+  PENDING = 'pending',
+  RUNNING = 'running',
+  CUTTING = 'cutting',
+  PAUSED = 'paused',
+  QUEUED = 'queued',
+  SCHEDULED = 'scheduled',
+  COMPLETED = 'completed',
+  VIP = 'vip',
+  ERROR = 'error'
+}
+
+export enum TaskItemStatus {
+  IDLE = 'idle',
+  PENDING = 'pending',
+  WAITING = 'waiting',
+  PROCESSING = 'processing',
+  SUCCESS = 'success',
+  FAILED = 'failed'
+}
+

+ 61 - 0
src/routes/sms-task.routes.ts

@@ -0,0 +1,61 @@
+import { FastifyInstance } from 'fastify'
+import { SmsTaskController } from '../controllers/sms-task.controller'
+import { authenticate, hasRole } from '../middlewares/auth.middleware'
+import { ListSmsTaskItemQuery, UpdateSmsTaskBody, ListSmsTaskQuery, GetSmsTaskParams } from '../dto/sms-task.dto'
+import { UserRole } from '../entities/user.entity'
+
+export default async function smsTaskRoutes(fastify: FastifyInstance) {
+  const smsTaskController = new SmsTaskController(fastify)
+
+  // 创建任务
+  fastify.post('/create', { onRequest: [authenticate] }, smsTaskController.create.bind(smsTaskController))
+
+  // 查询任务列表
+  fastify.get<{ Querystring: ListSmsTaskQuery }>(
+    '/list',
+    { onRequest: [authenticate] },
+    smsTaskController.list.bind(smsTaskController)
+  )
+
+  // 查询单个任务
+  fastify.get<{ Params: GetSmsTaskParams }>(
+    '/:id',
+    { onRequest: [authenticate] },
+    smsTaskController.getById.bind(smsTaskController)
+  )
+
+  // 更新任务
+  fastify.post<{ Body: UpdateSmsTaskBody }>(
+    '/update',
+    { onRequest: [authenticate] },
+    smsTaskController.update.bind(smsTaskController)
+  )
+
+  // 删除任务
+  fastify.get<{ Params: GetSmsTaskParams }>(
+    '/:id/delete',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    smsTaskController.delete.bind(smsTaskController)
+  )
+
+  // 查询任务项列表
+  fastify.get<{ Querystring: ListSmsTaskItemQuery }>(
+    '/items',
+    { onRequest: [hasRole(UserRole.ADMIN)] },
+    smsTaskController.listSmsTaskItems.bind(smsTaskController)
+  )
+
+  // 开始任务
+  fastify.post<{ Params: GetSmsTaskParams }>(
+    '/:id/start',
+    { onRequest: [authenticate] },
+    smsTaskController.start.bind(smsTaskController)
+  )
+
+  // 暂停任务
+  fastify.post<{ Params: GetSmsTaskParams }>(
+    '/:id/pause',
+    { onRequest: [authenticate] },
+    smsTaskController.pause.bind(smsTaskController)
+  )
+}

+ 207 - 0
src/services/sms-task.service.ts

@@ -0,0 +1,207 @@
+import { Repository, Like } from 'typeorm'
+import { FastifyInstance } from 'fastify'
+import { SmsTask } from '../entities/sms-task.entity'
+import { SmsTaskItem } from '../entities/sms-task-item.entity'
+import { PaginationResponse } from '../dto/common.dto'
+import { UpdateSmsTaskBody, ListSmsTaskQuery, ListSmsTaskItemQuery } from '../dto/sms-task.dto'
+import { TaskStatus, TaskItemStatus } from '../enum/task.enum'
+
+export class SmsTaskService {
+  private smsTaskRepository: Repository<SmsTask>
+  private smsTaskItemRepository: Repository<SmsTaskItem>
+  private app: FastifyInstance
+  private taskItemInsertChunkSize: number = 1000
+
+  constructor(app: FastifyInstance) {
+    this.app = app
+    this.smsTaskRepository = app.dataSource.getRepository(SmsTask)
+    this.smsTaskItemRepository = app.dataSource.getRepository(SmsTaskItem)
+  }
+
+  async create(data: {
+    userId: number
+    name: string
+    message: string
+    buffer: Buffer
+    remark?: string
+  }): Promise<SmsTask> {
+    const task = this.smsTaskRepository.create({
+      userId: data.userId,
+      name: data.name,
+      message: data.message,
+      remark: data.remark,
+      delFlag: false
+    })
+    const savedTask = await this.smsTaskRepository.save(task)
+
+    const total = await this.createTaskItemByBuffer({ taskId: savedTask.id, buffer: data.buffer })
+    await this.smsTaskRepository.update(savedTask.id, { total })
+    return await this.smsTaskRepository.findOneOrFail({ where: { id: savedTask.id } })
+  }
+
+  private async createTaskItemByBuffer(data: { taskId: number; buffer: Buffer }): Promise<number> {
+    const content = data.buffer.toString('utf-8')
+    const lines = content.split(/\r?\n/).filter(line => line.trim())
+    if (lines.length === 0) {
+      return 0
+    }
+
+    const values = lines.map(line => ({
+      taskId: data.taskId,
+      target: line.trim(),
+      status: TaskItemStatus.PENDING as TaskItemStatus
+    }))
+
+    await this.app.dataSource.transaction(async manager => {
+      const repo = manager.getRepository(SmsTaskItem)
+      for (let i = 0; i < values.length; i += this.taskItemInsertChunkSize) {
+        const chunk = values.slice(i, i + this.taskItemInsertChunkSize)
+        await repo.insert(chunk)
+      }
+    })
+
+    return values.length
+  }
+
+  async findById(id: number): Promise<SmsTask> {
+    return this.smsTaskRepository.findOneOrFail({
+      where: { id, delFlag: false }
+    })
+  }
+
+  async findAll(query: ListSmsTaskQuery): Promise<PaginationResponse<SmsTask>> {
+    const { page = 0, size = 20, userId, status, name } = query
+
+    const where: any = {
+      delFlag: false
+    }
+
+    if (userId) {
+      where.userId = userId
+    }
+
+    if (status) {
+      where.status = status
+    }
+
+    if (name) {
+      where.name = Like(`%${name}%`)
+    }
+
+    const [tasks, total] = await this.smsTaskRepository.findAndCount({
+      where,
+      skip: Number(page) * Number(size),
+      take: Number(size),
+      order: {
+        createdAt: 'DESC'
+      }
+    })
+
+    return {
+      content: tasks,
+      metadata: {
+        total: Number(total),
+        page: Number(page),
+        size: Number(size)
+      }
+    }
+  }
+
+  async update(id: number, data: UpdateSmsTaskBody): Promise<SmsTask> {
+    const updateData: Partial<SmsTask> = {}
+    if (data.name !== undefined) updateData.name = data.name
+    if (data.message !== undefined) updateData.message = data.message
+    if (data.status !== undefined) updateData.status = data.status
+    if (data.processed !== undefined) updateData.processed = data.processed
+    if (data.successed !== undefined) updateData.successed = data.successed
+    if (data.total !== undefined) updateData.total = data.total
+    if (data.startedAt !== undefined) updateData.startedAt = data.startedAt
+    if (data.remark !== undefined) updateData.remark = data.remark
+
+    await this.smsTaskRepository.update(id, updateData)
+    return this.findById(id)
+  }
+
+  async delete(id: number): Promise<void> {
+    const task = await this.findById(id)
+    if (!task) {
+      throw new Error('当前任务不存在')
+    }
+    if (task.status !== TaskStatus.IDLE) {
+      throw new Error('当前任务状态无法删除')
+    }
+    await this.smsTaskRepository.update(id, { delFlag: true })
+  }
+
+  async findAllTaskItems(query: ListSmsTaskItemQuery): Promise<PaginationResponse<SmsTaskItem>> {
+    const { page = 0, size = 20, taskId, status } = query
+
+    const where: any = {}
+
+    if (taskId) {
+      where.taskId = taskId
+    }
+
+    if (status) {
+      where.status = status as TaskItemStatus
+    }
+
+    const [items, total] = await this.smsTaskItemRepository.findAndCount({
+      where,
+      skip: Number(page) * Number(size),
+      take: Number(size),
+      order: {
+        id: 'DESC'
+      }
+    })
+
+    return {
+      content: items,
+      metadata: {
+        total: Number(total),
+        page: Number(page),
+        size: Number(size)
+      }
+    }
+  }
+
+  async start(id: number): Promise<void> {
+    const task = await this.findById(id)
+
+    if (task.status !== TaskStatus.IDLE && task.status !== TaskStatus.PAUSED && task.status !== TaskStatus.SCHEDULED) {
+      throw new Error('当前任务状态无法开始任务')
+    }
+
+    const num = await this.smsTaskRepository.count({
+      where: {
+        status: TaskStatus.PENDING,
+        delFlag: false
+      }
+    })
+
+    const newStatus = num > 0 ? TaskStatus.QUEUED : TaskStatus.PENDING
+
+    await this.smsTaskRepository.update(id, {
+      status: newStatus,
+      startedAt: new Date()
+    })
+  }
+
+  async pause(id: number): Promise<void> {
+    const task = await this.findById(id)
+
+    if (
+      task.status !== TaskStatus.PENDING &&
+      task.status !== TaskStatus.RUNNING &&
+      task.status !== TaskStatus.CUTTING &&
+      task.status !== TaskStatus.QUEUED &&
+      task.status !== TaskStatus.SCHEDULED
+    ) {
+      throw new Error('当前任务状态无法暂停任务')
+    }
+
+    await this.smsTaskRepository.update(id, {
+      status: TaskStatus.PAUSED
+    })
+  }
+}

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

@@ -34,6 +34,21 @@ declare module 'fastify' {
       filename: string
       mimetype: string
       toBuffer(): Promise<Buffer>
+      fields?: {
+        [key: string]: {
+          value: string
+        } | Array<{
+          value: string
+        }>
+      }
     } | null>
+    parts(): AsyncIterableIterator<{
+      type: 'file' | 'field'
+      fieldname: string
+      filename?: string
+      mimetype?: string
+      value?: string
+      toBuffer?(): Promise<Buffer>
+    }>
   }
 }