feat: prefer supabase jwt in taro api client

This commit is contained in:
Codex
2026-06-30 01:36:04 +08:00
parent 25d1b8e796
commit 3bd2ac1799
12 changed files with 220 additions and 36 deletions

View 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;
}

View File

@@ -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' });

View File

@@ -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;

View File

@@ -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',
});
}

View File

@@ -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' },
);
}