From c38f11dbd3fb11447b6e4884b64f16b33451cb87 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 19 Aug 2026 19:38:39 +0800 Subject: [PATCH] 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 --- .../src/app/api/daily-starlanguage/route.ts | 48 ++++++-- .../src/app/api/locations/search/route.ts | 10 ++ .../src/app/api/locations/timezone/route.ts | 10 ++ frontend/src/app/api/sessions/[id]/route.ts | 22 +++- frontend/src/app/api/sessions/route.ts | 16 ++- frontend/src/app/api/synastry/route.ts | 81 +++++++++++--- frontend/src/app/page.tsx | 14 ++- .../src/lib/chat-session-write-contract.ts | 51 ++++++++- frontend/src/lib/request-rate-limit.ts | 61 +++++++++++ .../src/lib/server-owned-birth-profile.ts | 103 ++++++++++++++++++ .../tests/chart-library-other-profile.test.ts | 8 +- frontend/tests/chat-session-write.test.ts | 22 ++++ frontend/tests/daily-starlanguage.test.ts | 8 ++ frontend/tests/global-birth-location.test.ts | 9 ++ frontend/tests/request-rate-limit.test.ts | 48 ++++++++ .../tests/server-owned-birth-profile.test.ts | 76 +++++++++++++ 16 files changed, 548 insertions(+), 39 deletions(-) create mode 100644 frontend/src/lib/request-rate-limit.ts create mode 100644 frontend/src/lib/server-owned-birth-profile.ts create mode 100644 frontend/tests/request-rate-limit.test.ts create mode 100644 frontend/tests/server-owned-birth-profile.test.ts diff --git a/frontend/src/app/api/daily-starlanguage/route.ts b/frontend/src/app/api/daily-starlanguage/route.ts index 7f3f6d29..3ecf45e8 100644 --- a/frontend/src/app/api/daily-starlanguage/route.ts +++ b/frontend/src/app/api/daily-starlanguage/route.ts @@ -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; type BirthPayload = NonNullable>>; +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); diff --git a/frontend/src/app/api/locations/search/route.ts b/frontend/src/app/api/locations/search/route.ts index 072f9f57..2aa0d729 100644 --- a/frontend/src/app/api/locations/search/route.ts +++ b/frontend/src/app/api/locations/search/route.ts @@ -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({ diff --git a/frontend/src/app/api/locations/timezone/route.ts b/frontend/src/app/api/locations/timezone/route.ts index b9d0b6de..6c1fb7ae 100644 --- a/frontend/src/app/api/locations/timezone/route.ts +++ b/frontend/src/app/api/locations/timezone/route.ts @@ -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(); diff --git a/frontend/src/app/api/sessions/[id]/route.ts b/frontend/src/app/api/sessions/[id]/route.ts index afe1f665..771085cb 100644 --- a/frontend/src/app/api/sessions/[id]/route.ts +++ b/frontend/src/app/api/sessions/[id]/route.ts @@ -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; @@ -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 }); } diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts index 8a548751..00cc62f7 100644 --- a/frontend/src/app/api/sessions/route.ts +++ b/frontend/src/app/api/sessions/route.ts @@ -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 }); } diff --git a/frontend/src/app/api/synastry/route.ts b/frontend/src/app/api/synastry/route.ts index 85a5023a..fef96a90 100644 --- a/frontend/src/app/api/synastry/route.ts +++ b/frontend/src/app/api/synastry/route.ts @@ -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) { const planets = chart.planets && typeof chart.planets === "object" ? chart.planets as Record : {}; const moon = planets.Moon && typeof planets.Moon === "object" ? planets.Moon as Record : {}; @@ -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([ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 3daa9506..97f791de 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -570,11 +570,11 @@ function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile, re const dailyStarlanguageRetryDelayMs = 5_000; -async function fetchDailyStarlanguage(profile: Profile, today: string, signal: AbortSignal): Promise { +async function fetchDailyStarlanguage(signal: AbortSignal): Promise { const response = await fetch("/api/daily-starlanguage", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ profile, today }), + body: JSON.stringify({}), signal, }); if (!response.ok) return { kind: "unavailable" }; @@ -1803,7 +1803,7 @@ export default function Home() { setDailyStarlanguage({ kind: "pending" }); } const attempt = (remainingRetries: number) => { - void fetchDailyStarlanguage(profile, today, controller.signal) + void fetchDailyStarlanguage(controller.signal) .then((next) => { if (controller.signal.aborted) return; if (next.kind === "unavailable" && remainingRetries > 0) { @@ -2640,7 +2640,7 @@ export default function Home() { const response = await fetch("/api/synastry", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ selfProfile: profile, partnerProfile: record.profile, relationshipType }), + body: JSON.stringify({ partnerChartProfileId: record.id, relationshipType }), }); const payload = await response.json().catch(() => null) as { status?: string; claimStatus?: string; blockedLayers?: string[]; evidenceLayers?: string[]; synastry?: { total_score?: number; max_score?: number; assessment?: string }; relationshipReport?: { headline?: string; scoreBand?: string; strengths?: string[]; risks?: string[]; nextEvidence?: string[] } } | null; if (response.ok && payload?.status === "ok") { @@ -2688,7 +2688,11 @@ export default function Home() { ].join("\n"), relationshipType === "business" ? "career" : "marriage"); } else { chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage"); - setComposerNotice(payload?.status === "blocked" ? "合盘计算暂时不可用,已先生成问题草稿。" : "已生成合盘问题草稿。"); + setComposerNotice(response.status === 404 + ? "请先把对方星盘保存到云端,再用于合盘。" + : response.status === 429 + ? "合盘请求过于频繁,请稍后再试。" + : payload?.status === "blocked" ? "合盘计算暂时不可用,已先生成问题草稿。" : "已生成合盘问题草稿。"); } } catch { chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage"); diff --git a/frontend/src/lib/chat-session-write-contract.ts b/frontend/src/lib/chat-session-write-contract.ts index e7471870..a0f060f1 100644 --- a/frontend/src/lib/chat-session-write-contract.ts +++ b/frontend/src/lib/chat-session-write-contract.ts @@ -7,9 +7,14 @@ import { } from "./consultation-agent-events.ts"; import { consultationDomainSchema, type ConsultationDomain } from "./consultation-domain-registry.ts"; +export const CHAT_SESSION_MAX_MESSAGES = 200; +export const CHAT_SESSION_MAX_MESSAGE_CHARS = 16_000; +export const CHAT_SESSION_MAX_TOTAL_MESSAGE_CHARS = 200_000; +export const CHAT_SESSION_MAX_BODY_CHARS = 500_000; + const chatMessageSchema = z.object({ role: z.enum(["user", "assistant"]), - text: z.string().max(100_000), + text: z.string().max(CHAT_SESSION_MAX_MESSAGE_CHARS), // Nothing writes suggestions since the follow-up chips were removed, but this schema // is strict and a client running the previous bundle still sends them; rejecting the // whole write would lose that user's message rather than a dead field. @@ -19,19 +24,53 @@ const chatMessageSchema = z.object({ workflowReceipt: workflowReceiptSchema.optional(), }).strict(); -export const chatSessionWriteSchema = z.object({ +const chatSessionWriteObjectSchema = z.object({ title: z.string().trim().min(1).max(160), theme: consultationDomainSchema, model_id: z.string().trim().min(1).max(64), - messages: z.array(chatMessageSchema).max(500), + messages: z.array(chatMessageSchema).max(CHAT_SESSION_MAX_MESSAGES), session_type: z.enum(["consultation", "birth_time_rectification"]), rectification_case_id: z.string().uuid().nullable(), updated_at: z.string().datetime(), }).strict(); -export const chatSessionCreateSchema = chatSessionWriteSchema.extend({ - id: z.string().uuid(), -}).strict(); +function limitTranscriptSize }>>(schema: Schema) { + return schema.superRefine((value, context) => { + const totalChars = value.messages.reduce((sum, message) => sum + message.text.length, 0); + if (totalChars > CHAT_SESSION_MAX_TOTAL_MESSAGE_CHARS) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["messages"], + message: "聊天记录过长", + }); + } + }); +} + +export const chatSessionWriteSchema = limitTranscriptSize(chatSessionWriteObjectSchema); +export const chatSessionCreateSchema = limitTranscriptSize( + chatSessionWriteObjectSchema.extend({ + id: z.string().uuid(), + }).strict(), +); + +export class ChatSessionBodyTooLargeError extends Error { + constructor() { + super("聊天记录过长"); + this.name = "ChatSessionBodyTooLargeError"; + } +} + +export async function readChatSessionJson(request: Request): Promise { + const raw = await request.text().catch(() => ""); + if (raw.length > CHAT_SESSION_MAX_BODY_CHARS) throw new ChatSessionBodyTooLargeError(); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} export const chatSessionModelPatchSchema = z.object({ model_id: z.string().trim().min(1).max(64), diff --git a/frontend/src/lib/request-rate-limit.ts b/frontend/src/lib/request-rate-limit.ts new file mode 100644 index 00000000..d14010fb --- /dev/null +++ b/frontend/src/lib/request-rate-limit.ts @@ -0,0 +1,61 @@ +export type RequestRateLimitStore = Map; + +export type RequestRateLimitDecision = + | { readonly ok: true } + | { readonly ok: false; readonly retryAfterSeconds: number }; + +export type RequestRateLimitInput = Readonly<{ + key: string; + limit: number; + windowMs: number; + now?: number; + store?: RequestRateLimitStore; +}>; + +const state = globalThis as typeof globalThis & { + jyotishaRequestRateLimits?: RequestRateLimitStore; +}; + +export const requestRateLimits = { + locationSearch: { limit: 20, windowMs: 60_000 }, + locationTimezone: { limit: 30, windowMs: 60_000 }, + dailyStarlanguage: { limit: 10, windowMs: 60_000 }, + synastry: { limit: 8, windowMs: 60 * 60_000 }, + sessionWrite: { limit: 40, windowMs: 60_000 }, +} as const; + +export type RequestRateLimitBucket = keyof typeof requestRateLimits; + +function defaultStore(): RequestRateLimitStore { + state.jyotishaRequestRateLimits ??= new Map(); + return state.jyotishaRequestRateLimits; +} + +export function consumeRequestRateLimit(input: RequestRateLimitInput): RequestRateLimitDecision { + const now = input.now ?? Date.now(); + const store = input.store ?? defaultStore(); + const windowStart = now - input.windowMs; + const recent = (store.get(input.key) ?? []).filter((timestamp) => timestamp > windowStart); + if (recent.length >= input.limit) { + const retryAfterSeconds = Math.max(1, Math.ceil((recent[0]! + input.windowMs - now) / 1000)); + store.set(input.key, recent); + return { ok: false, retryAfterSeconds }; + } + recent.push(now); + store.set(input.key, recent); + return { ok: true }; +} + +export function consumeUserRequestRateLimit( + bucket: RequestRateLimitBucket, + userId: string, + now?: number, +): RequestRateLimitDecision { + const spec = requestRateLimits[bucket]; + return consumeRequestRateLimit({ + key: `${bucket}:${userId}`, + limit: spec.limit, + windowMs: spec.windowMs, + now, + }); +} diff --git a/frontend/src/lib/server-owned-birth-profile.ts b/frontend/src/lib/server-owned-birth-profile.ts new file mode 100644 index 00000000..f5c56e04 --- /dev/null +++ b/frontend/src/lib/server-owned-birth-profile.ts @@ -0,0 +1,103 @@ +import type { GlobalBirthProfile } from "./global-birth-payloads.ts"; + +const usableActiveStatuses = new Set(["accepted", "confirmed"]); + +export type AccountBirthRow = Readonly<{ + name?: unknown; + birth_date?: unknown; + reported_birth_time?: unknown; + active_birth_time?: unknown; + birth_time?: unknown; + birth_time_status?: unknown; + country_code?: unknown; + province_code?: unknown; + city_code?: unknown; + district_code?: unknown; + latitude?: unknown; + longitude?: unknown; + timezone_offset?: unknown; + timezone_id?: unknown; +}>; + +function text(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function calendarDate(value: unknown): string | undefined { + if (value instanceof Date && Number.isFinite(value.getTime())) { + return value.toISOString().slice(0, 10); + } + const raw = text(value)?.slice(0, 10); + return raw && /^\d{4}-\d{2}-\d{2}$/.test(raw) ? raw : undefined; +} + +function clock(value: unknown): string | undefined { + const raw = text(value); + const match = raw ? /^(\d{1,2}):(\d{2})/.exec(raw) : null; + if (!match) return undefined; + const hour = Number.parseInt(match[1], 10); + const minute = Number.parseInt(match[2], 10); + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return undefined; + return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; +} + +function finiteNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function selectedClock(row: AccountBirthRow): string | undefined { + const status = text(row.birth_time_status) ?? ""; + const active = clock(row.active_birth_time) ?? clock(row.birth_time); + const reported = clock(row.reported_birth_time); + if (usableActiveStatuses.has(status) && active) return active; + return reported ?? active; +} + +export function globalBirthProfileFromAccountRow(row: AccountBirthRow): GlobalBirthProfile & { + birthTimeStatus?: string; +} { + return { + name: text(row.name), + date: calendarDate(row.birth_date), + time: selectedClock(row), + countryCode: text(row.country_code), + provinceCode: text(row.province_code), + cityCode: text(row.city_code), + districtCode: text(row.district_code), + latitude: finiteNumber(row.latitude) ?? null, + longitude: finiteNumber(row.longitude) ?? null, + timezoneOffset: finiteNumber(row.timezone_offset) ?? null, + timezoneId: text(row.timezone_id), + birthTimeStatus: text(row.birth_time_status), + }; +} + +export function globalBirthProfileFromStoredChart(value: unknown): GlobalBirthProfile | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const profile = globalBirthProfileFromAccountRow({ + name: record.name, + birth_date: record.date ?? record.birth_date, + reported_birth_time: record.reportedTime ?? record.reported_birth_time, + active_birth_time: record.time ?? record.active_birth_time, + birth_time: record.birth_time, + birth_time_status: record.birthTimeStatus ?? record.birth_time_status, + country_code: record.countryCode ?? record.country_code, + province_code: record.provinceCode ?? record.province_code, + city_code: record.cityCode ?? record.city_code, + district_code: record.districtCode ?? record.district_code, + latitude: record.latitude, + longitude: record.longitude, + timezone_offset: record.timezoneOffset ?? record.timezone_offset, + timezone_id: record.timezoneId ?? record.timezone_id, + }); + if (!profile.date || !profile.time) return null; + return profile; +} diff --git a/frontend/tests/chart-library-other-profile.test.ts b/frontend/tests/chart-library-other-profile.test.ts index 1bfb59a4..207457bb 100644 --- a/frontend/tests/chart-library-other-profile.test.ts +++ b/frontend/tests/chart-library-other-profile.test.ts @@ -63,7 +63,13 @@ test("relationship intent selects domain-specific evidence instead of treating e assert.match(source, /亲友\/家庭/); assert.match(source, /其他关系/); assert.match(source, /relationshipType: SynastryRelationshipType/); - assert.match(source, /body: JSON\.stringify\(\{ selfProfile: profile, partnerProfile: record\.profile, relationshipType \}\)/); + assert.match(source, /body: JSON\.stringify\(\{ partnerChartProfileId: record\.id, relationshipType \}\)/); + assert.doesNotMatch(source, /selfProfile: profile, partnerProfile: record\.profile/); + assert.match(route, /partnerChartProfileId/); + assert.match(route, /from\("profiles"\)/); + assert.match(route, /from\("chart_profiles"\)/); + assert.match(route, /eq\("role", "other"\)/); + assert.match(route, /consumeUserRequestRateLimit\("synastry"/); assert.match(route, /relationshipType === "business"/); assert.match(route, /divisions: \["D2", "D10", "D11"\]/); assert.match(route, /"D10_Dasamsa"/); diff --git a/frontend/tests/chat-session-write.test.ts b/frontend/tests/chat-session-write.test.ts index e3ccfe1f..a007f9ed 100644 --- a/frontend/tests/chat-session-write.test.ts +++ b/frontend/tests/chat-session-write.test.ts @@ -77,6 +77,24 @@ test("owner or validation failures are not retried", async () => { assert.equal(attempts, 1); }); +test("session writes reject oversized transcripts before they reach storage", () => { + const oversized = chatSessionWriteSchema.safeParse({ + ...values, + messages: [{ role: "user", text: "字".repeat(16_001) }], + }); + const tooMany = chatSessionWriteSchema.safeParse({ + ...values, + messages: Array.from({ length: 201 }, () => ({ role: "user" as const, text: "你好" })), + }); + const tooMuchText = chatSessionWriteSchema.safeParse({ + ...values, + messages: Array.from({ length: 20 }, () => ({ role: "user" as const, text: "字".repeat(12_000) })), + }); + assert.equal(oversized.success, false); + assert.equal(tooMany.success, false); + assert.equal(tooMuchText.success, false); +}); + test("session API owns create and update while answer UI keeps sync failures out of reply errors", () => { const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const collectionRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8"); @@ -87,6 +105,10 @@ test("session API owns create and update while answer UI keeps sync failures out assert.match(collectionRoute, /export async function POST/); assert.match(itemRoute, /export async function PATCH/); assert.match(itemRoute, /\.eq\("user_id", user\.id\)/); + assert.match(collectionRoute, /readChatSessionJson/); + assert.match(itemRoute, /readChatSessionJson/); + assert.match(collectionRoute, /ChatSessionBodyTooLargeError/); + assert.match(itemRoute, /ChatSessionBodyTooLargeError/); }); diff --git a/frontend/tests/daily-starlanguage.test.ts b/frontend/tests/daily-starlanguage.test.ts index 41b8f124..16a6828f 100644 --- a/frontend/tests/daily-starlanguage.test.ts +++ b/frontend/tests/daily-starlanguage.test.ts @@ -269,6 +269,11 @@ test("the homepage card is engine-backed first, with Agent polish off the reques assert.match(route, /supabase\.auth\.getUser\(\)/); assert.match(route, /\{ status: "unauthenticated" \}, \{ status: 401 \}/); + assert.match(route, /from\("profiles"\)/); + assert.match(route, /globalBirthProfileFromAccountRow/); + assert.match(route, /consumeUserRequestRateLimit\("dailyStarlanguage"/); + assert.doesNotMatch(route, /body\?\.profile/); + assert.doesNotMatch(route, /body\?\.today/); assert.match(route, /dailyStarlanguageCacheKey\(user\.id, JSON\.stringify\(payload\), today\)/); assert.match(route, /pending\(\)\.get\(key\)/); assert.match(route, /"\/api\/daily_guidance"/); @@ -289,6 +294,9 @@ test("the homepage card is engine-backed first, with Agent polish off the reques for (const source of [route, page]) { assert.doesNotMatch(source, /先收束,再推进|执行力比灵感更重要|适合观察资源流向/); } + assert.match(page, /fetchDailyStarlanguage\(controller\.signal\)/); + assert.match(page, /body: JSON\.stringify\(\{\}\)/); + assert.doesNotMatch(page, /JSON\.stringify\(\{ profile, today \}\)/); assert.doesNotMatch(page, /buildDailyStarlanguageCard/); assert.match(route, /status: "unavailable"/); assert.match(page, /今天的星语还没写出来/); diff --git a/frontend/tests/global-birth-location.test.ts b/frontend/tests/global-birth-location.test.ts index 489fe29b..cc773b2b 100644 --- a/frontend/tests/global-birth-location.test.ts +++ b/frontend/tests/global-birth-location.test.ts @@ -129,3 +129,12 @@ test("birth time cannot be submitted without a birth date", () => { assert.equal(birthLocationSearchQuerySchema.safeParse({ q: "Paris", birthTime: "05:30" }).success, false); assert.equal(birthLocationSearchQuerySchema.safeParse({ q: "Paris", birthDate: "2021-02-29" }).success, false); }); + +test("location search and timezone routes bound logged-in callers before spending upstream quota", () => { + const searchRoute = readFileSync(new URL("../src/app/api/locations/search/route.ts", import.meta.url), "utf8"); + const timezoneRoute = readFileSync(new URL("../src/app/api/locations/timezone/route.ts", import.meta.url), "utf8"); + assert.match(searchRoute, /consumeUserRequestRateLimit\("locationSearch"/); + assert.match(timezoneRoute, /consumeUserRequestRateLimit\("locationTimezone"/); + assert.match(searchRoute, /Retry-After/); + assert.match(timezoneRoute, /Retry-After/); +}); diff --git a/frontend/tests/request-rate-limit.test.ts b/frontend/tests/request-rate-limit.test.ts new file mode 100644 index 00000000..f5e9f2bd --- /dev/null +++ b/frontend/tests/request-rate-limit.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { consumeRequestRateLimit } from "../src/lib/request-rate-limit.ts"; + +test("a user is allowed up to the window limit and then must wait", () => { + const store = new Map(); + const first = consumeRequestRateLimit({ + key: "search:user-1", + limit: 2, + windowMs: 60_000, + now: 1_000, + store, + }); + const second = consumeRequestRateLimit({ + key: "search:user-1", + limit: 2, + windowMs: 60_000, + now: 2_000, + store, + }); + const blocked = consumeRequestRateLimit({ + key: "search:user-1", + limit: 2, + windowMs: 60_000, + now: 3_000, + store, + }); + const otherUser = consumeRequestRateLimit({ + key: "search:user-2", + limit: 2, + windowMs: 60_000, + now: 3_000, + store, + }); + const afterWindow = consumeRequestRateLimit({ + key: "search:user-1", + limit: 2, + windowMs: 60_000, + now: 61_500, + store, + }); + + assert.deepEqual(first, { ok: true }); + assert.deepEqual(second, { ok: true }); + assert.deepEqual(blocked, { ok: false, retryAfterSeconds: 58 }); + assert.deepEqual(otherUser, { ok: true }); + assert.deepEqual(afterWindow, { ok: true }); +}); diff --git a/frontend/tests/server-owned-birth-profile.test.ts b/frontend/tests/server-owned-birth-profile.test.ts new file mode 100644 index 00000000..ad673f1e --- /dev/null +++ b/frontend/tests/server-owned-birth-profile.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + globalBirthProfileFromAccountRow, + globalBirthProfileFromStoredChart, +} from "../src/lib/server-owned-birth-profile.ts"; + +test("accepted account rows use the server active minute, not a reported leftover", () => { + const profile = globalBirthProfileFromAccountRow({ + name: "测试", + birth_date: "1990-06-15", + reported_birth_time: "08:00:00", + active_birth_time: "09:15:00", + birth_time_status: "accepted", + country_code: "CN", + province_code: "310000", + city_code: "310100", + latitude: 31.2, + longitude: 121.5, + timezone_offset: 8, + timezone_id: "Asia/Shanghai", + }); + + assert.equal(profile.date, "1990-06-15"); + assert.equal(profile.time, "09:15"); + assert.equal(profile.timezoneId, "Asia/Shanghai"); + assert.equal(profile.birthTimeStatus, "accepted"); +}); + +test("reported rows keep the declared clock until an active minute exists", () => { + const profile = globalBirthProfileFromAccountRow({ + birth_date: new Date("1991-01-02T00:00:00.000Z"), + reported_birth_time: "07:40", + active_birth_time: null, + birth_time_status: "reported", + }); + + assert.equal(profile.date, "1991-01-02"); + assert.equal(profile.time, "07:40"); +}); + +test("stored other-chart JSON keeps the library camelCase shape", () => { + const profile = globalBirthProfileFromStoredChart({ + name: "对方", + date: "1988-03-04", + time: "18:20", + reportedTime: "18:20", + countryCode: "CN", + provinceCode: "110000", + cityCode: "110100", + latitude: 39.9, + longitude: 116.4, + timezoneOffset: 8, + timezoneId: "Asia/Shanghai", + }); + + assert.deepEqual(profile, { + name: "对方", + date: "1988-03-04", + time: "18:20", + countryCode: "CN", + provinceCode: "110000", + cityCode: "110100", + districtCode: undefined, + latitude: 39.9, + longitude: 116.4, + timezoneOffset: 8, + timezoneId: "Asia/Shanghai", + birthTimeStatus: undefined, + }); +}); + +test("a stored chart without a clock cannot be used for synastry", () => { + assert.equal(globalBirthProfileFromStoredChart({ date: "1990-01-01", name: "对方" }), null); + assert.equal(globalBirthProfileFromStoredChart(null), null); +});