138 lines
3.9 KiB
TypeScript
138 lines
3.9 KiB
TypeScript
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}`))
|
|
}
|
|
})
|
|
})
|
|
}
|