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 { mapSupportTicketTypeToDevPlanTask } from '@dukang/shared-types'; import { validateSupportTicketStatusTransition } from '@dukang/domain'; 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, UpdateSupportTicketDto, } from './dto/support-ticket.dto'; function normalizeAttachmentUrls(raw: unknown): string[] | null { if (!Array.isArray(raw)) return null; const urls = raw.map((u) => String(u || '').trim()).filter(Boolean); return urls.length ? urls : null; } function mapSupportTicketRow(ticket: T) { return { ...ticket, attachmentUrls: normalizeAttachmentUrls(ticket.attachmentUrls), }; } function generateSupportTicketNo() { return `ST${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; } const AUTO_VALIDATION_TICKET_APPS = new Set([ 'USER_H5', 'USER_MINI', 'SHOP_H5', 'PARTNER_H5', ]); @Injectable() export class SupportTicketService { private systemCreatorCache: { id: bigint; name: string } | null = null; 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, attachmentUrls: dto.attachmentUrls?.length ? (dto.attachmentUrls.map((u) => u.trim()).filter(Boolean) as unknown as Prisma.InputJsonValue) : undefined, 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(mapSupportTicketRow(ticket)); } /** 客户端 400 验证错误自动建单(1 小时内同标题去重) */ async createFromClientValidation(input: { clientApp: string; message: string; pagePath?: string; apiPath?: string; actorLabel?: string; }) { if (!AUTO_VALIDATION_TICKET_APPS.has(input.clientApp)) { return { skipped: true as const, reason: 'unsupported_app' as const }; } const title = `[客户端验证] ${input.clientApp}${input.pagePath ? ` · ${input.pagePath}` : ''} · ${input.message.slice(0, 60)}`; const oneHourAgo = new Date(Date.now() - 3600_000); const existing = await this.prisma.commonSupportTicket.findFirst({ where: { title, createdAt: { gte: oneHourAgo } }, select: { id: true }, }); if (existing) { return { skipped: true as const, ticketId: existing.id.toString() }; } const creator = await this.resolveSystemCreator(); const content = [ `端:${input.clientApp}`, input.pagePath ? `页面:${input.pagePath}` : null, input.apiPath ? `接口:${input.apiPath}` : null, input.actorLabel ? `用户:${input.actorLabel}` : null, '', input.message, ] .filter(Boolean) .join('\n'); const ticket = await this.create( { ticketType: 'BUG', title, content, remark: '客户端验证错误自动上报', }, creator, ); return { skipped: false as const, ticketId: String(ticket.id) }; } private async resolveSystemCreator() { if (this.systemCreatorCache) return this.systemCreatorCache; const account = await this.prisma.hqAccount.findFirst({ where: { adminRole: 'SUPER_ADMIN', status: 'ACTIVE' }, orderBy: { id: 'asc' }, select: { id: true }, }); if (!account) { throw new BadRequestException('未找到系统管理员账号,无法自动创建工单'); } this.systemCreatorCache = { id: account.id, name: '系统自动' }; return this.systemCreatorCache; } async update(id: bigint, dto: UpdateSupportTicketDto) { const ticket = await this.getOrThrow(id); if (ticket.status !== 'PENDING_REVIEW') { throw new BadRequestException('仅待评审工单可编辑'); } const data: Prisma.CommonSupportTicketUpdateInput = {}; if (dto.ticketType != null) data.ticketType = dto.ticketType as SupportTicketType; if (dto.title != null) data.title = dto.title.trim(); if (dto.content !== undefined) data.content = dto.content?.trim() || null; if (dto.remark !== undefined) data.remark = dto.remark?.trim() || null; if (dto.attachmentUrls !== undefined) { const urls = dto.attachmentUrls.map((u) => u.trim()).filter(Boolean); data.attachmentUrls = urls.length ? (urls as unknown as Prisma.InputJsonValue) : Prisma.JsonNull; } const updated = await this.prisma.commonSupportTicket.update({ where: { id }, data }); return serializeBigInt(mapSupportTicketRow(updated)); } async batchUpdateStatus( ticketIds: bigint[], status: SupportTicketStatus, ctx: { isSuperAdmin: boolean; rejectReason?: string; note?: string; reviewer?: { id: bigint; name: string }; }, ) { const results: Array<{ ticketId: string; ok: boolean; message?: string }> = []; for (const id of ticketIds) { try { const ticket = await this.getOrThrow(id); const linkedMap = await this.devPlan.listLinkedTasksByTicketIds([id]); const linkedTaskCount = linkedMap.get(String(id))?.length ?? 0; const guard = validateSupportTicketStatusTransition( ticket.status as 'PENDING_REVIEW' | 'REJECTED' | 'DEVELOPING' | 'TESTING' | 'PASSED', status as 'PENDING_REVIEW' | 'REJECTED' | 'DEVELOPING' | 'TESTING' | 'PASSED', { linkedTaskCount, isSuperAdmin: ctx.isSuperAdmin, rejectReason: ctx.rejectReason, }, ); if (!guard.ok) { results.push({ ticketId: String(id), ok: false, message: guard.message }); continue; } const data: Prisma.CommonSupportTicketUpdateInput = { status }; if (ctx.note?.trim()) data.remark = ctx.note.trim(); if (status === 'REJECTED') { data.rejectReason = ctx.rejectReason!.trim(); data.completedAt = new Date(); if (ctx.reviewer) { data.reviewerId = ctx.reviewer.id; data.reviewerName = ctx.reviewer.name; data.reviewedAt = new Date(); } } if (status === 'PASSED') data.completedAt = new Date(); await this.prisma.commonSupportTicket.update({ where: { id }, data }); results.push({ ticketId: String(id), ok: true }); } catch (err) { results.push({ ticketId: String(id), ok: false, message: err instanceof Error ? err.message : '更新失败', }); } } const successCount = results.filter((r) => r.ok).length; return { results, successCount, failCount: results.length - successCount }; } 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) => ({ ...mapSupportTicketRow(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({ ...mapSupportTicketRow(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[]; dispatchToWecom?: boolean; dispatchSupplement?: string; }, ) { 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('仅待评审工单可审批'); } const createdTasks = 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, }, }); if (input.dispatchToWecom) { try { await this.devPlan.dispatchTasks( { taskIds: createdTasks.map((t) => t.id), supplement: input.dispatchSupplement, }, reviewer.id, ); } catch (err) { this.alert.notify({ level: 'P2', category: 'ops', title: '审批后企微派发失败', detail: `工单 ${ticket.ticketNo}\n${err instanceof Error ? err.message : String(err)}`, dedupeKey: `support_review_dispatch_fail|${ticket.ticketNo}`, dedupeTtlSec: 120, }); } } const linkedMap = await this.devPlan.listLinkedTasksByTicketIds([id]); return serializeBigInt({ ...mapSupportTicketRow(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 }; } /** 批量一键创建开发任务(每工单 1 条,内容取自标题/说明) */ async batchCreateTasks( ticketIds: bigint[], operator: { id: bigint; name: string }, ) { const results: Array<{ ticketId: string; ok: boolean; message?: string; taskIds?: string[]; }> = []; for (const id of ticketIds) { try { const ticket = await this.getOrThrow(id); const linkedMap = await this.devPlan.listLinkedTasksByTicketIds([id]); const existing = linkedMap.get(String(id)) ?? []; if (existing.length > 0) { results.push({ ticketId: String(id), ok: false, message: '已有开发任务,已跳过' }); continue; } const summary = [ticket.title, ticket.content].filter(Boolean).join('\n').slice(0, 500); const createdTasks = await this.devPlan.createTasksFromTicket( id, [ { content: summary || ticket.title, type: mapSupportTicketTypeToDevPlanTask(ticket.ticketType as 'BUG' | 'SUGGESTION' | 'OTHER'), }, ], operator.id, ); if (ticket.status === 'PENDING_REVIEW') { await this.prisma.commonSupportTicket.update({ where: { id }, data: { status: 'DEVELOPING', reviewerId: operator.id, reviewerName: operator.name, reviewedAt: new Date(), }, }); } results.push({ ticketId: String(id), ok: true, taskIds: createdTasks.map((t) => t.id), }); } catch (err) { results.push({ ticketId: String(id), ok: false, message: err instanceof Error ? err.message : '创建失败', }); } } const successCount = results.filter((r) => r.ok).length; return { results, successCount, failCount: results.length - successCount }; } /** 批量一键发布:关联开发版本,可选企微派发 */ async batchPublish( ticketIds: bigint[], input: { versionId: string; dispatchToWecom?: boolean; dispatchSupplement?: string; }, operator: { id: bigint; name: string }, ) { const perTicket: Array<{ ticketId: string; ok: boolean; message?: string }> = []; const taskIdSet = new Set(); for (const id of ticketIds) { const linkedMap = await this.devPlan.listLinkedTasksByTicketIds([id]); const linked = linkedMap.get(String(id)) ?? []; if (!linked.length) { perTicket.push({ ticketId: String(id), ok: false, message: '无关联开发任务' }); continue; } linked.forEach((t) => taskIdSet.add(t.id)); perTicket.push({ ticketId: String(id), ok: true }); } const taskIds = [...taskIdSet]; if (!taskIds.length) { return { results: perTicket, successCount: 0, failCount: perTicket.length, linkedTaskCount: 0, }; } await this.devPlan.batchUpdateTasks({ taskIds, versionId: input.versionId }); if (input.dispatchToWecom) { try { await this.devPlan.dispatchTasks( { taskIds, supplement: input.dispatchSupplement }, operator.id, ); } catch (err) { this.alert.notify({ level: 'P2', category: 'ops', title: '批量发布企微派发失败', detail: err instanceof Error ? err.message : String(err), dedupeKey: `support_batch_publish_dispatch_fail|${Date.now()}`, dedupeTtlSec: 120, }); } } const successCount = perTicket.filter((r) => r.ok).length; return { results: perTicket, successCount, failCount: perTicket.length - successCount, linkedTaskCount: taskIds.length, versionId: input.versionId, }; } /** 开发完成 → 测试 */ 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); } }