点赞和改造开关加入后台配置
This commit is contained in:
@@ -81,7 +81,6 @@ describe('apiClient', () => {
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
@@ -156,7 +155,6 @@ describe('apiClient', () => {
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
@@ -334,6 +332,64 @@ describe('apiClient', () => {
|
||||
expect(getStoredAccessToken()).toBe('usable-local-token');
|
||||
});
|
||||
|
||||
it('keeps local token when refresh fails with transient server unavailable', async () => {
|
||||
setStoredAccessToken('usable-local-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 503 }));
|
||||
|
||||
await expect(refreshStoredAccessToken()).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('usable-local-token');
|
||||
});
|
||||
|
||||
it('keeps local token when refresh cannot reach the restarting server', async () => {
|
||||
setStoredAccessToken('usable-local-token', { emit: false });
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
|
||||
await expect(refreshStoredAccessToken()).rejects.toBeInstanceOf(TypeError);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('usable-local-token');
|
||||
});
|
||||
|
||||
it('clears local token when refresh confirms the session is unauthorized', async () => {
|
||||
setStoredAccessToken('expired-local-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
await expect(refreshStoredAccessToken()).rejects.toMatchObject({
|
||||
status: 401,
|
||||
});
|
||||
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
});
|
||||
|
||||
it('does not clear auth when protected request refresh fails transiently', async () => {
|
||||
setStoredAccessToken('expired-token-during-restart', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 503 }));
|
||||
|
||||
const response = await fetchWithApiAuth('/api/runtime/protected', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('expired-token-during-restart');
|
||||
});
|
||||
|
||||
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
@@ -344,7 +400,6 @@ describe('apiClient', () => {
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
@@ -366,7 +421,7 @@ describe('apiClient', () => {
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects refresh responses that do not return a renewed bearer token', async () => {
|
||||
it('rejects malformed refresh responses without treating them as logout', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
@@ -397,8 +452,8 @@ describe('apiClient', () => {
|
||||
message: '读取受保护数据失败',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(getStoredAccessToken()).toBe('expired-token');
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the current access token when a public request explicitly skips auth', async () => {
|
||||
|
||||
@@ -497,6 +497,13 @@ function withAuthorizationHeaders(
|
||||
|
||||
let refreshAccessTokenPromise: Promise<string> | null = null;
|
||||
|
||||
function shouldClearAuthAfterRefreshFailure(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiClientError &&
|
||||
(error.status === 401 || error.status === 403)
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (refreshAccessTokenPromise) {
|
||||
return refreshAccessTokenPromise;
|
||||
@@ -522,11 +529,11 @@ async function refreshAccessToken() {
|
||||
)
|
||||
: null;
|
||||
|
||||
if (payload?.ok !== true || !payload.token?.trim()) {
|
||||
const nextToken = payload?.token?.trim();
|
||||
if (!nextToken) {
|
||||
throw new Error('刷新登录状态失败');
|
||||
}
|
||||
|
||||
const nextToken = payload.token.trim();
|
||||
setStoredAccessToken(nextToken, { emit: false });
|
||||
return nextToken;
|
||||
})();
|
||||
@@ -556,7 +563,10 @@ export async function refreshStoredAccessToken(
|
||||
try {
|
||||
return await refreshAccessToken();
|
||||
} catch (error) {
|
||||
if (options.clearOnFailure !== false) {
|
||||
if (
|
||||
options.clearOnFailure !== false &&
|
||||
shouldClearAuthAfterRefreshFailure(error)
|
||||
) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
throw error;
|
||||
@@ -629,11 +639,15 @@ export async function fetchWithApiAuth(
|
||||
// 不能把当前业务请求的首次 401 直接放大成全局鉴权变更,
|
||||
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
|
||||
continue;
|
||||
} catch {
|
||||
if (hasAuthHeader && authFailurePolicy.clearAuthOnUnauthorized) {
|
||||
} catch (refreshError) {
|
||||
const shouldClearAuth =
|
||||
hasAuthHeader &&
|
||||
authFailurePolicy.clearAuthOnUnauthorized &&
|
||||
shouldClearAuthAfterRefreshFailure(refreshError);
|
||||
if (shouldClearAuth) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
if (authFailurePolicy.notifyAuthStateChange) {
|
||||
if (shouldClearAuth && authFailurePolicy.notifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,15 @@ export type CreationEntryEventBannerConfig = {
|
||||
htmlCode?: string | null;
|
||||
};
|
||||
|
||||
/** 公开作品详情页互动能力配置,前端只据此关闭已接入动作。 */
|
||||
export type PublicWorkInteractionConfig = {
|
||||
sourceType: string;
|
||||
likeEnabled: boolean;
|
||||
remixEnabled: boolean;
|
||||
likeDisabledMessage: string;
|
||||
remixDisabledMessage: string;
|
||||
};
|
||||
|
||||
/** 创作入口页完整配置;前端只展示后端事实源,不内置入口默认值。 */
|
||||
export type CreationEntryConfig = {
|
||||
startCard: {
|
||||
@@ -67,6 +76,8 @@ export type CreationEntryConfig = {
|
||||
eventBanner: CreationEntryEventBannerConfig;
|
||||
/** 底部加号创作入口页的多公告轮播配置。 */
|
||||
eventBanners?: CreationEntryEventBannerConfig[];
|
||||
/** 公开作品详情页点赞 / 改造能力矩阵。 */
|
||||
publicWorkInteractions?: PublicWorkInteractionConfig[];
|
||||
creationTypes: CreationEntryTypeConfig[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user