forked from wangziqi/gongxue-base
feat: prefer supabase jwt in taro api client
This commit is contained in:
61
apps/taro/src/services/api-auth.ts
Normal file
61
apps/taro/src/services/api-auth.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export type ApiAuthMode = 'auto' | 'none' | 'supabase' | 'legacy';
|
||||
|
||||
export type SupabaseAccessTokenProvider = () => Promise<string | null>;
|
||||
|
||||
async function defaultH5SupabaseAccessTokenProvider() {
|
||||
if (process.env.TARO_ENV !== 'h5') return null;
|
||||
const { getSupabaseAccessToken } = await import('./supabase');
|
||||
return getSupabaseAccessToken();
|
||||
}
|
||||
|
||||
let supabaseAccessTokenProvider: SupabaseAccessTokenProvider = defaultH5SupabaseAccessTokenProvider;
|
||||
|
||||
export function setSupabaseAccessTokenProviderForTest(provider: SupabaseAccessTokenProvider | null) {
|
||||
supabaseAccessTokenProvider = provider || defaultH5SupabaseAccessTokenProvider;
|
||||
}
|
||||
|
||||
export async function resolveApiAuthorization(input: {
|
||||
authMode?: ApiAuthMode;
|
||||
hasTokenOverride: boolean;
|
||||
explicitToken?: string | null;
|
||||
legacyToken?: string | null;
|
||||
}) {
|
||||
const authMode = input.authMode || 'auto';
|
||||
if (authMode === 'none') return null;
|
||||
|
||||
if (input.hasTokenOverride) {
|
||||
return input.explicitToken || null;
|
||||
}
|
||||
|
||||
if (authMode === 'legacy') {
|
||||
return input.legacyToken || null;
|
||||
}
|
||||
|
||||
const supabaseToken = await supabaseAccessTokenProvider();
|
||||
if (supabaseToken) return supabaseToken;
|
||||
|
||||
if (authMode === 'supabase') return null;
|
||||
return input.legacyToken || null;
|
||||
}
|
||||
|
||||
export function buildApiHeaders(input: {
|
||||
tenantId?: string | null;
|
||||
token?: string | null;
|
||||
extraHeaders?: Record<string, string>;
|
||||
}) {
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
...(input.tenantId ? { 'x-tenant-id': input.tenantId } : {}),
|
||||
...(input.token ? { authorization: `Bearer ${input.token}` } : {}),
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(input.extraHeaders || {})) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
if (normalizedKey === 'authorization' || normalizedKey === 'x-tenant-id') {
|
||||
throw new Error(`Reserved API header cannot be overridden by page code: ${key}`);
|
||||
}
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { appEnv, ensureRuntimeConfigLoaded } from '@/env';
|
||||
import type { ApiErrorPayload, ApiSession, TenantContext } from '@/types';
|
||||
import { buildApiHeaders, resolveApiAuthorization } from './api-auth';
|
||||
import type { ApiAuthMode } from './api-auth';
|
||||
import { getStorage, removeStorage, setStorage } from './storage';
|
||||
|
||||
const TENANT_KEY = 'tiku:tenant';
|
||||
@@ -44,6 +46,9 @@ export function clearSession() {
|
||||
removeStorage(SESSION_KEY);
|
||||
}
|
||||
|
||||
export type { ApiAuthMode, SupabaseAccessTokenProvider } from './api-auth';
|
||||
export { setSupabaseAccessTokenProviderForTest } from './api-auth';
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string) {
|
||||
return baseUrl.replace(/\/+$/, '');
|
||||
}
|
||||
@@ -64,20 +69,24 @@ export async function apiRequest<T>(
|
||||
body?: unknown;
|
||||
tenantId?: string | null;
|
||||
token?: string | null;
|
||||
authMode?: ApiAuthMode;
|
||||
headers?: Record<string, string>;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
await ensureRuntimeConfigLoaded();
|
||||
const tenant = getTenantContext();
|
||||
const session = getSession();
|
||||
const token = options.token ?? session?.token ?? null;
|
||||
const tenantId = options.tenantId ?? tenant?.tenantId ?? null;
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
...(tenantId ? { 'x-tenant-id': tenantId } : {}),
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
};
|
||||
const hasTenantOverride = Object.prototype.hasOwnProperty.call(options, 'tenantId');
|
||||
const hasTokenOverride = Object.prototype.hasOwnProperty.call(options, 'token');
|
||||
const tenantId = hasTenantOverride ? options.tenantId : tenant?.tenantId ?? null;
|
||||
const authMode = options.authMode || 'auto';
|
||||
const token = await resolveApiAuthorization({
|
||||
authMode,
|
||||
hasTokenOverride,
|
||||
explicitToken: options.token,
|
||||
legacyToken: session?.token,
|
||||
});
|
||||
const headers = buildApiHeaders({ tenantId, token, extraHeaders: options.headers });
|
||||
|
||||
const response = await Taro.request({
|
||||
url: buildUrl(path, options.query),
|
||||
@@ -116,6 +125,7 @@ export async function resolveTenant(input: { host?: string; tenantCode?: string
|
||||
tenantCode: input.tenantCode || appEnv.tenantCode,
|
||||
},
|
||||
tenantId: null,
|
||||
authMode: 'none',
|
||||
});
|
||||
const tenantId = payload.item?.tenantId || payload.tenant?.tenantId || payload.tenant?.id;
|
||||
if (!tenantId) throw new ApiError({ status: 500, code: 'TENANT_RESOLVE_INVALID', message: '租户解析结果缺少 tenantId' });
|
||||
|
||||
@@ -6,6 +6,7 @@ export async function sendSmsCode(phone: string, purpose: 'login' | 'bind_phone'
|
||||
method: 'POST',
|
||||
body: { phone, purpose },
|
||||
tenantId: null,
|
||||
authMode: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +14,7 @@ export async function verifySmsCode(phone: string, code: string, purpose: 'login
|
||||
const payload = await apiRequest<ApiEnvelope<CurrentUser>>('/api/auth/sms/verify', {
|
||||
method: 'POST',
|
||||
body: { phone, code, purpose },
|
||||
authMode: 'none',
|
||||
});
|
||||
if (payload.session?.token) saveSession(payload.session);
|
||||
return payload;
|
||||
|
||||
@@ -20,9 +20,9 @@ export interface DashboardSnapshot {
|
||||
|
||||
export async function loadStudentDashboard(regionId?: string): Promise<DashboardSnapshot> {
|
||||
const [entries, banners, announcements, profile] = await Promise.all([
|
||||
apiRequest<{ items?: ContentEntry[] }>('/api/catalog/content-entries', { query: { regionId } }),
|
||||
apiRequest<{ items?: unknown[] }>('/api/catalog/banners'),
|
||||
apiRequest<{ items?: unknown[] }>('/api/catalog/announcements'),
|
||||
apiRequest<{ items?: ContentEntry[] }>('/api/catalog/content-entries', { query: { regionId }, authMode: 'none' }),
|
||||
apiRequest<{ items?: unknown[] }>('/api/catalog/banners', { authMode: 'none' }),
|
||||
apiRequest<{ items?: unknown[] }>('/api/catalog/announcements', { authMode: 'none' }),
|
||||
apiRequest<{ item?: unknown }>('/api/profile/me').catch(() => ({ item: null })),
|
||||
]);
|
||||
return {
|
||||
@@ -145,64 +145,69 @@ export interface SignedAssetLink {
|
||||
}
|
||||
|
||||
export async function loadRegions() {
|
||||
return apiRequest<{ items?: RegionItem[] }>('/api/catalog/regions');
|
||||
return apiRequest<{ items?: RegionItem[] }>('/api/catalog/regions', { authMode: 'none' });
|
||||
}
|
||||
|
||||
export async function loadContentEntries(regionId?: string, entryType?: string) {
|
||||
return apiRequest<{ items?: ContentEntry[] }>('/api/catalog/content-entries', { query: { regionId, entryType } });
|
||||
return apiRequest<{ items?: ContentEntry[] }>('/api/catalog/content-entries', { query: { regionId, entryType }, authMode: 'none' });
|
||||
}
|
||||
|
||||
export async function loadContentNodes(entryId: string, parentId: string | null = 'root', mode: 'children' | 'flat' = 'children') {
|
||||
return apiRequest<{ items?: ContentNode[] }>('/api/catalog/content-nodes', { query: { entryId, parentId, mode } });
|
||||
return apiRequest<{ items?: ContentNode[] }>('/api/catalog/content-nodes', { query: { entryId, parentId, mode }, authMode: 'none' });
|
||||
}
|
||||
|
||||
export async function loadQuestionCollections(input: { entryId?: string; nodeId?: string; limit?: number }) {
|
||||
return apiRequest<{ items?: QuestionCollection[] }>('/api/catalog/question-collections', {
|
||||
query: { entryId: input.entryId, nodeId: input.nodeId, limit: input.limit || 100 },
|
||||
authMode: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadCollectionQuestions(collectionId: string, limit = 200) {
|
||||
return apiRequest<{ items?: import('./learning').QuestionItem[] }>('/api/catalog/question-collections/questions', {
|
||||
query: { collectionId, limit },
|
||||
authMode: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadQuestions(query: { entryId?: string; contentNodeId?: string; collectionId?: string; questionIds?: string[]; limit?: number }) {
|
||||
return apiRequest<{ items?: import('./learning').QuestionItem[] }>('/api/catalog/questions', {
|
||||
query: { ...query, questionIds: query.questionIds?.join(','), limit: query.limit || 200 },
|
||||
authMode: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPracticeBlueprints(input: { entryId?: string; nodeId?: string; collectionId?: string; mode?: string }) {
|
||||
return apiRequest<{ items?: PracticeBlueprint[] }>('/api/catalog/practice-blueprints', {
|
||||
query: input,
|
||||
authMode: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadVocabularyUnits(regionId?: string) {
|
||||
return apiRequest<{ items?: VocabularyUnit[] }>('/api/catalog/vocabulary-units', { query: { regionId } });
|
||||
return apiRequest<{ items?: VocabularyUnit[] }>('/api/catalog/vocabulary-units', { query: { regionId }, authMode: 'none' });
|
||||
}
|
||||
|
||||
export async function loadVocabularyWords(unitId: string) {
|
||||
return apiRequest<{ items?: import('./learning').VocabularyWord[] }>('/api/catalog/vocabulary-words', { query: { unitId } });
|
||||
return apiRequest<{ items?: import('./learning').VocabularyWord[] }>('/api/catalog/vocabulary-words', { query: { unitId }, authMode: 'none' });
|
||||
}
|
||||
|
||||
export async function loadHandbookSubjects(regionId?: string) {
|
||||
return apiRequest<{ items?: HandbookSubject[] }>('/api/catalog/handbook-subjects', { query: { regionId } });
|
||||
return apiRequest<{ items?: HandbookSubject[] }>('/api/catalog/handbook-subjects', { query: { regionId }, authMode: 'none' });
|
||||
}
|
||||
|
||||
export async function loadHandbookChapters(subjectId: string) {
|
||||
return apiRequest<{ items?: HandbookChapter[] }>('/api/catalog/handbook-chapters', { query: { subjectId } });
|
||||
return apiRequest<{ items?: HandbookChapter[] }>('/api/catalog/handbook-chapters', { query: { subjectId }, authMode: 'none' });
|
||||
}
|
||||
|
||||
export async function loadHandbookEntries(chapterId: string, includeContent = true) {
|
||||
return apiRequest<{ items?: HandbookEntry[] }>('/api/catalog/handbook-entries', { query: { chapterId, includeContent } });
|
||||
return apiRequest<{ items?: HandbookEntry[] }>('/api/catalog/handbook-entries', { query: { chapterId, includeContent }, authMode: 'none' });
|
||||
}
|
||||
|
||||
export async function loadScorelineRecords(query: { regionId?: string; schoolId?: string; majorId?: string; year?: number; pageSize?: number } = {}) {
|
||||
return apiRequest<{ items?: ScorelineRecord[]; total?: number }>('/api/scoreline/records', {
|
||||
query: { ...query, pageSize: query.pageSize || 20 },
|
||||
authMode: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export interface CouponClaimResult {
|
||||
}
|
||||
|
||||
export async function loadSvipPlans(regionId?: string) {
|
||||
return apiRequest<{ items?: SvipPlan[] }>('/api/catalog/svip-plans', { query: { regionId } });
|
||||
return apiRequest<{ items?: SvipPlan[] }>('/api/catalog/svip-plans', { query: { regionId }, authMode: 'none' });
|
||||
}
|
||||
|
||||
export async function createOrder(body: {
|
||||
@@ -146,7 +146,7 @@ export async function loadEntitlements() {
|
||||
export async function checkActivationCode(code: string, regionId?: string) {
|
||||
return apiRequest<{ valid?: boolean; item?: Record<string, unknown>; reasonCode?: string; message?: string }>(
|
||||
'/api/commerce/activation-codes/check',
|
||||
{ method: 'POST', body: { code, regionId } },
|
||||
{ method: 'POST', body: { code, regionId }, authMode: 'none' },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user