Files
dukang/server/dukang-api/src/modules/common/support-ticket.service.ts
T
jacy 46361ec713 feat(v3.4.16): partner UX, package pricing, CS gate, HQ list polish
Stop auto ST from validation_error; show package prices with right-aligned layout; partner store list status/filter and onboard CS QR gate; HQ store table truncation/fixed actions; expose CS config in wechat_mini settings; bind local servers for LAN.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 16:00:45 +08:00

530 lines
18 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, SupportTicketPriority } 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)}`;
}
@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,
priority: (dto.priority as SupportTicketPriority | undefined) ?? 'NORMAL',
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));
}
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;
}
if (dto.priority != null) data.priority = dto.priority as SupportTicketPriority;
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;
}
if (query.priority) {
where.priority = query.priority as SupportTicketPriority;
}
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);
}
}