feat: 完成全部接口替换为新后端接口

This commit is contained in:
R524809
2026-10-19 13:19:07 +08:00
parent 8de06ce66b
commit 997042789d
40 changed files with 1946 additions and 785 deletions
+170
View File
@@ -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 } : {}) };
}
+93 -65
View File
@@ -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;
}
}
}
+100
View File
@@ -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,
});
});
}
+301
View File
@@ -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);
}
+5 -1
View File
@@ -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('题型数据异常,请稍后重试');
+53
View File
@@ -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;
}
+12
View File
@@ -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 } },
);
}