chore: retire legacy rectification runtime
This commit is contained in:
@@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
|
||||
import {
|
||||
parseRectificationPriceCredits,
|
||||
} from "@/lib/birth-time-consultation-consent";
|
||||
import { resolveAccountRectificationCase, resolveAccountRectificationV4Case } from "@/lib/account-rectification-case";
|
||||
import {
|
||||
accountProfilePatchSchema,
|
||||
applyAccountProfileConcurrencyGuards,
|
||||
@@ -18,7 +17,6 @@ import { readSelfHostedIdentityConfig } from "@/modules/identity/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const unfinishedRectificationStatuses = ["starting", "active", "paused", "confirming"] as const;
|
||||
|
||||
function adminEntryUrl(): string {
|
||||
return process.env.AUTH_PROVIDER?.trim() === "self-hosted"
|
||||
@@ -34,14 +32,6 @@ function isMissingProfileColumn(error: { code?: string; message?: string } | nul
|
||||
|| message.includes("column");
|
||||
}
|
||||
|
||||
function isMissingRectificationV4Relation(error: { code?: string; message?: string } | null) {
|
||||
const message = error?.message?.toLowerCase() ?? "";
|
||||
return error?.code === "42P01"
|
||||
|| error?.code === "PGRST205"
|
||||
|| message.includes('relation "public.birth_time_rectification_v4_cases" does not exist')
|
||||
|| (message.includes("schema cache") && message.includes("birth_time_rectification_v4_cases"));
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
@@ -56,34 +46,6 @@ export async function GET() {
|
||||
process.env.RECTIFICATION_PRICE_CREDITS,
|
||||
);
|
||||
const admin = createAdminSupabaseClient();
|
||||
const { data: rectificationV4CaseData, error: rectificationV4CaseError } = await admin
|
||||
.from("birth_time_rectification_v4_cases")
|
||||
.select("id,status,version,accepted_range_start,updated_at")
|
||||
.eq("user_id", user.id)
|
||||
.neq("status", "abandoned")
|
||||
.is("accepted_range_start", null)
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(1);
|
||||
if (rectificationV4CaseError && !isMissingRectificationV4Relation(rectificationV4CaseError)) {
|
||||
return NextResponse.json({ error: "暂时无法读取生时校正状态" }, { status: 500 });
|
||||
}
|
||||
const rectificationV4CaseRows = rectificationV4CaseError ? [] : rectificationV4CaseData;
|
||||
|
||||
const { data: rectificationCaseRows, error: rectificationCaseError } = await admin
|
||||
.from("birth_time_rectification_cases")
|
||||
.select("id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,declared_birth_input,updated_at")
|
||||
.eq("user_id", user.id)
|
||||
.eq("journey_protocol", "conversational-evidence-v3")
|
||||
.in("status", [...unfinishedRectificationStatuses])
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(50);
|
||||
if (rectificationCaseError) {
|
||||
return NextResponse.json({ error: "暂时无法读取生时校正状态" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Read the profile after the case snapshot. If a declaration edit races
|
||||
// with this request, matching uses the later profile and cannot resurrect
|
||||
// an older case. A concurrently created case simply appears on refresh.
|
||||
let { data: profile, error: profileError } = await supabase
|
||||
.from("profiles")
|
||||
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,name,birth_time,rectification_case_id")
|
||||
@@ -127,13 +89,6 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "头像设置无效" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const rectificationCase = resolveAccountRectificationV4Case(
|
||||
Array.isArray(rectificationV4CaseRows) ? rectificationV4CaseRows : [],
|
||||
) ?? resolveAccountRectificationCase(
|
||||
profile,
|
||||
Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [],
|
||||
);
|
||||
const { data: activeSubscriptions, error: subscriptionError } = await admin
|
||||
.from("user_subscriptions")
|
||||
.select("id,status,starts_at,ends_at,product_code,product_version,product_snapshot,entitlement_snapshot")
|
||||
@@ -171,7 +126,6 @@ export async function GET() {
|
||||
&& typeof profile.active_birth_time === "string",
|
||||
hasUsableBirthTime: (profile.birth_time_status === "accepted" || profile.birth_time_status === "confirmed")
|
||||
&& typeof profile.active_birth_time === "string",
|
||||
rectificationCase,
|
||||
profile,
|
||||
birthLocation: {
|
||||
label: profile.birth_place_label ?? null,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,44 +0,0 @@
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { createRectificationHandoffService } from "@/lib/rectification-handoff-service";
|
||||
import {
|
||||
createRectificationHandoffHandlers,
|
||||
type RectificationHandoffRouteDependencies,
|
||||
} from "@/lib/rectification-handoff-route";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const productionDependencies: RectificationHandoffRouteDependencies = {
|
||||
async authenticate() {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
return error || !user ? null : { userId: user.id };
|
||||
},
|
||||
service() {
|
||||
return createRectificationHandoffService(createAdminSupabaseClient());
|
||||
},
|
||||
};
|
||||
|
||||
const handlers = createRectificationHandoffHandlers(productionDependencies);
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return await handlers.get();
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ code: "handoff_unavailable", message: "原问题交接服务暂时不可用。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
return await handlers.post(request);
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ code: "handoff_unavailable", message: "原问题交接服务暂时不可用。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export { POST } from "./handler";
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
getGeneralJyotishAgent,
|
||||
getJyotishAgent,
|
||||
runConsultationWorkflow,
|
||||
toAgentConsultationContext,
|
||||
} from "@/mastra";
|
||||
import { languageModelConfigurationMessage } from "@/mastra/model";
|
||||
import { blocksPromptExtraction } from "@/lib/consult-safety";
|
||||
@@ -35,12 +34,6 @@ import {
|
||||
ConsultationProfileTruthError,
|
||||
prepareConsultationRoute,
|
||||
} from "@/lib/consultation-route-service";
|
||||
import {
|
||||
createRectificationHandoffService,
|
||||
createRectificationV4HandoffService,
|
||||
type RectificationHandoffExecution,
|
||||
type RectificationV4HandoffExecution,
|
||||
} from "@/lib/rectification-handoff-service";
|
||||
import { z } from "zod";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -62,38 +55,12 @@ const chatRequestMetadataSchema = z.object({
|
||||
.default([]),
|
||||
});
|
||||
|
||||
const rectificationV3HandoffSchema = z.object({
|
||||
protocol: z.literal("conversational-evidence-v3").optional(),
|
||||
caseId: z.string().uuid(),
|
||||
turnVersion: z.number().int().nonnegative(),
|
||||
claimActionId: z.string().uuid(),
|
||||
requestId: z.string().uuid(),
|
||||
}).strict();
|
||||
|
||||
const rectificationV4HandoffSchema = z.object({
|
||||
protocol: z.literal("rectification-evidence-v4"),
|
||||
caseId: z.string().uuid(),
|
||||
caseVersion: z.number().int().nonnegative(),
|
||||
claimActionId: z.string().uuid(),
|
||||
requestId: z.string().uuid(),
|
||||
}).strict();
|
||||
|
||||
const v4ContinuationRequestSchema = z.object({
|
||||
...chatRequestMetadataSchema.shape,
|
||||
consultationMode: consultationBirthTimeModeSchema,
|
||||
question: z.string().trim().min(1).max(500),
|
||||
theme: z.enum(["career", "marriage", "wealth", "timing", "general"]),
|
||||
entrypoint: z.undefined().optional(),
|
||||
rectificationHandoff: rectificationV4HandoffSchema,
|
||||
}).strict();
|
||||
|
||||
const chartChatRequestSchema = consultationInputSchema.extend({
|
||||
...chatRequestMetadataSchema.shape,
|
||||
consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time"])
|
||||
.optional()
|
||||
.default("verified_chart"),
|
||||
entrypoint: consultationEntrypointSchema.optional(),
|
||||
rectificationHandoff: rectificationV3HandoffSchema.optional(),
|
||||
}).strict();
|
||||
|
||||
const generalChatRequestSchema = z.object({
|
||||
@@ -104,11 +71,7 @@ const generalChatRequestSchema = z.object({
|
||||
entrypoint: z.undefined().optional(),
|
||||
}).strict();
|
||||
|
||||
const chatRequestSchema = z.union([
|
||||
v4ContinuationRequestSchema,
|
||||
generalChatRequestSchema,
|
||||
chartChatRequestSchema,
|
||||
]);
|
||||
const chatRequestSchema = z.union([generalChatRequestSchema, chartChatRequestSchema]);
|
||||
|
||||
function currentTimeContext(now = new Date()) {
|
||||
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
||||
@@ -122,54 +85,6 @@ function chinaCalendarDate(now: Date) {
|
||||
return new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function rangeBoundaryWorkflowContext(
|
||||
start: Awaited<ReturnType<typeof runConsultationWorkflow>>,
|
||||
end: Awaited<ReturnType<typeof runConsultationWorkflow>>,
|
||||
acceptedRange: Readonly<{ start: string; end: string }>,
|
||||
) {
|
||||
const startConsumer = start.consumer_context;
|
||||
const endConsumer = end.consumer_context;
|
||||
return {
|
||||
...start,
|
||||
success: start.success && end.success,
|
||||
consumer_context: {
|
||||
...startConsumer,
|
||||
core_status: startConsumer.core_status === "blocked" || endConsumer.core_status === "blocked"
|
||||
? "blocked"
|
||||
: startConsumer.core_status === "degraded" || endConsumer.core_status === "degraded"
|
||||
? "degraded"
|
||||
: "ready",
|
||||
available_layers: startConsumer.available_layers.filter((layer) =>
|
||||
endConsumer.available_layers.includes(layer)),
|
||||
missing_route_layers: [...new Set([
|
||||
...startConsumer.missing_route_layers,
|
||||
...endConsumer.missing_route_layers,
|
||||
])],
|
||||
hard_blockers: [...new Set([
|
||||
...startConsumer.hard_blockers,
|
||||
...endConsumer.hard_blockers,
|
||||
])],
|
||||
answer_policy: {
|
||||
...startConsumer.answer_policy,
|
||||
can_answer_direction: startConsumer.answer_policy.can_answer_direction
|
||||
&& endConsumer.answer_policy.can_answer_direction,
|
||||
can_answer_precise_timing: false,
|
||||
birth_time_confidence: "accepted_candidate_range",
|
||||
candidate_is_confirmed: false,
|
||||
require_boundary_agreement: true,
|
||||
},
|
||||
},
|
||||
candidate_range: {
|
||||
...acceptedRange,
|
||||
claim_status: "candidate_range_not_birth_time_truth",
|
||||
},
|
||||
range_boundary_contexts: {
|
||||
start: toAgentConsultationContext(start),
|
||||
end: toAgentConsultationContext(end),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
|
||||
let accounting: ReturnType<typeof createAdminSupabaseClient>;
|
||||
@@ -252,92 +167,6 @@ export async function POST(request: Request) {
|
||||
|
||||
const userId = user.id;
|
||||
const requestId = parsed.data.requestId;
|
||||
const handoff = "rectificationHandoff" in parsed.data
|
||||
? parsed.data.rectificationHandoff
|
||||
: undefined;
|
||||
const v4Handoff = handoff?.protocol === "rectification-evidence-v4";
|
||||
let handoffExecution: RectificationHandoffExecution | RectificationV4HandoffExecution | null = null;
|
||||
let settleHandoffRequest: ((emitted: boolean) => Promise<void>) | null = null;
|
||||
let handoffSettlement: Promise<void> | null = null;
|
||||
|
||||
async function settleHandoff(emitted: boolean) {
|
||||
if (!settleHandoffRequest || !handoffExecution
|
||||
|| handoffExecution.status !== "ready") return;
|
||||
handoffSettlement ??= settleHandoffRequest(emitted);
|
||||
await handoffSettlement;
|
||||
}
|
||||
|
||||
if (handoff) {
|
||||
if ((!v4Handoff
|
||||
&& !["verified_chart", "unverified_birth_time"].includes(parsed.data.consultationMode))
|
||||
|| parsed.data.entrypoint !== undefined
|
||||
|| requestId !== handoff.requestId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "原问题交接请求不一致",
|
||||
message: "请刷新校正结果后重新点击继续,本次不会扣点。",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (handoff.protocol === "rectification-evidence-v4") {
|
||||
const service = createRectificationV4HandoffService(accounting);
|
||||
handoffExecution = await service.beginExecution({
|
||||
userId,
|
||||
caseId: handoff.caseId,
|
||||
caseVersion: handoff.caseVersion,
|
||||
claimActionId: handoff.claimActionId,
|
||||
requestId: handoff.requestId,
|
||||
question: parsed.data.question,
|
||||
});
|
||||
settleHandoffRequest = (emitted) => service.settle({
|
||||
userId,
|
||||
caseId: handoff.caseId,
|
||||
claimActionId: handoff.claimActionId,
|
||||
requestId: handoff.requestId,
|
||||
emitted,
|
||||
}).then(() => undefined);
|
||||
} else {
|
||||
const service = createRectificationHandoffService(accounting);
|
||||
handoffExecution = await service.beginExecution({
|
||||
userId,
|
||||
caseId: handoff.caseId,
|
||||
turnVersion: handoff.turnVersion,
|
||||
claimActionId: handoff.claimActionId,
|
||||
requestId: handoff.requestId,
|
||||
question: parsed.data.question,
|
||||
});
|
||||
settleHandoffRequest = (emitted) => service.settle({
|
||||
userId,
|
||||
caseId: handoff.caseId,
|
||||
claimActionId: handoff.claimActionId,
|
||||
requestId: handoff.requestId,
|
||||
emitted,
|
||||
}).then(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "原问题状态已经变化",
|
||||
message: "请刷新后查看最新状态,本次不会扣点。",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (handoffExecution.status !== "ready") {
|
||||
const consumed = handoffExecution.status === "consumed";
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: consumed ? "原问题已经继续回答" : "原问题正在另一处继续",
|
||||
message: consumed
|
||||
? "刷新后即可查看最新状态,不会再次扣点。"
|
||||
: "请等待当前回答完成后刷新,本次不会重复扣点。",
|
||||
},
|
||||
{ status: consumed ? 410 : 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userControlledPrompt = [
|
||||
parsed.data.question,
|
||||
@@ -346,16 +175,6 @@ export async function POST(request: Request) {
|
||||
.map((message) => message.text),
|
||||
].join("\n");
|
||||
if (blocksPromptExtraction(userControlledPrompt)) {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法释放原问题", message: "请稍后刷新状态。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "无法处理该请求",
|
||||
@@ -370,10 +189,6 @@ export async function POST(request: Request) {
|
||||
prepared = await prepareConsultationRoute({
|
||||
userId,
|
||||
mode: parsed.data.consultationMode,
|
||||
...(v4Handoff && handoffExecution?.status === "ready"
|
||||
&& "acceptedRange" in handoffExecution
|
||||
? { candidateRange: handoffExecution.acceptedRange }
|
||||
: {}),
|
||||
async loadProfile(profileUserId) {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
@@ -386,39 +201,20 @@ export async function POST(request: Request) {
|
||||
reserve: () => reserveConsultationModel(
|
||||
chatSession.model_id,
|
||||
(modelId) => sessionModel?.id === modelId ? sessionModel : null,
|
||||
(model) => handoffExecution?.billingReused
|
||||
? Promise.resolve({
|
||||
success: true,
|
||||
credits: handoffExecution.credits ?? null,
|
||||
error_code: null,
|
||||
})
|
||||
: authorizeUsage(accounting, {
|
||||
userId,
|
||||
requestId,
|
||||
featureKey: "chat.standard",
|
||||
requestedModelId: model.id,
|
||||
creditCost: model.creditCost,
|
||||
}).then((result) => ({
|
||||
success: result.success,
|
||||
credits: result.credits,
|
||||
error_code: result.reason,
|
||||
})),
|
||||
(model) => authorizeUsage(accounting, {
|
||||
userId,
|
||||
requestId,
|
||||
featureKey: "chat.standard",
|
||||
requestedModelId: model.id,
|
||||
creditCost: model.creditCost,
|
||||
}).then((result) => ({
|
||||
success: result.success,
|
||||
credits: result.credits,
|
||||
error_code: result.reason,
|
||||
})),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "暂时无法释放原问题",
|
||||
message: "请稍后刷新状态,本次不会重复扣点。",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (error instanceof ConsultationProfileTruthError) {
|
||||
const modeChanged = error.code === "mode_changed";
|
||||
return NextResponse.json(
|
||||
@@ -447,16 +243,6 @@ export async function POST(request: Request) {
|
||||
const modelSelection = prepared.reservation;
|
||||
|
||||
if (modelSelection.status === "unavailable") {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法释放原问题", message: "请稍后刷新状态。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "模型暂不可用",
|
||||
@@ -470,16 +256,6 @@ export async function POST(request: Request) {
|
||||
const reserveResult = modelSelection.reservation;
|
||||
|
||||
if (!reserveResult.success) {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法释放原问题", message: "请稍后刷新状态。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
const insufficient = reserveResult.error_code === "insufficient_credits";
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -493,17 +269,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.name : "UnknownError";
|
||||
console.error(
|
||||
`[billing] handoff release failed request=${requestId} reason=${reason}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await releaseUsage(accounting, userId, requestId, "consultation_cancelled");
|
||||
} catch (error) {
|
||||
@@ -516,10 +281,6 @@ export async function POST(request: Request) {
|
||||
|
||||
const usageStartedAt = Date.now();
|
||||
async function complete(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
await settleHandoff(true);
|
||||
return;
|
||||
}
|
||||
const resolved = await usage;
|
||||
const inputTokens = Math.max(0, Math.trunc(resolved.inputTokens ?? 0));
|
||||
const outputTokens = Math.max(0, Math.trunc(resolved.outputTokens ?? 0));
|
||||
@@ -549,9 +310,7 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const { history } = parsed.data;
|
||||
const name = prepared.serverChart?.name ?? parsed.data.name;
|
||||
const consultationMode: ConsultationBirthTimeMode = v4Handoff
|
||||
? "unverified_birth_time"
|
||||
: prepared.consultationMode;
|
||||
const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode;
|
||||
if (!shouldRunBirthChartWorkflow(consultationMode)) {
|
||||
const result = await getGeneralJyotishAgent(selectedModel).stream([
|
||||
{
|
||||
@@ -579,7 +338,6 @@ export async function POST(request: Request) {
|
||||
"x-jyotish-missing-layers": "birth-minute",
|
||||
"x-jyotish-birth-time-mode": consultationMode,
|
||||
},
|
||||
...(handoff ? { onFirstOutput: () => settle(completeWithUsage) } : {}),
|
||||
onComplete: () => settle(completeWithUsage),
|
||||
onError: (_error, emitted) => settleInterrupted(emitted),
|
||||
onCancel: settleInterrupted,
|
||||
@@ -587,69 +345,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (!prepared.serverChart) throw new Error("server_chart_truth_missing");
|
||||
if (v4Handoff) {
|
||||
if (!handoffExecution || handoffExecution.status !== "ready"
|
||||
|| !("acceptedRange" in handoffExecution)) {
|
||||
throw new Error("rectification_v4_range_missing");
|
||||
}
|
||||
const acceptedRange = handoffExecution.acceptedRange;
|
||||
const boundaryInput = (time: string) => {
|
||||
const [hour, minute] = time.split(":").map(Number);
|
||||
return consultationInputSchema.parse({
|
||||
...prepared.serverChart?.toolInput,
|
||||
hour,
|
||||
minute,
|
||||
entryMode: "direct_chart",
|
||||
question: resolvedQuestion.modelQuestion,
|
||||
theme: parsed.data.theme,
|
||||
});
|
||||
};
|
||||
const [startWorkflow, endWorkflow] = await Promise.all([
|
||||
runConsultationWorkflow(boundaryInput(acceptedRange.start)),
|
||||
runConsultationWorkflow(boundaryInput(acceptedRange.end)),
|
||||
]);
|
||||
const workflowContext = rangeBoundaryWorkflowContext(
|
||||
startWorkflow,
|
||||
endWorkflow,
|
||||
acceptedRange,
|
||||
);
|
||||
const workflowReceipt = consultationWorkflowReceipt(workflowContext);
|
||||
const result = await getJyotishAgent(selectedModel, workflowContext).stream([
|
||||
...history.map((message) => message.role === "user"
|
||||
? { role: "user" as const, content: message.text }
|
||||
: { role: "assistant" as const, content: message.text }),
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
currentTimeContext(requestTime),
|
||||
name ? `用户称呼:${name}` : "",
|
||||
resolvedQuestion.modelQuestion,
|
||||
`本次只能使用已保存候选范围 ${acceptedRange.start}–${acceptedRange.end} 的两个边界共同支持的结论。`,
|
||||
"不得选择中点、峰值或单一代表分钟,不得把候选范围说成已确认出生时间。",
|
||||
].filter(Boolean).join("\n"),
|
||||
},
|
||||
]);
|
||||
const completeWithUsage = () => complete(result.totalUsage);
|
||||
const settleInterrupted = (emitted: boolean) =>
|
||||
settle(emitted ? completeWithUsage : cancel);
|
||||
return streamTextResponse(result.textStream, {
|
||||
transformText: createBirthTimeModeOutputGuard("unverified_birth_time", false),
|
||||
mode: "mastra",
|
||||
requestId,
|
||||
headers: {
|
||||
"x-jyotish-workflow-route": workflowReceipt.route,
|
||||
"x-jyotish-workflow-status": workflowReceipt.status,
|
||||
"x-jyotish-technique-truth": workflowReceipt.techniqueTruth,
|
||||
"x-jyotish-precise-timing": "blocked",
|
||||
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
|
||||
"x-jyotish-birth-time-mode": "unverified_birth_time",
|
||||
},
|
||||
onFirstOutput: () => settle(completeWithUsage),
|
||||
onComplete: () => settle(completeWithUsage),
|
||||
onError: (_error, emitted) => settleInterrupted(emitted),
|
||||
onCancel: settleInterrupted,
|
||||
});
|
||||
}
|
||||
const toolInput = consultationInputSchema.parse({
|
||||
...prepared.serverChart.toolInput,
|
||||
// Unverified use is still a normal chart calculation with a hard answer
|
||||
@@ -697,7 +392,6 @@ export async function POST(request: Request) {
|
||||
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
|
||||
"x-jyotish-birth-time-mode": consultationMode,
|
||||
},
|
||||
...(handoff ? { onFirstOutput: () => settle(completeWithUsage) } : {}),
|
||||
onComplete: () => settle(completeWithUsage),
|
||||
onError: (_error, emitted) => settleInterrupted(emitted),
|
||||
onCancel: settleInterrupted,
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
conversationalRectificationCreationPolicyFromEnvironment,
|
||||
conversationalRectificationDeploymentShaFromEnvironment,
|
||||
} from "../../../lib/conversational-rectification/creation-policy.ts";
|
||||
|
||||
import { getTruthSourceRuntimeIdentity } from "@/lib/truth-source-runtime-identity";
|
||||
|
||||
type Check = {
|
||||
@@ -59,10 +54,10 @@ function aggregate(checks: Record<string, Check>) {
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const gitCommit = conversationalRectificationDeploymentShaFromEnvironment();
|
||||
const rectificationV3MigrationsReady =
|
||||
process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true";
|
||||
const creationPolicy = conversationalRectificationCreationPolicyFromEnvironment();
|
||||
const gitCommit = process.env.GITHUB_SHA
|
||||
?? process.env.VERCEL_GIT_COMMIT_SHA
|
||||
?? process.env.NEXT_PUBLIC_GIT_COMMIT
|
||||
?? "unknown";
|
||||
const truthSourceIdentity = getTruthSourceRuntimeIdentity();
|
||||
const selfHosted = process.env.AUTH_PROVIDER?.trim() === "self-hosted";
|
||||
const databaseChecks: Record<string, Check> = selfHosted
|
||||
@@ -85,8 +80,6 @@ export async function GET() {
|
||||
} satisfies Check,
|
||||
};
|
||||
const status = aggregate(checks);
|
||||
const rectificationV3Ready = status === "ok"
|
||||
&& creationPolicy.audience === "public";
|
||||
return NextResponse.json(
|
||||
{
|
||||
status,
|
||||
@@ -94,16 +87,6 @@ export async function GET() {
|
||||
deployment: {
|
||||
gitCommit,
|
||||
},
|
||||
rollout: {
|
||||
conversationalRectificationV3: {
|
||||
protocol: "conversational-evidence-v3",
|
||||
newCaseCreation: creationPolicy.audience === "paused" ? "paused" : "enabled",
|
||||
creationAudience: creationPolicy.audience,
|
||||
migrations: rectificationV3MigrationsReady ? "ready" : "unverified",
|
||||
syntheticSmoke: creationPolicy.smokeMatchesDeployment ? "matched" : "pending",
|
||||
readyForNewCases: rectificationV3Ready,
|
||||
},
|
||||
},
|
||||
truthSource: truthSourceIdentity,
|
||||
checks,
|
||||
},
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { parseBirthTimeProfile } from "@/lib/birth-time-journey-adapters";
|
||||
import { assessBirthTime } from "@/lib/birth-time-journey";
|
||||
import { resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone";
|
||||
import type { CalculationSpec } from "@/lib/rectification-v4/contracts";
|
||||
import { createRectificationV4CaseService } from "@/lib/rectification-v4/case-service";
|
||||
import { RectificationV4StoreError } from "@/lib/rectification-v4/store";
|
||||
import { createRectificationV4SupabaseStore } from "@/lib/rectification-v4/supabase-store";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
const idSchema = z.string().uuid();
|
||||
|
||||
export async function rectificationV4Context() {
|
||||
const auth = await createServerSupabaseClient();
|
||||
const { data: { user }, error } = await auth.auth.getUser();
|
||||
if (error || !user) throw new RectificationV4HttpError(401, "请先登录后再继续生时校正。");
|
||||
const admin = createAdminSupabaseClient();
|
||||
return {
|
||||
userId: user.id,
|
||||
auth,
|
||||
service: createRectificationV4CaseService(createRectificationV4SupabaseStore(admin)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestBody<T>(request: Request, schema: z.ZodType<T>): Promise<T> {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) throw new RectificationV4HttpError(400, "提交内容不完整,请检查后重试。");
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function routeId(value: string): string {
|
||||
const parsed = idSchema.safeParse(value);
|
||||
if (!parsed.success) throw new RectificationV4HttpError(404, "没有找到这次生时校正记录。");
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export async function calculationSpecForUser(
|
||||
auth: Awaited<ReturnType<typeof createServerSupabaseClient>>,
|
||||
userId: string,
|
||||
): Promise<CalculationSpec> {
|
||||
const { data, error } = await auth.from("profiles")
|
||||
.select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_source,timezone_offset")
|
||||
.eq("id", userId).maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data) throw new RectificationV4HttpError(409, "请先补全出生日期、时间线索和出生地点。");
|
||||
let resolvedLocalTimeStatus: CalculationSpec["localTimeStatus"] | undefined;
|
||||
const profile = await resolveMissingBirthTimezoneOffset(data, {
|
||||
fetchImpl: async (input, init) => {
|
||||
const response = await fetch(input, init);
|
||||
const payload = await response.clone().json().catch(() => null) as { localTimeStatus?: unknown } | null;
|
||||
const status = payload?.localTimeStatus;
|
||||
if (status === "resolved" || status === "not_provided" || status === "ambiguous" || status === "nonexistent") {
|
||||
resolvedLocalTimeStatus = status;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
});
|
||||
const assessment = parseBirthTimeProfile(profile);
|
||||
const range = assessBirthTime(assessment, { kind: "unavailable" }).reportedRange;
|
||||
const birthTimeSource = typeof data.birth_time_source === "string" && data.birth_time_source.trim()
|
||||
? data.birth_time_source.trim() as CalculationSpec["birthTimeSource"]
|
||||
: undefined;
|
||||
const timezoneId = typeof data.timezone_id === "string" && data.timezone_id.trim()
|
||||
? data.timezone_id.trim()
|
||||
: undefined;
|
||||
const timezoneSource = typeof data.timezone_source === "string" && data.timezone_source.trim()
|
||||
? data.timezone_source.trim()
|
||||
: undefined;
|
||||
return {
|
||||
version: "rectification-calculation-spec-v4",
|
||||
birthDate: assessment.date,
|
||||
candidateRange: {
|
||||
start: range.startTime ?? "00:00",
|
||||
end: range.endTime ?? "23:59",
|
||||
},
|
||||
latitude: assessment.location.lat,
|
||||
longitude: assessment.location.lon,
|
||||
timezoneOffsetHours: assessment.location.tz,
|
||||
...(birthTimeSource ? { birthTimeSource } : {}),
|
||||
...(timezoneId ? { timezoneId } : {}),
|
||||
...(timezoneSource ? { timezoneSource } : {}),
|
||||
...(resolvedLocalTimeStatus ? { localTimeStatus: resolvedLocalTimeStatus } : {}),
|
||||
ayanamsa: "lahiri",
|
||||
nodeMode: "mean",
|
||||
minuteStep: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export class RectificationV4HttpError extends Error {
|
||||
constructor(readonly status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function rectificationV4Error(error: unknown): NextResponse {
|
||||
if (error instanceof RectificationV4HttpError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof RectificationV4StoreError) {
|
||||
const responses: Record<RectificationV4StoreError["code"], readonly [number, string]> = {
|
||||
not_found: [404, "没有找到这次生时校正记录。"],
|
||||
stale_version: [409, "记录已在其他位置更新,正在重新载入。"],
|
||||
invalid_state: [409, "当前状态无法执行这个操作,请刷新后重试。"],
|
||||
stale_job: [409, "这次计算已过期,请以最新结果为准。"],
|
||||
lease_lost: [409, "计算任务已由其他进程接管,请稍后刷新。"],
|
||||
};
|
||||
const response = responses[error.code];
|
||||
return NextResponse.json({ error: response[1] }, { status: response[0] });
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: "出生资料或提交内容格式不正确。" }, { status: 400 });
|
||||
}
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "生时校正服务尚未配置。" }, { status: 503 });
|
||||
}
|
||||
console.error("rectification_v4_route_failed", error);
|
||||
return NextResponse.json({ error: "暂时无法处理,请稍后再试。" }, { status: 500 });
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { rectificationV4Error } from "../../../_server";
|
||||
import { transitionCase } from "../../_action";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
|
||||
try {
|
||||
return await transitionCase(request, params, "abandon");
|
||||
} catch (error) {
|
||||
return rectificationV4Error(error);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { acceptRangeRequestSchema } from "@/lib/rectification-v4/contracts";
|
||||
import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../_server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
|
||||
try {
|
||||
const body = await requestBody(request, acceptRangeRequestSchema);
|
||||
const context = await rectificationV4Context();
|
||||
const result = await context.service.acceptRange({
|
||||
...body,
|
||||
userId: context.userId,
|
||||
caseId: routeId((await params).caseId),
|
||||
});
|
||||
return result
|
||||
? NextResponse.json(result)
|
||||
: NextResponse.json({ error: "当前结果还不足以保存这个范围。" }, { status: 409 });
|
||||
} catch (error) {
|
||||
return rectificationV4Error(error);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { answerRequestSchema } from "@/lib/rectification-v4/contracts";
|
||||
import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../_server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
|
||||
try {
|
||||
const body = await requestBody(request, answerRequestSchema);
|
||||
const context = await rectificationV4Context();
|
||||
const result = await context.service.answer({ ...body, userId: context.userId, caseId: routeId((await params).caseId) });
|
||||
return result
|
||||
? NextResponse.json(result, { status: 202 })
|
||||
: NextResponse.json({ error: "当前没有待回答的问题,请刷新后重试。" }, { status: 409 });
|
||||
} catch (error) {
|
||||
return rectificationV4Error(error);
|
||||
}
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { reviseEventRequestSchema } from "@/lib/rectification-v4/contracts";
|
||||
import { appendEventRevision, eventDateProvenance } from "@/lib/rectification-v4/evidence-ledger";
|
||||
import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../../../_server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string; eventId: string }> }) {
|
||||
try {
|
||||
const body = await requestBody(request, reviseEventRequestSchema);
|
||||
const context = await rectificationV4Context();
|
||||
const values = await params;
|
||||
const caseId = routeId(values.caseId);
|
||||
const eventId = routeId(values.eventId);
|
||||
const current = await context.service.loadCase(context.userId, caseId);
|
||||
if (!current) return NextResponse.json({ error: "没有找到这次生时校正记录。" }, { status: 404 });
|
||||
const revision = appendEventRevision(current.events, {
|
||||
eventId,
|
||||
domain: body.domain,
|
||||
eventKind: body.eventKind,
|
||||
subject: body.subject,
|
||||
relatedPerson: body.relatedPerson,
|
||||
summary: body.summary,
|
||||
rawText: body.rawText,
|
||||
dateRange: body.dateRange,
|
||||
...eventDateProvenance(body),
|
||||
scoreability: body.scoreability,
|
||||
});
|
||||
return NextResponse.json(await context.service.reviseEvent({
|
||||
userId: context.userId,
|
||||
caseId,
|
||||
actionId: body.actionId,
|
||||
expectedCaseVersion: body.expectedCaseVersion,
|
||||
revision,
|
||||
}), { status: 202 });
|
||||
} catch (error) {
|
||||
return rectificationV4Error(error);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { rectificationV4Error } from "../../../_server";
|
||||
import { transitionCase } from "../../_action";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
|
||||
try {
|
||||
return await transitionCase(request, params, "pause");
|
||||
} catch (error) {
|
||||
return rectificationV4Error(error);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { caseActionRequestSchema } from "@/lib/rectification-v4/contracts";
|
||||
import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../_server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
|
||||
try {
|
||||
const body = await requestBody(request, caseActionRequestSchema);
|
||||
const context = await rectificationV4Context();
|
||||
const result = await context.service.regenerateQuestion({
|
||||
...body,
|
||||
userId: context.userId,
|
||||
caseId: routeId((await params).caseId),
|
||||
});
|
||||
return result
|
||||
? NextResponse.json(result)
|
||||
: NextResponse.json({ error: "当前问题不能重新生成,请刷新后重试。" }, { status: 409 });
|
||||
} catch (error) {
|
||||
return rectificationV4Error(error);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { rectificationV4Error } from "../../../_server";
|
||||
import { transitionCase } from "../../_action";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
|
||||
try {
|
||||
return await transitionCase(request, params, "resume");
|
||||
} catch (error) {
|
||||
return rectificationV4Error(error);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { rectificationV4Context, rectificationV4Error, routeId } from "../../_server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ caseId: string }> }) {
|
||||
try {
|
||||
const context = await rectificationV4Context();
|
||||
const result = await context.service.loadCase(context.userId, routeId((await params).caseId));
|
||||
return result ? NextResponse.json(result) : NextResponse.json({ error: "没有找到这次生时校正记录。" }, { status: 404 });
|
||||
} catch (error) {
|
||||
return rectificationV4Error(error);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { caseActionRequestSchema } from "@/lib/rectification-v4/contracts";
|
||||
import { rectificationV4Context, requestBody, routeId } from "../_server";
|
||||
|
||||
export async function transitionCase(
|
||||
request: Request,
|
||||
params: Promise<{ caseId: string }>,
|
||||
kind: "pause" | "resume" | "abandon",
|
||||
) {
|
||||
const body = await requestBody(request, caseActionRequestSchema);
|
||||
const context = await rectificationV4Context();
|
||||
return NextResponse.json(await context.service.transition({
|
||||
...body,
|
||||
userId: context.userId,
|
||||
caseId: routeId((await params).caseId),
|
||||
kind,
|
||||
}));
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { rectificationV4Context, rectificationV4Error } from "../../_server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const context = await rectificationV4Context();
|
||||
const result = await context.service.loadActive(context.userId);
|
||||
return result ? NextResponse.json(result) : new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return rectificationV4Error(error);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createCaseRequestSchema } from "@/lib/rectification-v4/contracts";
|
||||
import { calculationSpecForUser, rectificationV4Context, rectificationV4Error, requestBody } from "../_server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await requestBody(request, createCaseRequestSchema);
|
||||
const context = await rectificationV4Context();
|
||||
return NextResponse.json(await context.service.createCase({
|
||||
userId: context.userId,
|
||||
actionId: body.actionId,
|
||||
calculationSpec: await calculationSpecForUser(context.auth, context.userId),
|
||||
}));
|
||||
} catch (error) {
|
||||
return rectificationV4Error(error);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { createRectificationV4HandoffService } from "@/lib/rectification-handoff-service";
|
||||
import {
|
||||
createRectificationV4HandoffHandlers,
|
||||
type RectificationV4HandoffRouteDependencies,
|
||||
} from "@/lib/rectification-v4/handoff-route";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const dependencies: RectificationV4HandoffRouteDependencies = {
|
||||
async authenticate() {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
return error || !user ? null : { userId: user.id };
|
||||
},
|
||||
service() {
|
||||
return createRectificationV4HandoffService(createAdminSupabaseClient());
|
||||
},
|
||||
};
|
||||
|
||||
const handlers = createRectificationV4HandoffHandlers(dependencies);
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return handlers.get(request);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return handlers.post(request);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { rectificationV4Context, rectificationV4Error, routeId } from "../../_server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ jobId: string }> }) {
|
||||
try {
|
||||
const context = await rectificationV4Context();
|
||||
const job = await context.service.loadJob(context.userId, routeId((await params).jobId));
|
||||
return job ? NextResponse.json({ job }) : NextResponse.json({ error: "没有找到这次处理任务。" }, { status: 404 });
|
||||
} catch (error) {
|
||||
return rectificationV4Error(error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user