Files
Jyotisha/frontend/src/app/api/reports/route.ts
T

344 lines
14 KiB
TypeScript

import { NextResponse } from "next/server";
import { runConsultationWorkflow } from "@/mastra";
import { createPersonalReportAgent } from "@/mastra/personal-report";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
import {
resolveSkillSnapshot,
} from "@/lib/personal-report-generation";
import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes";
import { summarizePersonalReportFailure } from "@/lib/personal-report-failure-summary";
import { isProductEnabled } from "@/lib/product-access";
import {
isPersonalReportFeatureEnabled,
readPersonalReportDailyLimit,
resolveAllowedReportOrigins,
} from "@/lib/personal-report-entitlement";
import {
resolveReportCreate,
reportListTimestamp,
type ReportCreateCoreDeps,
} from "@/lib/personal-report-route-core";
import {
createPersonalReportDataClient,
createSupabasePersonalReportService,
type PersonalReportService,
} from "@/lib/personal-report-service";
import { createSupabasePersonalReportJobService } from "@/lib/personal-report-job-service";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing";
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
export const maxDuration = 120;
const REPORT_LIST_COLUMNS = [
"id",
"request_id",
"report_type",
"presentation_mode",
"depth",
"requested_themes",
"status",
"failure_code",
"created_at",
"completed_at",
].join(",");
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 });
}
function listReportView(
value: unknown,
failure?: ReturnType<typeof summarizePersonalReportFailure> | null,
) {
const row = value && typeof value === "object" ? value as Record<string, unknown> : {};
return {
id: typeof row.id === "string" ? row.id : "",
requestId: typeof row.request_id === "string" ? row.request_id : "",
reportType: typeof row.report_type === "string" ? row.report_type : "personal_full",
presentationMode: typeof row.presentation_mode === "string" ? row.presentation_mode : "default",
depth: typeof row.depth === "string" ? row.depth : "standard",
themes: Array.isArray(row.requested_themes)
? row.requested_themes.filter((theme): theme is string => typeof theme === "string")
: [],
status: typeof row.status === "string" ? row.status : "failed",
failureCode: typeof row.failure_code === "string" ? row.failure_code : null,
createdAt: reportListTimestamp(row.created_at),
completedAt: row.completed_at == null || row.completed_at === ""
? null
: reportListTimestamp(row.completed_at) || null,
...(failure?.summary ? { failureSummary: failure.summary } : {}),
};
}
/** Lightweight report-centre index. Report bodies never leave this endpoint. */
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const { data, error } = await supabase
.from("personal_reports")
.select(REPORT_LIST_COLUMNS)
.eq("user_id", user.id)
.order("created_at", { ascending: false })
.limit(20);
if (error) throw error;
const reports = Array.isArray(data) ? data.map((row) => listReportView(row)).filter((report) => report.id) : [];
const failed = reports.filter((report) => report.status === "failed" && report.requestId);
if (failed.length > 0) {
const { data: sections, error: sectionError } = await supabase
.from("personal_report_sections")
.select("request_id, status, last_error_code")
.eq("user_id", user.id)
.in("request_id", failed.map((report) => report.requestId));
if (sectionError) throw sectionError;
const grouped = new Map<string, Array<{ status: string; lastErrorCode: string | null }>>();
for (const row of Array.isArray(sections) ? sections : []) {
const requestId = typeof row.request_id === "string" ? row.request_id : "";
if (!requestId) continue;
const list = grouped.get(requestId) ?? [];
list.push({
status: typeof row.status === "string" ? row.status : "pending",
lastErrorCode: typeof row.last_error_code === "string" ? row.last_error_code : null,
});
grouped.set(requestId, list);
}
for (const [index, report] of reports.entries()) {
if (report.status !== "failed") continue;
const source = Array.isArray(data) ? data.find((row) => (
row && typeof row === "object" && (row as { id?: unknown }).id === report.id
)) : null;
reports[index] = listReportView(source, summarizePersonalReportFailure({
themeCount: report.themes.length,
sections: grouped.get(report.requestId) ?? [],
failureCode: report.failureCode,
}));
}
}
return NextResponse.json({ reports });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json(
{ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" },
{ status: 503 },
);
}
console.error(`[reports] list failed reason=${sanitizedErrorCode(error)}`);
return NextResponse.json(
{ error: "报告列表暂时无法读取", code: REPORT_STABLE_CODES.generationFailed },
{ status: 500 },
);
}
}
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;
const reportProductEnabled = userId
? await isProductEnabled("report_center")
: false;
// 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,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,rectification_case_id,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_offset,birth_place_label,ayanamsa")
.eq("id", userId)
.maybeSingle();
profile = result.data ?? null;
profileError = result.error;
}
const catalog = await loadLanguageModelCatalog();
const defaultModel = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null;
let pricingConfigurationErrorCode: string | null = null;
const deps: ReportCreateCoreDeps = {
requestUrl: request.url,
origin: request.headers.get("origin"),
allowedOrigins: resolveAllowedReportOrigins(process.env),
requestHeaders: request.headers,
userId,
rawBody: await request.json().catch(() => null),
profile,
loadCandidateRange: async () => {
const profileRow = profile && typeof profile === "object" ? profile as Record<string, unknown> : {};
const caseId = typeof profileRow.rectification_case_id === "string"
? profileRow.rectification_case_id
: null;
if (caseId) {
const { data, error } = await admin
.from("birth_time_rectification_cases")
.select("candidate_start,candidate_end")
.eq("id", caseId)
.eq("user_id", userId as string)
.in("status", ["confirmed", "completed"])
.maybeSingle();
if (error) throw error;
const row = data && typeof data === "object" ? data as Record<string, unknown> : null;
if (row && typeof row.candidate_start === "string" && typeof row.candidate_end === "string") {
return { startTime: row.candidate_start, endTime: row.candidate_end };
}
}
const { data, error } = await admin
.from("agentic_rectification_cases")
.select("candidate_range,updated_at")
.eq("user_id", userId as string)
.eq("status", "candidate_accepted")
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
const row = data && typeof data === "object" ? data as Record<string, unknown> : null;
const range = row?.candidate_range && typeof row.candidate_range === "object"
? row.candidate_range as Record<string, unknown>
: null;
return range && typeof range.start_time === "string" && typeof range.end_time === "string"
? { startTime: range.start_time, endTime: range.end_time }
: null;
},
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: reportProductEnabled && 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: defaultModel,
billing: {
reserve: async ({ userId: billingUserId, requestId, modelId }) => {
try {
const pricing = await resolveFeaturePricing(admin, "report.full", modelId);
return authorizeUsage(admin, {
userId: billingUserId, requestId, featureKey: "report.full",
requestedModelId: modelId, creditCost: pricing.credit_cost,
});
} catch (error) {
if (error instanceof FeaturePricingError) {
pricingConfigurationErrorCode = error.code;
console.error(`[reports] billing reservation failed request=${requestId} reason=${error.code}`);
}
throw error;
}
},
complete: async ({ userId: billingUserId, requestId, usage }) => {
if (!defaultModel) return false;
const costMicrousd = Math.round((
usage.inputTokens * (defaultModel.inputCostMicrousdPerMillion ?? 0)
+ usage.outputTokens * (defaultModel.outputCostMicrousdPerMillion ?? 0)
) / 1_000_000);
const settled = await completeUsage(admin, billingUserId, requestId, {
eventKey: "report.full", actualModelId: usage.actualModelId,
modelConfigVersion: usage.modelConfigVersion, inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens, costMicrousd, durationMs: usage.durationMs,
...(usage.cache ? { metadata: { cache: { ...usage.cache, hit: usage.cache.readTokens > 0 } } } : {}),
});
return settled.success;
},
release: async ({ userId: billingUserId, requestId, reason }) => {
const released = await releaseUsage(admin, billingUserId, requestId, reason);
return released.success;
},
},
runWorkflow: (input) => runConsultationWorkflow(input),
createAgent: (model) => createPersonalReportAgent(model as Parameters<typeof createPersonalReportAgent>[0]),
skillSnapshot: resolveSkillSnapshot(),
jobs: createSupabasePersonalReportJobService(admin),
};
const response = await resolveReportCreate(deps);
if (pricingConfigurationErrorCode) {
return NextResponse.json(
{
error: "计费配置不可用",
message: "当前服务的计费配置尚未完成,请联系支持人员,本次不会扣点。",
code: "pricing_configuration_unavailable",
},
{ status: 503 },
);
}
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: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" },
{ status: 503 },
);
}
console.error(`[reports] create failed reason=${sanitizedErrorCode(error)}`);
return NextResponse.json(
{ error: "报告生成暂时不可用", code: REPORT_STABLE_CODES.generationFailed },
{ status: 500 },
);
}
}