mpSession.ts 6.54 KB
import { apiEnabled } from "@/config/api";
import type { ApiEnvelope } from "@/utils/api/http";
import { getMpToken, MP_LOGIN_CHANNEL_KEY, post, setMpAuth } from "@/utils/api/http";
import { loadUserProfile, saveUserProfile, type UserProfile } from "@/utils/userProfileStorage";
import { resolveMediaUrl } from "@/utils/mediaUrl";

let bootstrapPromise: Promise<void> | null = null;

export type MpAuthUser = {
  id: number;
  nickname: string;
  avatar: string;
  bio: string;
  gender: number;
  phone?: string;
};

type MpAuthPayload = {
  token: string;
  client_id: string;
  user: MpAuthUser;
};

function isGenericNickname(name: string): boolean {
  const n = String(name || "").trim();
  return n === "" || n === "微信用户" || n === "游客";
}

function syncLocalProfile(u: MpAuthUser) {
  const gender: UserProfile["gender"] =
    u.gender === 1 ? "male" : u.gender === 2 ? "female" : "secret";
  const nickname = String(u.nickname || "").trim();
  const avatar = String(u.avatar || "").trim();
  const bio = typeof u.bio === "string" ? u.bio : "";
  const phone = typeof u.phone === "string" ? u.phone.trim() : "";
  saveUserProfile({
    nickname,
    avatarUrl: avatar ? resolveMediaUrl(avatar) : "",
    bio,
    gender,
    /* 与后端一致;空字符串覆盖本地,避免换账号后仍残留旧手机号 */
    phone,
  });
}

function finishAuth(data: MpAuthPayload) {
  setMpAuth(data.client_id, data.token);
  syncLocalProfile(data.user);
}

type MpLoginChannel = "guest" | "weixin" | "phone";

function setMpLoginChannel(ch: MpLoginChannel): void {
  try {
    uni.setStorageSync(MP_LOGIN_CHANNEL_KEY, ch);
  } catch {
    /* ignore */
  }
}

/** 当前登录渠道(小程序端上传/「我的」仅允许 weixin) */
export function getMpLoginChannel(): MpLoginChannel | "" {
  try {
    const v = String(uni.getStorageSync(MP_LOGIN_CHANNEL_KEY) || "").trim();
    if (v === "guest" || v === "weixin" || v === "phone") {
      return v;
    }
    return "";
  } catch {
    return "";
  }
}

function readClientId(): string {
  try {
    return (uni.getStorageSync("mp_client_id") as string) || "";
  } catch {
    return "";
  }
}

export function resetMpBootstrap() {
  bootstrapPromise = null;
}

/** 调用登录接口并写入 token / 本地资料 */
export async function authMpRequest(body: Record<string, string | number>): Promise<ApiEnvelope<MpAuthPayload>> {
  return post<MpAuthPayload>("/api/v1/auth/mp", body);
}

/**
 * 无 token 时用游客流换取 token(不含 wx_code,避免绕过登录页静默绑微信)。
 * 需微信授权登录请使用 {@link loginWithWeixin}。
 */
export function ensureMpSession(): Promise<void> {
  if (!apiEnabled()) {
    return Promise.resolve();
  }
  if (getMpToken()) {
    return Promise.resolve();
  }
  if (bootstrapPromise) {
    return bootstrapPromise;
  }
  bootstrapPromise = (async () => {
    const res = await authMpRequest({ client_id: readClientId() });
    finishAuth(res.data);
    setMpLoginChannel("guest");
  })().finally(() => {
    bootstrapPromise = null;
  });
  return bootstrapPromise;
}

/** 登录页:与 ensureMpSession 相同,显式调用 */
export async function loginAsGuest(): Promise<void> {
  resetMpBootstrap();
  const res = await authMpRequest({ client_id: readClientId() });
  finishAuth(res.data);
  setMpLoginChannel("guest");
}

/** 登录页:微信小程序 code 换票并绑定 openid */
export async function loginWithWeixin(): Promise<MpAuthUser> {
  resetMpBootstrap();
  let wxCode = "";
  // #ifdef MP-WEIXIN
  wxCode = await new Promise<string>((resolve, reject) => {
    uni.login({
      provider: "weixin",
      success: (r) => {
        if (r.code) {
          resolve(r.code);
        } else {
          reject(new Error("未获取到登录凭证"));
        }
      },
      fail: (e) => reject(e),
    });
  });
  // #endif
  if (!wxCode) {
    throw new Error("请在小程序内完成快捷登录");
  }
  const res = await authMpRequest({
    client_id: readClientId(),
    wx_code: wxCode,
  });
  finishAuth(res.data);
  setMpLoginChannel("weixin");
  return {
    ...res.data.user,
    nickname: isGenericNickname(res.data.user.nickname) ? "" : String(res.data.user.nickname || "").trim(),
    avatar: String(res.data.user.avatar || "").trim(),
  };
}

/** 兼容旧后端:登录时附带昵称头像 */
export async function loginWithWeixinProfile(payload: {
  nickname: string;
  avatar: string;
  gender?: number;
}): Promise<MpAuthUser> {
  resetMpBootstrap();
  let wxCode = "";
  // #ifdef MP-WEIXIN
  wxCode = await new Promise<string>((resolve, reject) => {
    uni.login({
      provider: "weixin",
      success: (r) => {
        if (r.code) {
          resolve(r.code);
        } else {
          reject(new Error("未获取到登录凭证"));
        }
      },
      fail: (e) => reject(e),
    });
  });
  // #endif
  if (!wxCode) {
    throw new Error("请在小程序内完成快捷登录");
  }
  const res = await authMpRequest({
    client_id: readClientId(),
    wx_code: wxCode,
    wx_nickname: String(payload.nickname || "").trim(),
    wx_avatar: String(payload.avatar || "").trim(),
    wx_gender: Number(payload.gender || 0),
  });
  finishAuth(res.data);
  setMpLoginChannel("weixin");
  return {
    ...res.data.user,
    nickname: isGenericNickname(res.data.user.nickname)
      ? String(payload.nickname || "").trim()
      : String(res.data.user.nickname || "").trim(),
    avatar: String(res.data.user.avatar || "").trim() || String(payload.avatar || "").trim(),
  };
}

/** 登录后绑定手机号(getPhoneNumber 回调 code) */
export async function bindWechatPhone(phoneCode: string): Promise<string> {
  const res = await post<{ phone: string }>("/api/v1/auth/bind-phone", {
    phone_code: phoneCode,
  });
  const phone = String(res.data.phone || "").trim();
  if (!phone) {
    throw new Error("手机号绑定失败");
  }
  const cur = loadUserProfile();
  saveUserProfile({ ...cur, phone });
  return phone;
}

/** 手机号 + 密码登录 */
export async function loginWithPhone(phone: string, pwd: string): Promise<void> {
  resetMpBootstrap();
  const res = await post<MpAuthPayload>("/api/v1/auth/login-phone", {
    phone,
    password: pwd,
  });
  finishAuth(res.data);
  setMpLoginChannel("phone");
}

/** 手机号注册(成功后直接登录) */
export async function registerWithPhone(
  phone: string,
  password: string,
  password_confirm: string
): Promise<void> {
  resetMpBootstrap();
  const res = await post<MpAuthPayload>("/api/v1/auth/register", {
    phone,
    password,
    password_confirm,
  });
  finishAuth(res.data);
  setMpLoginChannel("phone");
}