feat: add WeChat session authentication
This commit is contained in:
98
utils/api.ts
98
utils/api.ts
@@ -14,13 +14,18 @@ import type {
|
||||
VoiceLessonFinished,
|
||||
VoiceProcessingStatus
|
||||
} from './types'
|
||||
import {
|
||||
AuthenticationError,
|
||||
clearAuthSession,
|
||||
ensureAuthSession,
|
||||
getAccessToken
|
||||
} from './auth'
|
||||
import type { AuthSession } from './auth'
|
||||
|
||||
const DEFAULT_API_BASE_URL = 'https://feedback.shay7sev.site'
|
||||
const LEGACY_DEFAULT_API_BASE_URL = 'http://127.0.0.1:8080'
|
||||
const API_BASE_URL_KEY = 'teaching-feedback-api-base-url-v1'
|
||||
|
||||
export const DEVELOPMENT_USER_ID = '5a4da7f0-d70c-465f-bcab-124c504aa9f0'
|
||||
|
||||
interface ApiErrorBody {
|
||||
error?: string
|
||||
}
|
||||
@@ -62,6 +67,8 @@ interface RawHealthStatus {
|
||||
speech_available: boolean
|
||||
speech_provider: string | null
|
||||
summary_configured: boolean
|
||||
wechat_auth_configured: boolean
|
||||
development_auth_enabled: boolean
|
||||
}
|
||||
|
||||
interface RawVoiceAudioSegment {
|
||||
@@ -158,23 +165,56 @@ export function setApiBaseUrl(value: string): string {
|
||||
}
|
||||
|
||||
export function getApiErrorMessage(error: unknown): string {
|
||||
if (error instanceof AuthenticationError) return error.message
|
||||
if (!(error instanceof ApiRequestError)) return '请求失败,请稍后重试'
|
||||
if (error.statusCode === 401) return '开发身份未通过后端校验'
|
||||
if (error.statusCode === 401) return '登录状态已失效,请重试'
|
||||
if (error.statusCode === 404) return '请求的数据不存在或无权访问'
|
||||
if (error.statusCode === 503 && error.message.includes('database')) return '后端尚未配置数据库连接'
|
||||
return error.message
|
||||
}
|
||||
|
||||
function request<T>(path: string, method: HttpMethod = 'GET', data?: object, timeout = 10000): Promise<T> {
|
||||
export function ensureAuthentication(): Promise<AuthSession> {
|
||||
return ensureAuthSession(getApiBaseUrl())
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
method: HttpMethod = 'GET',
|
||||
data?: object,
|
||||
timeout = 10000,
|
||||
authenticated = true
|
||||
): Promise<T> {
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
if (!authenticated) return requestOnce<T>(apiBaseUrl, path, method, data, timeout)
|
||||
|
||||
let accessToken = await getAccessToken(apiBaseUrl)
|
||||
try {
|
||||
return await requestOnce<T>(apiBaseUrl, path, method, data, timeout, accessToken)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiRequestError) || error.statusCode !== 401) throw error
|
||||
clearAuthSession()
|
||||
accessToken = await getAccessToken(apiBaseUrl, true)
|
||||
return requestOnce<T>(apiBaseUrl, path, method, data, timeout, accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
function requestOnce<T>(
|
||||
apiBaseUrl: string,
|
||||
path: string,
|
||||
method: HttpMethod,
|
||||
data: object | undefined,
|
||||
timeout: number,
|
||||
accessToken?: string
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.request({
|
||||
url: `${getApiBaseUrl()}${path}`,
|
||||
url: `${apiBaseUrl}${path}`,
|
||||
method,
|
||||
data,
|
||||
timeout,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': DEVELOPMENT_USER_ID
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
|
||||
},
|
||||
success(response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
@@ -288,14 +328,16 @@ function mapFeedbackSession(session: RawFeedbackSession): FeedbackSession {
|
||||
}
|
||||
|
||||
export async function getHealth(): Promise<HealthStatus> {
|
||||
const result = await request<RawHealthStatus>('/health')
|
||||
const result = await request<RawHealthStatus>('/health', 'GET', undefined, 10000, false)
|
||||
return {
|
||||
status: result.status,
|
||||
databaseConfigured: result.database_configured,
|
||||
speechConfigured: result.speech_configured,
|
||||
speechAvailable: result.speech_available,
|
||||
speechProvider: result.speech_provider,
|
||||
summaryConfigured: result.summary_configured
|
||||
summaryConfigured: result.summary_configured,
|
||||
wechatAuthConfigured: result.wechat_auth_configured,
|
||||
developmentAuthEnabled: result.development_auth_enabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +432,41 @@ export async function createVoiceLesson(sessionId: string): Promise<VoiceLessonC
|
||||
}
|
||||
}
|
||||
|
||||
export function uploadVoiceSegment(
|
||||
export async function uploadVoiceSegment(
|
||||
lessonId: string,
|
||||
filePath: string,
|
||||
sequence: number,
|
||||
durationMs: number
|
||||
): Promise<VoiceAudioSegment> {
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
let accessToken = await getAccessToken(apiBaseUrl)
|
||||
try {
|
||||
return await uploadVoiceSegmentOnce(
|
||||
apiBaseUrl,
|
||||
accessToken,
|
||||
lessonId,
|
||||
filePath,
|
||||
sequence,
|
||||
durationMs
|
||||
)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiRequestError) || error.statusCode !== 401) throw error
|
||||
clearAuthSession()
|
||||
accessToken = await getAccessToken(apiBaseUrl, true)
|
||||
return uploadVoiceSegmentOnce(
|
||||
apiBaseUrl,
|
||||
accessToken,
|
||||
lessonId,
|
||||
filePath,
|
||||
sequence,
|
||||
durationMs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function uploadVoiceSegmentOnce(
|
||||
apiBaseUrl: string,
|
||||
accessToken: string,
|
||||
lessonId: string,
|
||||
filePath: string,
|
||||
sequence: number,
|
||||
@@ -398,7 +474,7 @@ export function uploadVoiceSegment(
|
||||
): Promise<VoiceAudioSegment> {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.uploadFile({
|
||||
url: `${getApiBaseUrl()}/api/v1/feedback-lessons/${encodeURIComponent(lessonId)}/segments`,
|
||||
url: `${apiBaseUrl}/api/v1/feedback-lessons/${encodeURIComponent(lessonId)}/segments`,
|
||||
filePath,
|
||||
name: 'audio',
|
||||
formData: {
|
||||
@@ -407,7 +483,7 @@ export function uploadVoiceSegment(
|
||||
audio_format: 'mp3'
|
||||
},
|
||||
timeout: 120000,
|
||||
header: { 'X-User-Id': DEVELOPMENT_USER_ID },
|
||||
header: { Authorization: `Bearer ${accessToken}` },
|
||||
success(response) {
|
||||
let body: RawVoiceAudioSegment | ApiErrorBody
|
||||
try {
|
||||
|
||||
137
utils/auth.ts
Normal file
137
utils/auth.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
const AUTH_SESSION_KEY = 'teaching-feedback-auth-session-v1'
|
||||
const EXPIRY_SKEW_MS = 60_000
|
||||
|
||||
interface RawLoginResponse {
|
||||
access_token: string
|
||||
expires_at: string
|
||||
user_id: string
|
||||
}
|
||||
|
||||
interface ErrorBody {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
apiBaseUrl: string
|
||||
accessToken: string
|
||||
expiresAt: number
|
||||
userId: string
|
||||
}
|
||||
|
||||
export class AuthenticationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'AuthenticationError'
|
||||
}
|
||||
}
|
||||
|
||||
let pendingLogin: { apiBaseUrl: string; promise: Promise<AuthSession> } | null = null
|
||||
|
||||
export function getStoredAuthSession(apiBaseUrl: string): AuthSession | null {
|
||||
const value = wx.getStorageSync(AUTH_SESSION_KEY) as Partial<AuthSession> | string
|
||||
if (!value || typeof value !== 'object') return null
|
||||
if (
|
||||
value.apiBaseUrl !== apiBaseUrl ||
|
||||
typeof value.accessToken !== 'string' ||
|
||||
!value.accessToken ||
|
||||
typeof value.expiresAt !== 'number' ||
|
||||
typeof value.userId !== 'string' ||
|
||||
!value.userId
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return value as AuthSession
|
||||
}
|
||||
|
||||
export function clearAuthSession(): void {
|
||||
wx.removeStorageSync(AUTH_SESSION_KEY)
|
||||
}
|
||||
|
||||
export async function getAccessToken(apiBaseUrl: string, forceRefresh = false): Promise<string> {
|
||||
if (!forceRefresh) {
|
||||
const stored = getStoredAuthSession(apiBaseUrl)
|
||||
if (stored && stored.expiresAt > Date.now() + EXPIRY_SKEW_MS) return stored.accessToken
|
||||
}
|
||||
return (await login(apiBaseUrl)).accessToken
|
||||
}
|
||||
|
||||
export async function ensureAuthSession(apiBaseUrl: string): Promise<AuthSession> {
|
||||
const accessToken = await getAccessToken(apiBaseUrl)
|
||||
const session = getStoredAuthSession(apiBaseUrl)
|
||||
if (!session || session.accessToken !== accessToken) {
|
||||
throw new AuthenticationError('登录状态保存失败,请重试')
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
async function login(apiBaseUrl: string): Promise<AuthSession> {
|
||||
if (pendingLogin?.apiBaseUrl === apiBaseUrl) return pendingLogin.promise
|
||||
|
||||
const promise = loginWithWechat(apiBaseUrl).finally(() => {
|
||||
if (pendingLogin?.promise === promise) pendingLogin = null
|
||||
})
|
||||
pendingLogin = { apiBaseUrl, promise }
|
||||
return promise
|
||||
}
|
||||
|
||||
async function loginWithWechat(apiBaseUrl: string): Promise<AuthSession> {
|
||||
const code = await getWechatLoginCode()
|
||||
const response = await exchangeCode(apiBaseUrl, code)
|
||||
const expiresAt = Date.parse(response.expires_at)
|
||||
if (
|
||||
!response.access_token ||
|
||||
!response.user_id ||
|
||||
Number.isNaN(expiresAt) ||
|
||||
expiresAt <= Date.now()
|
||||
) {
|
||||
throw new AuthenticationError('登录服务返回了无效会话')
|
||||
}
|
||||
const session: AuthSession = {
|
||||
apiBaseUrl,
|
||||
accessToken: response.access_token,
|
||||
expiresAt,
|
||||
userId: response.user_id
|
||||
}
|
||||
wx.setStorageSync(AUTH_SESSION_KEY, session)
|
||||
return session
|
||||
}
|
||||
|
||||
function getWechatLoginCode(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.login({
|
||||
success(result) {
|
||||
if (result.code) {
|
||||
resolve(result.code)
|
||||
return
|
||||
}
|
||||
reject(new AuthenticationError('微信未返回登录凭证'))
|
||||
},
|
||||
fail(error) {
|
||||
reject(new AuthenticationError(`微信登录失败:${error.errMsg}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function exchangeCode(apiBaseUrl: string, code: string): Promise<RawLoginResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.request({
|
||||
url: `${apiBaseUrl}/api/v1/auth/wechat/login`,
|
||||
method: 'POST',
|
||||
data: { code },
|
||||
timeout: 15_000,
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
success(response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
resolve(response.data as RawLoginResponse)
|
||||
return
|
||||
}
|
||||
const body = response.data as ErrorBody
|
||||
reject(new AuthenticationError(body?.error || `登录服务返回 HTTP ${response.statusCode}`))
|
||||
},
|
||||
fail(error) {
|
||||
reject(new AuthenticationError(`无法连接登录服务:${error.errMsg}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -54,6 +54,8 @@ export interface HealthStatus {
|
||||
speechAvailable: boolean
|
||||
speechProvider: string | null
|
||||
summaryConfigured: boolean
|
||||
wechatAuthConfigured: boolean
|
||||
developmentAuthEnabled: boolean
|
||||
}
|
||||
|
||||
export type VoiceProcessingStatus = 'recording' | 'processing' | 'ready' | 'failed'
|
||||
|
||||
Reference in New Issue
Block a user