feat: 完成全部接口替换为新后端接口
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user