import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import type { SupportTicketStatus, SupportTicketType } from '@prisma/client'; import { Prisma } from '@prisma/client'; import type { CreateDevPlanTaskFromTicketInput } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { AlertService } from '../../common/alert/alert.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; import { DevPlanService } from '../dev-plan/dev-plan.service'; import type { CreateSupportTicketDto, RejectSupportTicketDto, SupportTicketListQueryDto, SupportTicketRemarkDto, } from './dto/support-ticket.dto'; function generateSupportTicketNo() { return `ST${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; } @Injectable() export class SupportTicketService { constructor( private readonly prisma: PrismaService, private readonly alert: AlertService, private readonly devPlan: DevPlanService, private readonly wecomPush: WecomMessagePushService, ) {} async create( dto: CreateSupportTicketDto, creator: { id: bigint; name: string }, ) { const ticket = await this.prisma.commonSupportTicket.create({ data: { ticketNo: generateSupportTicketNo(), ticketType: dto.ticketType as SupportTicketType, status: 'PENDING_REVIEW', title: dto.title.trim(), content: dto.content?.trim() || null, remark: dto.remark?.trim() || null, creatorId: creator.id, creatorName: creator.name, }, }); this.alert.notify({ level: 'P2', category: 'ops', title: '新建技术支持工单', detail: `工单 ${ticket.ticketNo}\n类型 ${ticket.ticketType}\n标题 ${ticket.title}\n创建人 ${creator.name}`, dedupeKey: `support_ticket_create|${ticket.ticketNo}`, eventKeys: ['alert.ops'], }); const envLabel = (process.env.WECOM_ALERT_ENV_LABEL || process.env.NODE_ENV || 'local').trim(); const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); void this.wecomPush .dispatchMarkdown( 'support_ticket.created', [ `## 新建技术支持工单`, `> 环境:${envLabel}`, `> 时间:${now}`, '', `**工单号**:${ticket.ticketNo}`, `**类型**:${ticket.ticketType}`, `**标题**:${ticket.title}`, `**创建人**:${creator.name}`, ticket.content ? `\n${ticket.content.slice(0, 2000)}` : '', ].join('\n'), ) .catch(() => {}); return serializeBigInt(ticket); } async list(query: SupportTicketListQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.CommonSupportTicketWhereInput = {}; if (query.ticketType) { where.ticketType = query.ticketType as SupportTicketType; } if (query.status) { where.status = query.status as SupportTicketStatus; } const [items, total] = await Promise.all([ this.prisma.commonSupportTicket.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.commonSupportTicket.count({ where }), ]); const linkedMap = await this.devPlan.listLinkedTasksByTicketIds(items.map((i) => i.id)); const enriched = items.map((ticket) => ({ ...ticket, linkedTasks: (linkedMap.get(String(ticket.id)) ?? []).map((t) => ({ id: t.id, taskNo: t.taskNo, content: t.content, status: t.status, })), })); return serializeBigInt({ items: enriched, total, page, pageSize }); } async detail(id: bigint) { const ticket = await this.prisma.commonSupportTicket.findUnique({ where: { id } }); if (!ticket) throw new NotFoundException('技术支持工单不存在'); const linkedMap = await this.devPlan.listLinkedTasksByTicketIds([id]); return serializeBigInt({ ...ticket, linkedTasks: (linkedMap.get(String(id)) ?? []).map((t) => ({ id: t.id, taskNo: t.taskNo, content: t.content, status: t.status, })), }); } private async getOrThrow(id: bigint) { const ticket = await this.prisma.commonSupportTicket.findUnique({ where: { id } }); if (!ticket) throw new NotFoundException('技术支持工单不存在'); return ticket; } /** 最高管理员评审通过 → 进入开发 */ async approve( id: bigint, reviewer: { id: bigint; name: string }, dto?: SupportTicketRemarkDto, ) { const ticket = await this.getOrThrow(id); if (ticket.status !== 'PENDING_REVIEW') { throw new BadRequestException('仅待评审工单可通过评审'); } const updated = await this.prisma.commonSupportTicket.update({ where: { id }, data: { status: 'DEVELOPING', reviewerId: reviewer.id, reviewerName: reviewer.name, reviewedAt: new Date(), remark: dto?.remark?.trim() || ticket.remark, }, }); return serializeBigInt(updated); } /** 最高管理员评审驳回 */ async reject( id: bigint, reviewer: { id: bigint; name: string }, dto: RejectSupportTicketDto, ) { const ticket = await this.getOrThrow(id); if (ticket.status !== 'PENDING_REVIEW') { throw new BadRequestException('仅待评审工单可驳回'); } const reason = dto.rejectReason.trim(); if (!reason) throw new BadRequestException('请填写驳回理由'); const updated = await this.prisma.commonSupportTicket.update({ where: { id }, data: { status: 'REJECTED', rejectReason: reason, reviewerId: reviewer.id, reviewerName: reviewer.name, reviewedAt: new Date(), completedAt: new Date(), }, }); return serializeBigInt(updated); } /** 统一审批:通过时创建开发计划任务 */ async review( id: bigint, reviewer: { id: bigint; name: string }, input: { decision: 'APPROVE' | 'REJECT'; rejectReason?: string; note?: string; tasks?: CreateDevPlanTaskFromTicketInput[]; }, ) { if (input.decision === 'REJECT') { if (!input.rejectReason?.trim()) throw new BadRequestException('请填写驳回理由'); return this.reject(id, reviewer, { rejectReason: input.rejectReason }); } if (!input.tasks?.length) throw new BadRequestException('审批通过需至少创建 1 条开发任务'); const ticket = await this.getOrThrow(id); if (ticket.status !== 'PENDING_REVIEW') { throw new BadRequestException('仅待评审工单可审批'); } await this.devPlan.createTasksFromTicket(id, input.tasks, reviewer.id); const updated = await this.prisma.commonSupportTicket.update({ where: { id }, data: { status: 'DEVELOPING', reviewerId: reviewer.id, reviewerName: reviewer.name, reviewedAt: new Date(), remark: input.note?.trim() || ticket.remark, }, }); const linkedMap = await this.devPlan.listLinkedTasksByTicketIds([id]); return serializeBigInt({ ...updated, linkedTasks: (linkedMap.get(String(id)) ?? []).map((t) => ({ id: t.id, taskNo: t.taskNo, content: t.content, status: t.status, })), }); } /** 批量确认审批 */ async batchReviewConfirm( reviewer: { id: bigint; name: string }, items: Array<{ ticketId: string; decision: 'APPROVE' | 'REJECT'; rejectReason?: string; note?: string; tasks?: CreateDevPlanTaskFromTicketInput[]; }>, ) { const results: unknown[] = []; for (const item of items) { const result = await this.review(BigInt(item.ticketId), reviewer, item); results.push(result); } return { items: results }; } /** 开发完成 → 测试 */ async startTesting(id: bigint, dto?: SupportTicketRemarkDto) { const ticket = await this.getOrThrow(id); if (ticket.status !== 'DEVELOPING') { throw new BadRequestException('仅开发中工单可转入测试'); } const updated = await this.prisma.commonSupportTicket.update({ where: { id }, data: { status: 'TESTING', remark: dto?.remark?.trim() || ticket.remark, }, }); return serializeBigInt(updated); } /** 测试通过 */ async pass(id: bigint, dto?: SupportTicketRemarkDto) { const ticket = await this.getOrThrow(id); if (ticket.status !== 'TESTING') { throw new BadRequestException('仅测试中工单可标记通过'); } const updated = await this.prisma.commonSupportTicket.update({ where: { id }, data: { status: 'PASSED', remark: dto?.remark?.trim() || ticket.remark, completedAt: new Date(), }, }); return serializeBigInt(updated); } }