Files
dukang/server/dukang-api/src/modules/common/support-ticket.service.ts
T
jacy 02d89e6385 feat(ops): kb doc edit, validation auto-tickets, support batch ops
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>
2026-08-05 00:08:40 +08:00

594 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}