http.ts 10 KB
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<string, string> {
  const t = getMpToken();
  return t ? { Authorization: `Bearer ${t}` } : {};
}

/** App 等端对 JSON POST 需传字符串,否则后端可能收不到 body */
function jsonBody(data?: Record<string, unknown>): 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<T> {
  /** 后端多为 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<unknown> {
  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, string | number | undefined>
): 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<T>(
  path: string,
  query?: Record<string, string | number | undefined>
): Promise<ApiEnvelope<T>> {
  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<T>);
      },
      fail: (e) => rejectAsRequestError(e, reject),
    });
  });
}

export function post<T>(path: string, data?: Record<string, unknown>): Promise<ApiEnvelope<T>> {
  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<T>);
      },
      fail: (e) => rejectAsRequestError(e, reject),
    });
  });
}

export function uploadImage(filePath: string): Promise<string> {
  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),
      });
    })();
  });
}