02d89e6385
Add knowledge document GET/PUT and admin edit UI; auto-create support tickets on client 400 validation errors across user/shop/partner apps; batch create tasks and publish from support tickets; restore mini-user store env single-column layout. Co-authored-by: Cursor <cursoragent@cursor.com>
594 lines
19 KiB
TypeScript
594 lines
19 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 { 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<T extends { attachmentUrls?: unknown }>(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',
|
||
[
|
||
`## 新建技术支持工单`,
|
||
`> 环境:<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(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<string>();
|
||
|
||
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);
|
||
}
|
||
}
|