diff --git a/apps/api/src/ai/ai.controller.ts b/apps/api/src/ai/ai.controller.ts index 3ca3ff9..f9f0c16 100644 --- a/apps/api/src/ai/ai.controller.ts +++ b/apps/api/src/ai/ai.controller.ts @@ -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 { + return this.aiService.listUsageLogs(this.resolveUserId(userIdHeader), query); + } + @Post("bindings") async upsertBinding( @Headers("x-user-id") userIdHeader: string | string[] | undefined, diff --git a/apps/api/src/ai/ai.service.ts b/apps/api/src/ai/ai.service.ts index efee87e..dfbbd9a 100644 --- a/apps/api/src/ai/ai.service.ts +++ b/apps/api/src/ai/ai.service.ts @@ -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 { + 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 { 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 { const taskSummary = await this.buildTaskContextSummary(userId); if (!taskSummary) { diff --git a/apps/api/src/ai/dto/list-ai-usage-logs-query.dto.ts b/apps/api/src/ai/dto/list-ai-usage-logs-query.dto.ts new file mode 100644 index 0000000..49aa2e0 --- /dev/null +++ b/apps/api/src/ai/dto/list-ai-usage-logs-query.dto.ts @@ -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; +} diff --git a/apps/api/test/ai.spec.ts b/apps/api/test/ai.spec.ts index 38f1b7f..4fd9b2f 100644 --- a/apps/api/test/ai.spec.ts +++ b/apps/api/test/ai.spec.ts @@ -3,6 +3,7 @@ import { INestApplication, ValidationPipe } from "@nestjs/common"; import { Test, TestingModule } from "@nestjs/testing"; import { AiChannel, + AiUsageLog, AiProviderBinding, AiPublicPoolConfig, TaskPriority, @@ -20,6 +21,7 @@ import { import { PrismaService } from "../src/prisma/prisma.service"; type AiUsageLogRecord = { + id: string; userId: string | null; channel: AiChannel; providerName: string | null; @@ -30,6 +32,7 @@ type AiUsageLogRecord = { latencyMs: number | null; success: boolean; errorCode: string | null; + createdAt: Date; }; type AiTaskRecord = { @@ -45,6 +48,7 @@ type AiTaskRecord = { class InMemoryAiPrismaService { private bindingIdSequence = 1; private publicPoolIdSequence = 1; + private usageLogIdSequence = 1; private bindings: AiProviderBinding[] = []; private publicPools: AiPublicPoolConfig[] = []; private usageLogs: AiUsageLogRecord[] = []; @@ -197,9 +201,47 @@ class InMemoryAiPrismaService { }; readonly aiUsageLog = { - create: async (args: { data: AiUsageLogRecord }) => { - this.usageLogs.push(args.data); - return args.data; + create: async (args: { data: Omit }) => { + const usageLog: AiUsageLogRecord = { + id: `usage_log_${this.usageLogIdSequence++}`, + createdAt: new Date(), + ...args.data + }; + + this.usageLogs.push(usageLog); + return usageLog; + }, + + findMany: async (args: { + where?: { + userId?: string; + channel?: AiChannel; + success?: boolean; + }; + orderBy?: { + createdAt: "asc" | "desc"; + }; + skip?: number; + take?: number; + }) => { + const filteredLogs = this.filterUsageLogs(args.where); + const sortedLogs = [...filteredLogs].sort((left, right) => { + const direction = args.orderBy?.createdAt === "asc" ? 1 : -1; + return (left.createdAt.getTime() - right.createdAt.getTime()) * direction; + }); + const start = args.skip ?? 0; + const end = args.take === undefined ? undefined : start + args.take; + return sortedLogs.slice(start, end); + }, + + count: async (args?: { + where?: { + userId?: string; + channel?: AiChannel; + success?: boolean; + }; + }) => { + return this.filterUsageLogs(args?.where).length; } }; @@ -258,6 +300,33 @@ class InMemoryAiPrismaService { seedTask(task: AiTaskRecord): void { this.tasks.push(task); } + + seedUsageLog(log: Omit & { id?: string }): void { + this.usageLogs.push({ + id: log.id ?? `usage_log_${this.usageLogIdSequence++}`, + ...log + }); + } + + private filterUsageLogs(where?: { + userId?: string; + channel?: AiChannel; + success?: boolean; + }): AiUsageLogRecord[] { + return this.usageLogs.filter((log) => { + if (where?.userId !== undefined && log.userId !== where.userId) { + return false; + } + if (where?.channel !== undefined && log.channel !== where.channel) { + return false; + } + if (where?.success !== undefined && log.success !== where.success) { + return false; + } + + return true; + }); + } } class StaticExecutor implements AiChannelExecutor { @@ -458,6 +527,7 @@ describe("AiController (integration)", () => { ]); expect(prismaService.getUsageLogs()).toEqual([ { + id: expect.any(String), userId: "user_1", channel: AiChannel.USER_KEY, providerName: "openai", @@ -467,9 +537,11 @@ describe("AiController (integration)", () => { totalTokens: 0, latencyMs: expect.any(Number), success: false, - errorCode: "UPSTREAM_UNREACHABLE" + errorCode: "UPSTREAM_UNREACHABLE", + createdAt: expect.any(Date) }, { + id: expect.any(String), userId: "user_1", channel: AiChannel.ASTRBOT, providerName: "default", @@ -479,7 +551,8 @@ describe("AiController (integration)", () => { totalTokens: 20, latencyMs: expect.any(Number), success: true, - errorCode: null + errorCode: null, + createdAt: expect.any(Date) } ]); }); @@ -593,4 +666,94 @@ describe("AiController (integration)", () => { ]); expect(prismaService.getUsageLogs()).toEqual([]); }); + it("should list usage logs with pagination and filters", async () => { + prismaService.seedUsageLog({ + id: "usage_log_1", + userId: "user_1", + channel: AiChannel.ASTRBOT, + providerName: "default", + model: "deepseek-chat", + promptTokens: 10, + completionTokens: 6, + totalTokens: 16, + latencyMs: 120, + success: true, + errorCode: null, + createdAt: new Date("2026-04-06T08:00:00.000Z") + }); + prismaService.seedUsageLog({ + id: "usage_log_2", + userId: "user_1", + channel: AiChannel.ASTRBOT, + providerName: "default", + model: "deepseek-chat", + promptTokens: 14, + completionTokens: 9, + totalTokens: 23, + latencyMs: 100, + success: true, + errorCode: null, + createdAt: new Date("2026-04-06T09:00:00.000Z") + }); + prismaService.seedUsageLog({ + id: "usage_log_3", + userId: "user_1", + channel: AiChannel.USER_KEY, + providerName: "openai", + model: "gpt-4o-mini", + promptTokens: 20, + completionTokens: 12, + totalTokens: 32, + latencyMs: 210, + success: false, + errorCode: "UPSTREAM_UNREACHABLE", + createdAt: new Date("2026-04-06T10:00:00.000Z") + }); + prismaService.seedUsageLog({ + id: "usage_log_4", + userId: "user_2", + channel: AiChannel.ASTRBOT, + providerName: "default", + model: "deepseek-chat", + promptTokens: 18, + completionTokens: 11, + totalTokens: 29, + latencyMs: 90, + success: true, + errorCode: null, + createdAt: new Date("2026-04-06T11:00:00.000Z") + }); + + const response = await request(app.getHttpServer()) + .get("/ai/usage-logs") + .set("x-user-id", "user_1") + .query({ + page: 2, + pageSize: 1, + channel: AiChannel.ASTRBOT, + success: true + }) + .expect(200); + + expect(response.body).toEqual({ + items: [ + { + id: "usage_log_1", + channel: AiChannel.ASTRBOT, + providerName: "default", + model: "deepseek-chat", + promptTokens: 10, + completionTokens: 6, + totalTokens: 16, + latencyMs: 120, + success: true, + errorCode: null, + createdAt: "2026-04-06T08:00:00.000Z" + } + ], + page: 2, + pageSize: 1, + total: 2 + }); + }); });