344 lines
14 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|