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:
dekun
2026-06-16 21:31:20 +08:00
parent 123a5cce6d
commit 187b08c3e1
8 changed files with 145 additions and 53 deletions
+12 -1
View File
@@ -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 },
);
}
} }
+17
View File
@@ -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 },
);
}
} }
+8 -2
View File
@@ -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}
+8 -2
View File
@@ -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}
+10 -2
View File
@@ -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
+10
View File
@@ -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>
) : ( ) : (
+18
View File
@@ -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";
+16
View File
@@ -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",