feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 deletions
@@ -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 }],
};
}
}