302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
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<typeof getAuthContext>;
|
|
type History = 'new' | 'used' | 'blocked';
|
|
interface SessionState {
|
|
session: AuthSession | null;
|
|
history: History;
|
|
generation: number;
|
|
flight?: Promise<AuthSession>;
|
|
}
|
|
const states = new Map<string, SessionState>();
|
|
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<IAppOption>().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<string> {
|
|
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<AuthSession> {
|
|
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);
|
|
}
|