feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 deletions
@@ -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);
}
}