Files
2026-10-19 17:49:19 +08:00

321 lines
11 KiB
TypeScript

import { getApiConfig, apiContextKey, isCurrentApiConfig } 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_v2:${apiContextKey(config)}`,
};
}
/** 用户资料白名单只供展示,不缓存角色、微信身份或服务端内部字段。 */
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 {
// 旧版会话只迁入原默认环境,不能复制到同址的 staging/production 开发配置。
const legacyDefault =
context.version === 'develop'
? context.environment === 'local'
: context.environment === 'production';
const stored =
wx.getStorageSync(context.key) ||
(legacyDefault
? wx.getStorageSync(
`doodle_auth_v1:${encodeURIComponent(wx.getAccountInfoSync().miniProgram.appId)}:${encodeURIComponent(context.baseUrl)}`,
)
: null);
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 (!isCurrentApiConfig(context))
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 || !isCurrentApiConfig(context))
throw new ApiRequestError('登录状态已变化,请重新操作');
state.session = session;
state.history = 'used';
persist(context, state);
return session;
} catch (error) {
if (
state.generation === generation &&
isCurrentApiConfig(context) &&
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;
}
}
/** 保留各环境已保存会话和账户历史,只作废尚未完成的登录任务。 */
export function invalidatePendingAuthentication() {
for (const state of states.values()) {
state.generation++;
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);
}