fix(api): bind daily and synastry charts to stored profiles
Stop accepting client-supplied birth data on those paths, and cap session writes plus location lookups so a logged-in caller cannot farm compute. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,19 +10,34 @@ import {
|
||||
type DailyStarlanguageCard,
|
||||
type DailyStarlanguageEvidence,
|
||||
} from "@/lib/daily-starlanguage";
|
||||
import {
|
||||
dailyProfilePayload,
|
||||
type GlobalBirthProfile,
|
||||
} from "@/lib/global-birth-payloads";
|
||||
import { dailyProfilePayload } from "@/lib/global-birth-payloads";
|
||||
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
|
||||
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
import { globalBirthProfileFromAccountRow } from "@/lib/server-owned-birth-profile";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { getDailyStarlanguageAgent } from "@/mastra";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 20;
|
||||
|
||||
type Profile = GlobalBirthProfile & { birthTimeStatus?: string; timezoneId?: string };
|
||||
type Profile = ReturnType<typeof globalBirthProfileFromAccountRow>;
|
||||
type BirthPayload = NonNullable<Awaited<ReturnType<typeof dailyProfilePayload>>>;
|
||||
const accountBirthColumns = [
|
||||
"name",
|
||||
"birth_date",
|
||||
"reported_birth_time",
|
||||
"active_birth_time",
|
||||
"birth_time",
|
||||
"birth_time_status",
|
||||
"country_code",
|
||||
"province_code",
|
||||
"city_code",
|
||||
"district_code",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"timezone_offset",
|
||||
"timezone_id",
|
||||
].join(",");
|
||||
type CardSource = "engine_evidence" | "agent";
|
||||
type CacheEntry = { readonly day: string; readonly card: DailyStarlanguageCard; readonly source: CardSource };
|
||||
type Generated =
|
||||
@@ -173,10 +188,17 @@ export async function POST(request: Request) {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ status: "unauthenticated" }, { status: 401 });
|
||||
await request.json().catch(() => null);
|
||||
|
||||
const body = await request.json().catch(() => null) as { profile?: Profile; today?: string } | null;
|
||||
const profile = body?.profile ?? {};
|
||||
const today = body?.today || calendarDateInTimeZone(new Date(), profile.timezoneId);
|
||||
const { data: row, error } = await supabase
|
||||
.from("profiles")
|
||||
.select(accountBirthColumns)
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
if (error || !row) return unavailable("birth_profile_incomplete");
|
||||
|
||||
const profile = globalBirthProfileFromAccountRow(row);
|
||||
const today = calendarDateInTimeZone(new Date(), profile.timezoneId);
|
||||
const payload = await dailyProfilePayload(profile, today).catch(() => null);
|
||||
if (!payload) return unavailable("birth_profile_incomplete");
|
||||
|
||||
@@ -184,6 +206,16 @@ export async function POST(request: Request) {
|
||||
const cached = readCache(key, today);
|
||||
if (cached) return ok(cached.card, `${cached.source}_cache`);
|
||||
|
||||
const limited = consumeUserRequestRateLimit("dailyStarlanguage", user.id);
|
||||
if (!limited.ok) {
|
||||
return NextResponse.json({
|
||||
status: "unavailable",
|
||||
reason: "rate_limited",
|
||||
claim_status: "exploratory_unvalidated",
|
||||
boundary: "not_deterministic_prediction",
|
||||
}, { status: 429, headers: { "Retry-After": String(limited.retryAfterSeconds) } });
|
||||
}
|
||||
|
||||
const inFlight = pending().get(key) ?? generateCard(payload, profile, today)
|
||||
.catch((error: unknown): Generated => {
|
||||
console.warn("daily_starlanguage_generation_failed", error);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { birthLocationSearchQuerySchema } from "@/lib/location-contract";
|
||||
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
import { searchGlobalBirthLocations } from "@/lib/geoapify-location-service";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
@@ -10,6 +11,15 @@ export async function GET(request: Request) {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const limited = consumeUserRequestRateLimit("locationSearch", user.id);
|
||||
if (!limited.ok) {
|
||||
return NextResponse.json({
|
||||
error: "请求过于频繁",
|
||||
code: "rate_limited",
|
||||
retryAfterSeconds: limited.retryAfterSeconds,
|
||||
}, { status: 429, headers: { "Retry-After": String(limited.retryAfterSeconds) } });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const parsed = birthLocationSearchQuerySchema.safeParse(Object.fromEntries(url.searchParams));
|
||||
if (!parsed.success) return NextResponse.json({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveBirthLocationTimezone } from "@/lib/birth-location-timezone-service";
|
||||
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
import { birthLocationTimezoneQuerySchema } from "@/lib/location-contract";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
@@ -10,6 +11,15 @@ export async function POST(request: Request) {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const limited = consumeUserRequestRateLimit("locationTimezone", user.id);
|
||||
if (!limited.ok) {
|
||||
return NextResponse.json({
|
||||
error: "请求过于频繁",
|
||||
code: "rate_limited",
|
||||
retryAfterSeconds: limited.retryAfterSeconds,
|
||||
}, { status: 429, headers: { "Retry-After": String(limited.retryAfterSeconds) } });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
|
||||
@@ -2,16 +2,30 @@ import { NextResponse } from "next/server";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import {
|
||||
ChatSessionBodyTooLargeError,
|
||||
chatSessionModelPatchSchema,
|
||||
chatSessionWriteSchema,
|
||||
readChatSessionJson,
|
||||
} from "@/lib/chat-session-write-contract";
|
||||
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const payload = await request.json().catch(() => null);
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
const limited = consumeUserRequestRateLimit("sessionWrite", user.id);
|
||||
if (!limited.ok) {
|
||||
return NextResponse.json({
|
||||
error: "请求过于频繁",
|
||||
code: "rate_limited",
|
||||
retryAfterSeconds: limited.retryAfterSeconds,
|
||||
}, { status: 429, headers: { "Retry-After": String(limited.retryAfterSeconds) } });
|
||||
}
|
||||
const payload = await readChatSessionJson(request);
|
||||
const fullWrite = chatSessionWriteSchema.safeParse(payload);
|
||||
const modelPatch = chatSessionModelPatchSchema.safeParse(payload);
|
||||
let values: Record<string, unknown>;
|
||||
@@ -22,9 +36,6 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||
} else {
|
||||
return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
|
||||
}
|
||||
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("chat_sessions")
|
||||
.update(values)
|
||||
@@ -36,6 +47,9 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||
if (!data) return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSessionBodyTooLargeError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||
}
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { chatSessionCreateSchema } from "@/lib/chat-session-write-contract";
|
||||
import { chatSessionCreateSchema, ChatSessionBodyTooLargeError, readChatSessionJson } from "@/lib/chat-session-write-contract";
|
||||
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
@@ -28,7 +29,15 @@ export async function POST(request: Request) {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
const parsed = chatSessionCreateSchema.safeParse(await request.json().catch(() => null));
|
||||
const limited = consumeUserRequestRateLimit("sessionWrite", user.id);
|
||||
if (!limited.ok) {
|
||||
return NextResponse.json({
|
||||
error: "请求过于频繁",
|
||||
code: "rate_limited",
|
||||
retryAfterSeconds: limited.retryAfterSeconds,
|
||||
}, { status: 429, headers: { "Retry-After": String(limited.retryAfterSeconds) } });
|
||||
}
|
||||
const parsed = chatSessionCreateSchema.safeParse(await readChatSessionJson(request));
|
||||
if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
|
||||
const { id, ...values } = parsed.data;
|
||||
const { error } = await supabase.from("chat_sessions").insert({
|
||||
@@ -39,6 +48,9 @@ export async function POST(request: Request) {
|
||||
if (error) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 });
|
||||
return NextResponse.json({ ok: true }, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSessionBodyTooLargeError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||
}
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isProductEnabled } from "@/lib/product-access";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
import {
|
||||
synastryBirthPayload,
|
||||
type GlobalBirthProfile as Profile,
|
||||
} from "@/lib/global-birth-payloads";
|
||||
|
||||
type RelationshipType = "romance" | "business" | "family" | "general";
|
||||
globalBirthProfileFromAccountRow,
|
||||
globalBirthProfileFromStoredChart,
|
||||
} from "@/lib/server-owned-birth-profile";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { synastryBirthPayload } from "@/lib/global-birth-payloads";
|
||||
|
||||
const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
const accountBirthColumns = [
|
||||
"name",
|
||||
"birth_date",
|
||||
"reported_birth_time",
|
||||
"active_birth_time",
|
||||
"birth_time",
|
||||
"birth_time_status",
|
||||
"country_code",
|
||||
"province_code",
|
||||
"city_code",
|
||||
"district_code",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"timezone_offset",
|
||||
"timezone_id",
|
||||
].join(",");
|
||||
const synastryRequestSchema = z.object({
|
||||
partnerChartProfileId: z.string().uuid(),
|
||||
relationshipType: z.enum(["romance", "business", "family", "general"]).optional(),
|
||||
}).strict();
|
||||
|
||||
function moonLongitude(chart: Record<string, unknown>) {
|
||||
const planets = chart.planets && typeof chart.planets === "object" ? chart.planets as Record<string, unknown> : {};
|
||||
const moon = planets.Moon && typeof planets.Moon === "object" ? planets.Moon as Record<string, unknown> : {};
|
||||
@@ -130,18 +152,51 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null) as { selfProfile?: Profile; partnerProfile?: Profile; relationshipType?: RelationshipType } | null;
|
||||
if (!body?.selfProfile || !body.partnerProfile) {
|
||||
return NextResponse.json({ error: "请提供双方星盘资料" }, { status: 400 });
|
||||
const limited = consumeUserRequestRateLimit("synastry", user.id);
|
||||
if (!limited.ok) {
|
||||
return NextResponse.json({
|
||||
error: "请求过于频繁",
|
||||
code: "rate_limited",
|
||||
retryAfterSeconds: limited.retryAfterSeconds,
|
||||
}, { status: 429, headers: { "Retry-After": String(limited.retryAfterSeconds) } });
|
||||
}
|
||||
|
||||
const parsed = synastryRequestSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "请选择已保存的对方星盘" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: selfRow, error: selfError } = await supabase
|
||||
.from("profiles")
|
||||
.select(accountBirthColumns)
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
if (selfError || !selfRow) {
|
||||
return NextResponse.json({ error: "请先完成本人出生资料" }, { status: 409 });
|
||||
}
|
||||
const { data: partnerRow, error: partnerError } = await supabase
|
||||
.from("chart_profiles")
|
||||
.select("id, role, profile")
|
||||
.eq("id", parsed.data.partnerChartProfileId)
|
||||
.eq("user_id", user.id)
|
||||
.eq("role", "other")
|
||||
.maybeSingle();
|
||||
if (partnerError) {
|
||||
return NextResponse.json({ error: "暂时无法读取对方星盘" }, { status: 503 });
|
||||
}
|
||||
const partnerProfile = globalBirthProfileFromStoredChart(partnerRow?.profile);
|
||||
if (!partnerProfile) {
|
||||
return NextResponse.json({ error: "请先把对方星盘保存到云端星盘库" }, { status: 404 });
|
||||
}
|
||||
|
||||
const [selfPayload, partnerPayload] = await Promise.all([
|
||||
synastryBirthPayload(body.selfProfile),
|
||||
synastryBirthPayload(body.partnerProfile),
|
||||
synastryBirthPayload(globalBirthProfileFromAccountRow(selfRow)),
|
||||
synastryBirthPayload(partnerProfile),
|
||||
]);
|
||||
const selfChart = await postPython("/api/chart", selfPayload);
|
||||
const partnerChart = await postPython("/api/chart", partnerPayload);
|
||||
const relationshipType = body.relationshipType === "business" || body.relationshipType === "family" || body.relationshipType === "general"
|
||||
? body.relationshipType
|
||||
const relationshipType = parsed.data.relationshipType === "business" || parsed.data.relationshipType === "family" || parsed.data.relationshipType === "general"
|
||||
? parsed.data.relationshipType
|
||||
: "romance";
|
||||
if (relationshipType === "business") {
|
||||
const [selfVargas, partnerVargas] = await Promise.all([
|
||||
|
||||
Reference in New Issue
Block a user