shop-web/doc/登录流程详细文档.md

23 KiB
Raw Blame History

微信登录、企业微信登录与Fake登录流程详细文档

文档概述

本文档详细描述了智柜宝系统中三种登录方式的完整流程、参数说明、返回信息及实现逻辑。

一、微信登录(普通微信公众号登录)

1.1 登录流程图

用户访问应用
    ↓
检查微信环境micromessenger
    ↓
跳转微信授权页面
    ↓
用户授权
    ↓
微信回调携带 code 和 state
    ↓
调用 getOpenIdApi 换取 openid
    ↓
调用 getBalanceApi 获取余额
    ↓
完成认证

1.2 核心实现

前端回调处理(src/main.ts 或页面组件)

onMounted(() => {
  const urlParams = new URLSearchParams(window.location.search);
  const code = urlParams.get('code');
  const state = urlParams.get('state');

  if (code) {
    wxStore.handleWxCallback({ code, state });
  }
});

Store 处理逻辑(src/pinia/stores/wx.ts:81-147

const handleWxCallback = async (params: { corpid?: string; code?: string; state?: string; }) => {
  isHandleWxCallbackComplete.value = false;

  if (params.code) {
    code.value = params.code;
    state.value = params.state || state.value;
    corpid.value = params.corpid || corpid.value;
    corpidLogin.value = !!corpid.value;

    try {
      // 微信公众号登录(无 corpid
      if (!corpid.value) {
        const res = await getOpenIdApi({ code: params.code });
        if (res && res.code == 0) {
          openid.value = res.data;
        }

        // 获取余额信息
        if (openid.value) {
          corpid.value = "wpZ1ZrEgAA2QTxIRcB4cMtY7hQbTcPAw"; // 默认企业ID
          const balanceRes = await getBalanceApi(corpid.value, openid.value);
          if (balanceRes && balanceRes.code == 0) {
            balance.value = balanceRes.data.balance;
            useBalance.value = balanceRes.data.useBalance;
            balanceLimit.value = balanceRes.data.balanceLimit;
            userid.value = balanceRes.data.userid;
            if (!ab98User.value && balanceRes.data.ab98User) {
              setAb98User(balanceRes.data.ab98User);
            }
          }
        }
      }
    } catch (err) {
      console.error('获取 openid 失败:', err);
    } finally {
      isHandleWxCallbackComplete.value = true;
    }
  } else {
    isHandleWxCallbackComplete.value = true;
  }
};

1.3 API 接口详情

获取 OpenID

接口地址: GET /api/v1/payment/getOpenId

请求参数:

interface GetOpenIdRequestParams {
  code: string;  // 微信授权后返回的授权码
}

返回数据:

interface ApiResponseData<T> {
  code: number;  // 状态码0 表示成功
  msg: string;   // 消息
  data: string;  // openid 字符串
}

示例响应:

{
  "code": 0,
  "msg": "success",
  "data": "oMRxw6Eum0DB1IjI_pEX_yrawBHw"
}

获取余额

接口地址: GET /api/v1/payment/getBalance

请求参数:

{
  corpid: string;   // 企业ID
  openid: string;   // 微信用户唯一标识
}

返回数据:

interface GetBalanceResponse {
  userid: string;        // 系统用户ID
  corpid: string;        // 企业ID
  balance: number;       // 剩余借呗
  useBalance: number;    // 已用借呗
  balanceLimit: number;  // 借呗总额
  ab98User: ab98UserDTO; // 汇邦云用户信息
}

示例响应:

{
  "code": 0,
  "msg": "success",
  "data": {
    "userid": "woZ1ZrEgAAV9AEdRt1MGQxSg-KDJrDlA",
    "corpid": "wpZ1ZrEgAA2QTxIRcB4cMtY7hQbTcPAw",
    "balance": 5000,
    "useBalance": 2000,
    "balanceLimit": 7000,
    "ab98User": {
      "userid": "123456",
      "name": "张三",
      "tel": "13800138000",
      "sex": "男",
      "faceImg": "https://example.com/avatar.jpg",
      "registered": true,
      "ab98Balance": 10000
    }
  }
}

1.4 微信授权 URL 生成

const generateWxAuthUrl = () => {
  const appId = import.meta.env.VITE_WX_APP_ID;
  const redirectUri = encodeURIComponent(window.location.origin + '/callback');
  const scope = 'snsapi_userinfo'; // 或 snsapi_base
  const state = Math.random().toString(36).substring(2, 15);

  return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&state=${state}#wechat_redirect`;
};

参数说明:

  • appid: 微信公众号的 AppID
  • redirect_uri: 授权后回调的网址(需 URL 编码)
  • scope: 授权范围
    • snsapi_base: 静默授权,只能获取 openid
    • snsapi_userinfo: 需用户授权,可获取用户信息
  • state: 自定义参数,用于防止 CSRF 攻击

二、企业微信登录

2.1 登录流程图

用户访问应用
    ↓
检测 URL 中的 corpid 参数
    ↓
跳转企业微信授权页面
    ↓
用户授权
    ↓
企业微信回调携带 corpid、code、state
    ↓
调用 qyLogin 验证并获取用户信息
    ↓
调用 getBalanceByQyUserid 获取余额
    ↓
关联 AB98 用户系统
    ↓
完成认证

2.2 核心实现

路由守卫处理(src/router/guard.ts:22-31

// 企业微信登录参数检测
const urlParams = new URLSearchParams(window.location.search);
const corpid = urlParams.get('corpid') || undefined;
if (corpid) {
  return true;
}
const isAdmin = urlParams.get('isAdmin') || undefined;
if (isAdmin) {
  return true;
}

Store 处理逻辑(src/pinia/stores/wx.ts:93-109

if (!corpid.value) {
  // 微信公众号登录
  const res = await getOpenIdApi({ code: params.code });
  if (res && res.code == 0) {
    openid.value = res.data;
  }
} else {
  // 企业微信登录
  const res = await qyLogin({
    corpid: corpid.value,
    code: params.code,
    state: params.state
  });

  if (res && res.code == 0) {
    userid.value = res.data.userid;           // 系统用户ID
    openid.value = res.data.openid;           // 微信 openid
    isCabinetAdmin.value = res.data.isCabinetAdmin === 1; // 是否为柜子管理员
    name.value = res.data.name;               // 用户姓名
    qyUserId.value = res.data.qyUserId;       // 企业微信用户ID
    setAb98User(res.data.ab98User);           // 设置 AB98 用户信息
  }
}

余额获取(src/pinia/stores/wx.ts:66-79

const refreshBalance = async () => {
  if (corpid.value && userid.value) {
    const res = await getBalanceByQyUserid(corpid.value, userid.value);
    if (res && res.code == 0) {
      balance.value = res.data.balance;
      useBalance.value = res.data.useBalance;
      balanceLimit.value = res.data.balanceLimit;

      if (res.data.ab98User) {
        setAb98User(res.data.ab98User);
      }
    }
  }
};

2.3 API 接口详情

企业微信登录

接口地址: GET /api/v1/payment/login/qy

请求参数:

interface QyLoginRequestParams {
  corpid: string;  // 企业ID
  code: string;    // 企业微信授权后返回的授权码
  state?: string;  // 状态参数防CSRF
}

返回数据:

interface QyLoginDTO {
  userid: string;           // 系统用户ID
  openid: string;           // 微信 openid
  isCabinetAdmin: number;   // 是否为柜子管理员1是 0否
  qyUserId: number;         // 企业微信用户ID
  name: string;             // 用户姓名
  ab98User: ab98UserDTO;    // 汇邦云用户信息
}

示例响应:

{
  "code": 0,
  "msg": "success",
  "data": {
    "userid": "woZ1ZrEgAAV9AEdRt1MGQxSg-KDJrDlA",
    "openid": "oMRxw6Eum0DB1IjI_pEX_yrawBHw",
    "isCabinetAdmin": 1,
    "qyUserId": 10001,
    "name": "张三",
    "ab98User": {
      "userid": "123456",
      "name": "张三",
      "tel": "13800138000",
      "sex": "男",
      "faceImg": "https://example.com/avatar.jpg",
      "registered": true,
      "ab98Balance": 10000
    }
  }
}

根据企业微信用户ID获取余额

接口地址: GET /api/v1/payment/getBalanceByQyUserid

请求参数:

{
  corpid: string;   // 企业ID
  userid: string;   // 系统用户ID
}

返回数据: 同 GetBalanceResponse 结构

2.4 企业微信授权 URL 生成

const generateQyWxAuthUrl = () => {
  const corpid = import.meta.env.VITE_WX_CORP_ID;
  const redirectUri = encodeURIComponent(window.location.origin + '/callback');
  const scope = 'snsapi_base'; // 企业微信通常使用 base
  const state = Math.random().toString(36).substring(2, 15);

  return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${corpid}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&state=${state}#wechat_redirect`;
};

三、Fake登录模拟企业微信登录

3.1 应用场景

Fake登录用于开发测试环境模拟企业微信登录流程无需真实的企业微信授权直接使用预设的测试账号信息。

3.2 登录流程图

触发 fake 登录
    ↓
设置 fake 登录状态
    ↓
设置默认企业ID
    ↓
设置默认用户ID
    ↓
调用 fakeQyLoginApi
    ↓
获取模拟用户信息
    ↓
完成认证

3.3 核心实现(src/pinia/stores/wx.ts:149-163

const fakeQyLogin = async () => {
  isFakeQyLogin.value = true;         // 标记为 fake 登录
  corpid.value = "wpZ1ZrEgAA2QTxIRcB4cMtY7hQbTcPAw"; // 默认企业ID
  corpidLogin.value = true;            // 标记为企业微信登录
  userid.value = "woZ1ZrEgAAV9AEdRt1MGQxSg-KDJrDlA"; // 默认用户ID

  // 调用 fake API
  const res = await fakeQyLoginApi({ corpid: corpid.value, userid: userid.value });

  if (res && res.code == 0) {
    userid.value = res.data.userid;
    openid.value = "oMRxw6Eum0DB1IjI_pEX_yrawBHw"; // 固定的测试 openid
    isCabinetAdmin.value = res.data.isCabinetAdmin === 1;
    name.value = res.data.name;
    qyUserId.value = res.data.qyUserId;
    setAb98User(res.data.ab98User);
  }
};

3.4 API 接口详情

接口地址: GET /api/v1/payment/login/qy/fake

请求参数:

{
  corpid: string;   // 企业ID
  userid: string;   // 系统用户ID
}

返回数据: 与 QyLoginDTO 相同

示例响应:

{
  "code": 0,
  "msg": "success",
  "data": {
    "userid": "woZ1ZrEgAAV9AEdRt1MGQxSg-KDJrDlA",
    "openid": "oMRxw6Eum0DB1IjI_pEX_yrawBHw",
    "isCabinetAdmin": 1,
    "qyUserId": 10001,
    "name": "测试用户",
    "ab98User": {
      "userid": "123456",
      "name": "测试用户",
      "tel": "13800138000",
      "sex": "男",
      "faceImg": "https://example.com/test-avatar.jpg",
      "registered": true,
      "ab98Balance": 10000
    }
  }
}

3.5 调用方式

在页面中调用

import { useWxStore } from '@/pinia/stores/wx';

const wxStore = useWxStore();

// 触发 fake 登录
const handleFakeLogin = () => {
  wxStore.fakeQyLogin();
};

在组件模板中调用

<template>
  <van-button @click="wxStore.fakeQyLogin">
    模拟登录
  </van-button>
</template>

四、状态管理Pinia Store

4.1 WxStore 状态定义(src/pinia/stores/wx.ts:7-38

export const useWxStore = defineStore("wx", () => {
  // 授权信息
  const code = ref<string>("");           // 微信授权 code
  const state = ref<string>("");          // 防CSRF的state参数

  // 用户标识
  const openid = ref<string>("");         // 微信用户唯一标识
  const userid = ref<string>("");         // 系统用户ID
  const qyUserId = ref<number>(0);        // 企业微信用户ID

  // 企业信息
  const corpid = ref<string>("");         // 企业ID
  const corpidLogin = ref<boolean>(false);// 是否企业微信登录

  // 用户信息
  const isCabinetAdmin = ref<boolean>(false);  // 是否是柜子管理员
  const name = ref<string>("");               // 企业微信用户姓名
  const ab98User = ref<ab98UserDTO | null>(null); // 汇邦云用户信息

  // 余额信息
  const balance = ref<number>(0);        // 剩余借呗
  const useBalance = ref<number>(0);     // 已用借呗
  const balanceLimit = ref<number>(0);   // 借呗总额

  // 登录状态
  const isFakeQyLogin = ref<boolean>(false);  // 是否fake登录
  const isHandleWxCallbackComplete = ref<boolean>(false); // 回调处理是否完成
});

4.2 AB98用户Storesrc/pinia/stores/ab98-user.ts

export const useAb98UserStore = defineStore("ab98User", () => {
  // 用户基本信息
  const face_img = ref<string>('');   // 用户头像
  const sex = ref<string>('');        // 性别
  const name = ref<string>('');       // 姓名
  const userid = ref<string>("");     // 用户ID
  const registered = ref<boolean>(false); // 是否已注册
  const tel = ref<string>("");        // 手机号
  const token = ref<string>("");      // 认证令牌

  // 登录状态
  const isLogin = ref<boolean>(false);
  const tokenLogin = ref<string>("");
  const loginCode = '1';              // 默认验证码
});

4.3 核心方法

设置 AB98 用户信息

const setAb98User = (user: ab98UserDTO) => {
  ab98User.value = user;
  const ab98UserStore = useAb98UserStore();
  ab98UserStore.setUserInfo({
    face_img: ab98User.value.faceImg || "",
    success: true,
    sex: ab98User.value.sex || "",
    name: ab98User.value.name || "",
    userid: ab98User.value.userid || "",
    registered: true,
    tel: ab98User.value.tel || "",
  });
};

等待回调完成

const waitForHandleWxCallbackComplete = async (timeout = 30000): Promise<boolean> => {
  const startTime = Date.now();
  while (!isHandleWxCallbackComplete.value) {
    if (Date.now() - startTime > timeout) {
      console.warn('等待 handleWxCallback 完成超时');
      return false;
    }
    await new Promise(resolve => setTimeout(resolve, 100));
  }
  return true;
};

五、数据类型定义

5.1 ab98UserDTO

interface ab98UserDTO {
  /** 主键ID */
  ab98UserId?: number;
  /** openid */
  openid?: string;
  /** 汇邦云用户唯一ID */
  userid?: string;
  /** 真实姓名 */
  name?: string;
  /** 手机号码 */
  tel?: string;
  /** 身份证号码 */
  idnum?: string;
  /** 性别(男 女) */
  sex?: string;
  /** 人脸照片地址 */
  faceImg?: string;
  /** 身份证正面地址 */
  idcardFront?: string;
  /** 身份证背面地址 */
  idcardBack?: string;
  /** 身份证登记地址 */
  address?: string;
  /** 是否已注册0未注册 1已注册 */
  registered?: boolean;
  /** 借呗余额 单位分 */
  ab98Balance?: number;
}

5.2 通用响应类型

interface ApiResponseData<T> {
  code: number;    // 状态码0表示成功其他值表示失败
  msg: string;     // 消息
  data: T;         // 数据
}

interface ApiResponseMsgData<T> {
  code: number;    // 状态码
  msg: string;     // 消息
  data: T;         // 数据
}

六、环境配置

6.1 环境变量

开发环境(.env.development

# 微信公众号配置
VITE_WX_APP_ID=your_dev_app_id
VITE_WX_CORP_ID=your_dev_corp_id

# API 基础地址
VITE_BASE_URL=https://apifoxmock.com/m1/2930465-2145633-default

生产环境(.env.production

# 微信公众号配置
VITE_WX_APP_ID=your_prod_app_id
VITE_WX_CORP_ID=your_prod_corp_id

# API 基础地址
VITE_BASE_URL=https://api.example.com

6.2 微信环境检测(src/common/utils/wx.ts

function checkInWeChat(targetUrl: string) {
  const ua = navigator.userAgent.toLowerCase();
  if (!ua.includes('micromessenger')) {
    // 不在微信内,提示用户在微信中打开
    const weChatUrl = `weixin://dl/business/?t=${encodeURIComponent(targetUrl)}`;
    window.location.href = weChatUrl;

    setTimeout(() => {
      if (document.hidden) return;
      alert("未检测到微信,请手动打开微信并访问链接。");
    }, 2000);
  } else {
    console.log("已在微信内,无需跳转");
  }
}

七、路由守卫与权限控制

7.1 登录检查(src/router/guard.ts

router.beforeEach((to, _from) => {
  // 检测企业微信登录参数
  const urlParams = new URLSearchParams(window.location.search);
  const corpid = urlParams.get('corpid') || undefined;
  if (corpid) return true;

  const isAdmin = urlParams.get('isAdmin') || undefined;
  if (isAdmin) return true;

  // 检查 AB98 用户登录状态
  const ab98UserStore = useAb98UserStore();
  if (!ab98UserStore.isLogin) {
    if (isWhiteList(to)) return true;  // 白名单页面直接访问
    return "/ab98";                    // 重定向到登录页
  }

  return true;
});

7.2 等待认证完成

在页面组件中使用:

<script setup lang="ts">
import { useWxStore } from '@/pinia/stores/wx';

const wxStore = useWxStore();

onMounted(async () => {
  const success = await wxStore.waitForHandleWxCallbackComplete();
  if (!success) {
    console.error('微信认证超时');
  }
});
</script>

<template>
  <div v-if="!wxStore.isHandleWxCallbackComplete">
    <van-loading>微信认证中...</van-loading>
  </div>
  <div v-else>
    <!-- 页面内容 -->
  </div>
</template>

八、错误处理与调试

8.1 常见错误

1. 微信认证失败

可能原因:

  • 回调域名未在微信公众平台配置
  • AppID 或 Secret 错误
  • 网络问题导致 API 调用失败

解决方案:

const retryWxAuth = async (maxRetries = 3) => {
  for (let i = 0; i < maxRetries; i++) {
    try {
      await wxStore.handleWxCallback(params);
      if (wxStore.isHandleWxCallbackComplete) break;
    } catch (error) {
      console.error(`微信认证重试 ${i + 1} 失败:`, error);
      if (i === maxRetries - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, 1000));
    }
  }
};

2. OpenID 获取失败

调试方法:

const debugWxAuth = async () => {
  console.log('当前 URL 参数:', window.location.search);

  const urlParams = new URLSearchParams(window.location.search);
  const code = urlParams.get('code');

  if (!code) {
    console.error('未获取到微信 code');
    return;
  }

  console.log('获取到 code:', code);

  try {
    const response = await getOpenIdApi({ code });
    console.log('OpenID 响应:', response);
  } catch (error) {
    console.error('获取 OpenID 失败:', error);
  }
};

8.2 日志输出

在关键位置添加日志:

const handleWxCallback = async (params: any) => {
  console.log('=== 微信回调开始 ===');
  console.log('参数:', params);
  console.log('corpid:', corpid.value);
  console.log('corpidLogin:', corpidLogin.value);

  // ... 处理逻辑

  console.log('=== 微信回调完成 ===');
};

九、最佳实践

9.1 安全性

  1. CSRF 防护

    • 使用 state 参数防止 CSRF 攻击
    • 生成随机字符串作为 state
  2. Token 加密

    const encryptToken = (token: string) => {
      return btoa(encodeURIComponent(token));
    };
    
  3. 敏感信息存储

    • 避免在 localStorage 中存储敏感信息
    • 使用加密后存储

9.2 性能优化

  1. 减少 API 调用

    const cachedUserInfo = ref<ab98UserDTO | null>(null);
    
    const getUserInfo = async (forceRefresh = false) => {
      if (cachedUserInfo.value && !forceRefresh) {
        return cachedUserInfo.value;
      }
      const userInfo = await fetchUserInfo();
      cachedUserInfo.value = userInfo;
      return userInfo;
    };
    
  2. 并行请求

    const loadUserData = async () => {
      const [userInfo, balanceInfo] = await Promise.all([
        getUserInfo(),
        getBalanceInfo()
      ]);
      return { userInfo, balanceInfo };
    };
    

9.3 用户体验

  1. 加载状态提示

    <template>
      <van-loading v-if="loading">认证中...</van-loading>
      <div v-else>页面内容</div>
    </template>
    
  2. 错误提示

    const handleError = (error: any) => {
      Toast.fail(error.message || '操作失败');
    };
    

十、测试

10.1 单元测试

// tests/stores/wx.test.ts
import { describe, it, expect } from "vitest";
import { setActivePinia, createPinia } from "pinia";
import { useWxStore } from "@/pinia/stores/wx";

describe("Wx Store", () => {
  beforeEach(() => {
    setActivePinia(createPinia());
  });

  it("应该处理微信回调", async () => {
    const wxStore = useWxStore();
    await wxStore.handleWxCallback({
      code: "test_code",
      state: "test_state"
    });
    expect(wxStore.code).toBe("test_code");
    expect(wxStore.state).toBe("test_state");
  });

  it("应该进行 fake 登录", async () => {
    const wxStore = useWxStore();
    await wxStore.fakeQyLogin();
    expect(wxStore.isFakeQyLogin).toBe(true);
  });
});

10.2 集成测试

// tests/integration/login.test.ts
describe("登录流程集成测试", () => {
  it("应该完成完整的微信认证流程", async () => {
    // 1. 模拟微信回调
    const params = { code: "test_code", state: "test_state" };

    // 2. 处理回调
    await wxStore.handleWxCallback(params);

    // 3. 验证状态更新
    expect(wxStore.isHandleWxCallbackComplete).toBe(true);

    // 4. 检查用户信息
    expect(wxStore.openid).toBeTruthy();
  });
});

十一、总结

11.1 三种登录方式对比

特性 微信登录 企业微信登录 Fake登录
触发方式 微信授权回调 企业微信授权回调 手动调用
依赖 微信公众号 企业微信
适用环境 生产/测试 生产/测试 仅测试
获取openid ✓(模拟)
获取用户信息 ✓(模拟)
获取余额 ✓(模拟)
管理员权限 根据后端返回 根据后端返回 根据后端返回

11.2 关键要点

  1. 微信登录:使用 snsapi_userinfosnsapi_base 授权,通过 code 获取 openid
  2. 企业微信登录:需要 corpid 参数,通过 code 获取企业微信用户信息
  3. Fake登录:仅用于测试环境,直接使用预设的测试账号
  4. 状态管理:使用 Pinia 管理登录状态和用户信息
  5. 余额系统:通过 balanceuseBalancebalanceLimit 管理借呗额度
  6. AB98集成:三种登录方式最终都会关联到汇邦云用户系统

11.3 开发注意事项

  1. 确保在微信/企业微信环境下测试
  2. 正确配置回调域名
  3. 使用 state 参数防止 CSRF 攻击
  4. 合理处理 API 调用错误
  5. 在测试环境使用 Fake 登录提高效率
  6. 遵循状态管理最佳实践

文档版本: v1.0 最后更新: 2025-11-07 维护者: 智柜宝开发团队