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([
|
||||
|
||||
@@ -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<DailyStarlanguageState> {
|
||||
async function fetchDailyStarlanguage(signal: AbortSignal): Promise<DailyStarlanguageState> {
|
||||
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");
|
||||
|
||||
@@ -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 extends z.ZodType<{ messages: Array<{ text: string }> }>>(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<unknown> {
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
export type RequestRateLimitStore = Map<string, number[]>;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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"/);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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, /今天的星语还没写出来/);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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<string, number[]>();
|
||||
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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user