feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user