feat(api-ai): add usage log query endpoint

This commit is contained in:
2026-04-06 13:08:36 +08:00
parent 4578116a30
commit 5c956c195b
4 changed files with 312 additions and 7 deletions
+16 -2
View File
@@ -1,7 +1,13 @@
import { Body, Controller, Get, Headers, Post, UnauthorizedException } from "@nestjs/common";
import { Body, Controller, Get, Headers, Post, Query, UnauthorizedException } from "@nestjs/common";
import { AiChatDto } from "./dto/ai-chat.dto";
import { ListAiUsageLogsQueryDto } from "./dto/list-ai-usage-logs-query.dto";
import { UpsertAiProviderBindingDto } from "./dto/upsert-ai-provider-binding.dto";
import { AiChatResponse, AiService, ListAiBindingsResponse } from "./ai.service";
import {
AiChatResponse,
AiService,
ListAiBindingsResponse,
ListAiUsageLogsResponse
} from "./ai.service";
@Controller("ai")
export class AiController {
@@ -14,6 +20,14 @@ export class AiController {
return this.aiService.listBindings(this.resolveUserId(userIdHeader));
}
@Get("usage-logs")
async listUsageLogs(
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
@Query() query: ListAiUsageLogsQueryDto
): Promise<ListAiUsageLogsResponse> {
return this.aiService.listUsageLogs(this.resolveUserId(userIdHeader), query);
}
@Post("bindings")
async upsertBinding(
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
+80
View File
@@ -7,6 +7,7 @@
} from "@nestjs/common";
import {
AiChannel,
AiUsageLog,
AiProviderBinding,
AiPublicPoolConfig,
Prisma,
@@ -16,6 +17,7 @@ import {
import { PrismaService } from "../prisma/prisma.service";
import { AiProviderRegistryService } from "./ai-provider-registry.service";
import { AiChatDto } from "./dto/ai-chat.dto";
import { ListAiUsageLogsQueryDto } from "./dto/list-ai-usage-logs-query.dto";
import { UpsertAiProviderBindingDto } from "./dto/upsert-ai-provider-binding.dto";
import {
AiResolvedRouteCandidate,
@@ -61,6 +63,27 @@ export type ListAiBindingsResponse = {
} | null;
};
type AiUsageLogSummary = {
id: string;
channel: AiChannel;
providerName: string | null;
model: string | null;
promptTokens: number;
completionTokens: number;
totalTokens: number;
latencyMs: number | null;
success: boolean;
errorCode: string | null;
createdAt: string;
};
export type ListAiUsageLogsResponse = {
items: AiUsageLogSummary[];
page: number;
pageSize: number;
total: number;
};
export type AiChatResponse = {
channel: AiChannel;
providerName: string;
@@ -111,6 +134,47 @@ export class AiService {
};
}
async listUsageLogs(
userId: string,
query: ListAiUsageLogsQueryDto
): Promise<ListAiUsageLogsResponse> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where: Prisma.AiUsageLogWhereInput = {
userId
};
if (query.channel) {
where.channel = query.channel;
}
if (query.success !== undefined) {
where.success = query.success;
}
const [items, total] = await Promise.all([
this.prismaService.aiUsageLog.findMany({
where,
orderBy: {
createdAt: "desc"
},
skip,
take: pageSize
}),
this.prismaService.aiUsageLog.count({
where
})
]);
return {
items: items.map((item) => this.serializeUsageLog(item)),
page,
pageSize,
total
};
}
async upsertBinding(userId: string, dto: UpsertAiProviderBindingDto): Promise<AiBindingSummary> {
if (dto.channel === AiChannel.PUBLIC_POOL) {
throw new BadRequestException("公共 AI 通道只能由管理员配置");
@@ -421,6 +485,22 @@ export class AiService {
};
}
private serializeUsageLog(log: AiUsageLog): AiUsageLogSummary {
return {
id: log.id,
channel: log.channel,
providerName: log.providerName,
model: log.model,
promptTokens: log.promptTokens,
completionTokens: log.completionTokens,
totalTokens: log.totalTokens,
latencyMs: log.latencyMs,
success: log.success,
errorCode: log.errorCode,
createdAt: log.createdAt.toISOString()
};
}
private async buildPromptMessage(userId: string, userMessage: string): Promise<string> {
const taskSummary = await this.buildTaskContextSummary(userId);
if (!taskSummary) {
@@ -0,0 +1,48 @@
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsEnum, IsInt, IsOptional, Max, Min } from "class-validator";
import { AiChannel } from "../../../generated/prisma/client";
function normalizeBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") {
return value;
}
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1") {
return true;
}
if (normalized === "false" || normalized === "0") {
return false;
}
return undefined;
}
export class ListAiUsageLogsQueryDto {
@Type(() => Number)
@IsOptional()
@IsInt()
@Min(1)
page?: number;
@Type(() => Number)
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
@IsOptional()
@IsEnum(AiChannel)
channel?: AiChannel;
@Transform(({ value }) => normalizeBoolean(value))
@IsOptional()
@IsBoolean()
success?: boolean;
}