507 lines
19 KiB
Plaintext
507 lines
19 KiB
Plaintext
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'
|
||
}
|
||
|
||
// 组装最终 header(plain 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;
|