diff --git a/src/app/api/__tests__/copy-renwei.test.ts b/src/app/api/__tests__/copy-renwei.test.ts new file mode 100644 index 0000000..b846ee5 --- /dev/null +++ b/src/app/api/__tests__/copy-renwei.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const llmMocks = vi.hoisted(() => ({ + generateValidatedJson: vi.fn(), +})); + +vi.mock("../../../lib/llm/client", async () => { + const actual = await vi.importActual( + "../../../lib/llm/client", + ); + return { + ...actual, + generateValidatedJson: llmMocks.generateValidatedJson, + }; +}); + +import { POST as optimizeCopy } from "../copy/renwei-optimize/route"; + +describe("renwei copy optimization API route", () => { + const originalApiKey = process.env.API_ACCESS_KEY; + const originalAuthDisabled = process.env.API_AUTH_DISABLED; + + beforeEach(() => { + process.env.API_ACCESS_KEY = "test-key"; + process.env.API_AUTH_DISABLED = "false"; + }); + + afterEach(() => { + process.env.API_ACCESS_KEY = originalApiKey; + process.env.API_AUTH_DISABLED = originalAuthDisabled; + llmMocks.generateValidatedJson.mockReset(); + }); + + it("rejects requests without the access key", async () => { + const response = await optimizeCopy( + request( + { + source_text: "这是一段普通文案。", + intensity: "light", + }, + { apiKey: null }, + ), + ); + + expect(response.status).toBe(401); + }); + + it("returns 400 for empty source text", async () => { + const response = await optimizeCopy( + request({ + source_text: " ", + intensity: "light", + }), + ); + const body = (await response.json()) as { error: string }; + + expect(response.status).toBe(400); + expect(body.error).toBe("请输入需要优化的文案"); + }); + + it("returns structured copy optimization results", async () => { + llmMocks.generateValidatedJson.mockResolvedValueOnce({ + optimized_text: "我把这段文案顺了一下。", + change_notes: [ + { + original: "我把这段文案顺顺。", + revised: "我把这段文案顺了一下。", + reason: "修正重复表达。", + confidence: "confident", + revertible: false, + }, + ], + ai_taste_checks: [ + { + rule_id: "promotion_tone", + status: "pass", + evidence: "没有新增宣传腔。", + suggestion: "", + }, + ], + warnings: [], + }); + + const response = await optimizeCopy( + request({ + source_text: "我把这段文案顺顺。", + goal: "", + intensity: "light", + user_instructions: "保留口语。", + }), + ); + const body = (await response.json()) as { + result: { optimized_text: string; change_notes: unknown[] }; + }; + + expect(response.status).toBe(200); + expect(body.result.optimized_text).toBe("我把这段文案顺了一下。"); + expect(body.result.change_notes).toHaveLength(1); + expect(llmMocks.generateValidatedJson).toHaveBeenCalledWith( + expect.objectContaining({ + task: "renwei_copy_optimizer", + }), + ); + }); + + it("surfaces LLM failures as a 502", async () => { + llmMocks.generateValidatedJson.mockRejectedValueOnce( + new Error("LLM response failed schema validation: optimized_text"), + ); + + const response = await optimizeCopy( + request({ + source_text: "这是一段普通文案。", + intensity: "light", + }), + ); + const body = (await response.json()) as { error: string }; + + expect(response.status).toBe(502); + expect(body.error).toBe( + "LLM response failed schema validation: optimized_text", + ); + }); +}); + +function request(body: unknown, options: { apiKey?: string | null } = {}) { + const headers: Record = { "content-type": "application/json" }; + const apiKey = options.apiKey === undefined ? "test-key" : options.apiKey; + if (apiKey) { + headers["x-api-key"] = apiKey; + } + + return new Request("http://localhost/api/copy/renwei-optimize", { + method: "POST", + body: JSON.stringify(body), + headers, + }); +} diff --git a/src/app/api/copy/renwei-optimize/route.ts b/src/app/api/copy/renwei-optimize/route.ts new file mode 100644 index 0000000..2e33192 --- /dev/null +++ b/src/app/api/copy/renwei-optimize/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { requireApiAccess } from "../../../../lib/api/auth"; +import { copyOptimizationRequestSchema } from "../../../../lib/domain/validation"; +import { LlmValidationError } from "../../../../lib/llm/client"; +import { optimizeRenweiCopy } from "../../../../lib/workflow/renwei-copy-optimizer"; + +export async function POST(request: Request) { + const access = requireApiAccess(request); + if (!access.ok) { + return access.response; + } + + try { + const payload = copyOptimizationRequestSchema.parse(await request.json()); + const result = await optimizeRenweiCopy(payload); + return NextResponse.json({ result }); + } catch (error) { + return jsonError(error, getErrorStatus(error)); + } +} + +function jsonError(error: unknown, status: number) { + const message = + error instanceof z.ZodError + ? "请输入需要优化的文案" + : error instanceof Error + ? error.message + : "文案优化失败"; + + return NextResponse.json({ error: message }, { status }); +} + +function getErrorStatus(error: unknown) { + if (error instanceof LlmValidationError) return 502; + if (error instanceof Error && /^LLM\b|provider/i.test(error.message)) return 502; + return 400; +}