Selaa lähdekoodia

feat: 企业微信内外成员支持与访客缓存及重授权

支持 OAuth 后内部成员走 user/get 写入访客缓存;缓存包装 expireAt 并设 24 小时过期,兼容旧版扁平缓存迁移。首页换票后剥离 code/state,无有效缓存或换票失败时 reLaunch 至授权页;问题上报入口常驻并后台尝试拉取群主;移除授权调试弹窗与 console。

Made-with: Cursor
haifeng.zhang 1 viikko sitten
vanhempi
commit
b8c1819c5c
3 muutettua tiedostoa jossa 177 lisäystä ja 173 poistoa
  1. 81 19
      src/api/wecom/index.ts
  2. 2 0
      src/api/wecom/types.ts
  3. 94 154
      src/pages/home/index.vue

+ 81 - 19
src/api/wecom/index.ts

@@ -2,7 +2,7 @@ import axios, { AxiosRequestConfig } from 'axios'
 import globalConfig from '@/config/global'
 import { showNotify } from 'vant'
 import 'vant/es/notify/style'
-import {
+import type {
   WecomAuthUserInfoResponse,
   WecomBaseResponse,
   WecomExternalContactResponse,
@@ -21,8 +21,14 @@ const wecomService = axios.create({
 const ACCESS_TOKEN_KEY = 'CommunityFillingAccessToken'
 const ACCESS_TOKEN_EXPIRE_AT_KEY = 'CommunityFillingAccessTokenExpireAt'
 const VISITOR_PROFILE_KEY = 'CommunityFillingWecomVisitorProfile'
+const ONE_DAY_MS = 24 * 60 * 60 * 1000
 const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
 
+type VisitorProfileCacheRecord = {
+  expireAt: number
+  profile: WecomVisitorProfile
+}
+
 let refreshTokenPromise: Promise<string> | null = null
 
 const TOKEN_EXPIRED_ERRCODES = new Set([40014, 42001])
@@ -182,19 +188,54 @@ export function getWecomExternalContact(externalUserId: string) {
 }
 
 export function cacheWecomVisitorProfile(profile: WecomVisitorProfile) {
-  localStorage.setItem(VISITOR_PROFILE_KEY, JSON.stringify(profile))
+  const record: VisitorProfileCacheRecord = {
+    expireAt: Date.now() + ONE_DAY_MS,
+    profile,
+  }
+  localStorage.setItem(VISITOR_PROFILE_KEY, JSON.stringify(record))
 }
 
 export function getCachedWecomVisitorProfile(): WecomVisitorProfile | null {
   const rawProfile = localStorage.getItem(VISITOR_PROFILE_KEY)
   if (!rawProfile) return null
 
+  let parsed: unknown
   try {
-    return JSON.parse(rawProfile) as WecomVisitorProfile
-  } catch (error) {
+    parsed = JSON.parse(rawProfile)
+  } catch {
+    localStorage.removeItem(VISITOR_PROFILE_KEY)
+    return null
+  }
+
+  if (!parsed || typeof parsed !== 'object') {
     localStorage.removeItem(VISITOR_PROFILE_KEY)
     return null
   }
+
+  const rec = parsed as Record<string, unknown>
+
+  if (typeof rec.expireAt === 'number' && rec.profile && typeof rec.profile === 'object') {
+    if (Date.now() >= rec.expireAt) {
+      localStorage.removeItem(VISITOR_PROFILE_KEY)
+      return null
+    }
+    return rec.profile as WecomVisitorProfile
+  }
+
+  if (
+    typeof rec.name === 'string' ||
+    typeof rec.mobile === 'string' ||
+    rec.externalUserId ||
+    rec.internalUserid ||
+    rec.unionid
+  ) {
+    const profile = parsed as WecomVisitorProfile
+    cacheWecomVisitorProfile(profile)
+    return profile
+  }
+
+  localStorage.removeItem(VISITOR_PROFILE_KEY)
+  return null
 }
 
 export function getWecomGroupChat(chatId: string, needName = 1) {
@@ -214,6 +255,11 @@ export function getWecomGroupChat(chatId: string, needName = 1) {
   )
 }
 
+function pickMemberUserId(userInfo: WecomAuthUserInfoResponse) {
+  const raw = userInfo.userid || userInfo.UserId
+  return typeof raw === 'string' && raw ? raw : ''
+}
+
 export async function checkExternalUserInGroup(chatId: string, unionid: string): Promise<WecomGroupCheckResult> {
   const resp = await requestWithAccessToken<WecomGroupChatResponse>(
     (accessToken) => ({
@@ -255,29 +301,45 @@ export async function syncExternalVisitorProfile(code: string) {
   const userInfo = await getWecomUserIdentity(code)
   const externalUserId = userInfo.external_userid || ''
 
-  if (!externalUserId) {
+  if (externalUserId) {
+    const externalContact = await getWecomExternalContact(externalUserId)
+    const profile: WecomVisitorProfile = {
+      name: externalContact.external_contact?.name || '',
+      mobile: getExternalContactMobile(externalContact),
+      unionid: externalContact.external_contact?.unionid || '',
+      externalUserId,
+    }
+    cacheWecomVisitorProfile(profile)
     return {
-      externalUserId: '',
-      profile: null,
+      externalUserId,
+      profile,
       userInfo,
-      externalContact: null,
+      externalContact,
     }
   }
 
-  const externalContact = await getWecomExternalContact(externalUserId)
-  const profile: WecomVisitorProfile = {
-    name: externalContact.external_contact?.name || '',
-    mobile: getExternalContactMobile(externalContact),
-    unionid: externalContact.external_contact?.unionid || '',
-    externalUserId,
+  const memberUserId = pickMemberUserId(userInfo)
+  if (memberUserId) {
+    const internal = await getWecomInternalUser(memberUserId)
+    const profile: WecomVisitorProfile = {
+      name: internal.name || '',
+      mobile: internal.mobile || '',
+      unionid: '',
+      internalUserid: memberUserId,
+    }
+    cacheWecomVisitorProfile(profile)
+    return {
+      externalUserId: '',
+      profile,
+      userInfo,
+      externalContact: null,
+    }
   }
 
-  cacheWecomVisitorProfile(profile)
-
   return {
-    externalUserId,
-    profile,
+    externalUserId: '',
+    profile: null,
     userInfo,
-    externalContact,
+    externalContact: null,
   }
 }

+ 2 - 0
src/api/wecom/types.ts

@@ -11,6 +11,7 @@ export interface WecomTokenResponse extends WecomBaseResponse {
 
 export interface WecomAuthUserInfoResponse extends WecomBaseResponse {
   UserId?: string
+  userid?: string
   DeviceId?: string
   user_ticket?: string
   external_userid?: string
@@ -68,4 +69,5 @@ export interface WecomVisitorProfile {
   mobile: string
   unionid?: string
   externalUserId?: string
+  internalUserid?: string
 }

+ 94 - 154
src/pages/home/index.vue

@@ -3,16 +3,14 @@ import { onLoad } from '@dcloudio/uni-app';
 import { ref } from 'vue';
 import globalConfig from '@/config/global';
 import * as ww from '@wecom/jssdk';
-import { checkExternalUserInGroup, syncExternalVisitorProfile } from '@/api/wecom';
-import { showNotify } from 'vant';
-import 'vant/es/notify/style'
+import {
+  checkExternalUserInGroup,
+  getCachedWecomVisitorProfile,
+  syncExternalVisitorProfile,
+} from '@/api/wecom';
+import type { WecomVisitorProfile } from '@/api/wecom/types';
 
-const codeParams = ref('');
-const authInfoPopupVisible = ref(false);
-const authInfoText = ref('');
-const shouldShowAuthInfoPopup = ref(false);
-const externalContactPopupVisible = ref(false);
-const externalContactPopupText = ref('');
+const AUTH_PAGE = '/pages/index/index'
 
 const getCodeFromLocation = () => {
   if (typeof window === 'undefined') return ''
@@ -21,126 +19,116 @@ const getCodeFromLocation = () => {
   return searchParams.get('code') || ''
 }
 
-onLoad(async (option) => {
-  if (option && option.code) {
-    const { code } = option;
-    codeParams.value = code;
-    shouldShowAuthInfoPopup.value = true;
-  }
-
-  if (!codeParams.value) {
-    const codeFromLocation = getCodeFromLocation()
-    if (codeFromLocation) {
-      codeParams.value = codeFromLocation
-      shouldShowAuthInfoPopup.value = true;
-    }
-  }
-
-  if (!codeParams.value) {
-    showNotify({ type: 'warning', message: '缺少企业微信授权参数,请重新进入页面' });
-    return;
-  }
-
-  try {
-    await initPageData();
-  } catch (error) {
-    console.error('初始化企业微信信息失败:', error);
-  }
-})
+const stripOAuthCodeFromUrl = () => {
+  if (typeof window === 'undefined') return
+  const url = new URL(window.location.href)
+  const hadOAuthParams = url.searchParams.has('code') || url.searchParams.has('state')
+  if (!hadOAuthParams) return
+  url.searchParams.delete('code')
+  url.searchParams.delete('state')
+  const nextSearch = url.searchParams.toString()
+  const next = `${url.pathname}${nextSearch ? `?${nextSearch}` : ''}${url.hash}`
+  window.history.replaceState({}, '', next)
+}
 
 const externalUserid = ref('');
 const unionidValue = ref('');
-const isQuestionEntry = ref(false); // 是否显示问题上报入口
-const ownerId = ref(''); // 群主id
+const ownerId = ref('');
 
-const openAuthInfoPopup = (data: Record<string, any>) => {
-  if (!shouldShowAuthInfoPopup.value) return;
-  authInfoText.value = JSON.stringify(data, null, 2);
-  authInfoPopupVisible.value = true;
-  shouldShowAuthInfoPopup.value = false;
+const applyVisitorFromSyncResult = (result: Awaited<ReturnType<typeof syncExternalVisitorProfile>>) => {
+  externalUserid.value = result.externalUserId
+  unionidValue.value = result.profile?.unionid || ''
 }
 
-const openExternalContactPopup = (data: Record<string, any> | null | undefined) => {
-  if (!data) return;
-  externalContactPopupText.value = JSON.stringify(data, null, 2);
-  externalContactPopupVisible.value = true;
+const applyVisitorFromCachedProfile = (profile: WecomVisitorProfile) => {
+  externalUserid.value = profile.externalUserId || ''
+  unionidValue.value = profile.unionid || ''
 }
 
-const initPageData = async () => {
-  const { externalUserId, profile, userInfo, externalContact } = await syncExternalVisitorProfile(codeParams.value);
+const resolveExternalChatId = () =>
+  new Promise<string>((resolve, reject) => {
+    ww.register({
+      corpId: globalConfig.corpid,
+      agentId: globalConfig.agentId,
+      jsApiList: ['getCurExternalChat'],
+    });
 
-  externalUserid.value = externalUserId;
-  if (!externalUserId) {
-    openAuthInfoPopup({
-      callbackParams: {
-        code: codeParams.value,
+    ww.getCurExternalChat({
+      success(res) {
+        if (res?.chatId) {
+          resolve(res.chatId);
+          return;
+        }
+        reject(new Error('chatId missing'));
+      },
+      fail(err) {
+        reject(err);
       },
-      getuserinfo: userInfo,
-      externalcontact: externalContact,
-      message: '当前授权结果中未返回 external_userid',
     });
-    isQuestionEntry.value = false;
-    return;
-  }
-
-  unionidValue.value = profile?.unionid || '';
-  openAuthInfoPopup({
-    callbackParams: {
-      code: codeParams.value,
-    },
-    getuserinfo: userInfo,
-    externalcontact: externalContact,
   });
-  openExternalContactPopup(externalContact);
 
-  if (!unionidValue.value) {
-    isQuestionEntry.value = false;
-    return;
+const tryPrefetchOwnerInBackground = async () => {
+  if (!unionidValue.value) return
+  try {
+    const chatId = await resolveExternalChatId();
+    const outcome = await checkExternalUserInGroup(chatId, unionidValue.value);
+    ownerId.value = outcome.ownerUserId;
+  } catch {
+    ownerId.value = '';
   }
+};
 
-  await getGroupChatStatus();
+const bootstrapWithCode = async (code: string) => {
+  const result = await syncExternalVisitorProfile(code)
+  if (!result.profile) {
+    throw new Error('identity incomplete')
+  }
+  applyVisitorFromSyncResult(result)
 }
 
-const getCurrentExternalChatId = () =>
-  new Promise<string>((resolve, reject) => {
-  ww.register({
-    corpId: globalConfig.corpid, // 必填,从企业微信后台获取
-    agentId: globalConfig.agentId, // 必填,自建应用或授权应用的AgentID
-    jsApiList: ['getCurExternalChat'], // 声明需调用的API
-  });
+const cacheLooksUsable = (cached: WecomVisitorProfile | null): cached is WecomVisitorProfile =>
+  !!cached &&
+  !!(cached.name || cached.mobile || cached.internalUserid || cached.externalUserId || cached.unionid)
 
-  ww.getCurExternalChat({
-    success(res) {
-      if (res?.chatId) {
-        resolve(res.chatId);
-        return;
+const goReauthorize = () => {
+  uni.reLaunch({ url: AUTH_PAGE })
+}
+
+onLoad(async (option) => {
+  const code = (option && option.code) || getCodeFromLocation()
+
+  if (code) {
+    try {
+      await bootstrapWithCode(code)
+      void tryPrefetchOwnerInBackground()
+    } catch {
+      const cached = getCachedWecomVisitorProfile()
+      if (cacheLooksUsable(cached)) {
+        applyVisitorFromCachedProfile(cached)
+        void tryPrefetchOwnerInBackground()
+      } else {
+        goReauthorize()
       }
-      reject(new Error('chatId missing'));
-    },
-    fail(err) {
-      showNotify({ type: 'warning', message: '当前会话信息获取失败,请稍后重试' });
-      console.error('获取失败:', err.errMsg);
-      reject(err);
+    } finally {
+      stripOAuthCodeFromUrl()
     }
-  });
-});
+    return
+  }
 
-// 获取客户群的群ID
-const getGroupChatStatus = async () => {
-  const chatId = await getCurrentExternalChatId();
-  const { inGroup, ownerUserId } = await checkExternalUserInGroup(chatId, unionidValue.value);
-  ownerId.value = ownerUserId;
-  isQuestionEntry.value = inGroup;
-  return {
-    chatId,
-    inGroup,
-    ownerUserId,
-  };
-}
+  const cached = getCachedWecomVisitorProfile()
+  if (cacheLooksUsable(cached)) {
+    applyVisitorFromCachedProfile(cached)
+    void tryPrefetchOwnerInBackground()
+    return
+  }
+
+  goReauthorize()
+})
 
 const getQuestionPage = () => {
+  const ownerQuery = ownerId.value ? `?owner=${encodeURIComponent(ownerId.value)}` : ''
   uni.navigateTo({
-    url: `/subPages/pages/reportProblems/index?owner=${ownerId.value}`
+    url: `/subPages/pages/reportProblems/index${ownerQuery}`
   })
 }
 </script>
@@ -149,7 +137,7 @@ const getQuestionPage = () => {
   <view class="box">
     <img class="box_bg_top" src="@/assets/bg_top.png" alt="" srcset="">
     <view class="container">
-      <div v-if="isQuestionEntry" class="shadow question-box" @click="getQuestionPage">
+      <div class="shadow question-box" @click="getQuestionPage">
         <img class="box_img" src="@/assets/1.png" alt="" srcset="">
         <div class="box_title">问题上报</div>
       </div>
@@ -161,24 +149,8 @@ const getQuestionPage = () => {
         <img class="box_img" src="@/assets/3.png" alt="" srcset="">
         <div class="box_title">问答库</div>
       </navigator>
-      <!-- <navigator url="/subPages/pages/inspectionResults/index" class="shadow">店铺/企业检查结果</navigator>
-      <navigator url="/subPages/pages/my/index" class="shadow">我的管理</navigator> -->
     </view>
     <img class="box_bg_bottom" src="@/assets/bg_bottom.png" alt="" srcset="">
-
-    <van-popup v-model:show="authInfoPopupVisible" round closeable class="auth-popup">
-      <view class="auth-popup__header">授权回跳信息</view>
-      <scroll-view scroll-y class="auth-popup__content">
-        <text selectable class="auth-popup__json">{{ authInfoText }}</text>
-      </scroll-view>
-    </van-popup>
-
-    <van-popup v-model:show="externalContactPopupVisible" round closeable class="auth-popup">
-      <view class="auth-popup__header">外部联系人接口返回</view>
-      <scroll-view scroll-y class="auth-popup__content">
-        <text selectable class="auth-popup__json">{{ externalContactPopupText }}</text>
-      </scroll-view>
-    </van-popup>
   </view>
 
 </template>
@@ -264,36 +236,4 @@ const getQuestionPage = () => {
     width: 100%;
   }
 }
-
-.auth-popup {
-  width: 88vw;
-  max-height: 75vh;
-  padding: 20px 16px 16px;
-  box-sizing: border-box;
-
-  &__header {
-    margin-bottom: 12px;
-    padding-right: 24px;
-    font-size: 16px;
-    font-weight: 600;
-    color: #333;
-  }
-
-  &__content {
-    max-height: 60vh;
-    background: #f7f8fa;
-    border-radius: 8px;
-    padding: 12px;
-    box-sizing: border-box;
-  }
-
-  &__json {
-    display: block;
-    white-space: pre-wrap;
-    word-break: break-all;
-    font-size: 12px;
-    line-height: 1.6;
-    color: #333;
-  }
-}
 </style>