import { getApiConfig } from '../config/api'; import { ApiRequestError, requestApi } from './api-transport'; export interface ServerUser { id: string; nickName: string | null; avatarUrl: string | null; avatarAssetId?: string | null; avatarUrlExpiresAt: string | null; createdAt: string; } export interface AuthSession { accessToken: string; expiresAt: number; user: ServerUser; } type Context = ReturnType; type History = 'new' | 'used' | 'blocked'; interface SessionState { session: AuthSession | null; history: History; generation: number; flight?: Promise; } const states = new Map(); const EARLY_RENEW_MS = 60000; /** 环境、完整接口入口和 AppID 隔离;正式版不会读取开发版凭证。 */ export function getAuthContext() { const config = getApiConfig(); const appId = wx.getAccountInfoSync().miniProgram.appId; if (!appId) throw new ApiRequestError('无法识别小程序身份'); return { ...config, key: `doodle_auth_v1:${encodeURIComponent(appId)}:${encodeURIComponent(config.baseUrl)}`, }; } /** 用户资料白名单只供展示,不缓存角色、微信身份或服务端内部字段。 */ export function parseServerUser(value: unknown): ServerUser { const row = value as ServerUser | null; if ( !row || typeof row.id !== 'string' || !row.id || (row.nickName !== null && typeof row.nickName !== 'string') || (row.avatarUrl !== null && typeof row.avatarUrl !== 'string') || (row.avatarUrlExpiresAt !== null && typeof row.avatarUrlExpiresAt !== 'string') || typeof row.createdAt !== 'string' || !Number.isFinite(Date.parse(row.createdAt)) ) throw new ApiRequestError('登录资料格式异常'); return { id: row.id, nickName: row.nickName, avatarUrl: row.avatarUrl, avatarAssetId: typeof row.avatarAssetId === 'string' ? row.avatarAssetId : null, avatarUrlExpiresAt: row.avatarUrlExpiresAt, createdAt: row.createdAt, }; } function readState(context: Context): SessionState { const existing = states.get(context.key); if (existing) return existing; const state: SessionState = { session: null, history: 'new', generation: 0, }; try { const stored = wx.getStorageSync(context.key); if (stored) { state.history = 'used'; if ( stored.version === 1 && ['new', 'used', 'blocked'].includes(stored.history) ) { state.history = stored.history; const session = stored.session; if ( state.history === 'used' && session && typeof session.accessToken === 'string' && session.accessToken && !/\s/.test(session.accessToken) && Number.isFinite(session.expiresAt) ) state.session = { accessToken: session.accessToken, expiresAt: session.expiresAt, user: parseServerUser(session.user), }; } } } catch { /* 损坏资料不作为有效凭证;保留已使用账户标记。 */ } states.set(context.key, state); return state; } function syncGlobal(context: Context, user: ServerUser | null) { try { if (getAuthContext().key === context.key) getApp().globalData.user = user; } catch { /* App 未初始化时不影响认证流程。 */ } } function persist(context: Context, state: SessionState) { try { wx.setStorageSync(context.key, { version: 1, history: state.history, session: state.session, }); } catch { /* Storage 不可写时仍可使用本次内存会话,不记录任何凭据。 */ } syncGlobal(context, state.session?.user || null); } function usable(session: AuthSession | null): session is AuthSession { return !!session && session.expiresAt > Date.now() + EARLY_RENEW_MS; } function wechatCode(): Promise { return new Promise((resolve, reject) => wx.login({ timeout: 10000, success(res) { res.code ? resolve(res.code) : reject(new ApiRequestError('微信登录失败')); }, fail() { reject(new ApiRequestError('微信登录失败')); }, }), ); } /** 合并启动、续期及 401 恢复;每次尝试取新 code,明确无效 code 最多补试一次。 */ export async function ensureSession( context = getAuthContext(), force = false, explicitStart = false, ): Promise { if (getAuthContext().key !== context.key) throw new ApiRequestError('接口环境已变化,请重新操作'); const state = readState(context); if (state.history === 'blocked' && !explicitStart) throw new ApiRequestError( '账户需要重新确认后才能使用', 401, 'ACCOUNT_RESTART_REQUIRED', ); if (!force && usable(state.session)) { syncGlobal(context, state.session.user); return state.session; } if (state.flight) return state.flight; const generation = state.generation; const mode = explicitStart || state.history === 'new' ? 'start' : 'resume'; const flight = (async () => { try { let result: | { accessToken: string; tokenType: string; expiresIn: number; expiresAt: string; user: ServerUser; } | undefined; for (let attempt = 0; attempt < 2; attempt++) { try { result = await requestApi( '/auth/wechat/login', { method: 'POST', data: { code: await wechatCode(), mode }, }, context, ); break; } catch (error) { if (!( error instanceof ApiRequestError && error.reason === 'WECHAT_CODE_INVALID' && attempt === 0 )) throw error; } } if ( !result || result.tokenType !== 'Bearer' || typeof result.accessToken !== 'string' || !result.accessToken || /\s/.test(result.accessToken) || !Number.isSafeInteger(result.expiresIn) || result.expiresIn < 60 || result.expiresIn > 604800 || !Number.isFinite(Date.parse(result.expiresAt)) ) throw new ApiRequestError('登录响应格式异常'); const session = { accessToken: result.accessToken, expiresAt: Date.now() + result.expiresIn * 1000, user: parseServerUser(result.user), }; // 清理、切换环境期间迟到的登录响应不得恢复会话或写入当前界面。 if ( state.generation !== generation || getAuthContext().key !== context.key ) throw new ApiRequestError('登录状态已变化,请重新操作'); state.session = session; state.history = 'used'; persist(context, state); return session; } catch (error) { if ( state.generation === generation && error instanceof ApiRequestError && [ 'ACCOUNT_NOT_FOUND', 'ACCOUNT_DISABLED', 'WECHAT_LOGIN_BLOCKED', ].includes(error.reason) ) { state.session = null; state.history = 'blocked'; persist(context, state); } throw error; } })(); state.flight = flight; try { return await flight; } finally { if (state.flight === flight) state.flight = undefined; } } /** 同一原请求只恢复一次;迟到的旧 401 直接复用较新凭证。 */ export async function recoverSession(context: Context, failed: AuthSession) { const state = readState(context); if ( usable(state.session) && state.session.accessToken !== failed.accessToken ) return state.session; // 已获得过凭证就只恢复账户,绝不在恢复失败后自动切回 start 建号。 state.session = null; state.history = state.history === 'blocked' ? 'blocked' : 'used'; persist(context, state); return ensureSession(context, true); } /** 清理保留账户历史;注销/账号消失后只能由用户主动调用 startAccount 再建号。 */ export function clearSession(blocked = false, context = getAuthContext()) { const state = readState(context); state.generation++; state.flight = undefined; state.session = null; state.history = blocked ? 'blocked' : state.history === 'new' ? 'new' : 'used'; persist(context, state); } export function cachedServerUser(): ServerUser | null { const context = getAuthContext(); return readState(context).session?.user || null; } /** GET /me 结果只更新同一环境、同一用户的现有会话,不混入旧云业务资料。 */ export function updateSessionUser( value: unknown, context = getAuthContext(), ): ServerUser { const user = parseServerUser(value); const state = readState(context); if (!state.session || state.session.user.id !== user.id) throw new ApiRequestError('账户已变化,请重新操作'); state.session = { ...state.session, user }; persist(context, state); return user; } /** 只清理本次请求使用的凭证;旧请求迟到的错误不能撤销新登录。 */ export function rejectSession( context: Context, token: string, blocked: boolean, ) { if (readState(context).session?.accessToken === token) clearSession(blocked, context); }