thread-master/apps/web/src/lib/apiErrors.ts

239 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 後端 envelope code / data.message → 語言包。
* 顯示時一律用目前 UI 語系,不直接秀後端英文。
*/
import { useCallback } from "react";
import { useI18n } from "../i18n/I18nContext";
import { translate } from "./i18n/messages";
import { loadUiPrefs } from "./i18n/prefs";
import type { AppLocale } from "./i18n/types";
/** 與 backend internal/response + middleware 對齊 */
export const API_CODES = {
SUCCESS: 102000,
BAD_REQUEST: 400001,
WEAK_PASSWORD: 400003,
INVALID_CODE: 400004,
SELF_SUSPEND: 400020,
LAST_ADMIN: 400021,
MISSING_AUTH: 401001,
INVALID_TOKEN: 401002,
MEMBER_NOT_FOUND_AUTH: 401003,
BAD_PASSWORD: 401010,
SUSPENDED: 403001,
ADMIN_REQUIRED: 403002,
NOT_FOUND: 404001,
EMAIL_NOT_REGISTERED: 404002,
EMAIL_TAKEN: 409001,
QUOTA: 402001,
METER_CAP: 402002,
PLATFORM_CAPACITY: 503002,
SERVER: 500000,
NOT_IMPLEMENTED: 501000,
NETWORK: 0,
} as const;
/** code → messages.ts 的 key */
export const API_ERROR_I18N_KEY: Record<number, string> = {
[API_CODES.BAD_REQUEST]: "api.err.400001",
[API_CODES.WEAK_PASSWORD]: "api.err.400003",
[API_CODES.INVALID_CODE]: "api.err.400004",
[API_CODES.SELF_SUSPEND]: "api.err.400020",
[API_CODES.LAST_ADMIN]: "api.err.400021",
[API_CODES.MISSING_AUTH]: "api.err.401001",
[API_CODES.INVALID_TOKEN]: "api.err.401002",
[API_CODES.MEMBER_NOT_FOUND_AUTH]: "api.err.401003",
[API_CODES.BAD_PASSWORD]: "api.err.401010",
[API_CODES.SUSPENDED]: "api.err.403001",
[API_CODES.ADMIN_REQUIRED]: "api.err.403002",
[API_CODES.NOT_FOUND]: "api.err.404001",
[API_CODES.EMAIL_NOT_REGISTERED]: "api.err.404002",
[API_CODES.EMAIL_TAKEN]: "api.err.409001",
[API_CODES.QUOTA]: "api.err.402001",
[API_CODES.METER_CAP]: "usage.err.meterCap",
[API_CODES.PLATFORM_CAPACITY]: "usage.err.platformCapacity",
[API_CODES.SERVER]: "api.err.500000",
[API_CODES.NOT_IMPLEMENTED]: "api.err.501000",
[API_CODES.NETWORK]: "api.err.network",
// 400050/400060 不進表:後端 message 常是具體中文原因(人設分析等),由 rawMessage 顯示
400061: "api.err.crawlerSession",
503001: "api.err.timeoutAI",
// 503002 = PLATFORM_CAPACITY已在上方 API_CODES 映射
};
export function i18nKeyForApiCode(code: number): string | undefined {
return API_ERROR_I18N_KEY[code];
}
/**
* 後端 data.message英文固定字→ 語言包 key。
* 對齊 apps/backend internal/logic/** 回傳的 Message 字串。
*/
const API_OK_MESSAGE_KEY: Record<string, string> = {
"verification code sent": "api.ok.verifySent",
"verification code issued": "api.ok.verifyIssued",
"reset code sent": "api.ok.resetSent",
"password updated": "api.ok.passwordUpdated",
"logged out": "api.ok.loggedOut",
"unbound": "api.ok.unbound",
// 舊文案相容
"if the account exists, a reset code was issued": "api.ok.resetSent",
};
export function i18nKeyForApiOkMessage(raw: string): string | undefined {
const k = raw.trim().toLowerCase();
if (!k) return undefined;
// exact
if (API_OK_MESSAGE_KEY[raw.trim()]) return API_OK_MESSAGE_KEY[raw.trim()];
if (API_OK_MESSAGE_KEY[k]) return API_OK_MESSAGE_KEY[k];
// case-insensitive scan
for (const [en, key] of Object.entries(API_OK_MESSAGE_KEY)) {
if (en.toLowerCase() === k) return key;
}
return undefined;
}
/** 成功 data.message → 目前語系文案;未知則回原文或 fallbackKey */
export function messageForApiOk(
raw: string | undefined | null,
fallbackKey = "common.success",
locale?: AppLocale,
): string {
const loc = locale ?? currentLocale();
const text = (raw || "").trim();
if (!text) return translate(loc, fallbackKey);
const key = i18nKeyForApiOkMessage(text);
if (key) return translate(loc, key);
return text;
}
function currentLocale(): AppLocale {
try {
return loadUiPrefs().locale;
} catch {
return "zh-TW";
}
}
/** 後端業務提示(多為中文)可直接顯示,避免被「操作失敗」蓋掉 */
function cleanBizMessage(raw: string): string {
let s = raw.trim();
for (const p of ["studio validation: ", "inspire validation: ", "scout validation: ", "validation: "]) {
if (s.toLowerCase().startsWith(p)) {
s = s.slice(p.length).trim();
break;
}
}
// errors.Is wrap: "studio validation: detail"
const m = s.match(/^(?:studio|inspire|scout)\s+validation:\s*(.+)$/i);
if (m?.[1]) s = m[1].trim();
return s;
}
function isUserFacingBizMessage(raw: string | undefined | null): boolean {
const s = (raw || "").trim();
if (!s) return false;
// 含中日韓或明確業務提示
if (/[\u4e00-\u9fff]/.test(s)) return true;
if (s.length >= 24 && !/^error\b/i.test(s)) return true;
return false;
}
/**
* 依業務碼 + 目前語系取文案(給非 React 層,如 http client
* 已知 code 用語言包;否則若 fallback 是可讀業務提示則直接顯示。
*/
export function messageForApiCode(
code: number,
fallback?: string,
locale?: AppLocale,
): string {
const loc = locale ?? currentLocale();
// 後端業務錯誤直接回 i18n keyinvite.err.* / usage.err.*
const fb = (fallback || "").trim();
if (fb.startsWith("invite.err.") || fb.startsWith("usage.err.")) return translate(loc, fb);
const key = i18nKeyForApiCode(code);
if (key) return translate(loc, key);
if (isUserFacingBizMessage(fallback)) return cleanBizMessage(fallback!);
return translate(loc, "api.err.unknown");
}
type ErrLike = {
code?: number;
message?: string;
name?: string;
rawMessage?: string;
};
/** 從 thrown value 取可顯示字串(依目前語言包) */
export function formatThrownError(err: unknown, fallbackKey = "common.error"): string {
const loc = currentLocale();
const e = err as ErrLike | null;
if (e && typeof e === "object" && e.name === "ApiError") {
const code = e.code != null ? Number(e.code) : NaN;
const raw = e.rawMessage || e.message;
if (typeof raw === "string" && (raw.startsWith("invite.err.") || raw.startsWith("usage.err."))) {
return translate(loc, raw);
}
if (!Number.isNaN(code)) {
const key = i18nKeyForApiCode(code);
// 驗證類業務碼:優先後端中文原因
if (
isUserFacingBizMessage(raw) &&
(code === 400050 || code === 400060 || code === 400061 || !key)
) {
return cleanBizMessage(String(raw));
}
if (key) return translate(loc, key);
}
if (isUserFacingBizMessage(raw)) return cleanBizMessage(String(raw));
return translate(loc, "api.err.unknown");
}
if (e && typeof e === "object" && e.code != null && !Number.isNaN(Number(e.code))) {
const code = Number(e.code);
const key = i18nKeyForApiCode(code);
if (key) return translate(loc, key);
if (isUserFacingBizMessage(e.message)) return cleanBizMessage(String(e.message));
return translate(loc, "api.err.unknown");
}
// mock 前端 throw 的中文 Error或無 code 的例外
if (err instanceof Error && err.message && err.name !== "ApiError") {
return err.message;
}
return translate(loc, fallbackKey);
}
/**
* React hook用目前 I18n 語言包t / locale格式化錯誤。
* 頁面 catch 請用這個,確保跟 UI 語系完全一致。
*/
export function useFormatApiError() {
const { locale } = useI18n();
return useCallback(
(err: unknown, fallbackKey = "common.error"): string => {
void locale;
return formatThrownError(err, fallbackKey);
},
[locale],
);
}
/**
* React hook後端成功 message → 目前語言包。
* 例verification code sent → 「驗證碼已寄出」
*/
export function useFormatApiOk() {
const { t } = useI18n();
return useCallback(
(raw: string | undefined | null, fallbackKey = "common.success"): string => {
const text = (raw || "").trim();
if (!text) return t(fallbackKey);
const key = i18nKeyForApiOkMessage(text);
if (key) return t(key);
return text;
},
[t],
);
}