import { apiUsesSameOrigin, appendLoopbackConnectHint, getApiBase } from "@/config/api"; import { prepareImageForUpload } from "@/utils/prepareUploadImage"; const MP_TOKEN_KEY = "mp_api_token"; const MP_CLIENT_KEY = "mp_client_id"; /** 与 mpSession 一致:guest | weixin | phone */ export const MP_LOGIN_CHANNEL_KEY = "mp_login_channel_v1"; export function getMpToken(): string { try { return (uni.getStorageSync(MP_TOKEN_KEY) as string) || ""; } catch { return ""; } } export function setMpAuth(clientId: string, token: string): void { resetMpUnauthorizedRelaunchGuard(); uni.setStorageSync(MP_CLIENT_KEY, clientId); uni.setStorageSync(MP_TOKEN_KEY, token); } export function clearMpAuth(): void { try { uni.removeStorageSync(MP_TOKEN_KEY); } catch { /* ignore */ } } /** 清除小程序端登录态(含设备标识,下次将分配新游客) */ export function clearMpSession(): void { clearMpAuth(); try { uni.removeStorageSync(MP_CLIENT_KEY); } catch { /* ignore */ } try { uni.removeStorageSync(MP_LOGIN_CHANNEL_KEY); } catch { /* ignore */ } } function authHeaders(): Record { const t = getMpToken(); return t ? { Authorization: `Bearer ${t}` } : {}; } /** App 等端对 JSON POST 需传字符串,否则后端可能收不到 body */ function jsonBody(data?: Record): string { return JSON.stringify(data ?? {}); } /** uni.request fail 多为 { errMsg },转成 Error 便于统一提示 */ function rejectAsRequestError(e: unknown, reject: (reason?: unknown) => void): void { if (e instanceof Error) { reject(e); return; } if (e && typeof e === "object" && "errMsg" in e) { const m = (e as { errMsg?: string }).errMsg; reject(new Error(typeof m === "string" && m.trim() ? m : "网络请求失败")); return; } reject(new Error(typeof e === "string" && e ? e : "网络请求失败")); } export interface ApiEnvelope { /** 后端多为 number;个别网关/序列化可能为字符串 */ code: number | string; msg: string; data: T; } function isApiSuccessCode(code: unknown): boolean { return code === 0 || code === "0"; } /** 后端小程序鉴权失败(token 无效、过期、未登录等) */ function isMpUnauthorizedApiCode(code: unknown): boolean { return code === 401 || code === "401"; } let mpUnauthorizedRelaunchScheduled = false; function resetMpUnauthorizedRelaunchGuard(): void { mpUnauthorizedRelaunchScheduled = false; } function handleMpApiUnauthorized(): void { if (mpUnauthorizedRelaunchScheduled) { return; } mpUnauthorizedRelaunchScheduled = true; clearMpSession(); uni.showToast({ title: "登录已过期,请重新登录", icon: "none", duration: 2000, }); uni.reLaunch({ url: "/pages/login/login" }); } /** 已在 {@link get}/{@link post}/{@link uploadImage} 内触发清 token 与跳转登录;catch 中可据此跳过重复提示 */ export class MpSessionExpiredError extends Error { override readonly name = "MpSessionExpiredError"; constructor(message = "登录已失效") { super(message); } } export function isMpSessionExpiredError(e: unknown): boolean { return e instanceof MpSessionExpiredError; } function isApiEnvelopeShape(body: unknown): body is ApiEnvelope { return !!body && typeof body === "object" && "code" in body; } /** App 等端偶发把 JSON 当字符串返回,不解析会导致「响应格式错误」 */ function normalizeUniResponseData(data: unknown): unknown { if (typeof data !== "string") { return data; } const s = data.trim(); if (!s) { return data; } const c0 = s[0]; if (c0 !== "{" && c0 !== "[") { return data; } try { return JSON.parse(s) as unknown; } catch { return data; } } /** 从接口 reject 的 envelope 或 Error 中取出可读提示(不含登录失效) */ export function apiFailureMessage(e: unknown, fallback = "请求失败"): string { if (isMpSessionExpiredError(e)) { return ""; } if (e && typeof e === "object" && "msg" in e) { const m = (e as { msg?: unknown }).msg; if (typeof m === "string" && m.trim()) { return m.trim(); } } if (e instanceof Error && e.message.trim()) { return appendLoopbackConnectHint(e.message.trim()); } return fallback; } /** 弱网 / 真机首次建连可能较慢;过短易误报 request:fail timeout */ const REQUEST_TIMEOUT_MS = 60000; function toQueryString( query?: Record ): string { if (!query) { return ""; } const parts: string[] = []; for (const [k, v] of Object.entries(query)) { if (v === undefined || v === "") { continue; } parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`); } return parts.join("&"); } function rejectIfBadHttpStatus( res: UniApp.RequestSuccessCallbackResult, reject: (reason?: unknown) => void ): boolean { const sc = res.statusCode; if (sc !== undefined && sc !== 200) { if (sc === 401) { const body = normalizeUniResponseData(res.data as unknown); if (isApiEnvelopeShape(body) && isMpUnauthorizedApiCode(body.code)) { handleMpApiUnauthorized(); reject(new MpSessionExpiredError()); return true; } } reject(new Error(`网络异常(HTTP ${sc})`)); return true; } return false; } export function get( path: string, query?: Record ): Promise> { const base = getApiBase(); if (!base && !apiUsesSameOrigin()) { return Promise.reject(new Error("API 未配置")); } let url = path.startsWith("http") ? path : `${base}${path}`; if (query && Object.keys(query).length) { const s = toQueryString(query); if (s) { url += (url.includes("?") ? "&" : "?") + s; } } return new Promise((resolve, reject) => { uni.request({ url, method: "GET", header: { ...authHeaders() }, timeout: REQUEST_TIMEOUT_MS, dataType: "json", success: (res) => { if (rejectIfBadHttpStatus(res, reject)) { return; } const body = normalizeUniResponseData(res.data as unknown); if (!isApiEnvelopeShape(body)) { reject(new Error("响应格式错误")); return; } if (!isApiSuccessCode(body.code)) { if (isMpUnauthorizedApiCode(body.code)) { handleMpApiUnauthorized(); reject(new MpSessionExpiredError()); return; } reject(body); return; } resolve(body as ApiEnvelope); }, fail: (e) => rejectAsRequestError(e, reject), }); }); } export function post(path: string, data?: Record): Promise> { const base = getApiBase(); if (!base && !apiUsesSameOrigin()) { return Promise.reject(new Error("API 未配置")); } const url = path.startsWith("http") ? path : `${base}${path}`; return new Promise((resolve, reject) => { uni.request({ url, method: "POST", header: { "Content-Type": "application/json", ...authHeaders(), }, data: jsonBody(data), timeout: REQUEST_TIMEOUT_MS, dataType: "json", success: (res) => { if (rejectIfBadHttpStatus(res, reject)) { return; } const body = normalizeUniResponseData(res.data as unknown); if (!isApiEnvelopeShape(body)) { reject(new Error("响应格式错误")); return; } if (!isApiSuccessCode(body.code)) { if (isMpUnauthorizedApiCode(body.code)) { handleMpApiUnauthorized(); reject(new MpSessionExpiredError()); return; } reject(body); return; } resolve(body as ApiEnvelope); }, fail: (e) => rejectAsRequestError(e, reject), }); }); } export function uploadImage(filePath: string): Promise { const base = getApiBase(); if (!base && !apiUsesSameOrigin()) { return Promise.reject(new Error("API 未配置")); } return new Promise((resolve, reject) => { void (async () => { let path = filePath; try { path = await prepareImageForUpload(filePath); } catch { path = filePath; } uni.uploadFile({ url: `${base}/api/v1/upload`, filePath: path, name: "file", header: { ...authHeaders() }, timeout: REQUEST_TIMEOUT_MS, success: (res) => { const code = (res as { statusCode?: number }).statusCode; const rawEarly = res.data || ""; if (code !== undefined && code !== 200) { if (code === 401) { try { const parsed = typeof rawEarly === "string" ? (JSON.parse(rawEarly || "{}") as unknown) : rawEarly; if (isApiEnvelopeShape(parsed) && isMpUnauthorizedApiCode(parsed.code)) { handleMpApiUnauthorized(); reject(new MpSessionExpiredError()); return; } } catch { /* fall through */ } } reject(new Error(`上传失败(HTTP ${code})`)); return; } const raw = res.data || ""; try { const body = typeof raw === "string" ? (JSON.parse(raw || "{}") as ApiEnvelope<{ url: string }>) : raw; if (!isApiEnvelopeShape(body) || !isApiSuccessCode(body.code)) { if (isApiEnvelopeShape(body) && isMpUnauthorizedApiCode(body.code)) { handleMpApiUnauthorized(); reject(new MpSessionExpiredError()); return; } reject(body); return; } resolve(body.data.url); } catch { const tip = typeof raw === "string" && raw.length < 120 ? raw : "上传解析失败,请检查接口与登录态"; reject(new Error(tip)); } }, fail: (e) => rejectAsRequestError(e, reject), }); })(); }); }