feat: v3.4.12 ticket iteration

Refund rollback, winery T+3, multi withdraw, mini-user store detail, dev plan batch edit and WeCom dispatch, support ticket edit/attachments/batch status, package imageUrl.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-04 23:53:31 +08:00
parent 71f508e02b
commit 4372018c09
34 changed files with 1032 additions and 86 deletions
@@ -6,6 +6,7 @@ import {
import type { SupportTicketStatus, SupportTicketType } from '@prisma/client';
import { Prisma } from '@prisma/client';
import type { CreateDevPlanTaskFromTicketInput } 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';
@@ -16,8 +17,22 @@ import type {
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)}`;
}
@@ -43,6 +58,9 @@ export class SupportTicketService {
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,
},
@@ -73,7 +91,80 @@ export class SupportTicketService {
].join('\n'),
)
.catch(() => {});
return serializeBigInt(ticket);
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;
}
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) {
@@ -98,7 +189,7 @@ export class SupportTicketService {
]);
const linkedMap = await this.devPlan.listLinkedTasksByTicketIds(items.map((i) => i.id));
const enriched = items.map((ticket) => ({
...ticket,
...mapSupportTicketRow(ticket),
linkedTasks: (linkedMap.get(String(ticket.id)) ?? []).map((t) => ({
id: t.id,
taskNo: t.taskNo,
@@ -114,7 +205,7 @@ export class SupportTicketService {
if (!ticket) throw new NotFoundException('技术支持工单不存在');
const linkedMap = await this.devPlan.listLinkedTasksByTicketIds([id]);
return serializeBigInt({
...ticket,
...mapSupportTicketRow(ticket),
linkedTasks: (linkedMap.get(String(id)) ?? []).map((t) => ({
id: t.id,
taskNo: t.taskNo,
@@ -189,6 +280,8 @@ export class SupportTicketService {
rejectReason?: string;
note?: string;
tasks?: CreateDevPlanTaskFromTicketInput[];
dispatchToWecom?: boolean;
dispatchSupplement?: string;
},
) {
if (input.decision === 'REJECT') {
@@ -202,7 +295,7 @@ export class SupportTicketService {
throw new BadRequestException('仅待评审工单可审批');
}
await this.devPlan.createTasksFromTicket(id, input.tasks, reviewer.id);
const createdTasks = await this.devPlan.createTasksFromTicket(id, input.tasks, reviewer.id);
const updated = await this.prisma.commonSupportTicket.update({
where: { id },
@@ -214,9 +307,31 @@ export class SupportTicketService {
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({
...updated,
...mapSupportTicketRow(updated),
linkedTasks: (linkedMap.get(String(id)) ?? []).map((t) => ({
id: t.id,
taskNo: t.taskNo,