fix: decouple AI completion from history save failures and improve history API errors
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,11 +1,15 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getHistoryUsername } from "@/lib/history/request-user";
|
import { getHistoryUsername } from "@/lib/history/request-user";
|
||||||
import { deleteHistoryEntry } from "@/lib/history/server-store";
|
import {
|
||||||
|
deleteHistoryEntry,
|
||||||
|
historyStoreErrorMessage,
|
||||||
|
} from "@/lib/history/server-store";
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
|
try {
|
||||||
const username = await getHistoryUsername();
|
const username = await getHistoryUsername();
|
||||||
if (!username) {
|
if (!username) {
|
||||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||||
@@ -18,4 +22,11 @@ export async function DELETE(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[history DELETE]", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: historyStoreErrorMessage(err) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { NextResponse } from "next/server";
|
|||||||
import { getHistoryUsername } from "@/lib/history/request-user";
|
import { getHistoryUsername } from "@/lib/history/request-user";
|
||||||
import {
|
import {
|
||||||
addHistoryEntry,
|
addHistoryEntry,
|
||||||
|
historyStoreErrorMessage,
|
||||||
listHistoryEntries,
|
listHistoryEntries,
|
||||||
} from "@/lib/history/server-store";
|
} from "@/lib/history/server-store";
|
||||||
import type { CalcHistoryCreate } from "@/lib/history/types";
|
import type { CalcHistoryCreate } from "@/lib/history/types";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
try {
|
||||||
const username = await getHistoryUsername();
|
const username = await getHistoryUsername();
|
||||||
if (!username) {
|
if (!username) {
|
||||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||||
@@ -14,9 +16,17 @@ export async function GET() {
|
|||||||
|
|
||||||
const items = await listHistoryEntries(username);
|
const items = await listHistoryEntries(username);
|
||||||
return NextResponse.json({ items });
|
return NextResponse.json({ items });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[history GET]", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: historyStoreErrorMessage(err) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
const username = await getHistoryUsername();
|
const username = await getHistoryUsername();
|
||||||
if (!username) {
|
if (!username) {
|
||||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||||
@@ -46,4 +56,11 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ entry });
|
return NextResponse.json({ entry });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[history POST]", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: historyStoreErrorMessage(err) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import RegionSelect, {
|
|||||||
} from "@/components/shared/region-select";
|
} from "@/components/shared/region-select";
|
||||||
import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
|
import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
|
||||||
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
||||||
import { saveHistoryEntry } from "@/lib/history/storage";
|
import { saveHistoryEntrySafe } from "@/lib/history/storage";
|
||||||
|
|
||||||
export default function BaziForm() {
|
export default function BaziForm() {
|
||||||
const [date, setDate] = useState(todaySolarYmd());
|
const [date, setDate] = useState(todaySolarYmd());
|
||||||
@@ -29,6 +29,7 @@ export default function BaziForm() {
|
|||||||
const [chart, setChart] = useState<BaziChart | null>(null);
|
const [chart, setChart] = useState<BaziChart | null>(null);
|
||||||
const [completion, setCompletion] = useState("");
|
const [completion, setCompletion] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [warning, setWarning] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const location = useRegionLocation(provinceCode, cityCode);
|
const location = useRegionLocation(provinceCode, cityCode);
|
||||||
@@ -77,6 +78,7 @@ export default function BaziForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setError("");
|
setError("");
|
||||||
|
setWarning("");
|
||||||
setCompletion("");
|
setCompletion("");
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
@@ -92,7 +94,7 @@ export default function BaziForm() {
|
|||||||
},
|
},
|
||||||
setCompletion,
|
setCompletion,
|
||||||
);
|
);
|
||||||
await saveHistoryEntry({
|
const saveResult = await saveHistoryEntrySafe({
|
||||||
mode: "bazi",
|
mode: "bazi",
|
||||||
title: "生辰八字解读",
|
title: "生辰八字解读",
|
||||||
question,
|
question,
|
||||||
@@ -115,6 +117,9 @@ export default function BaziForm() {
|
|||||||
四柱: `${activeChart.pillars.year.ganZhi} ${activeChart.pillars.month.ganZhi} ${activeChart.pillars.day.ganZhi} ${activeChart.pillars.time.ganZhi}`,
|
四柱: `${activeChart.pillars.year.ganZhi} ${activeChart.pillars.month.ganZhi} ${activeChart.pillars.day.ganZhi} ${activeChart.pillars.time.ganZhi}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (!saveResult.ok) {
|
||||||
|
setWarning(saveResult.error);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -137,6 +142,7 @@ export default function BaziForm() {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onCompletion={handleAnalyze}
|
onCompletion={handleAnalyze}
|
||||||
error={error}
|
error={error}
|
||||||
|
warning={warning}
|
||||||
emptyHint="排盘后点击「AI 测算」获取解读"
|
emptyHint="排盘后点击「AI 测算」获取解读"
|
||||||
downloadFilename={completion ? "生辰八字解读.md" : undefined}
|
downloadFilename={completion ? "生辰八字解读.md" : undefined}
|
||||||
downloadPreamble={downloadPreamble}
|
downloadPreamble={downloadPreamble}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import RegionSelect, {
|
|||||||
import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
|
import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
|
||||||
import type { GuaResult } from "@/lib/calc/hexagram";
|
import type { GuaResult } from "@/lib/calc/hexagram";
|
||||||
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
||||||
import { saveHistoryEntry } from "@/lib/history/storage";
|
import { saveHistoryEntrySafe } from "@/lib/history/storage";
|
||||||
|
|
||||||
export default function CombinedForm() {
|
export default function CombinedForm() {
|
||||||
const [birthDate, setBirthDate] = useState(todaySolarYmd());
|
const [birthDate, setBirthDate] = useState(todaySolarYmd());
|
||||||
@@ -45,6 +45,7 @@ export default function CombinedForm() {
|
|||||||
const [chart, setChart] = useState<BaziChart | null>(null);
|
const [chart, setChart] = useState<BaziChart | null>(null);
|
||||||
const [completion, setCompletion] = useState("");
|
const [completion, setCompletion] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [warning, setWarning] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const birthLocation = useRegionLocation(birthProvince, birthCity);
|
const birthLocation = useRegionLocation(birthProvince, birthCity);
|
||||||
@@ -112,6 +113,7 @@ export default function CombinedForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setError("");
|
setError("");
|
||||||
|
setWarning("");
|
||||||
setCompletion("");
|
setCompletion("");
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
@@ -155,7 +157,7 @@ export default function CombinedForm() {
|
|||||||
},
|
},
|
||||||
setCompletion,
|
setCompletion,
|
||||||
);
|
);
|
||||||
await saveHistoryEntry({
|
const saveResult = await saveHistoryEntrySafe({
|
||||||
mode: "combined",
|
mode: "combined",
|
||||||
title: "综合测算解读",
|
title: "综合测算解读",
|
||||||
question,
|
question,
|
||||||
@@ -189,6 +191,9 @@ export default function CombinedForm() {
|
|||||||
六爻: withHexagram && guaData ? guaData.result.guaTitle : "无",
|
六爻: withHexagram && guaData ? guaData.result.guaTitle : "无",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (!saveResult.ok) {
|
||||||
|
setWarning(saveResult.error);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -211,6 +216,7 @@ export default function CombinedForm() {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onCompletion={handleAnalyze}
|
onCompletion={handleAnalyze}
|
||||||
error={error}
|
error={error}
|
||||||
|
warning={warning}
|
||||||
emptyHint="填写完整信息后,点击「综合测算」"
|
emptyHint="填写完整信息后,点击「综合测算」"
|
||||||
downloadFilename={completion ? "综合测算解读.md" : undefined}
|
downloadFilename={completion ? "综合测算解读.md" : undefined}
|
||||||
downloadPreamble={downloadPreamble}
|
downloadPreamble={downloadPreamble}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import RegionSelect, {
|
|||||||
useRegionLocation,
|
useRegionLocation,
|
||||||
} from "@/components/shared/region-select";
|
} from "@/components/shared/region-select";
|
||||||
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
||||||
import { saveHistoryEntry } from "@/lib/history/storage";
|
import { saveHistoryEntrySafe } from "@/lib/history/storage";
|
||||||
import type { GuaResult } from "@/lib/calc/hexagram";
|
import type { GuaResult } from "@/lib/calc/hexagram";
|
||||||
import todayJson from "@/lib/data/today.json";
|
import todayJson from "@/lib/data/today.json";
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ export default function LiuyaoForm() {
|
|||||||
const [guaData, setGuaData] = useState<GuaResult | null>(null);
|
const [guaData, setGuaData] = useState<GuaResult | null>(null);
|
||||||
const [completion, setCompletion] = useState("");
|
const [completion, setCompletion] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [warning, setWarning] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const location = useRegionLocation(provinceCode, cityCode);
|
const location = useRegionLocation(provinceCode, cityCode);
|
||||||
@@ -65,6 +66,7 @@ export default function LiuyaoForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setError("");
|
setError("");
|
||||||
|
setWarning("");
|
||||||
setCompletion("");
|
setCompletion("");
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
@@ -86,7 +88,7 @@ export default function LiuyaoForm() {
|
|||||||
},
|
},
|
||||||
setCompletion,
|
setCompletion,
|
||||||
);
|
);
|
||||||
await saveHistoryEntry({
|
const saveResult = await saveHistoryEntrySafe({
|
||||||
mode: "liuyao",
|
mode: "liuyao",
|
||||||
title: guaData!.result.guaTitle,
|
title: guaData!.result.guaTitle,
|
||||||
question,
|
question,
|
||||||
@@ -104,6 +106,9 @@ export default function LiuyaoForm() {
|
|||||||
卦象: guaData!.result.guaTitle,
|
卦象: guaData!.result.guaTitle,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (!saveResult.ok) {
|
||||||
|
setWarning(saveResult.error);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -122,6 +127,7 @@ export default function LiuyaoForm() {
|
|||||||
setGuaData(null);
|
setGuaData(null);
|
||||||
setCompletion("");
|
setCompletion("");
|
||||||
setError("");
|
setError("");
|
||||||
|
setWarning("");
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +135,7 @@ export default function LiuyaoForm() {
|
|||||||
setGuaData(data);
|
setGuaData(data);
|
||||||
setCompletion("");
|
setCompletion("");
|
||||||
setError("");
|
setError("");
|
||||||
|
setWarning("");
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadPreamble =
|
const downloadPreamble =
|
||||||
@@ -145,6 +152,7 @@ export default function LiuyaoForm() {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onCompletion={handleAnalyze}
|
onCompletion={handleAnalyze}
|
||||||
error={error}
|
error={error}
|
||||||
|
warning={warning}
|
||||||
emptyHint="完成起卦后,点击「AI 解读」"
|
emptyHint="完成起卦后,点击「AI 解读」"
|
||||||
downloadFilename={
|
downloadFilename={
|
||||||
completion && guaData
|
completion && guaData
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function ResultAI({
|
|||||||
isLoading,
|
isLoading,
|
||||||
onCompletion,
|
onCompletion,
|
||||||
error,
|
error,
|
||||||
|
warning,
|
||||||
panel = false,
|
panel = false,
|
||||||
emptyHint = "完成左侧填写并起卦/排盘后,点击「AI 解读」",
|
emptyHint = "完成左侧填写并起卦/排盘后,点击「AI 解读」",
|
||||||
downloadFilename,
|
downloadFilename,
|
||||||
@@ -20,6 +21,7 @@ function ResultAI({
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onCompletion: () => void;
|
onCompletion: () => void;
|
||||||
error: string;
|
error: string;
|
||||||
|
warning?: string;
|
||||||
panel?: boolean;
|
panel?: boolean;
|
||||||
emptyHint?: string;
|
emptyHint?: string;
|
||||||
downloadFilename?: string;
|
downloadFilename?: string;
|
||||||
@@ -94,9 +96,17 @@ function ResultAI({
|
|||||||
<p className="mt-1 whitespace-pre-wrap">{error}</p>
|
<p className="mt-1 whitespace-pre-wrap">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
) : completion ? (
|
) : completion ? (
|
||||||
|
<>
|
||||||
|
{warning && (
|
||||||
|
<div className="mb-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
<p className="font-medium">解读已完成,但历史未保存</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap">{warning}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Markdown className="prose max-w-none text-sm dark:prose-invert prose-p:leading-relaxed">
|
<Markdown className="prose max-w-none text-sm dark:prose-invert prose-p:leading-relaxed">
|
||||||
{completion}
|
{completion}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
|
</>
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">正在等待 AI 响应...</p>
|
<p className="text-sm text-muted-foreground">正在等待 AI 响应...</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -15,6 +15,24 @@ function getDataDir(): string {
|
|||||||
return path.join(process.cwd(), "data", "history");
|
return path.join(process.cwd(), "data", "history");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function historyStoreErrorMessage(err: unknown): string {
|
||||||
|
if (!(err instanceof Error)) {
|
||||||
|
return "保存测算历史失败";
|
||||||
|
}
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
const dir = getDataDir();
|
||||||
|
if (code === "EACCES" || code === "EPERM") {
|
||||||
|
return `测算历史目录无写入权限(${dir}),请检查 HISTORY_DATA_DIR 或目录权限`;
|
||||||
|
}
|
||||||
|
if (code === "ENOENT") {
|
||||||
|
return `测算历史目录不存在(${dir}),请创建目录或配置 HISTORY_DATA_DIR`;
|
||||||
|
}
|
||||||
|
if (code === "ENOSPC") {
|
||||||
|
return "磁盘空间不足,无法保存测算历史";
|
||||||
|
}
|
||||||
|
return `保存测算历史失败:${err.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeUsername(username: string): string {
|
function sanitizeUsername(username: string): string {
|
||||||
const safe = username.replace(/[^a-zA-Z0-9_-]/g, "_");
|
const safe = username.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
return safe || "guest";
|
return safe || "guest";
|
||||||
|
|||||||
@@ -34,6 +34,22 @@ export async function saveHistoryEntry(
|
|||||||
return data.entry;
|
return data.entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveHistoryEntrySafe(
|
||||||
|
entry: CalcHistoryCreate,
|
||||||
|
): Promise<
|
||||||
|
{ ok: true; entry: CalcHistoryEntry } | { ok: false; error: string }
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const saved = await saveHistoryEntry(entry);
|
||||||
|
return { ok: true, entry: saved };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteHistoryEntry(id: string): Promise<void> {
|
export async function deleteHistoryEntry(id: string): Promise<void> {
|
||||||
const res = await fetch(`/api/history/${encodeURIComponent(id)}`, {
|
const res = await fetch(`/api/history/${encodeURIComponent(id)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
|||||||
Reference in New Issue
Block a user