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

239 lines
7.8 KiB
TypeScript
Raw Normal View History

2026-07-10 12:54:45 +00:00
/**
* 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,
2026-07-13 03:18:08 +00:00
QUOTA: 402001,
METER_CAP: 402002,
PLATFORM_CAPACITY: 503002,
2026-07-10 12:54:45 +00:00
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",
2026-07-13 03:18:08 +00:00
[API_CODES.QUOTA]: "api.err.402001",
[API_CODES.METER_CAP]: "usage.err.meterCap",
[API_CODES.PLATFORM_CAPACITY]: "usage.err.platformCapacity",
2026-07-10 12:54:45 +00:00
[API_CODES.SERVER]: "api.err.500000",
[API_CODES.NOT_IMPLEMENTED]: "api.err.501000",
[API_CODES.NETWORK]: "api.err.network",
2026-07-13 01:15:30 +00:00
// 400050/400060 不進表:後端 message 常是具體中文原因(人設分析等),由 rawMessage 顯示
400061: "api.err.crawlerSession",
503001: "api.err.timeoutAI",
2026-07-13 03:18:08 +00:00
// 503002 = PLATFORM_CAPACITY已在上方 API_CODES 映射
2026-07-10 12:54:45 +00:00
};
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";
}
}
2026-07-13 01:15:30 +00:00
/** 後端業務提示(多為中文)可直接顯示,避免被「操作失敗」蓋掉 */
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;
}
2026-07-10 12:54:45 +00:00
/**
* + React http client
2026-07-13 01:15:30 +00:00
* code fallback
2026-07-10 12:54:45 +00:00
*/
export function messageForApiCode(
code: number,
2026-07-13 01:15:30 +00:00
fallback?: string,
2026-07-10 12:54:45 +00:00
locale?: AppLocale,
): string {
const loc = locale ?? currentLocale();
2026-07-13 03:18:08 +00:00
// 後端業務錯誤直接回 i18n keyinvite.err.* / usage.err.*
const fb = (fallback || "").trim();
if (fb.startsWith("invite.err.") || fb.startsWith("usage.err.")) return translate(loc, fb);
2026-07-10 12:54:45 +00:00
const key = i18nKeyForApiCode(code);
if (key) return translate(loc, key);
2026-07-13 01:15:30 +00:00
if (isUserFacingBizMessage(fallback)) return cleanBizMessage(fallback!);
2026-07-10 12:54:45 +00:00
return translate(loc, "api.err.unknown");
}
type ErrLike = {
code?: number;
message?: string;
name?: string;
2026-07-13 01:15:30 +00:00
rawMessage?: string;
2026-07-10 12:54:45 +00:00
};
/** 從 thrown value 取可顯示字串(依目前語言包) */
export function formatThrownError(err: unknown, fallbackKey = "common.error"): string {
const loc = currentLocale();
const e = err as ErrLike | null;
2026-07-13 01:15:30 +00:00
if (e && typeof e === "object" && e.name === "ApiError") {
const code = e.code != null ? Number(e.code) : NaN;
const raw = e.rawMessage || e.message;
2026-07-13 03:18:08 +00:00
if (typeof raw === "string" && (raw.startsWith("invite.err.") || raw.startsWith("usage.err."))) {
return translate(loc, raw);
}
2026-07-13 01:15:30 +00:00
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");
}
2026-07-10 12:54:45 +00:00
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);
2026-07-13 01:15:30 +00:00
if (isUserFacingBizMessage(e.message)) return cleanBizMessage(String(e.message));
2026-07-10 12:54:45 +00:00
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() {
2026-07-13 01:15:30 +00:00
const { locale } = useI18n();
2026-07-10 12:54:45 +00:00
return useCallback(
(err: unknown, fallbackKey = "common.error"): string => {
2026-07-13 01:15:30 +00:00
void locale;
return formatThrownError(err, fallbackKey);
2026-07-10 12:54:45 +00:00
},
2026-07-13 01:15:30 +00:00
[locale],
2026-07-10 12:54:45 +00:00
);
}
/**
* React hook message
* verification code sent
*/
export function useFormatApiOk() {
const { t, locale } = 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, locale],
);
}