import { AkReqUploadOptions, AkReqOptions, AkReqResponse, AkReqError } from './interface.uts'; import { SUPA_URL, SUPA_KEY, IS_TEST_MODE } from '@/ak/config.uts'; // token 持久化 key const ACCESS_TOKEN_KEY = 'akreq_access_token'; const REFRESH_TOKEN_KEY = 'akreq_refresh_token'; const EXPIRES_AT_KEY = 'akreq_expires_at'; // 优化:用静态变量缓存 token,只有 set/clear 时同步 storage // Web 端(H5)由于多账号联调需要,开启单 Tab 隔离的储流(sessionStorage) function setAuthStore(key : string, value : any) { // #ifdef H5 sessionStorage.setItem(key, String(value)); return; // #endif uni.setStorageSync(key, value); } function getAuthStore(key : string) : any | null { // #ifdef H5 return sessionStorage.getItem(key); // #endif return uni.getStorageSync(key); } function removeAuthStore(key : string) { // #ifdef H5 sessionStorage.removeItem(key); return; // #endif uni.removeStorageSync(key); } /** * 安全读取 UTSJSONObject 或普通 JS 对象的字段。 * 微信小程序编译后 res.header / res.data / options.headers 均为普通 JS 对象, * 直接调用 .getString() / .set() 会抛 "not a function" 异常。 * 此函数统一处理两种情况:先尝试 getString,不可用则 fallback 到 []。 */ function safeStr(obj: any, key: string): string | null { if (obj == null) return null try { if (typeof obj.getString === 'function') { const v = obj.getString(key) return v != null ? String(v) : null } } catch (e) {} const v = obj[key] return (v != null && v !== '') ? String(v) : null } let _accessToken : string | null = null; let _refreshToken : string | null = null; let _expiresAt : number | null = null; export class AkReq { static setToken(token : string, refreshToken : string, expiresAt : number) { _accessToken = token; _refreshToken = refreshToken; _expiresAt = expiresAt; setAuthStore(ACCESS_TOKEN_KEY, token); setAuthStore(REFRESH_TOKEN_KEY, refreshToken); setAuthStore(EXPIRES_AT_KEY, expiresAt); } static getToken() : string | null { if (_accessToken != null) return _accessToken; const t = getAuthStore(ACCESS_TOKEN_KEY) as string | null; _accessToken = t; return t; } static getRefreshToken() : string | null { if (_refreshToken != null) return _refreshToken; const t = getAuthStore(REFRESH_TOKEN_KEY) as string | null; _refreshToken = t; return t; } static getExpiresAt() : number | null { const val = _expiresAt; if (val != null) return val; const t = getAuthStore(EXPIRES_AT_KEY) as number | null; _expiresAt = t; return t; } static clearToken() { _accessToken = null; _refreshToken = null; _expiresAt = null; removeAuthStore(ACCESS_TOKEN_KEY); removeAuthStore(REFRESH_TOKEN_KEY); removeAuthStore(EXPIRES_AT_KEY); } // 判断 token 是否即将过期(提前5分钟刷新) static isTokenExpiring() : boolean { const expiresAt = this.getExpiresAt(); if (expiresAt === null || expiresAt == 0) { return true; } const now = Math.floor(Date.now() / 1000); return (expiresAt - now) < 300; // 提前5分钟刷新 } // 自动刷新 token,返回 true=已刷新,false=未刷新 static async refreshTokenIfNeeded(apikey ?: string) : Promise { // 没有 access_token 直接返回,不刷新 const accessToken = this.getToken(); if (accessToken === null || accessToken === "") { return false; } if (!this.isTokenExpiring()) { return false; } const refreshToken = this.getRefreshToken(); if (refreshToken === null || refreshToken === "") { this.clearToken(); return false; } // 构造 header,必须带 apikey let headers = new UTSJSONObject(); if (apikey !== null && apikey !== "") { headers.set('apikey', apikey) } const reqData = new UTSJSONObject() reqData.set('refresh_token', refreshToken) try { const res = await this.request({ url: SUPA_URL + '/auth/v1/token?grant_type=refresh_token', method: 'POST', data: reqData, headers: headers, contentType: 'application/json' }, true); // skipRefresh=true,避免递归 const data = res.data as UTSJSONObject | null; let accessToken : string | null = null; let refreshTokenNew : string | null = null; let expiresAt : number | null = null; if (data != null && typeof data.getString === 'function' && typeof data.getNumber === 'function') { accessToken = data.getString('access_token'); refreshTokenNew = data.getString('refresh_token'); expiresAt = data.getNumber('expires_at'); } if (accessToken !== null && refreshTokenNew !== null && expiresAt !== null) { this.setToken(accessToken, refreshTokenNew, expiresAt); return true; } else { this.clearToken(); uni.$emit('AUTH_SESSION_EXPIRED', { reason: 'refresh_failed' }); return false; } } catch (e) { this.clearToken(); return false; } } // options: AkReqOptions, skipRefresh: boolean = false static async request(options : AkReqOptions, skipRefresh ?: boolean) : Promise> { // 自动刷新 token if (skipRefresh != true) { // safeStr 兼容 plain object 和 UTSJSONObject const apikey = safeStr(options.headers as any, 'apikey'); await this.refreshTokenIfNeeded(apikey); } // 构建请求 headers:用 safeStr 兼容 plain object / UTSJSONObject // aksupa.uts 创建的 headers 是“{ key: val } as UTSJSONObject” ,只是类型标注,运行时是 plain object const origHdr = options.headers as any const origApikey = safeStr(origHdr, 'apikey') const origCT = safeStr(origHdr, 'Content-Type') ?? safeStr(origHdr, 'content-type') const origPrefer = safeStr(origHdr, 'Prefer') const origAuth = safeStr(origHdr, 'Authorization') ?? safeStr(origHdr, 'authorization') const origRange = safeStr(origHdr, 'Range') const origRangeUnit = safeStr(origHdr, 'Range-Unit') // apikey:优先用传入的,再 fallback 到全局 SUPA_KEY const finalApikey = origApikey ?? (SUPA_KEY !== '' ? SUPA_KEY : null) // Authorization:登录态使用用户 access_token。 // 匿名态仅发送 apikey,避免自托管 Kong/PostgREST 将 anon key 当作 Bearer JWT 校验,触发 401/PGRST301。 const token = this.getToken() let finalAuth: string | null = origAuth let authMode: string = 'none' if (finalAuth == null) { if (token != null && token !== '') { // 登录态:用用户 access_token finalAuth = `Bearer ${token}` authMode = 'user-token' } else if (finalApikey != null && finalApikey !== '') { // 匿名态:只发送 apikey,不补 Authorization finalAuth = null authMode = 'apikey-only' } } else { authMode = 'pre-set' } // 组装最终 header(plain object,所有平台均可正确传递) const currentHeaders: Record = {} if (finalApikey != null && finalApikey !== '') currentHeaders['apikey'] = finalApikey if (finalAuth != null && finalAuth !== '') currentHeaders['Authorization'] = finalAuth currentHeaders['Content-Type'] = origCT ?? options.contentType ?? 'application/json' currentHeaders['Accept'] = 'application/json' if (origPrefer != null) currentHeaders['Prefer'] = origPrefer if (origRange != null) currentHeaders['Range'] = origRange if (origRangeUnit != null) currentHeaders['Range-Unit'] = origRangeUnit // 请求发出前诊断日志(脱敏:前6位+后4位) const dbgKey = finalApikey != null && finalApikey.length > 14 ? finalApikey.substring(0, 6) + '...' + finalApikey.substring(finalApikey.length - 4) : (finalApikey != null ? '(short)' : '(MISSING!)') const dbgAuth = finalAuth != null && finalAuth.length > 15 ? finalAuth.substring(0, 13) + '...' + finalAuth.substring(finalAuth.length - 4) : (finalAuth != null ? finalAuth : '(MISSING!)') console.log('[ak-req]', (options.method ?? 'GET'), options.url) console.log('[ak-req] apikey:', dbgKey, '| Authorization:', dbgAuth, '| auth-mode:', authMode, '| prefer:', origPrefer ?? '(none)') const timeout = options.timeout ?? 10000; const maxRetry = Math.max(0, options.retryCount ?? 0); const baseDelay = Math.max(0, options.retryDelayMs ?? 300); const doOnce = (): Promise> => { return new Promise>((resolve) => { uni.request({ url: options.url, method: options.method ?? 'GET', data: options.data, header: currentHeaders, timeout: timeout, success: (res) => { // HEAD 请求特殊处理:没有响应体,只有 headers if (options.method == 'HEAD') { const result = AkReq.createResponse( res.statusCode, [] as Array, res.header as UTSJSONObject ); resolve(result); return; } // 兼容 res.data 可能为 string 或 UTSJSONObject 或 UTSArray // 先读取响应 content-type,判断是否应该 JSON.parse // res.header 在 MP 环境下是普通 JS 对象(无 getString),用括号访问 let respContentType = '' try { const hdr = res.header as any if (hdr != null) { respContentType = hdr['content-type'] ?? hdr['Content-Type'] ?? '' } } catch(e) {} let data : UTSJSONObject | Array | null; if (typeof res.data == 'string') { const strData = res.data as string; if (strData.length > 0 && /[^\s]/.test(strData)) { const looksLikeHtml = strData.trimStart().startsWith('<') if (looksLikeHtml || (respContentType.indexOf('text/html') >= 0)) { // 明确是 HTML,直接跳过 JSON.parse const preview = strData.substring(0, 250) console.error('[ak-req] non-json response (HTML)') console.error('[ak-req] status:', res.statusCode) console.error('[ak-req] content-type:', respContentType) console.error('[ak-req] raw preview:', preview) data = new UTSJSONObject({ raw: strData }) } else { try { data = JSON.parse(strData) as UTSJSONObject; } catch (e) { // 非 JSON 响应(纯文本/数字等) const preview = strData.substring(0, 250) console.error('[ak-req] json parse failed') console.error('[ak-req] status:', res.statusCode) console.error('[ak-req] content-type:', respContentType) console.error('[ak-req] raw preview:', preview) data = new UTSJSONObject({ raw: strData }); } } } else { data = null; } } else if (Array.isArray(res.data)) { data = res.data as UTSJSONObject[]; } else { // MP 下 res.data 可能是 plain JS object(平台已预解析 JSON),用 safeStr 兼容 const objData = res.data as any; data = objData as UTSJSONObject; if (objData != null) { const accessToken = safeStr(objData, 'access_token') const refreshTokenNew = safeStr(objData, 'refresh_token') const expiresAtRaw = objData['expires_at'] const expiresAt = expiresAtRaw != null ? Number(expiresAtRaw) : null if (accessToken !== null && refreshTokenNew !== null && expiresAt !== null) { AkReq.setToken(accessToken, refreshTokenNew, expiresAt); } } } const result = AkReq.createResponse( res.statusCode, data ?? {}, res.header as UTSJSONObject ); if (res.statusCode >= 400) { let bodyPreview = '' try { if (typeof res.data == 'string') { bodyPreview = (res.data as string).substring(0, 500) } else { bodyPreview = JSON.stringify(res.data).substring(0, 500) } } catch (e) { bodyPreview = '[unserializable response body]' } console.error('[ak-req] HTTP error response') console.error('[ak-req] status:', res.statusCode) console.error('[ak-req] url:', options.url) console.error('[ak-req] body:', bodyPreview) } resolve(result); }, fail: (err) => { const errStatus = (err.errCode != null && typeof err.errCode === 'number') ? err.errCode : 0; const result = AkReq.createResponse( errStatus, {} as UTSJSONObject, {} as UTSJSONObject, new UniError('uni-request', errStatus, err.errMsg ?? 'request fail') ); resolve(result); } }); }); }; let attempt = 0; let lastRes: AkReqResponse | null = null; while (attempt <= maxRetry) { const res = await doOnce(); lastRes = res; // 仅网络失败/超时(errCode 非 0 且 status 非 2xx/3xx)时重试 const status = res.status ?? 0; const isOk = status >= 200 && status < 400; if (isOk) return res; if (attempt === maxRetry) break; // 简单退避 const delay = baseDelay * Math.pow(2, attempt); await new Promise((r) => { setTimeout(() => { r(); }, delay); }); attempt++; } const finalRes = lastRes!!; // 全局处理 401 未授权 if ((finalRes.status === 401) && (skipRefresh !== true)) { // 401 诊断日志:区分"前端 header 问题"vs"服务端 key/实例问题" const sentApikey = (currentHeaders as any)['apikey'] const sentAuth = (currentHeaders as any)['Authorization'] const dbgSentKey = sentApikey != null && sentApikey.length > 14 ? sentApikey.substring(0, 6) + '...' + sentApikey.substring(sentApikey.length - 4) : (sentApikey != null ? '(short)' : '(MISSING!)') const dbgSentAuth = sentAuth != null && sentAuth.length > 15 ? sentAuth.substring(0, 13) + '...' + sentAuth.substring(sentAuth.length - 4) : (sentAuth != null ? '(short)' : '(MISSING!)') console.error('[ak-req] ★ 401 Unauthorized') console.error('[ak-req] url:', options.url) console.error('[ak-req] auth-mode: ' + authMode) console.error('[ak-req] 发送 apikey:', dbgSentKey) console.error('[ak-req] 发送 Authorization:', dbgSentAuth) console.error('[ak-req] response body:', JSON.stringify(finalRes.data)) // 前端 header 检查 if (sentApikey == null || sentApikey === '') { console.error('[ak-req] ✗ 前端问题:apikey 未发送,检查 SUPA_KEY 是否已配置') } else if ((sentAuth == null || sentAuth === '') && authMode == 'apikey-only') { console.error('[ak-req] ℹ 当前请求按 apikey-only 模式发送,这是匿名登录/匿名接口的预期行为') console.error('[ak-req] ✗ 401 更可能来自服务端网关认证:请检查 SUPA_URL 对应实例与 SUPA_KEY/ANON_KEY 是否匹配') } else if (sentAuth == null || sentAuth === '') { console.error('[ak-req] ✗ 前端问题:Authorization 未发送,请检查当前请求是否应携带用户 token 或显式 Authorization') } else { console.error('[ak-req] ✓ 前端 header 已正确发送,401 来自服务端') console.error('[ak-req] 请运维核查以下服务端配置:') console.error('[ak-req] 1. SUPA_KEY 是否属于 9126 这个实例(不同实例 key 不通用)') console.error('[ak-req] 2. 9126 和 9127 是否为同一套 docker-compose / 同一实例') console.error('[ak-req] 3. Kong 是否已 reload 到最新 consumer/key 配置') console.error('[ak-req] 4. Supabase 实例的 ANON_KEY 环境变量与 SUPA_KEY 是否一致') console.error('[ak-req] 5. categories/ml_brands/ml_member_levels 是否在该实例 public schema 下') console.error('[ak-req] 6. RLS policy 是否允许 anon role SELECT') } uni.$emit('AUTH_SESSION_EXPIRED', { reason: '401' }); try { this.clearToken(); // IS_TEST_MODE=true 时不 showToast 和跳转,避免干扰调试 if (IS_TEST_MODE !== true) { uni.showToast({ title: '未授权或登录已过期,请重新登录', icon: 'none' }); } } catch (e) {} } return finalRes; } // 新增 upload 方法,支持 uni.uploadFile,自动带 token/apikey static async upload(options : AkReqUploadOptions) : Promise> { // 上传前尝试刷新 token(若即将过期)。优先从 options.headers 或 apikey 字段获取 apikey let apikey: string | null = null; const hdr = options.headers; if (hdr != null && typeof hdr.getString === 'function') { apikey = hdr.getString('apikey'); } if (apikey == null && options.apikey != null) apikey = options.apikey; await this.refreshTokenIfNeeded(apikey != null ? apikey : null); let headers = options.headers ?? ({} as UTSJSONObject); const token = this.getToken(); if (token != null && token !== "") { headers = Object.assign({}, headers, { Authorization: `Bearer ${token}` }) as UTSJSONObject; } if (apikey != null && apikey !== "") { headers = Object.assign({}, headers, { apikey: apikey }) as UTSJSONObject; } // 默认 Accept headers = Object.assign({ Accept: 'application/json' } as UTSJSONObject, headers) as UTSJSONObject; const timeout = options.timeout ?? 10000; const maxRetry = Math.max(0, options.retryCount ?? 0); const baseDelay = Math.max(0, options.retryDelayMs ?? 300); const doOnce = (): Promise> => { return new Promise>((resolve) => { const task = uni.uploadFile({ url: options.url, filePath: options.filePath, name: options.name, formData: options.formData ?? {}, header: headers, timeout: timeout, success: (res : UploadFileSuccess) => { let parsed: UTSJSONObject | null = null; try { parsed = JSON.parse(res.data) as UTSJSONObject; } catch (e) { parsed = null; } if (parsed != null) { const accessToken = parsed.getString('access_token'); const refreshTokenNew = parsed.getString('refresh_token'); const expiresAt = parsed.getNumber('expires_at'); if (accessToken !== null && refreshTokenNew !== null && expiresAt !== null) { AkReq.setToken(accessToken, refreshTokenNew, expiresAt); } } const result = AkReq.createResponse( res.statusCode, parsed ?? {}, headers ); resolve(result); }, fail: (err) => { const errStatus = (err.errCode != null && typeof err.errCode === 'number') ? err.errCode : 0; const result = AkReq.createResponse( errStatus, {} as UTSJSONObject, {} as UTSJSONObject, new UniError('uni-upload', errStatus, err.errMsg ?? 'upload fail') ); resolve(result); } }); if (options.onProgress != null && task != null) { const progressCallback = (res: OnProgressUpdateResult) => { const percent = res.progress as number; // 0-100 const sent = res.totalBytesSent as number | null; const expected = res.totalBytesExpectedToSend as number | null; if (options.onProgress != null) { options.onProgress(percent, sent, expected); } }; task.onProgressUpdate(progressCallback); } }); }; let attempt = 0; let lastRes: AkReqResponse | null = null; while (attempt <= maxRetry) { const res = await doOnce(); lastRes = res; const status = res.status ?? 0; const isOk = status >= 200 && status < 400; if (isOk) return res; if (attempt === maxRetry) break; const delay = baseDelay * Math.pow(2, attempt); await new Promise((resolve) => { setTimeout(() => { resolve(); }, delay); }); attempt++; } return lastRes!!; } // 辅助方法:创建 AkReqResponse 对象,避免类型推断问题 static createResponse( status: number, data: T | Array , headers: UTSJSONObject, error: UniError | null = null, total: number | null = null, page: number | null = null, limit: number | null = null, hasmore: boolean | null = null, origin: any | null = null ): AkReqResponse { return { status, data, headers, error, total, page, limit, hasmore, origin }; } } export default AkReq;