技术支持工单
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-07-26 07:31:49 +08:00
parent ac1e0a9f13
commit 959e0b66cb
15 changed files with 906 additions and 4 deletions
+25
View File
@@ -101,6 +101,31 @@ CREATE TABLE common_ticket (
KEY idx_common_ticket_type_status (ticket_type, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通用工单表';
DROP TABLE IF EXISTS common_support_ticket;
CREATE TABLE common_support_ticket (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
ticket_no VARCHAR(32) NOT NULL,
ticket_type VARCHAR(32) NOT NULL COMMENT 'BUG|SUGGESTION|OTHER',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT 'PENDING_REVIEW|REJECTED|DEVELOPING|TESTING|PASSED',
title VARCHAR(128) NOT NULL,
content TEXT DEFAULT NULL,
reject_reason VARCHAR(512) DEFAULT NULL,
creator_id BIGINT UNSIGNED NOT NULL,
creator_name VARCHAR(64) NOT NULL,
reviewer_id BIGINT UNSIGNED DEFAULT NULL,
reviewer_name VARCHAR(64) DEFAULT NULL,
reviewed_at DATETIME(3) DEFAULT NULL,
remark VARCHAR(512) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_common_support_ticket_no (ticket_no),
KEY idx_common_support_ticket_status (status, created_at),
KEY idx_common_support_ticket_type_status (ticket_type, status),
KEY idx_common_support_ticket_creator (creator_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HQ技术支持工单';
DROP TABLE IF EXISTS common_product_item;
CREATE TABLE common_product_item (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+39
View File
@@ -88,6 +88,20 @@ enum TicketType {
RETURN_REFUND
}
enum SupportTicketType {
BUG
SUGGESTION
OTHER
}
enum SupportTicketStatus {
PENDING_REVIEW
REJECTED
DEVELOPING
TESTING
PASSED
}
enum InvoiceTitleType {
PERSONAL
ENTERPRISE
@@ -440,6 +454,31 @@ model CommonTicket {
@@map("common_ticket")
}
/// HQ 内部技术支持工单(BUG / 建议 / 其他)
model CommonSupportTicket {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
ticketNo String @unique @map("ticket_no") @db.VarChar(32)
ticketType SupportTicketType @map("ticket_type")
status SupportTicketStatus @default(PENDING_REVIEW)
title String @db.VarChar(128)
content String? @db.Text
rejectReason String? @map("reject_reason") @db.VarChar(512)
creatorId BigInt @map("creator_id") @db.UnsignedBigInt
creatorName String @map("creator_name") @db.VarChar(64)
reviewerId BigInt? @map("reviewer_id") @db.UnsignedBigInt
reviewerName String? @map("reviewer_name") @db.VarChar(64)
reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
completedAt DateTime? @map("completed_at") @db.DateTime(3)
@@index([status, createdAt])
@@index([ticketType, status])
@@index([creatorId])
@@map("common_support_ticket")
}
model CommonProductItem {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
skuCode String @unique @map("sku_code") @db.VarChar(32)
@@ -46,6 +46,11 @@ export const HqOperationAction = {
TICKET_APPROVE: 'TICKET_APPROVE',
TICKET_REJECT: 'TICKET_REJECT',
TICKET_CREATE: 'TICKET_CREATE',
SUPPORT_TICKET_CREATE: 'SUPPORT_TICKET_CREATE',
SUPPORT_TICKET_APPROVE: 'SUPPORT_TICKET_APPROVE',
SUPPORT_TICKET_REJECT: 'SUPPORT_TICKET_REJECT',
SUPPORT_TICKET_START_TESTING: 'SUPPORT_TICKET_START_TESTING',
SUPPORT_TICKET_PASS: 'SUPPORT_TICKET_PASS',
INVOICE_CREATE: 'INVOICE_CREATE',
INVOICE_ISSUE: 'INVOICE_ISSUE',
INVOICE_REJECT: 'INVOICE_REJECT',
@@ -124,6 +129,11 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.TICKET_APPROVE]: '工单通过',
[HqOperationAction.TICKET_REJECT]: '工单驳回',
[HqOperationAction.TICKET_CREATE]: '创建工单',
[HqOperationAction.SUPPORT_TICKET_CREATE]: '创建技术支持工单',
[HqOperationAction.SUPPORT_TICKET_APPROVE]: '技术支持评审通过',
[HqOperationAction.SUPPORT_TICKET_REJECT]: '技术支持评审驳回',
[HqOperationAction.SUPPORT_TICKET_START_TESTING]: '技术支持转入测试',
[HqOperationAction.SUPPORT_TICKET_PASS]: '技术支持测试通过',
[HqOperationAction.INVOICE_CREATE]: '创建发票申请',
[HqOperationAction.INVOICE_ISSUE]: '开具发票',
[HqOperationAction.INVOICE_REJECT]: '驳回发票',
@@ -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,