Merge origin/main into agent-guided birth time rectification
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAdminEmail } from "@/lib/supabase/admin";
|
||||
import { createAdminSupabaseClient, isAdminEmail } from "@/lib/supabase/admin";
|
||||
import {
|
||||
isSupabaseConfigurationError,
|
||||
} from "@/lib/supabase/config";
|
||||
@@ -7,6 +7,35 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type ProfilePatchPayload = {
|
||||
name?: unknown;
|
||||
birth_date?: unknown;
|
||||
birth_time?: unknown;
|
||||
country_code?: unknown;
|
||||
province_code?: unknown;
|
||||
city_code?: unknown;
|
||||
district_code?: unknown;
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
timezone_offset?: unknown;
|
||||
};
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function nullableNumber(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function isMissingProfileColumn(error: { code?: string; message?: string } | null) {
|
||||
const message = error?.message?.toLowerCase() ?? "";
|
||||
return error?.code === "PGRST204"
|
||||
|| error?.code === "42703"
|
||||
|| message.includes("schema cache")
|
||||
|| message.includes("column");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
@@ -38,3 +67,64 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "账户服务暂时不可用" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await request.json().catch(() => null) as ProfilePatchPayload | null;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return NextResponse.json({ error: "账户资料格式不正确" }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = createAdminSupabaseClient();
|
||||
const baseProfile = {
|
||||
id: user.id,
|
||||
name: nullableString(payload.name),
|
||||
birth_date: nullableString(payload.birth_date),
|
||||
birth_time: nullableString(payload.birth_time),
|
||||
country_code: nullableString(payload.country_code),
|
||||
province_code: nullableString(payload.province_code),
|
||||
city_code: nullableString(payload.city_code),
|
||||
district_code: nullableString(payload.district_code),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
const withCoordinates = {
|
||||
...baseProfile,
|
||||
latitude: nullableNumber(payload.latitude),
|
||||
longitude: nullableNumber(payload.longitude),
|
||||
timezone_offset: nullableNumber(payload.timezone_offset),
|
||||
};
|
||||
const withoutCoordinates = baseProfile;
|
||||
let { data, error } = await admin
|
||||
.from("profiles")
|
||||
.upsert(withCoordinates, { onConflict: "id" })
|
||||
.select("id")
|
||||
.single();
|
||||
if (error && isMissingProfileColumn(error)) {
|
||||
const fallback = await admin
|
||||
.from("profiles")
|
||||
.upsert(withoutCoordinates, { onConflict: "id" })
|
||||
.select("id")
|
||||
.single();
|
||||
data = fallback.data;
|
||||
error = fallback.error;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return NextResponse.json({ error: "暂时无法保存账户资料" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: "账户服务暂时不可用" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { chinaLocations } from "@/data/china-locations";
|
||||
|
||||
type Profile = {
|
||||
date?: string;
|
||||
time?: string;
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
districtCode?: string;
|
||||
};
|
||||
|
||||
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
|
||||
function payloadFromProfile(profile: Profile) {
|
||||
if (!profile.date || !profile.time) return null;
|
||||
const province = chinaLocations.country.provinces.find((item) => item.code === profile.provinceCode);
|
||||
const city = province?.cities.find((item) => item.code === profile.cityCode);
|
||||
const district = city?.districts.find((item) => item.code === profile.districtCode);
|
||||
const location = district ?? city;
|
||||
if (!location) return null;
|
||||
return {
|
||||
birth_time: `${profile.date} ${profile.time}`,
|
||||
uncertainty_minutes: 30,
|
||||
step_minutes: 2,
|
||||
lat: location.center[1],
|
||||
lon: location.center[0],
|
||||
tz: chinaLocations.country.timezone,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchQuestionnaire(payload: Record<string, unknown>) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2500);
|
||||
try {
|
||||
const response = await fetch(`${jyotishApiBase}/api/active_rectification_questions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`jyotish_api_${response.status}`);
|
||||
return await response.json() as Record<string, unknown>;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null) as { profile?: Profile } | null;
|
||||
const payload = payloadFromProfile(body?.profile ?? {});
|
||||
if (!payload) {
|
||||
return NextResponse.json({
|
||||
status: "blocked",
|
||||
boundary: "not_auto_rectified",
|
||||
reason: "profile_incomplete",
|
||||
});
|
||||
}
|
||||
const questionnaire = await fetchQuestionnaire(payload).catch(() => null);
|
||||
const candidateScan = questionnaire?.candidate_scan && typeof questionnaire.candidate_scan === "object"
|
||||
? questionnaire.candidate_scan
|
||||
: null;
|
||||
const questions = Array.isArray(questionnaire?.questions) ? questionnaire.questions : [];
|
||||
return NextResponse.json({
|
||||
status: questionnaire ? "ok" : "blocked",
|
||||
candidate_scan: candidateScan,
|
||||
question_count: questions.length,
|
||||
boundary: "not_auto_rectified",
|
||||
source: questionnaire ? "active_rectification_questions" : "fallback_unavailable",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const { error } = await supabase
|
||||
.from("chart_profiles")
|
||||
.delete()
|
||||
.eq("id", id)
|
||||
.eq("user_id", user.id)
|
||||
.eq("role", "other");
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage(error, "星盘删除失败") }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
|
||||
type ChartProfilePayload = {
|
||||
id?: string;
|
||||
role?: "self" | "other";
|
||||
profile?: unknown;
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("chart_profiles")
|
||||
.select("id, role, profile, updated_at")
|
||||
.eq("user_id", user.id)
|
||||
.order("updated_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ profiles: data ?? [] });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage(error, "星盘库暂时不可用") }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null) as ChartProfilePayload | null;
|
||||
if (!body?.profile || typeof body.profile !== "object") {
|
||||
return NextResponse.json({ error: "星盘资料格式不正确" }, { status: 400 });
|
||||
}
|
||||
const role = body.role === "self" ? "self" : "other";
|
||||
const updatedAt = new Date().toISOString();
|
||||
let data;
|
||||
let error;
|
||||
if (role === "self") {
|
||||
const existing = await supabase
|
||||
.from("chart_profiles")
|
||||
.select("id")
|
||||
.eq("user_id", user.id)
|
||||
.eq("role", "self")
|
||||
.maybeSingle();
|
||||
if (existing.error) throw existing.error;
|
||||
const query = existing.data?.id
|
||||
? supabase
|
||||
.from("chart_profiles")
|
||||
.update({ profile: body.profile, updated_at: updatedAt })
|
||||
.eq("id", existing.data.id)
|
||||
.select("id, role, profile, updated_at")
|
||||
.single()
|
||||
: supabase
|
||||
.from("chart_profiles")
|
||||
.insert({ user_id: user.id, role, profile: body.profile, updated_at: updatedAt })
|
||||
.select("id, role, profile, updated_at")
|
||||
.single();
|
||||
({ data, error } = await query);
|
||||
} else {
|
||||
const record = {
|
||||
...(body.id ? { id: body.id } : {}),
|
||||
user_id: user.id,
|
||||
role,
|
||||
profile: body.profile,
|
||||
updated_at: updatedAt,
|
||||
};
|
||||
({ data, error } = await supabase
|
||||
.from("chart_profiles")
|
||||
.upsert(record, { onConflict: "id" })
|
||||
.select("id, role, profile, updated_at")
|
||||
.single());
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ profile: data });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage(error, "星盘保存失败") }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { chinaLocations } from "@/data/china-locations";
|
||||
|
||||
type Profile = {
|
||||
name?: string;
|
||||
date?: string;
|
||||
time?: string;
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
districtCode?: string;
|
||||
};
|
||||
|
||||
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
|
||||
const cards = [
|
||||
{ trend: "先收束,再推进。适合把一个悬而未决的问题拆小。", action: "选一件最重要的事,给它留出 45 分钟不被打断的时间。", caution: "避免在情绪最满时做承诺。" },
|
||||
{ trend: "适合整理关系与边界。越清楚,越不容易被外界节奏带走。", action: "把今天要回复的人和要推迟的事分开列出来。", caution: "不要把暂时的沉默误读成最终答案。" },
|
||||
{ trend: "执行力比灵感更重要。小步完成会比大计划更有力量。", action: "先完成一个可交付版本,再考虑优化。", caution: "别让完美感拖慢开始。" },
|
||||
{ trend: "适合观察资源流向:时间、注意力、金钱都算。", action: "检查一个正在消耗你的习惯,并给它设上限。", caution: "不要为了短期安心做长期成本高的选择。" },
|
||||
];
|
||||
|
||||
function pickCard(profile: Profile, today: string) {
|
||||
const seed = `${today}-${profile.date ?? ""}-${profile.time ?? ""}-${profile.provinceCode ?? ""}-${profile.cityCode ?? ""}`;
|
||||
const index = Array.from(seed).reduce((sum, char) => sum + char.charCodeAt(0), 0) % cards.length;
|
||||
return cards[index];
|
||||
}
|
||||
|
||||
function profilePayload(profile: Profile, today: string) {
|
||||
if (!profile.date || !profile.time) return null;
|
||||
const [year, month, day] = profile.date.split("-").map(Number);
|
||||
const [hour, minute] = profile.time.split(":").map(Number);
|
||||
const province = chinaLocations.country.provinces.find((item) => item.code === profile.provinceCode);
|
||||
const city = province?.cities.find((item) => item.code === profile.cityCode);
|
||||
const district = city?.districts.find((item) => item.code === profile.districtCode);
|
||||
const location = district ?? city;
|
||||
if (!year || !month || !day || Number.isNaN(hour) || Number.isNaN(minute) || !location) return null;
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
lat: location.center[1],
|
||||
lon: location.center[0],
|
||||
tz: chinaLocations.country.timezone,
|
||||
transit_date: today,
|
||||
today,
|
||||
ayanamsa: "lahiri",
|
||||
node_mode: "mean",
|
||||
};
|
||||
}
|
||||
|
||||
function chartPoints(chart: Record<string, unknown>) {
|
||||
const modules = chart.modules && typeof chart.modules === "object" ? chart.modules as Record<string, unknown> : {};
|
||||
const chartModule = modules.chart && typeof modules.chart === "object" ? modules.chart as Record<string, unknown> : chart;
|
||||
const planets = chartModule.planets && typeof chartModule.planets === "object" ? chartModule.planets : undefined;
|
||||
const ascendant = chartModule.ascendant && typeof chartModule.ascendant === "object" ? chartModule.ascendant : undefined;
|
||||
return planets && ascendant ? { planets, ascendant } : null;
|
||||
}
|
||||
|
||||
async function fetchJson(path: string, body: Record<string, unknown>) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2500);
|
||||
try {
|
||||
const response = await fetch(`${jyotishApiBase}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`jyotish_api_${response.status}`);
|
||||
return await response.json() as Record<string, unknown>;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function transitBackedCard(profile: Profile, today: string) {
|
||||
const payload = profilePayload(profile, today);
|
||||
if (!payload) return null;
|
||||
const chart = await fetchJson("/api/chart", payload);
|
||||
const points = chartPoints(chart);
|
||||
if (!points) return null;
|
||||
const tomorrow = new Date(`${today}T00:00:00.000Z`);
|
||||
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
|
||||
const transit = await fetchJson("/api/transit", {
|
||||
natal_planets: points.planets,
|
||||
ascendant: points.ascendant,
|
||||
start: today,
|
||||
end: tomorrow.toISOString().slice(0, 10),
|
||||
planets_to_check: ["Saturn", "Jupiter", "Rahu", "Ketu"],
|
||||
});
|
||||
const summary = transit.summary && typeof transit.summary === "object" ? transit.summary as Record<string, unknown> : {};
|
||||
const total = Number(summary.total_triggers ?? 0);
|
||||
return {
|
||||
trend: total > 0 ? `今日有 ${total} 个可观察过境触发点,适合把它当作时间窗口观察。` : "今日未发现强精确过境触发,适合按本命节奏稳步推进。",
|
||||
action: "把今日计划压缩到一件主事,并记录实际发生的触发点。",
|
||||
caution: "过境触发不能单独定事件,需与 Dasha、分盘和本命承诺交叉确认。",
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null) as { profile?: Profile; today?: string } | null;
|
||||
const profile = body?.profile ?? {};
|
||||
const today = body?.today || new Date().toISOString().slice(0, 10);
|
||||
const transitCard = await transitBackedCard(profile, today).catch(() => null);
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
card: transitCard ?? pickCard(profile, today),
|
||||
source: transitCard ? "jyotish_api_transit_lite" : "calculation_lite",
|
||||
claim_status: "exploratory_unvalidated",
|
||||
boundary: "not_deterministic_prediction",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
type Check = {
|
||||
status: "ok" | "degraded" | "blocked";
|
||||
message?: string;
|
||||
latencyMs?: number;
|
||||
};
|
||||
|
||||
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
|
||||
function envCheck(names: string[]): Check {
|
||||
const missing = names.filter((name) => !process.env[name]);
|
||||
return missing.length
|
||||
? { status: "blocked", message: `missing:${missing.join(",")}` }
|
||||
: { status: "ok" };
|
||||
}
|
||||
|
||||
function anyEnvCheck(names: string[]): Check {
|
||||
return names.some((name) => process.env[name])
|
||||
? { status: "ok" }
|
||||
: { status: "blocked", message: `missing_one_of:${names.join("|")}` };
|
||||
}
|
||||
|
||||
async function jyotishApiCheck(): Promise<Check> {
|
||||
const started = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const response = await fetch(`${jyotishApiBase}/api/health`, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
return {
|
||||
status: response.ok ? "ok" : "degraded",
|
||||
message: response.ok ? undefined : `http:${response.status}`,
|
||||
latencyMs: Date.now() - started,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "blocked",
|
||||
message: error instanceof Error ? error.name : "jyotish_api_unavailable",
|
||||
latencyMs: Date.now() - started,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function aggregate(checks: Record<string, Check>) {
|
||||
if (Object.values(checks).some((check) => check.status === "blocked")) return "blocked";
|
||||
if (Object.values(checks).some((check) => check.status === "degraded")) return "degraded";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const checks = {
|
||||
web: { status: "ok" } satisfies Check,
|
||||
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
|
||||
supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]),
|
||||
modelProvider: anyEnvCheck(["LLM_MODELS_JSON", "OPENAI_API_KEY", "LLM_API_KEY", "DEEPSEEK_API_KEY"]),
|
||||
jyotishApi: await jyotishApiCheck(),
|
||||
};
|
||||
const status = aggregate(checks);
|
||||
return NextResponse.json(
|
||||
{
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
},
|
||||
{ status: status === "ok" ? 200 : 503 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
type SynastryReportPayload = {
|
||||
id?: string;
|
||||
partnerName?: string;
|
||||
report?: unknown;
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("synastry_reports")
|
||||
.select("id, partner_name, report, created_at")
|
||||
.eq("user_id", user.id)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ reports: data ?? [] });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage(error, "合盘历史暂时不可用") }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json().catch(() => null) as SynastryReportPayload | null;
|
||||
if (!body?.report || typeof body.report !== "object" || Array.isArray(body.report)) {
|
||||
return NextResponse.json({ error: "合盘报告格式不正确" }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const partnerName = body.partnerName?.trim() || "对方";
|
||||
const record = {
|
||||
...(body.id ? { id: body.id } : {}),
|
||||
user_id: user.id,
|
||||
partner_name: partnerName,
|
||||
report: body.report,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("synastry_reports")
|
||||
.insert(record)
|
||||
.select("id, partner_name, report, created_at")
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ report: data });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage(error, "合盘历史保存失败") }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { chinaLocations } from "@/data/china-locations";
|
||||
|
||||
type Profile = {
|
||||
name?: string;
|
||||
date?: string;
|
||||
time?: string;
|
||||
countryCode?: "CN";
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
districtCode?: string;
|
||||
};
|
||||
|
||||
const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
const china = chinaLocations.country;
|
||||
|
||||
function birthPayload(profile: Profile) {
|
||||
const [year, month, day] = String(profile.date || "").split("-").map(Number);
|
||||
const [hour, minute] = String(profile.time || "").split(":").map(Number);
|
||||
const province = china.provinces.find((item) => item.code === profile.provinceCode);
|
||||
const city = province?.cities.find((item) => item.code === profile.cityCode);
|
||||
const district = city?.districts.find((item) => item.code === profile.districtCode);
|
||||
const location = district ?? city;
|
||||
if (![year, month, day, hour, minute].every(Number.isFinite) || !location) {
|
||||
throw new Error("birth_profile_incomplete");
|
||||
}
|
||||
return {
|
||||
year, month, day, hour, minute,
|
||||
second: 0,
|
||||
lat: location.center[1],
|
||||
lon: location.center[0],
|
||||
tz: china.timezone,
|
||||
};
|
||||
}
|
||||
|
||||
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> : {};
|
||||
const value = moon.lon ?? moon.longitude ?? moon.degree;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) throw new Error("moon_longitude_missing");
|
||||
return numeric;
|
||||
}
|
||||
|
||||
function moonSummary(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> : {};
|
||||
return {
|
||||
sign: moon.sign,
|
||||
nakshatra: moon.nakshatra,
|
||||
pada: moon.nakshatra_pada,
|
||||
lord: moon.nakshatra_lord,
|
||||
longitude: moonLongitude(chart),
|
||||
};
|
||||
}
|
||||
|
||||
function d9Summary(varga: Record<string, unknown>) {
|
||||
const result = varga.result && typeof varga.result === "object" ? varga.result as Record<string, unknown> : {};
|
||||
const d9 = result.D9_Navamsa && typeof result.D9_Navamsa === "object" ? result.D9_Navamsa as Record<string, unknown> : {};
|
||||
const ascendant = d9.ascendant && typeof d9.ascendant === "object" ? d9.ascendant as Record<string, unknown> : {};
|
||||
const planets = d9.planets && typeof d9.planets === "object" ? d9.planets as Record<string, unknown> : {};
|
||||
return {
|
||||
ascendant,
|
||||
moon: planets.Moon,
|
||||
venus: planets.Venus,
|
||||
mars: planets.Mars,
|
||||
source: varga.source,
|
||||
};
|
||||
}
|
||||
|
||||
function planetSign(point: unknown) {
|
||||
return point && typeof point === "object" && "sign" in point
|
||||
? String((point as Record<string, unknown>).sign || "unknown")
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
function relationshipReport(synastry: Record<string, unknown>, selfD9: Record<string, unknown>, partnerD9: Record<string, unknown>) {
|
||||
const total = Number(synastry.total_score ?? 0);
|
||||
const max = Number(synastry.max_score ?? 36);
|
||||
const ratio = max > 0 ? total / max : 0;
|
||||
const band = ratio >= 0.72 ? "supportive" : ratio >= 0.5 ? "mixed" : "challenging";
|
||||
const self = d9Summary(selfD9);
|
||||
const partner = d9Summary(partnerD9);
|
||||
return {
|
||||
status: "evidence_summary",
|
||||
scoreBand: band,
|
||||
headline: band === "supportive"
|
||||
? "基础匹配度偏支持,但仍需结合现实互动与长期运势。"
|
||||
: band === "mixed"
|
||||
? "基础匹配度中等,适合重点观察沟通节奏、价值观与关系承诺。"
|
||||
: "基础匹配度偏谨慎,需要先处理冲突模式与现实条件。",
|
||||
strengths: [
|
||||
`Ashtakoot ${total}/${max}`,
|
||||
`本人 D9 Moon:${planetSign(self.moon)}`,
|
||||
`对方 D9 Moon:${planetSign(partner.moon)}`,
|
||||
],
|
||||
risks: [
|
||||
"这不是完整婚恋结论;尚未纳入双方 Dasha、UL/DK 与长期时机。",
|
||||
`D9 Venus/Mars 需要继续解释:本人 ${planetSign(self.venus)}/${planetSign(self.mars)},对方 ${planetSign(partner.venus)}/${planetSign(partner.mars)}。`,
|
||||
],
|
||||
nextEvidence: ["双方 Dasha", "UL/DK", "D9 7宫/7主", "现实关系时间线"],
|
||||
};
|
||||
}
|
||||
|
||||
async function postPython(path: string, body: unknown) {
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || !data || typeof data !== "object") {
|
||||
throw new Error(`jyotish_api_${response.status}`);
|
||||
}
|
||||
return data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json().catch(() => null) as { selfProfile?: Profile; partnerProfile?: Profile } | null;
|
||||
if (!body?.selfProfile || !body.partnerProfile) {
|
||||
return NextResponse.json({ error: "请提供双方星盘资料" }, { status: 400 });
|
||||
}
|
||||
const selfChart = await postPython("/api/chart", birthPayload(body.selfProfile));
|
||||
const partnerChart = await postPython("/api/chart", birthPayload(body.partnerProfile));
|
||||
const selfD9 = await postPython("/api/varga_full", {
|
||||
...birthPayload(body.selfProfile),
|
||||
planets: selfChart.planets,
|
||||
ascendant: selfChart.ascendant,
|
||||
divisions: ["D9"],
|
||||
});
|
||||
const partnerD9 = await postPython("/api/varga_full", {
|
||||
...birthPayload(body.partnerProfile),
|
||||
planets: partnerChart.planets,
|
||||
ascendant: partnerChart.ascendant,
|
||||
divisions: ["D9"],
|
||||
});
|
||||
const synastry = await postPython("/api/synastry", {
|
||||
male_moon: moonLongitude(selfChart),
|
||||
female_moon: moonLongitude(partnerChart),
|
||||
});
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
method: "ashtakoot_plus_moon_nakshatra_d9",
|
||||
evidenceLayers: ["ashtakoot", "moon_nakshatra", "d9_navamsa"],
|
||||
selfChart: { moon: moonSummary(selfChart), d9: d9Summary(selfD9) },
|
||||
partnerChart: { moon: moonSummary(partnerChart), d9: d9Summary(partnerD9) },
|
||||
synastry,
|
||||
relationshipReport: relationshipReport(synastry, selfD9, partnerD9),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
status: "blocked",
|
||||
error: error instanceof Error ? error.message : "synastry_unavailable",
|
||||
message: "合盘计算暂时不可用;可先保留合盘问题草稿。",
|
||||
}, { status: 503 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user