fix(api): bind daily and synastry charts to stored profiles
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

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:
Jesse_Chen
2026-08-19 19:38:39 +08:00
parent 40a595045e
commit c38f11dbd3
16 changed files with 548 additions and 39 deletions
@@ -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();
+18 -4
View File
@@ -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 });
}
+14 -2
View File
@@ -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 });
}
+68 -13
View File
@@ -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([