index.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import axios, { AxiosRequestConfig } from 'axios'
  2. import globalConfig from '@/config/global'
  3. import { showNotify } from 'vant'
  4. import 'vant/es/notify/style'
  5. import type {
  6. WecomAuthUserInfoResponse,
  7. WecomBaseResponse,
  8. WecomExternalContactResponse,
  9. WecomGroupCheckResult,
  10. WecomGroupChatResponse,
  11. WecomInternalUserResponse,
  12. WecomTokenResponse,
  13. WecomUserDetailResponse,
  14. WecomVisitorProfile,
  15. } from './types'
  16. const wecomService = axios.create({
  17. baseURL: '/api',
  18. timeout: 5000,
  19. })
  20. const ACCESS_TOKEN_KEY = 'CommunityFillingAccessToken'
  21. const ACCESS_TOKEN_EXPIRE_AT_KEY = 'CommunityFillingAccessTokenExpireAt'
  22. const VISITOR_PROFILE_KEY = 'CommunityFillingWecomVisitorProfile'
  23. const ONE_DAY_MS = 24 * 60 * 60 * 1000
  24. const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
  25. const INVALID_OAUTH_CODE_ERRCODE = 40029
  26. type VisitorProfileCacheRecord = {
  27. expireAt: number
  28. profile: WecomVisitorProfile
  29. }
  30. let refreshTokenPromise: Promise<string> | null = null
  31. const TOKEN_EXPIRED_ERRCODES = new Set([40014, 42001])
  32. const wecomActionText: Record<string, string> = {
  33. gettoken: '企业微信凭证获取失败',
  34. getuserinfo: '身份获取失败',
  35. getuserdetail: '成员敏感信息获取失败',
  36. userGet: '成员信息获取失败',
  37. externalcontactGet: '外部联系人信息获取失败',
  38. groupchatGet: '客户群信息获取失败',
  39. }
  40. function getCachedAccessToken() {
  41. const token = localStorage.getItem(ACCESS_TOKEN_KEY) || ''
  42. const expireAt = Number(localStorage.getItem(ACCESS_TOKEN_EXPIRE_AT_KEY) || 0)
  43. if (!token || !expireAt) return ''
  44. if (Date.now() >= expireAt) return ''
  45. return token
  46. }
  47. function cacheAccessToken(token: string, expiresIn = 7200) {
  48. const safeExpiresIn = Math.max(expiresIn * 1000 - TOKEN_REFRESH_BUFFER_MS, 60 * 1000)
  49. localStorage.setItem(ACCESS_TOKEN_KEY, token)
  50. localStorage.setItem(ACCESS_TOKEN_EXPIRE_AT_KEY, String(Date.now() + safeExpiresIn))
  51. }
  52. function clearAccessTokenCache() {
  53. localStorage.removeItem(ACCESS_TOKEN_KEY)
  54. localStorage.removeItem(ACCESS_TOKEN_EXPIRE_AT_KEY)
  55. }
  56. function getExternalContactMobile(resp: WecomExternalContactResponse) {
  57. return (
  58. resp.external_contact?.mobile ||
  59. resp.external_user_profile?.mobile ||
  60. resp.external_user_profile?.phone ||
  61. ''
  62. )
  63. }
  64. function showWecomError(action: string, resp: WecomBaseResponse) {
  65. const defaultText = wecomActionText[action] || '企业微信接口调用失败'
  66. const detail = resp.errmsg ? `:${resp.errmsg}` : ''
  67. showNotify({ type: 'warning', message: `${defaultText}${detail}` })
  68. }
  69. function shouldRefreshToken(resp?: WecomBaseResponse) {
  70. return !!resp && TOKEN_EXPIRED_ERRCODES.has(resp.errcode)
  71. }
  72. async function requestWecom<T extends WecomBaseResponse>(config: AxiosRequestConfig) {
  73. const { data } = await wecomService.request<T>(config)
  74. return data
  75. }
  76. async function fetchAccessToken() {
  77. const resp = await requestWecom<WecomTokenResponse>({
  78. url: '/cgi-bin/gettoken',
  79. method: 'GET',
  80. params: {
  81. corpid: globalConfig.corpid,
  82. corpsecret: globalConfig.secret,
  83. },
  84. })
  85. if (resp.errcode !== 0 || !resp.access_token) {
  86. showWecomError('gettoken', resp)
  87. throw new Error(resp.errmsg || 'gettoken failed')
  88. }
  89. cacheAccessToken(resp.access_token, resp.expires_in)
  90. return resp.access_token
  91. }
  92. export async function getWecomAccessToken(forceRefresh = false) {
  93. if (!forceRefresh) {
  94. const cachedToken = getCachedAccessToken()
  95. if (cachedToken) return cachedToken
  96. }
  97. if (!refreshTokenPromise) {
  98. refreshTokenPromise = fetchAccessToken().finally(() => {
  99. refreshTokenPromise = null
  100. })
  101. }
  102. return refreshTokenPromise
  103. }
  104. async function requestWithAccessToken<T extends WecomBaseResponse>(
  105. configFactory: (accessToken: string) => AxiosRequestConfig,
  106. action: string,
  107. options?: {
  108. silentErrcodes?: number[]
  109. },
  110. ) {
  111. const accessToken = await getWecomAccessToken()
  112. let resp = await requestWecom<T>(configFactory(accessToken))
  113. if (shouldRefreshToken(resp)) {
  114. clearAccessTokenCache()
  115. const freshToken = await getWecomAccessToken(true)
  116. resp = await requestWecom<T>(configFactory(freshToken))
  117. }
  118. if (resp.errcode !== 0) {
  119. if (options?.silentErrcodes?.includes(resp.errcode)) {
  120. return resp
  121. }
  122. showWecomError(action, resp)
  123. throw new Error(resp.errmsg || `${action} failed`)
  124. }
  125. return resp
  126. }
  127. export function getWecomUserIdentity(code: string) {
  128. return requestWithAccessToken<WecomAuthUserInfoResponse>(
  129. (accessToken) => ({
  130. url: '/cgi-bin/auth/getuserinfo',
  131. method: 'GET',
  132. params: {
  133. access_token: accessToken,
  134. code,
  135. },
  136. }),
  137. 'getuserinfo',
  138. { silentErrcodes: [INVALID_OAUTH_CODE_ERRCODE] },
  139. )
  140. }
  141. export function getWecomInternalUser(userid: string) {
  142. return requestWithAccessToken<WecomInternalUserResponse>(
  143. (accessToken) => ({
  144. url: '/cgi-bin/user/get',
  145. method: 'GET',
  146. params: {
  147. access_token: accessToken,
  148. userid,
  149. },
  150. }),
  151. 'userGet',
  152. )
  153. }
  154. export function getWecomUserDetail(userTicket: string) {
  155. return requestWithAccessToken<WecomUserDetailResponse>(
  156. (accessToken) => ({
  157. url: '/cgi-bin/auth/getuserdetail',
  158. method: 'GET',
  159. params: {
  160. access_token: accessToken,
  161. user_ticket: userTicket,
  162. },
  163. }),
  164. 'getuserdetail',
  165. )
  166. }
  167. export function getWecomExternalContact(externalUserId: string) {
  168. return requestWithAccessToken<WecomExternalContactResponse>(
  169. (accessToken) => ({
  170. url: '/cgi-bin/externalcontact/get',
  171. method: 'GET',
  172. params: {
  173. access_token: accessToken,
  174. external_userid: externalUserId,
  175. },
  176. }),
  177. 'externalcontactGet',
  178. )
  179. }
  180. export function cacheWecomVisitorProfile(profile: WecomVisitorProfile) {
  181. const record: VisitorProfileCacheRecord = {
  182. expireAt: Date.now() + ONE_DAY_MS,
  183. profile,
  184. }
  185. localStorage.setItem(VISITOR_PROFILE_KEY, JSON.stringify(record))
  186. }
  187. export function getCachedWecomVisitorProfile(): WecomVisitorProfile | null {
  188. const rawProfile = localStorage.getItem(VISITOR_PROFILE_KEY)
  189. if (!rawProfile) return null
  190. let parsed: unknown
  191. try {
  192. parsed = JSON.parse(rawProfile)
  193. } catch {
  194. localStorage.removeItem(VISITOR_PROFILE_KEY)
  195. return null
  196. }
  197. if (!parsed || typeof parsed !== 'object') {
  198. localStorage.removeItem(VISITOR_PROFILE_KEY)
  199. return null
  200. }
  201. const rec = parsed as Record<string, unknown>
  202. if (typeof rec.expireAt === 'number' && rec.profile && typeof rec.profile === 'object') {
  203. if (Date.now() >= rec.expireAt) {
  204. localStorage.removeItem(VISITOR_PROFILE_KEY)
  205. return null
  206. }
  207. return rec.profile as WecomVisitorProfile
  208. }
  209. if (
  210. typeof rec.name === 'string' ||
  211. typeof rec.mobile === 'string' ||
  212. rec.externalUserId ||
  213. rec.internalUserid ||
  214. rec.unionid
  215. ) {
  216. const profile = parsed as WecomVisitorProfile
  217. cacheWecomVisitorProfile(profile)
  218. return profile
  219. }
  220. localStorage.removeItem(VISITOR_PROFILE_KEY)
  221. return null
  222. }
  223. export function getWecomGroupChat(chatId: string, needName = 1) {
  224. return requestWithAccessToken<WecomGroupChatResponse>(
  225. (accessToken) => ({
  226. url: '/cgi-bin/externalcontact/groupchat/get',
  227. method: 'POST',
  228. params: {
  229. access_token: accessToken,
  230. },
  231. data: {
  232. chat_id: chatId,
  233. need_name: needName,
  234. },
  235. }),
  236. 'groupchatGet',
  237. )
  238. }
  239. function pickMemberUserId(userInfo: WecomAuthUserInfoResponse) {
  240. const raw = userInfo.userid || userInfo.UserId
  241. return typeof raw === 'string' && raw ? raw : ''
  242. }
  243. export async function checkExternalUserInGroup(chatId: string, unionid: string): Promise<WecomGroupCheckResult> {
  244. const resp = await requestWithAccessToken<WecomGroupChatResponse>(
  245. (accessToken) => ({
  246. url: '/cgi-bin/externalcontact/groupchat/get',
  247. method: 'POST',
  248. params: {
  249. access_token: accessToken,
  250. },
  251. data: {
  252. chat_id: chatId,
  253. need_name: 1,
  254. },
  255. }),
  256. 'groupchatGet',
  257. { silentErrcodes: [60020] },
  258. )
  259. if (resp.errcode === 60020) {
  260. return {
  261. inGroup: false,
  262. ownerUserId: '',
  263. groupChat: resp.group_chat,
  264. }
  265. }
  266. const owner = resp.group_chat?.owner
  267. const ownerUserId = typeof owner === 'string' ? owner : owner?.userid || ''
  268. const memberList = resp.group_chat?.member_list || []
  269. const inGroup = memberList.some((item: { unionid?: string }) => item.unionid && item.unionid === unionid)
  270. return {
  271. inGroup,
  272. ownerUserId,
  273. groupChat: resp.group_chat,
  274. }
  275. }
  276. export async function syncExternalVisitorProfile(code: string) {
  277. const userInfo = await getWecomUserIdentity(code)
  278. if (userInfo.errcode === INVALID_OAUTH_CODE_ERRCODE) {
  279. throw new Error(userInfo.errmsg || 'invalid oauth code')
  280. }
  281. const externalUserId = userInfo.external_userid || ''
  282. if (externalUserId) {
  283. const externalContact = await getWecomExternalContact(externalUserId)
  284. const profile: WecomVisitorProfile = {
  285. name: externalContact.external_contact?.name || '',
  286. mobile: getExternalContactMobile(externalContact),
  287. unionid: externalContact.external_contact?.unionid || '',
  288. externalUserId,
  289. }
  290. cacheWecomVisitorProfile(profile)
  291. return {
  292. externalUserId,
  293. profile,
  294. userInfo,
  295. externalContact,
  296. internalUserRaw: undefined,
  297. }
  298. }
  299. const memberUserId = pickMemberUserId(userInfo)
  300. if (memberUserId) {
  301. const internal = await getWecomInternalUser(memberUserId)
  302. let detailRaw: WecomUserDetailResponse | undefined
  303. let detailMobile = ''
  304. if (userInfo.user_ticket) {
  305. try {
  306. const detail = await getWecomUserDetail(userInfo.user_ticket)
  307. detailRaw = detail
  308. detailMobile = (detail.mobile || '').trim()
  309. } catch {
  310. detailMobile = ''
  311. }
  312. }
  313. const profile: WecomVisitorProfile = {
  314. name: internal.name || '',
  315. mobile: detailMobile || (internal.mobile || internal.telephone || '').trim(),
  316. unionid: '',
  317. internalUserid: memberUserId,
  318. }
  319. cacheWecomVisitorProfile(profile)
  320. return {
  321. externalUserId: '',
  322. profile,
  323. userInfo,
  324. externalContact: null,
  325. internalUserRaw: detailRaw,
  326. }
  327. }
  328. return {
  329. externalUserId: '',
  330. profile: null,
  331. userInfo,
  332. externalContact: null,
  333. internalUserRaw: undefined,
  334. }
  335. }