feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.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 { DevPlanService } from './dev-plan.service';
|
||||
import {
|
||||
CreateDevPlanTaskDto,
|
||||
CreateDevPlanVersionDto,
|
||||
DevPlanTaskDispatchDto,
|
||||
DevPlanTaskListQueryDto,
|
||||
ReplaceVersionTasksDto,
|
||||
UpdateDevPlanSettingsDto,
|
||||
UpdateDevPlanTaskDto,
|
||||
UpdateDevPlanVersionDto,
|
||||
} from './dto/dev-plan.dto';
|
||||
|
||||
@Controller('admin/dev-plan')
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('dev_plan')
|
||||
export class AdminDevPlanController {
|
||||
constructor(
|
||||
private readonly service: DevPlanService,
|
||||
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('tasks')
|
||||
listTasks(@Query() query: DevPlanTaskListQueryDto) {
|
||||
return this.service.listTasks(query);
|
||||
}
|
||||
|
||||
@Post('tasks')
|
||||
@HqOperation({ action: HqOperationAction.DEV_PLAN_TASK_CREATE, refType: 'DEV_PLAN_TASK', includeBody: true })
|
||||
async createTask(@CurrentUser() user: AuthUser, @Body() body: CreateDevPlanTaskDto) {
|
||||
const account = await this.resolveHqAccount(user);
|
||||
return this.service.createTask(body, account.id);
|
||||
}
|
||||
|
||||
@Get('tasks/:id')
|
||||
getTask(@Param('id') id: string) {
|
||||
return this.service.getTask(BigInt(id));
|
||||
}
|
||||
|
||||
@Put('tasks/:id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.DEV_PLAN_TASK_UPDATE,
|
||||
refType: 'DEV_PLAN_TASK',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
updateTask(@Param('id') id: string, @Body() body: UpdateDevPlanTaskDto) {
|
||||
return this.service.updateTask(BigInt(id), body);
|
||||
}
|
||||
|
||||
@Delete('tasks/:id')
|
||||
@HqOperation({ action: HqOperationAction.DEV_PLAN_TASK_DELETE, refType: 'DEV_PLAN_TASK', refIdParam: 'id' })
|
||||
deleteTask(@Param('id') id: string) {
|
||||
return this.service.deleteTask(BigInt(id));
|
||||
}
|
||||
|
||||
@Post('tasks/dispatch')
|
||||
@HqOperation({ action: HqOperationAction.DEV_PLAN_TASK_DISPATCH, refType: 'DEV_PLAN_TASK', includeBody: true })
|
||||
async dispatchTasks(@CurrentUser() user: AuthUser, @Body() body: DevPlanTaskDispatchDto) {
|
||||
const account = await this.resolveHqAccount(user);
|
||||
return this.service.dispatchTasks(body, account.id);
|
||||
}
|
||||
|
||||
@Get('versions')
|
||||
listVersions(
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.listVersions({
|
||||
status,
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('versions')
|
||||
@HqOperation({ action: HqOperationAction.DEV_PLAN_VERSION_CREATE, refType: 'DEV_PLAN_VERSION', includeBody: true })
|
||||
createVersion(@Body() body: CreateDevPlanVersionDto) {
|
||||
return this.service.createVersion(body);
|
||||
}
|
||||
|
||||
@Get('versions/:id')
|
||||
getVersion(@Param('id') id: string) {
|
||||
return this.service.getVersion(BigInt(id));
|
||||
}
|
||||
|
||||
@Put('versions/:id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.DEV_PLAN_VERSION_UPDATE,
|
||||
refType: 'DEV_PLAN_VERSION',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
updateVersion(@Param('id') id: string, @Body() body: UpdateDevPlanVersionDto) {
|
||||
return this.service.updateVersion(BigInt(id), body);
|
||||
}
|
||||
|
||||
@Delete('versions/:id')
|
||||
@HqOperation({ action: HqOperationAction.DEV_PLAN_VERSION_DELETE, refType: 'DEV_PLAN_VERSION', refIdParam: 'id' })
|
||||
deleteVersion(@Param('id') id: string) {
|
||||
return this.service.deleteVersion(BigInt(id));
|
||||
}
|
||||
|
||||
@Put('versions/:id/tasks')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.DEV_PLAN_VERSION_LINK_TASKS,
|
||||
refType: 'DEV_PLAN_VERSION',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
replaceVersionTasks(@Param('id') id: string, @Body() body: ReplaceVersionTasksDto) {
|
||||
return this.service.replaceVersionTasksApi(BigInt(id), body.taskIds);
|
||||
}
|
||||
|
||||
@Post('versions/:id/tasks')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.DEV_PLAN_VERSION_ADD_TASKS,
|
||||
refType: 'DEV_PLAN_VERSION',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
addVersionTasks(@Param('id') id: string, @Body() body: ReplaceVersionTasksDto) {
|
||||
return this.service.addVersionTasksApi(BigInt(id), body.taskIds);
|
||||
}
|
||||
|
||||
@Get('settings')
|
||||
getSettings() {
|
||||
return this.service.getSettings();
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
@HqOperation({ action: HqOperationAction.DEV_PLAN_SETTINGS_UPDATE, refType: 'DEV_PLAN_SETTINGS', includeBody: true })
|
||||
updateSettings(@Body() body: UpdateDevPlanSettingsDto) {
|
||||
return this.service.updateSettings(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
|
||||
* 企微群机器人 markdown/text 消息 @ 成员扩展语法。
|
||||
|
||||
* @see https://developer.work.weixin.qq.com/document/path/91770
|
||||
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/** 生成 `<@userid>` 片段 */
|
||||
|
||||
export function formatWecomAtMention(wecomUserId?: string | null): string {
|
||||
|
||||
const uid = (wecomUserId || '').trim();
|
||||
|
||||
return uid ? `<@${uid}>` : '';
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LlmModule } from '../../integrations/llm/llm.module';
|
||||
|
||||
import { DevPlanService } from './dev-plan.service';
|
||||
|
||||
import { SupportTicketReviewAiService } from './support-ticket-review-ai.service';
|
||||
|
||||
|
||||
|
||||
@Module({
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
DEV_PLAN_TASK_STATUSES,
|
||||
DEV_PLAN_TASK_TYPES,
|
||||
DEV_PLAN_VERSION_STATUSES,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
export class DevPlanTaskListQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export class CreateDevPlanTaskDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content!: string;
|
||||
|
||||
@IsIn(DEV_PLAN_TASK_TYPES)
|
||||
type!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
supportTicketId?: string;
|
||||
}
|
||||
|
||||
export class UpdateDevPlanTaskDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(DEV_PLAN_TASK_TYPES)
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(DEV_PLAN_TASK_STATUSES)
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class CreateDevPlanVersionDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
versionNo!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(DEV_PLAN_VERSION_STATUSES)
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
taskIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateDevPlanVersionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
versionNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(DEV_PLAN_VERSION_STATUSES)
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
taskIds?: string[];
|
||||
}
|
||||
|
||||
export class ReplaceVersionTasksDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
taskIds!: string[];
|
||||
}
|
||||
|
||||
export class UpdateDevPlanSettingsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reviewAssistantLlmConfigId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reviewAssistantKnowledgeBaseId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reviewAssistantPrompt?: string | null;
|
||||
}
|
||||
|
||||
export class DevPlanTaskDispatchDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsString({ each: true })
|
||||
taskIds!: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
supplement?: string;
|
||||
}
|
||||
|
||||
export class DevPlanTaskFromTicketDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content!: string;
|
||||
|
||||
@IsIn(DEV_PLAN_TASK_TYPES)
|
||||
type!: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION';
|
||||
}
|
||||
|
||||
export class ReviewSupportTicketDto {
|
||||
@IsIn(['APPROVE', 'REJECT'])
|
||||
decision!: 'APPROVE' | 'REJECT';
|
||||
|
||||
@ValidateIf((o: ReviewSupportTicketDto) => o.decision === 'REJECT')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
rejectReason?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@ValidateIf((o: ReviewSupportTicketDto) => o.decision === 'APPROVE')
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => DevPlanTaskFromTicketDto)
|
||||
tasks?: DevPlanTaskFromTicketDto[];
|
||||
}
|
||||
|
||||
export class BatchReviewPreviewDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
ticketIds!: string[];
|
||||
}
|
||||
|
||||
export class BatchReviewConfirmDto {
|
||||
@IsArray()
|
||||
items!: Array<{
|
||||
ticketId: string;
|
||||
decision: 'APPROVE' | 'REJECT';
|
||||
rejectReason?: string;
|
||||
note?: string;
|
||||
tasks?: Array<{ content: string; type: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION' }>;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import type { BatchReviewPreviewItem } from '@dukang/shared-types';
|
||||
import { mapSupportTicketTypeToDevPlanTask } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { LlmChatClient } from '../../integrations/llm/llm-chat.client';
|
||||
import { KnowledgeRetrievalService } from '../../integrations/llm/knowledge-retrieval.service';
|
||||
import { DevPlanService } from './dev-plan.service';
|
||||
|
||||
const DEFAULT_REVIEW_PROMPT = [
|
||||
'你是杜康好客技术支持工单审核助手。',
|
||||
'根据工单内容与知识库片段,给出审批建议:通过(APPROVE)或驳回(REJECT)。',
|
||||
'通过时可建议拆分为 1~3 条开发任务(content + type: BUG/REQUIREMENT/OPTIMIZATION)。',
|
||||
'仅输出 JSON,格式:',
|
||||
'{"decision":"APPROVE|REJECT","rejectReason":"驳回时必填","note":"通过时附注","reportMarkdown":"markdown摘要","suggestedTasks":[{"content":"...","type":"BUG"}]}',
|
||||
].join('\n');
|
||||
|
||||
@Injectable()
|
||||
export class SupportTicketReviewAiService {
|
||||
private readonly logger = new Logger(SupportTicketReviewAiService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly llm: LlmChatClient,
|
||||
private readonly kb: KnowledgeRetrievalService,
|
||||
private readonly devPlan: DevPlanService,
|
||||
) {}
|
||||
|
||||
async preview(ticketIds: string[]): Promise<{ items: BatchReviewPreviewItem[] }> {
|
||||
if (!ticketIds.length) throw new BadRequestException('请选择工单');
|
||||
const settings = await this.devPlan.getSettingsRaw();
|
||||
if (!settings.reviewAssistantLlmConfigId) {
|
||||
throw new BadRequestException('请先在开发设置中配置审核 AI 助手的语言模型');
|
||||
}
|
||||
const llmCfg = await this.prisma.llmApiConfig.findUnique({
|
||||
where: { id: settings.reviewAssistantLlmConfigId },
|
||||
});
|
||||
if (!llmCfg?.enabled) throw new BadRequestException('审核 AI 助手绑定的语言模型未启用');
|
||||
|
||||
const ids = ticketIds.map(BigInt);
|
||||
const tickets = await this.prisma.commonSupportTicket.findMany({
|
||||
where: { id: { in: ids }, status: 'PENDING_REVIEW' },
|
||||
});
|
||||
if (tickets.length !== ids.length) {
|
||||
throw new BadRequestException('部分工单不存在或不在待评审状态');
|
||||
}
|
||||
|
||||
const items: BatchReviewPreviewItem[] = [];
|
||||
for (const ticket of tickets) {
|
||||
let kbBlock = '';
|
||||
if (settings.reviewAssistantKnowledgeBaseId) {
|
||||
try {
|
||||
kbBlock = await this.kb.buildContext(
|
||||
settings.reviewAssistantKnowledgeBaseId,
|
||||
`${ticket.title}\n${ticket.content ?? ''}`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.warn(`review kb failed: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const userPrompt = [
|
||||
`工单号:${ticket.ticketNo}`,
|
||||
`类型:${ticket.ticketType}`,
|
||||
`标题:${ticket.title}`,
|
||||
`内容:${ticket.content ?? '(无)'}`,
|
||||
kbBlock ? `\n知识库片段:\n${kbBlock}` : '',
|
||||
].join('\n');
|
||||
|
||||
const system = settings.reviewAssistantPrompt?.trim() || DEFAULT_REVIEW_PROMPT;
|
||||
const raw = await this.llm.chat({
|
||||
baseUrl: llmCfg.baseUrl,
|
||||
apiKey: llmCfg.apiKey,
|
||||
model: llmCfg.modelName,
|
||||
temperature: 0.2,
|
||||
maxTokens: 2048,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
});
|
||||
|
||||
const parsed = parseReviewJson(raw, ticket.ticketType);
|
||||
items.push({
|
||||
ticketId: String(ticket.id),
|
||||
ticketNo: ticket.ticketNo,
|
||||
title: ticket.title,
|
||||
decision: parsed.decision,
|
||||
rejectReason: parsed.rejectReason,
|
||||
note: parsed.note,
|
||||
reportMarkdown: parsed.reportMarkdown,
|
||||
suggestedTasks: parsed.suggestedTasks,
|
||||
});
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
}
|
||||
|
||||
function parseReviewJson(
|
||||
raw: string,
|
||||
ticketType: 'BUG' | 'SUGGESTION' | 'OTHER',
|
||||
): {
|
||||
decision: 'APPROVE' | 'REJECT';
|
||||
rejectReason?: string;
|
||||
note?: string;
|
||||
reportMarkdown: string;
|
||||
suggestedTasks: Array<{ content: string; type: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION' }>;
|
||||
} {
|
||||
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
||||
const fallbackType = mapSupportTicketTypeToDevPlanTask(ticketType);
|
||||
if (!jsonMatch) {
|
||||
return {
|
||||
decision: 'APPROVE',
|
||||
note: 'AI 未返回结构化结果,请人工确认',
|
||||
reportMarkdown: raw.slice(0, 2000),
|
||||
suggestedTasks: [{ content: '待人工填写任务内容', type: fallbackType }],
|
||||
};
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(jsonMatch[0]) as {
|
||||
decision?: string;
|
||||
rejectReason?: string;
|
||||
note?: string;
|
||||
reportMarkdown?: string;
|
||||
suggestedTasks?: Array<{ content?: string; type?: string }>;
|
||||
};
|
||||
const decision = obj.decision === 'REJECT' ? 'REJECT' : 'APPROVE';
|
||||
const suggestedTasks =
|
||||
decision === 'APPROVE'
|
||||
? (obj.suggestedTasks ?? [])
|
||||
.filter((t) => t.content?.trim())
|
||||
.map((t) => ({
|
||||
content: t.content!.trim(),
|
||||
type: (['BUG', 'REQUIREMENT', 'OPTIMIZATION'].includes(String(t.type))
|
||||
? t.type
|
||||
: fallbackType) as 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION',
|
||||
}))
|
||||
: [];
|
||||
if (decision === 'APPROVE' && !suggestedTasks.length) {
|
||||
suggestedTasks.push({ content: '待人工填写任务内容', type: fallbackType });
|
||||
}
|
||||
return {
|
||||
decision,
|
||||
rejectReason: obj.rejectReason?.trim() || undefined,
|
||||
note: obj.note?.trim() || undefined,
|
||||
reportMarkdown: obj.reportMarkdown?.trim() || raw.slice(0, 2000),
|
||||
suggestedTasks,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
decision: 'APPROVE',
|
||||
note: 'AI 返回解析失败,请人工确认',
|
||||
reportMarkdown: raw.slice(0, 2000),
|
||||
suggestedTasks: [{ content: '待人工填写任务内容', type: fallbackType }],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user