request.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. /**
  2. * 网络请求封装(对接 jeesharp 后端:统一响应 AjaxResult { code, msg, data })
  3. *
  4. * 鉴权策略:
  5. * - ERP 小程序为强制登录应用,所有业务接口均带 token 三件套
  6. * (access_token + Authorization: Bearer + satoken,与管理端 axios 拦截器一致)。
  7. * - code:200 成功;code:201 或 HTTP 401 → token 失效,清态跳登录页;
  8. * code:205 → 无该功能权限,友好 toast。
  9. */
  10. import { apiConfig } from '@/config/api'
  11. import { getAccessToken, getSsoToken, clearAuthState } from './tokenStorage'
  12. import { getDeviceId, getAppType, getMobileClientHeaders } from './device'
  13. import type { ApiResponse } from '@/types'
  14. import {
  15. NeedLoginError,
  16. isNeedLoginError,
  17. promptReLogin,
  18. shouldSuppressAuthErrorToast
  19. } from './needLoginError'
  20. // 请求配置
  21. export interface RequestConfig {
  22. url: string
  23. method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
  24. data?: any
  25. params?: any
  26. header?: Record<string, string>
  27. showLoading?: boolean
  28. loadingText?: string
  29. showErrorToast?: boolean
  30. /** 跳过 token 注入(用于免登录接口:getRsaPublicKey / login/captcha / login) */
  31. skipAuth?: boolean
  32. }
  33. // 请求拦截器
  34. function requestInterceptor(config: RequestConfig): RequestConfig {
  35. // 添加 Token(与管理端 axios 拦截器一致:access_token + Authorization + satoken)
  36. const token = config.skipAuth ? null : getAccessToken()
  37. if (token) {
  38. const satoken = getSsoToken()
  39. config.header = {
  40. ...config.header,
  41. access_token: token,
  42. Authorization: `Bearer ${token}`,
  43. ...(satoken ? { satoken } : {})
  44. }
  45. }
  46. // 添加设备信息(与 upload 共用 X-Platform / X-Version,便于后端与网关识别)
  47. config.header = {
  48. ...config.header,
  49. ...getMobileClientHeaders(),
  50. 'X-Device-Id': getDeviceId(),
  51. 'X-App-Type': getAppType(),
  52. 'Content-Type': 'application/json'
  53. }
  54. return config
  55. }
  56. /** 无权限错误:业务 catch 中可识别做降级展示 */
  57. export class NoPermissionError extends Error {
  58. readonly noPermission = true
  59. constructor(message?: string) {
  60. super(message || '无该功能权限')
  61. this.name = 'NoPermissionError'
  62. }
  63. }
  64. export function isNoPermissionError(error: unknown): boolean {
  65. return Boolean(
  66. error && typeof error === 'object' && (error as NoPermissionError).noPermission === true
  67. )
  68. }
  69. // 响应拦截器
  70. function responseInterceptor<T = any>(
  71. response: UniApp.RequestSuccessCallbackResult,
  72. skipAuth = false
  73. ): ApiResponse<T> {
  74. // HTTP 401 兜底(jeesharp 正常以业务码 201 表达 token 失效,此处双通道处理)
  75. if (response.statusCode === 401) {
  76. if (!skipAuth) {
  77. clearAuthState()
  78. promptReLogin()
  79. }
  80. throw new NeedLoginError()
  81. }
  82. const res = response.data as ApiResponse<T>
  83. // Token 过期或无效
  84. if (res.code === 201) {
  85. if (!skipAuth) {
  86. clearAuthState()
  87. promptReLogin()
  88. }
  89. throw new NeedLoginError(res.msg)
  90. }
  91. // 无权限(jeesharp 业务码 205)
  92. if (res.code === 205) {
  93. throw new NoPermissionError(res.msg)
  94. }
  95. // 其他错误
  96. if (res.code !== 200) {
  97. throw new Error(res.msg || '请求失败')
  98. }
  99. return res
  100. }
  101. function sleep(ms: number): Promise<void> {
  102. return new Promise((resolve) => setTimeout(resolve, ms))
  103. }
  104. /**
  105. * 请求方法
  106. * - GET 幂等请求失败(网络层错误)自动重试 apiConfig.retryCount 次
  107. * - 写操作(POST/PUT/DELETE)带提交锁:同 url+payload 300ms 内重复调用直接返回进行中 Promise,防连点重复提交
  108. */
  109. const pendingWrites = new Map<string, Promise<any>>()
  110. function writeLockKey(config: RequestConfig): string {
  111. return `${config.method}:${config.url}:${JSON.stringify(config.data ?? {})}`
  112. }
  113. export function request<T = any>(config: RequestConfig): Promise<ApiResponse<T>> {
  114. const method = (config.method || 'GET').toUpperCase()
  115. // 写操作提交锁
  116. if (method !== 'GET') {
  117. const key = writeLockKey(config)
  118. const pending = pendingWrites.get(key)
  119. if (pending) return pending
  120. const p = doRequest<T>(config).finally(() => {
  121. // 稍延迟释放,覆盖连点场景
  122. setTimeout(() => pendingWrites.delete(key), 300)
  123. })
  124. pendingWrites.set(key, p)
  125. return p
  126. }
  127. return doRequestWithRetry<T>(config)
  128. }
  129. async function doRequestWithRetry<T>(config: RequestConfig): Promise<ApiResponse<T>> {
  130. let lastError: unknown = null
  131. for (let attempt = 0; attempt <= apiConfig.retryCount; attempt++) {
  132. try {
  133. return await doRequest<T>(config)
  134. } catch (error) {
  135. lastError = error
  136. // 业务错误(含 201/205/400 系)不重试,仅网络层失败重试
  137. if (!(error instanceof Error && /^网络请求失败|request:fail|timeout/i.test(error.message))) {
  138. throw error
  139. }
  140. if (attempt < apiConfig.retryCount) {
  141. await sleep(apiConfig.retryDelay)
  142. }
  143. }
  144. }
  145. throw lastError
  146. }
  147. function doRequest<T = any>(config: RequestConfig): Promise<ApiResponse<T>> {
  148. return new Promise((resolve, reject) => {
  149. // 请求拦截
  150. const finalConfig = requestInterceptor({
  151. ...apiConfig,
  152. ...config
  153. })
  154. // 显示加载提示
  155. if (finalConfig.showLoading) {
  156. uni.showLoading({
  157. title: finalConfig.loadingText || '加载中...',
  158. mask: true
  159. })
  160. }
  161. // 发送请求
  162. const method = (finalConfig.method || 'GET').toUpperCase()
  163. const requestData = method === 'GET' ? (finalConfig.params ?? finalConfig.data) : finalConfig.data
  164. uni.request({
  165. url: finalConfig.url.startsWith('http')
  166. ? finalConfig.url
  167. : `${apiConfig.baseURL}${finalConfig.url}`,
  168. method: finalConfig.method || 'GET',
  169. data: requestData,
  170. header: finalConfig.header,
  171. timeout: apiConfig.timeout,
  172. success: (res) => {
  173. if (finalConfig.showLoading) {
  174. uni.hideLoading()
  175. }
  176. try {
  177. const result = responseInterceptor<T>(res, finalConfig.skipAuth)
  178. resolve(result)
  179. } catch (error) {
  180. if (
  181. finalConfig.showErrorToast &&
  182. !isNeedLoginError(error) &&
  183. !shouldSuppressAuthErrorToast(error)
  184. ) {
  185. uni.showToast({
  186. title: (error as Error).message || '请求失败',
  187. icon: 'none'
  188. })
  189. }
  190. reject(error)
  191. }
  192. },
  193. fail: (error) => {
  194. if (finalConfig.showLoading) {
  195. uni.hideLoading()
  196. }
  197. const errorMsg = error.errMsg || '网络请求失败'
  198. if (finalConfig.showErrorToast) {
  199. uni.showToast({
  200. title: errorMsg,
  201. icon: 'none'
  202. })
  203. }
  204. reject(new Error(errorMsg))
  205. }
  206. })
  207. })
  208. }
  209. /**
  210. * GET 请求
  211. */
  212. export function get<T = any>(url: string, params?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
  213. return request<T>({
  214. url,
  215. method: 'GET',
  216. params,
  217. ...config
  218. })
  219. }
  220. /**
  221. * POST 请求
  222. */
  223. export function post<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
  224. return request<T>({
  225. url,
  226. method: 'POST',
  227. data,
  228. ...config
  229. })
  230. }
  231. /**
  232. * PUT 请求
  233. */
  234. export function put<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
  235. return request<T>({
  236. url,
  237. method: 'PUT',
  238. data,
  239. ...config
  240. })
  241. }
  242. /**
  243. * DELETE 请求
  244. */
  245. export function del<T = any>(url: string, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
  246. return request<T>({
  247. url,
  248. method: 'DELETE',
  249. ...config
  250. })
  251. }
  252. export default request