feat(report): generate grounded reports with Mastra
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes";
|
||||
import { resolveAllowedReportOrigins } from "@/lib/personal-report-entitlement";
|
||||
import {
|
||||
resolveReportDelete,
|
||||
resolveReportRead,
|
||||
} from "@/lib/personal-report-route-core";
|
||||
import { safeParseServerReportDocument } from "@/lib/personal-report-contract.server";
|
||||
import {
|
||||
createSupabasePersonalReportService,
|
||||
type PersonalReportService,
|
||||
} from "@/lib/personal-report-service";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RouteContext = { params: Promise<{ reportId: string }> };
|
||||
|
||||
function sanitizedErrorCode(error: unknown): string {
|
||||
if (error instanceof Error) return error.name;
|
||||
return "UnknownError";
|
||||
}
|
||||
|
||||
function toNextResponse(response: { status: number; body: Record<string, unknown> }) {
|
||||
return NextResponse.json(response.body, { status: response.status });
|
||||
}
|
||||
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* GET/DELETE use the AUTHENTICATED client: RLS permits owners to select and
|
||||
* delete their own rows only, and the persistence service additionally scopes
|
||||
* every query by userId (least privilege — no service role here).
|
||||
*/
|
||||
async function resolvePersistenceForUser() {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return { userId: null as string | null, persistence: null as PersonalReportService | null };
|
||||
}
|
||||
const persistence = createSupabasePersonalReportService(supabase);
|
||||
return { userId: user.id, persistence };
|
||||
}
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { userId, persistence } = await resolvePersistenceForUser();
|
||||
const { reportId } = await context.params;
|
||||
if (!uuidPattern.test(reportId)) {
|
||||
return NextResponse.json(
|
||||
{ error: "报告不存在", code: REPORT_STABLE_CODES.notFound },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const response = await resolveReportRead({
|
||||
requestUrl: request.url,
|
||||
origin: request.headers.get("origin"),
|
||||
allowedOrigins: resolveAllowedReportOrigins(process.env),
|
||||
userId,
|
||||
reportId,
|
||||
persistence: persistence ?? {
|
||||
async getOwnedById() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
// Defense in depth: a stored ready document is re-validated through the
|
||||
// canonical server parse (schema + guards + evidence hash recompute)
|
||||
// before it is returned to the browser. Client-side validation is never
|
||||
// a substitute.
|
||||
validateReadyDocument: (document) => {
|
||||
const parsed = safeParseServerReportDocument(document);
|
||||
return parsed.ok
|
||||
? { ok: true, document: parsed.document }
|
||||
: { ok: false };
|
||||
},
|
||||
});
|
||||
return toNextResponse(response);
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
console.error(`[reports] read failed reason=${sanitizedErrorCode(error)}`);
|
||||
return NextResponse.json(
|
||||
{ error: "报告暂时无法读取", code: REPORT_STABLE_CODES.generationFailed },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { userId, persistence } = await resolvePersistenceForUser();
|
||||
const { reportId } = await context.params;
|
||||
if (!uuidPattern.test(reportId)) {
|
||||
return NextResponse.json(
|
||||
{ error: "报告不存在", code: REPORT_STABLE_CODES.notFound },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const response = await resolveReportDelete({
|
||||
requestUrl: request.url,
|
||||
origin: request.headers.get("origin"),
|
||||
allowedOrigins: resolveAllowedReportOrigins(process.env),
|
||||
userId,
|
||||
reportId,
|
||||
persistence: persistence ?? {
|
||||
async getOwnedById() {
|
||||
return null;
|
||||
},
|
||||
async deleteOwned() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
return toNextResponse(response);
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
console.error(`[reports] delete failed reason=${sanitizedErrorCode(error)}`);
|
||||
return NextResponse.json(
|
||||
{ error: "报告暂时无法删除", code: REPORT_STABLE_CODES.generationFailed },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runConsultationWorkflow } from "@/mastra";
|
||||
import { createPersonalReportAgent } from "@/mastra/personal-report";
|
||||
import { defaultLanguageModel } from "@/mastra/model";
|
||||
import {
|
||||
resolveSkillSnapshot,
|
||||
} from "@/lib/personal-report-generation";
|
||||
import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes";
|
||||
import {
|
||||
isPersonalReportFeatureEnabled,
|
||||
readPersonalReportDailyLimit,
|
||||
resolveAllowedReportOrigins,
|
||||
} from "@/lib/personal-report-entitlement";
|
||||
import {
|
||||
resolveReportCreate,
|
||||
type ReportCreateCoreDeps,
|
||||
} from "@/lib/personal-report-route-core";
|
||||
import {
|
||||
createPersonalReportDataClient,
|
||||
createSupabasePersonalReportService,
|
||||
type PersonalReportService,
|
||||
} from "@/lib/personal-report-service";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 120;
|
||||
|
||||
function sanitizedErrorCode(error: unknown): string {
|
||||
if (error instanceof Error) return error.name;
|
||||
return "UnknownError";
|
||||
}
|
||||
|
||||
function toNextResponse(response: { status: number; body: Record<string, unknown> }) {
|
||||
return NextResponse.json(response.body, { status: response.status });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Authenticated client: auth, profile, session/chart-profile owner reads.
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
const userId = authError || !user ? null : user.id;
|
||||
|
||||
// Admin client (service_role / self-hosted admin DB): generation writes
|
||||
// and counting. The authenticated client is forbidden by migration grants
|
||||
// from inserting/updating personal_reports.
|
||||
const admin = createAdminSupabaseClient();
|
||||
const persistence: PersonalReportService = createSupabasePersonalReportService(admin);
|
||||
const adminDataClient = createPersonalReportDataClient(admin);
|
||||
|
||||
let profile: unknown = null;
|
||||
let profileError: unknown = null;
|
||||
if (userId) {
|
||||
const result = await supabase
|
||||
.from("profiles")
|
||||
.select("name,birth_date,active_birth_time,birth_time_status,latitude,longitude,timezone_offset,birth_place_label")
|
||||
.eq("id", userId)
|
||||
.maybeSingle();
|
||||
profile = result.data ?? null;
|
||||
profileError = result.error;
|
||||
}
|
||||
|
||||
const deps: ReportCreateCoreDeps = {
|
||||
requestUrl: request.url,
|
||||
origin: request.headers.get("origin"),
|
||||
allowedOrigins: resolveAllowedReportOrigins(process.env),
|
||||
userId,
|
||||
rawBody: await request.json().catch(() => null),
|
||||
profile,
|
||||
checkSessionOwned: async (sessionId) => {
|
||||
const { data, error } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select("id")
|
||||
.eq("id", sessionId)
|
||||
.eq("user_id", userId as string)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return Boolean(data);
|
||||
},
|
||||
checkChartProfileOwned: async (chartProfileId) => {
|
||||
const { data, error } = await supabase
|
||||
.from("chart_profiles")
|
||||
.select("id")
|
||||
.eq("id", chartProfileId)
|
||||
.eq("user_id", userId as string)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return Boolean(data);
|
||||
},
|
||||
featureEnabled: isPersonalReportFeatureEnabled(process.env),
|
||||
dailyLimit: readPersonalReportDailyLimit(process.env),
|
||||
counts: {
|
||||
countGenerating: async () => {
|
||||
const { data, error } = await adminDataClient.from("personal_reports")
|
||||
.select("id")
|
||||
.eq("user_id", userId as string)
|
||||
.eq("status", "generating")
|
||||
.limit(2);
|
||||
if (error) throw error;
|
||||
return Array.isArray(data) ? data.length : 0;
|
||||
},
|
||||
countCreatedToday: async () => {
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const { data, error } = await adminDataClient.from("personal_reports")
|
||||
.select("id,created_at")
|
||||
.eq("user_id", userId as string);
|
||||
if (error) throw error;
|
||||
if (!Array.isArray(data)) return 0;
|
||||
const startIso = todayStart.toISOString();
|
||||
return data.filter((row) => {
|
||||
const createdAt = row && typeof row === "object"
|
||||
? (row as Record<string, unknown>).created_at
|
||||
: null;
|
||||
return typeof createdAt === "string" && createdAt >= startIso;
|
||||
}).length;
|
||||
},
|
||||
},
|
||||
persistence,
|
||||
model: defaultLanguageModel(),
|
||||
runWorkflow: (input) => runConsultationWorkflow(input),
|
||||
createAgent: (model) => createPersonalReportAgent(model as Parameters<typeof createPersonalReportAgent>[0]),
|
||||
skillSnapshot: resolveSkillSnapshot(),
|
||||
};
|
||||
|
||||
const response = await resolveReportCreate(deps);
|
||||
if (response.status >= 500 && profileError) {
|
||||
console.error(`[reports] create failed request=${String(deps.rawBody && typeof deps.rawBody === "object"
|
||||
? (deps.rawBody as Record<string, unknown>).requestId ?? "unknown"
|
||||
: "unknown")} reason=${sanitizedErrorCode(profileError)}`);
|
||||
}
|
||||
return toNextResponse(response);
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
console.error(`[reports] create failed reason=${sanitizedErrorCode(error)}`);
|
||||
return NextResponse.json(
|
||||
{ error: "报告生成暂时不可用", code: REPORT_STABLE_CODES.generationFailed },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user