feat: 完成全部接口替换为新后端接口
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import { apiRequest } from './api-request';
|
||||
import type { ApiWorksheet } from './content-api';
|
||||
|
||||
export type AdminAction =
|
||||
| 'categories.list'
|
||||
| 'categories.create'
|
||||
| 'categories.update'
|
||||
| 'worksheets.list'
|
||||
| 'worksheets.status'
|
||||
| 'worksheets.batch'
|
||||
| 'site.config'
|
||||
| 'site.rebuild'
|
||||
| 'site.timer';
|
||||
interface AdminWorksheet extends ApiWorksheet {
|
||||
status: 'draft' | 'active' | 'hidden';
|
||||
likesDisplay: string;
|
||||
downloadsDisplay: string;
|
||||
likesSeed: string;
|
||||
downloadsSeed: string;
|
||||
}
|
||||
interface AdminPage {
|
||||
items: AdminWorksheet[];
|
||||
total: number;
|
||||
hasNext: boolean;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
/** 管理页迁移适配:只调用具名 REST 接口,旧页面展示字段在客户端映射,不传旧用户身份。 */
|
||||
export async function managementRequest<T>(
|
||||
action: AdminAction,
|
||||
payload: Record<string, unknown> = {},
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: T;
|
||||
stats?: { total: number; active: number; draft: number; hidden: number };
|
||||
message?: string;
|
||||
}> {
|
||||
let data: unknown;
|
||||
let stats;
|
||||
switch (action) {
|
||||
case 'categories.list': {
|
||||
const rows =
|
||||
await apiRequest<Array<{ id: string }>>('/admin/categories');
|
||||
data = rows.map((row) => ({ ...row, _id: row.id }));
|
||||
break;
|
||||
}
|
||||
case 'categories.create':
|
||||
data = await apiRequest('/admin/categories', {
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
});
|
||||
break;
|
||||
case 'categories.update': {
|
||||
const { id, ...fields } = payload;
|
||||
data = await apiRequest(
|
||||
`/admin/categories/${encodeURIComponent(String(id))}`,
|
||||
{ method: 'PUT', data: fields },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'worksheets.list': {
|
||||
const rows: AdminWorksheet[] = [];
|
||||
let expected: number | undefined;
|
||||
for (let page = 1; ; page++) {
|
||||
const response = await apiRequest<AdminPage>(
|
||||
'/admin/catalog/worksheets',
|
||||
{
|
||||
data: {
|
||||
page,
|
||||
pageSize: 100,
|
||||
...(payload.category
|
||||
? { categoryId: payload.category }
|
||||
: {}),
|
||||
...(payload.status
|
||||
? { status: payload.status }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
if (
|
||||
response.page !== page ||
|
||||
response.pageSize !== 100 ||
|
||||
!Array.isArray(response.items) ||
|
||||
!Number.isSafeInteger(response.total) ||
|
||||
(expected !== undefined && response.total !== expected) ||
|
||||
response.hasNext !== page * 100 < response.total
|
||||
)
|
||||
throw new Error('管理目录发生变化,请重试');
|
||||
expected = response.total;
|
||||
rows.push(...response.items);
|
||||
if (!response.hasNext) break;
|
||||
}
|
||||
if (
|
||||
rows.length !== expected ||
|
||||
new Set(rows.map((row) => row.id)).size !== rows.length
|
||||
)
|
||||
throw new Error('管理目录发生变化,请重试');
|
||||
data = rows.map((row) => ({
|
||||
...row,
|
||||
_id: row.id,
|
||||
likes: payload.rawStats ? row.likes : row.likesDisplay,
|
||||
downloads: payload.rawStats
|
||||
? row.downloads
|
||||
: row.downloadsDisplay,
|
||||
}));
|
||||
stats = {
|
||||
total: rows.length,
|
||||
active: rows.filter((row) => row.status === 'active').length,
|
||||
draft: rows.filter((row) => row.status === 'draft').length,
|
||||
hidden: rows.filter((row) => row.status === 'hidden').length,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'worksheets.status':
|
||||
data = await apiRequest(
|
||||
`/admin/worksheets/${encodeURIComponent(String(payload.id))}/status`,
|
||||
{ method: 'PUT', data: { status: payload.status } },
|
||||
);
|
||||
break;
|
||||
case 'worksheets.batch': {
|
||||
const patches = (
|
||||
payload.patches as Array<Record<string, unknown>>
|
||||
).map(({ likes_seed, downloads_seed, ...row }) => ({
|
||||
...row,
|
||||
...(likes_seed === undefined ? {} : { likesSeed: likes_seed }),
|
||||
...(downloads_seed === undefined
|
||||
? {}
|
||||
: { downloadsSeed: downloads_seed }),
|
||||
}));
|
||||
data = await apiRequest('/admin/worksheets/batch', {
|
||||
method: 'POST',
|
||||
timeout: 60000,
|
||||
data: { patches, dryRun: payload.dryRun === true },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'site.config':
|
||||
data = await apiRequest('/admin/site/config');
|
||||
break;
|
||||
case 'site.rebuild':
|
||||
data = await apiRequest('/admin/site/rebuild', {
|
||||
method: 'POST',
|
||||
timeout: 60000,
|
||||
data: payload,
|
||||
});
|
||||
break;
|
||||
case 'site.timer':
|
||||
data = await apiRequest('/admin/site/timer', {
|
||||
method: 'PUT',
|
||||
data: { enabled: payload.enabled },
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error('不支持的管理操作');
|
||||
}
|
||||
if (
|
||||
[
|
||||
'categories.create',
|
||||
'categories.update',
|
||||
'worksheets.status',
|
||||
'worksheets.batch',
|
||||
'site.rebuild',
|
||||
].includes(action)
|
||||
) {
|
||||
const app = getApp<IAppOption>();
|
||||
if (typeof app.refreshPageConfig === 'function')
|
||||
await app.refreshPageConfig();
|
||||
}
|
||||
return { success: true, data: data as T, ...(stats ? { stats } : {}) };
|
||||
}
|
||||
@@ -1,74 +1,102 @@
|
||||
import { getApiConfig } from '../config/api';
|
||||
import {
|
||||
ApiRequestError,
|
||||
requestApi,
|
||||
type RequestOptions,
|
||||
} from './api-transport';
|
||||
import {
|
||||
cachedServerUser,
|
||||
clearSession,
|
||||
ensureSession,
|
||||
getAuthContext,
|
||||
recoverSession,
|
||||
rejectSession,
|
||||
} from './auth-session';
|
||||
export { ApiRequestError } from './api-transport';
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
requestId?: string;
|
||||
}
|
||||
const recoverable = new Set([
|
||||
'AUTH_REQUIRED',
|
||||
'AUTH_INVALID',
|
||||
'AUTH_EXPIRED',
|
||||
'AUTH_REVOKED',
|
||||
]);
|
||||
const blocked = new Set([
|
||||
'ACCOUNT_NOT_FOUND',
|
||||
'ACCOUNT_DISABLED',
|
||||
'WECHAT_LOGIN_BLOCKED',
|
||||
]);
|
||||
|
||||
/** 保留有限的状态用于页面反馈,不把服务器原始响应或凭证输出到界面。 */
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number = 0,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 新后端统一请求层:校验 HTTP 与业务状态,旧云函数/CDN 请求保持独立。 */
|
||||
/** 公开接口显式 auth=none;其余接口合并登录,最多恢复并重发一次,不跨账户补发。 */
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
options: {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
data?: Record<string, unknown>;
|
||||
header?: Record<string, string>;
|
||||
} = {},
|
||||
options: RequestOptions & { auth?: 'required' | 'none' } = {},
|
||||
): Promise<T> {
|
||||
const config = getApiConfig();
|
||||
if (!path.startsWith('/') || path.startsWith('//'))
|
||||
throw new Error('接口路径不正确');
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.request<ApiEnvelope<T>>({
|
||||
url: `${config.baseUrl}${path}`,
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
header: { 'Content-Type': 'application/json', ...options.header },
|
||||
timeout: config.timeout,
|
||||
success(res) {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(
|
||||
new ApiRequestError(
|
||||
res.statusCode === 404
|
||||
? '内容暂不可用'
|
||||
: '接口请求失败,请稍后重试',
|
||||
res.statusCode,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!res.data ||
|
||||
res.data.code !== 0 ||
|
||||
!Object.prototype.hasOwnProperty.call(res.data, 'data')
|
||||
) {
|
||||
reject(
|
||||
new ApiRequestError(
|
||||
'接口返回异常,请稍后重试',
|
||||
res.statusCode,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(res.data.data);
|
||||
// Authorization 由会话层统一提供;公开请求不附带调用方误传的凭证。
|
||||
const header = Object.fromEntries(
|
||||
Object.entries(options.header || {}).filter(
|
||||
([key]) => key.toLowerCase() !== 'authorization',
|
||||
),
|
||||
);
|
||||
if (options.auth === 'none')
|
||||
return requestApi(path, { ...options, header }, getApiConfig());
|
||||
const context = getAuthContext();
|
||||
const previousUserId = cachedServerUser()?.id;
|
||||
const initial = await ensureSession(context);
|
||||
if (previousUserId && initial.user.id !== previousUserId) {
|
||||
clearSession(false, context);
|
||||
throw new ApiRequestError(
|
||||
'账户已变化,请重新操作',
|
||||
401,
|
||||
'ACCOUNT_CHANGED',
|
||||
);
|
||||
}
|
||||
const send = async (accessToken: string) => {
|
||||
if (getAuthContext().key !== context.key)
|
||||
throw new ApiRequestError('接口环境已变化,请重新操作');
|
||||
const result = await requestApi<T>(
|
||||
path,
|
||||
{
|
||||
...options,
|
||||
header: { ...header, Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
fail() {
|
||||
reject(
|
||||
new ApiRequestError('连接失败,请确认后端已启动及网络配置'),
|
||||
context,
|
||||
);
|
||||
if (
|
||||
getAuthContext().key !== context.key ||
|
||||
cachedServerUser()?.id !== initial.user.id
|
||||
)
|
||||
throw new ApiRequestError('账户状态已变化,请重新操作');
|
||||
return result;
|
||||
};
|
||||
try {
|
||||
return await send(initial.accessToken);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiRequestError) || error.statusCode !== 401)
|
||||
throw error;
|
||||
if (!recoverable.has(error.reason)) {
|
||||
if (blocked.has(error.reason))
|
||||
rejectSession(context, initial.accessToken, true);
|
||||
throw error;
|
||||
}
|
||||
const renewed = await recoverSession(context, initial);
|
||||
if (renewed.user.id !== initial.user.id) {
|
||||
clearSession(false, context);
|
||||
throw new ApiRequestError(
|
||||
'账户已变化,请重新操作',
|
||||
401,
|
||||
'ACCOUNT_CHANGED',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await send(renewed.accessToken);
|
||||
} catch (second) {
|
||||
if (second instanceof ApiRequestError && second.statusCode === 401)
|
||||
rejectSession(
|
||||
context,
|
||||
renewed.accessToken,
|
||||
blocked.has(second.reason),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
throw second;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { getApiConfig } from '../config/api';
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode = 0,
|
||||
readonly reason = '',
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
data?: Record<string, unknown>;
|
||||
header?: Record<string, string>;
|
||||
timeout?: number;
|
||||
upload?: { filePath: string; uploadId: string };
|
||||
}
|
||||
|
||||
/** 底层只发请求,不触发登录;登录接口与鉴权包装共用,避免循环依赖。 */
|
||||
export function requestApi<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
config = getApiConfig(),
|
||||
): Promise<T> {
|
||||
if (!path.startsWith('/') || path.startsWith('//'))
|
||||
throw new Error('接口路径不正确');
|
||||
return new Promise((resolve, reject) => {
|
||||
const success = (res: { statusCode: number; data: unknown }) => {
|
||||
const body = res.data as {
|
||||
code?: number;
|
||||
data?: T;
|
||||
reason?: string;
|
||||
} | null;
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(
|
||||
new ApiRequestError(
|
||||
res.statusCode === 404
|
||||
? '内容暂不可用'
|
||||
: '接口请求失败,请稍后重试',
|
||||
res.statusCode,
|
||||
typeof body?.reason === 'string' &&
|
||||
/^[A-Z_]{1,64}$/.test(body.reason)
|
||||
? body.reason
|
||||
: '',
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
!body ||
|
||||
body.code !== 0 ||
|
||||
!Object.prototype.hasOwnProperty.call(body, 'data')
|
||||
) {
|
||||
reject(
|
||||
new ApiRequestError(
|
||||
'接口返回异常,请稍后重试',
|
||||
res.statusCode,
|
||||
),
|
||||
);
|
||||
} else resolve(body.data as T);
|
||||
};
|
||||
const fail = () =>
|
||||
reject(new ApiRequestError('连接失败,请确认后端已启动及网络配置'));
|
||||
if (options.upload) {
|
||||
// wx.uploadFile 由运行时生成 multipart boundary,不能自行设置 JSON Content-Type。
|
||||
wx.uploadFile({
|
||||
url: `${config.baseUrl}${path}`,
|
||||
filePath: options.upload.filePath,
|
||||
name: 'file',
|
||||
formData: { uploadId: options.upload.uploadId },
|
||||
header: options.header,
|
||||
timeout: 60000,
|
||||
success(res) {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(res.data);
|
||||
} catch {
|
||||
reject(new ApiRequestError('上传响应格式异常'));
|
||||
return;
|
||||
}
|
||||
success({ statusCode: res.statusCode, data });
|
||||
},
|
||||
fail,
|
||||
});
|
||||
} else
|
||||
wx.request({
|
||||
url: `${config.baseUrl}${path}`,
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.header,
|
||||
},
|
||||
timeout: options.timeout ?? config.timeout,
|
||||
success,
|
||||
fail,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -36,7 +36,9 @@ interface WorksheetPage {
|
||||
|
||||
/** 分类直接由服务端发布状态决定,不用本地目录补齐或恢复下架分类。 */
|
||||
export async function listCategories(): Promise<ApiCategory[]> {
|
||||
const rows = await apiRequest<ApiCategory[]>('/categories');
|
||||
const rows = await apiRequest<ApiCategory[]>('/categories', {
|
||||
auth: 'none',
|
||||
});
|
||||
if (
|
||||
!Array.isArray(rows) ||
|
||||
rows.some(
|
||||
@@ -87,6 +89,7 @@ export async function listAllWorksheets(): Promise<ApiWorksheet[]> {
|
||||
let expectedTotal: number | undefined;
|
||||
for (let page = 1; ; page++) {
|
||||
const result = await apiRequest<WorksheetPage>('/worksheets', {
|
||||
auth: 'none',
|
||||
data: { page, pageSize },
|
||||
});
|
||||
if (
|
||||
@@ -121,6 +124,7 @@ export async function listAllWorksheets(): Promise<ApiWorksheet[]> {
|
||||
export async function getWorksheet(id: string): Promise<ApiWorksheet> {
|
||||
const row = await apiRequest<ApiWorksheet>(
|
||||
`/worksheets/${encodeURIComponent(id)}`,
|
||||
{ auth: 'none' },
|
||||
);
|
||||
validateWorksheet(row);
|
||||
if (row.id !== id) throw new Error('题型数据异常,请稍后重试');
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { apiRequest } from './api-request';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import type { ApiWorksheet } from './content-api';
|
||||
export interface UserStats {
|
||||
favoriteCount: number;
|
||||
downloadCount: number;
|
||||
totalDownloads: string;
|
||||
}
|
||||
export interface MemberRecord {
|
||||
id: string;
|
||||
worksheetId: string;
|
||||
exportId?: string | null;
|
||||
createdAt: string;
|
||||
worksheet: ApiWorksheet | null;
|
||||
}
|
||||
export interface MemberPage {
|
||||
items: MemberRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasNext: boolean;
|
||||
}
|
||||
export const memberStats = () => apiRequest<UserStats>('/me/stats');
|
||||
/** uploadId 在一次操作中固定;401 重发沿用同一 ID,网络失败不自动重复上传。 */
|
||||
export async function uploadAsset(
|
||||
filePath: string,
|
||||
purpose: 'avatar' | 'preview',
|
||||
) {
|
||||
return apiRequest<{
|
||||
id: string;
|
||||
url: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
}>(
|
||||
purpose === 'avatar'
|
||||
? '/me/avatar-upload'
|
||||
: '/admin/assets/preview-upload',
|
||||
{ method: 'POST', upload: { filePath, uploadId: generateUUID() } },
|
||||
);
|
||||
}
|
||||
export function legacyWorksheet(row: ApiWorksheet | null) {
|
||||
return row
|
||||
? {
|
||||
...row,
|
||||
_id: row.id,
|
||||
subcategory:
|
||||
(row as ApiWorksheet & { subcategory?: string | null })
|
||||
.subcategory || '',
|
||||
tags: (row as ApiWorksheet & { tags?: string[] }).tags || [],
|
||||
previewImg: row.previewImg || '',
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { apiRequest } from './api-request';
|
||||
import type { ApiWorksheet } from './content-api';
|
||||
/** 保留微信预拉取,动态兜底与后台刷新改由新后端提供。 */
|
||||
export function fetchPageConfig() {
|
||||
return apiRequest<PageConfigData>('/page-config', { auth: 'none' });
|
||||
}
|
||||
export function fetchAgeRecommendations(ageBand: string) {
|
||||
return apiRequest<{ ageBand: string; items: ApiWorksheet[] }>(
|
||||
'/recommendations/age',
|
||||
{ auth: 'none', data: { ageBand } },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user