Files
medical-mall/uni_modules/ak-req/ak-req.uts
2026-06-04 18:32:08 +08:00

507 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<boolean> {
// 没有 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<AkReqResponse<any>> {
// 自动刷新 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'
}
// 组装最终 headerplain object所有平台均可正确传递
const currentHeaders: Record<string, string> = {}
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位
console.log('[ak-req] request', options.method ?? 'GET', '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<AkReqResponse<any>> => {
return new Promise<AkReqResponse<any>>((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<any>(
res.statusCode,
[] as Array<any>,
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<UTSJSONObject> | 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<any>(
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] body:', bodyPreview)
}
resolve(result);
},
fail: (err) => {
const errStatus = (err.errCode != null && typeof err.errCode === 'number') ? err.errCode : 0;
const result = AkReq.createResponse<any>(
errStatus,
{} as UTSJSONObject,
{} as UTSJSONObject,
new UniError('uni-request', errStatus, err.errMsg ?? 'request fail')
);
resolve(result);
}
});
});
};
let attempt = 0;
let lastRes: AkReqResponse<any> | 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<void>((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] auth-mode: ' + authMode)
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<AkReqResponse<any>> {
// 上传前尝试刷新 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<AkReqResponse<any>> => {
return new Promise<AkReqResponse<any>>((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<any>(
res.statusCode,
parsed ?? {},
headers
);
resolve(result);
},
fail: (err) => {
const errStatus = (err.errCode != null && typeof err.errCode === 'number') ? err.errCode : 0;
const result = AkReq.createResponse<any>(
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<any> | 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<void>((resolve) => {
setTimeout(() => {
resolve();
}, delay);
});
attempt++;
}
return lastRes!!;
}
// 辅助方法:创建 AkReqResponse 对象,避免类型推断问题
static createResponse<T>(
status: number,
data: T | Array<T> ,
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<T> {
return {
status,
data,
headers,
error,
total,
page,
limit,
hasmore,
origin
};
}
}
export default AkReq;