281 lines
8.9 KiB
TypeScript
281 lines
8.9 KiB
TypeScript
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',
|
||
[
|
||
`## 新建技术支持工单`,
|
||
`> 环境:<font color="comment">${envLabel}</font>`,
|
||
`> 时间:${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);
|
||
}
|
||
}
|