fix(ai): V3 长期问题修复+规则合规+竞品对标
## P1 安全加固 - 原子化每日限额(tryConsumeDailyQuota)解决 TOCTOU 竞态 - 流式端点补齐 Zod 校验 + rate limit + 服务端强制 systemPrompt - 配额回退机制(refundDailyQuota):过滤/失败不扣配额 - PII 最小化:移除 AI prompt 中的学生姓名 ## P1 数据一致性 - 修复 capability 埋点缺失 child_summary/study_path 类型 - 创建 data-access.ts:真实统计聚合替代硬编码零 - 修复 generateChildSummary/recommendStudyPath 的 capability 标记 ## P2 可靠性 - AI 调用重试机制(withRetry 指数退避,429/5xx,2 次重试) - 30s 超时配置 - 流式 controller 安全 enqueue(防已关闭抛错) - localStorage 防抖持久化(500ms,流式过程中跳过) ## P2 TypeScript/规则合规 - 移除 as 断言(VariantType 类型守卫、Permission 类型、StreamErrorKey) - 补齐返回类型标注(POST/getStatusFromError/DashboardLayout) - 拆分 use-ai-chat-stream hook(190→107 行,函数体≤80 行) - 抽取 stream-utils.ts(SSE 解析/错误映射/消息工具) - Tailwind 任意值添加注释说明(max-w-[80%] 聊天气泡) ## P3 竞品对标 - 苏格拉底式辅导强化(对标 Khanmigo): - SOCRATIC_TUTOR_SYSTEM_PROMPT 3 级提示升级 - 强化 STUDENT_BLOCKED_PATTERNS 正则(中英文答案拦截) - validateSocraticOutput 服务端校验(问号结尾+连续陈述句限制) - socratic_warning SSE 事件类型 - 知识图谱集成(对标 Squirrel AI): - StudyPathInput 新增 knowledgeGraph/textbookId 字段 - recommendStudyPathAction 自动从 textbooks 模块获取图谱+掌握度 - STUDY_PATH_SYSTEM_PROMPT 增加前置依赖链规则 - WEAKNESS_ANALYSIS_SYSTEM_PROMPT 增加 rootCause 字段 ## 架构文档同步 - 004 更新 AI 模块章节(V3 标记/新导出/依赖关系/安全机制/文件清单) - 005 更新 modules.ai 节点(dependsOn/exports/dataAccess/streamUtils/dependencyMatrix)
This commit is contained in:
@@ -34,7 +34,7 @@ export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<AiClientProvider service={aiClientService}>
|
||||
<SidebarProvider sidebar={<AppSidebar />}>
|
||||
|
||||
@@ -6,14 +6,14 @@ import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const getStatusFromError = (message: string) => {
|
||||
const getStatusFromError = (message: string): number => {
|
||||
if (message === "Invalid payload" || message === "Messages are required") return 400
|
||||
if (message === "AI API key missing") return 500
|
||||
if (message === "Empty response") return 502
|
||||
return 502
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
export async function POST(req: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.AI_CHAT)
|
||||
const userId = ctx.userId
|
||||
|
||||
@@ -6,15 +6,22 @@ import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guar
|
||||
import { createAiChatCompletionStream } from "@/shared/lib/ai/client"
|
||||
import { getAiErrorMessage } from "@/shared/lib/ai"
|
||||
import { trackEvent } from "@/shared/lib/track-event"
|
||||
import {
|
||||
rateLimit,
|
||||
rateLimitKey,
|
||||
RATE_LIMIT_RULES,
|
||||
} from "@/shared/lib/rate-limit"
|
||||
import { env } from "@/env.mjs"
|
||||
|
||||
import { CHAT_SYSTEM_PROMPT } from "@/modules/ai/services/prompt-templates"
|
||||
import { CHAT_SYSTEM_PROMPT, SOCRATIC_TUTOR_SYSTEM_PROMPT } from "@/modules/ai/services/prompt-templates"
|
||||
import {
|
||||
filterUserInput,
|
||||
filterAiOutput,
|
||||
checkDailyLimit,
|
||||
incrementDailyUsage,
|
||||
tryConsumeDailyQuota,
|
||||
refundDailyQuota,
|
||||
validateSocraticOutput,
|
||||
} from "@/modules/ai/services/content-safety"
|
||||
import { AiChatInputSchema } from "@/modules/ai/schema"
|
||||
import type { AiChatMessage } from "@/modules/ai/types"
|
||||
|
||||
/**
|
||||
@@ -23,11 +30,14 @@ import type { AiChatMessage } from "@/modules/ai/types"
|
||||
* 使用 Server-Sent Events 逐 token 推送 AI 回复,
|
||||
* 降低用户感知延迟。
|
||||
*
|
||||
* 安全:
|
||||
* 安全策略(与非流式端点一致):
|
||||
* - requirePermission(AI_CHAT) 权限校验
|
||||
* - rateLimit 每分钟限流(防高频滥用)
|
||||
* - Zod 校验输入(AiChatInputSchema,限制消息数/长度)
|
||||
* - tryConsumeDailyQuota 原子化每日限额(防 TOCTOU 竞态)
|
||||
* - 输入/输出内容安全过滤
|
||||
* - 每日交互限制
|
||||
* - 学生侧 Socratic 模式
|
||||
* - 学生侧 Socratic 模式(服务端强制,忽略客户端 systemPrompt)
|
||||
* - 过滤/失败时 refundDailyQuota(不惩罚用户)
|
||||
*/
|
||||
|
||||
const formatEvent = (data: unknown): string => {
|
||||
@@ -50,34 +60,45 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
const userRole = session?.user?.role ?? "student"
|
||||
const isStudent = userRole === "student"
|
||||
|
||||
// 2. 每日限制
|
||||
const limitCheck = checkDailyLimit(ctx.userId, userRole)
|
||||
if (limitCheck.blocked) {
|
||||
// 2. Rate limit(与非流式端点一致)
|
||||
const limitResult = rateLimit({
|
||||
key: rateLimitKey("ai-chat-stream", ctx.userId),
|
||||
...RATE_LIMIT_RULES.AI_CHAT,
|
||||
})
|
||||
if (!limitResult.success) {
|
||||
return new Response(formatError("Rate limit exceeded"), {
|
||||
status: 429,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Zod 校验输入(防止超大 message 导致 token 爆炸)
|
||||
const rawBody = await request.json().catch(() => null)
|
||||
const parseResult = AiChatInputSchema.safeParse(rawBody)
|
||||
if (!parseResult.success) {
|
||||
return new Response(formatError("Invalid input"), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
const body = parseResult.data
|
||||
|
||||
// 4. 原子化每日限额检查(防 TOCTOU 竞态)
|
||||
const quotaResult = tryConsumeDailyQuota(ctx.userId, userRole)
|
||||
if (quotaResult.blocked) {
|
||||
return new Response(formatError("Daily limit reached"), {
|
||||
status: 429,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 解析请求
|
||||
const body = (await request.json()) as {
|
||||
messages?: AiChatMessage[]
|
||||
providerId?: string
|
||||
systemPrompt?: string
|
||||
}
|
||||
|
||||
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
|
||||
return new Response(formatError("Messages are required"), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 输入安全过滤
|
||||
// 5. 输入安全过滤
|
||||
for (const msg of body.messages) {
|
||||
if (msg.role === "user") {
|
||||
const filterResult = filterUserInput(msg.content, { isStudent })
|
||||
if (filterResult.blocked) {
|
||||
// 输入被过滤,回退配额
|
||||
refundDailyQuota(ctx.userId)
|
||||
return new Response(formatError("Input blocked by safety filter"), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
@@ -86,24 +107,35 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 构建 system prompt(学生侧 Socratic 模式)
|
||||
const baseSystemPrompt = body.systemPrompt ?? CHAT_SYSTEM_PROMPT
|
||||
const studentSystemPrompt = isStudent
|
||||
? `${baseSystemPrompt}\n\nIMPORTANT: You are in student mode. Use the Socratic method. Do NOT give direct answers. Guide the student to find the answer themselves through questions and hints.`
|
||||
: baseSystemPrompt
|
||||
// 6. 构建 system prompt
|
||||
// 安全:服务端强制使用系统提示词,忽略客户端传入的 systemPrompt,
|
||||
// 防止学生绕过 Socratic 模式获取直接答案。
|
||||
const baseSystemPrompt = isStudent
|
||||
? SOCRATIC_TUTOR_SYSTEM_PROMPT
|
||||
: CHAT_SYSTEM_PROMPT
|
||||
|
||||
const messages: AiChatMessage[] = [
|
||||
{ role: "system", content: studentSystemPrompt },
|
||||
{ role: "system", content: baseSystemPrompt },
|
||||
...body.messages,
|
||||
]
|
||||
|
||||
// 6. 流式调用 AI
|
||||
// 7. 流式调用 AI
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const startTime = Date.now()
|
||||
let fullContent = ""
|
||||
let success = true
|
||||
let errorMessage: string | undefined
|
||||
let wasFiltered = false
|
||||
|
||||
const safeEnqueue = (text: string): void => {
|
||||
// 防止 controller 已关闭时 enqueue 抛错
|
||||
try {
|
||||
controller.enqueue(encoder.encode(text))
|
||||
} catch {
|
||||
// controller 已关闭,忽略
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const aiStream = createAiChatCompletionStream({
|
||||
@@ -119,27 +151,49 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
// 输出安全过滤(逐 chunk 检查关键词)
|
||||
const outputFilter = filterAiOutput(chunk, { isStudent })
|
||||
if (outputFilter.blocked) {
|
||||
controller.enqueue(encoder.encode(formatEvent({
|
||||
safeEnqueue(formatEvent({
|
||||
type: "filtered",
|
||||
message: "Content filtered for safety",
|
||||
})))
|
||||
}))
|
||||
success = false
|
||||
wasFiltered = true
|
||||
break
|
||||
}
|
||||
|
||||
controller.enqueue(encoder.encode(formatEvent({ type: "token", content: chunk })))
|
||||
safeEnqueue(formatEvent({ type: "token", content: chunk }))
|
||||
}
|
||||
|
||||
// 增加每日使用计数
|
||||
incrementDailyUsage(ctx.userId)
|
||||
// 仅在成功完成时保留配额计数
|
||||
// 过滤或失败时回退配额(不惩罚用户)
|
||||
if (!success || wasFiltered) {
|
||||
refundDailyQuota(ctx.userId)
|
||||
}
|
||||
|
||||
controller.enqueue(encoder.encode(FORMAT_DONE))
|
||||
// 学生侧:苏格拉底式辅导输出校验
|
||||
// 流式完成后检查完整回复是否符合引导式教学原则
|
||||
if (success && !wasFiltered && isStudent && fullContent) {
|
||||
const socraticResult = validateSocraticOutput(fullContent)
|
||||
if (!socraticResult.valid) {
|
||||
safeEnqueue(formatEvent({
|
||||
type: "socratic_warning",
|
||||
message: "AI response did not fully follow Socratic tutoring principles.",
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
safeEnqueue(FORMAT_DONE)
|
||||
} catch (error) {
|
||||
success = false
|
||||
errorMessage = error instanceof Error ? error.message : String(error)
|
||||
controller.enqueue(encoder.encode(formatError(getAiErrorMessage(error))))
|
||||
// AI 调用失败,回退配额
|
||||
refundDailyQuota(ctx.userId)
|
||||
safeEnqueue(formatError(getAiErrorMessage(error)))
|
||||
} finally {
|
||||
controller.close()
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// 已关闭,忽略
|
||||
}
|
||||
|
||||
// 埋点
|
||||
void trackEvent({
|
||||
@@ -152,6 +206,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
tokenCount: fullContent.length / 4,
|
||||
errorMessage,
|
||||
isStudent,
|
||||
filtered: wasFiltered,
|
||||
},
|
||||
}).catch(() => {
|
||||
// 静默失败
|
||||
|
||||
Reference in New Issue
Block a user