@@ -6,6 +6,7 @@ import { SystemConfigModule } from '../../common/system-config/system-config.mod
|
||||
import { ResourceService } from './resource.service';
|
||||
import { EventService } from './event.service';
|
||||
import { TicketService } from './ticket.service';
|
||||
import { SupportTicketService } from './support-ticket.service';
|
||||
import { ThirdPartyLogService } from './third-party-log.service';
|
||||
import { ResourceController } from './resource.controller';
|
||||
import { EventController } from './event.controller';
|
||||
@@ -25,7 +26,14 @@ import { WechatLocationService } from './wechat-location.service';
|
||||
WechatController,
|
||||
ClientConfigController,
|
||||
],
|
||||
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService, WechatLocationService],
|
||||
exports: [ResourceService, EventService, TicketService],
|
||||
providers: [
|
||||
ResourceService,
|
||||
EventService,
|
||||
TicketService,
|
||||
SupportTicketService,
|
||||
ThirdPartyLogService,
|
||||
WechatLocationService,
|
||||
],
|
||||
exports: [ResourceService, EventService, TicketService, SupportTicketService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
export class SupportTicketListQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(['BUG', 'SUGGESTION', 'OTHER'])
|
||||
ticketType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['PENDING_REVIEW', 'REJECTED', 'DEVELOPING', 'TESTING', 'PASSED'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number = 20;
|
||||
}
|
||||
|
||||
export class CreateSupportTicketDto {
|
||||
@IsIn(['BUG', 'SUGGESTION', 'OTHER'])
|
||||
ticketType: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(128)
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export class RejectSupportTicketDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(512)
|
||||
rejectReason: string;
|
||||
}
|
||||
|
||||
export class SupportTicketRemarkDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
remark?: string;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { SupportTicketStatus, SupportTicketType } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
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) {}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
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 }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const ticket = await this.prisma.commonSupportTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('技术支持工单不存在');
|
||||
return serializeBigInt(ticket);
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { SupportTicketService } from '../common/support-ticket.service';
|
||||
import {
|
||||
CreateSupportTicketDto,
|
||||
RejectSupportTicketDto,
|
||||
SupportTicketListQueryDto,
|
||||
SupportTicketRemarkDto,
|
||||
} from '../common/dto/support-ticket.dto';
|
||||
|
||||
@Controller('admin/support-tickets')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminSupportTicketsController {
|
||||
constructor(
|
||||
private readonly service: SupportTicketService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
private async resolveHqAccount(user: AuthUser) {
|
||||
const account = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: user.actorId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!account) throw new NotFoundException('HQ 账户不存在');
|
||||
return account;
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: SupportTicketListQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_CREATE,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
async create(@CurrentUser() user: AuthUser, @Body() body: CreateSupportTicketDto) {
|
||||
const account = await this.resolveHqAccount(user);
|
||||
return this.service.create(body, account);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_APPROVE,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
async approve(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: SupportTicketRemarkDto,
|
||||
) {
|
||||
const account = await this.resolveHqAccount(user);
|
||||
return this.service.approve(BigInt(id), account, body);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_REJECT,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
async reject(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: RejectSupportTicketDto,
|
||||
) {
|
||||
const account = await this.resolveHqAccount(user);
|
||||
return this.service.reject(BigInt(id), account, body);
|
||||
}
|
||||
|
||||
@Post(':id/start-testing')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_START_TESTING,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
startTesting(@Param('id') id: string, @Body() body: SupportTicketRemarkDto) {
|
||||
return this.service.startTesting(BigInt(id), body);
|
||||
}
|
||||
|
||||
@Post(':id/pass')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_PASS,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
pass(@Param('id') id: string, @Body() body: SupportTicketRemarkDto) {
|
||||
return this.service.pass(BigInt(id), body);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { AdminOssLogsController } from './admin-oss-logs.controller';
|
||||
import { AdminOssLogsService } from './admin-oss-logs.service';
|
||||
import { AdminTicketsController, PartnerTicketsController } from './admin-tickets.controller';
|
||||
import { AdminTicketsService } from './admin-tickets.service';
|
||||
import { AdminSupportTicketsController } from './admin-support-tickets.controller';
|
||||
import { AdminInvoicesController } from './admin-invoices.controller';
|
||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
@@ -88,6 +89,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
||||
AdminHqLogsController,
|
||||
AdminOssLogsController,
|
||||
AdminTicketsController,
|
||||
AdminSupportTicketsController,
|
||||
AdminInvoicesController,
|
||||
AdminXiaofeixiaController,
|
||||
AdminProductDetailTemplatesController,
|
||||
|
||||
Reference in New Issue
Block a user