This commit is contained in:
2026-04-30 17:49:07 +08:00
parent 805d6f8cae
commit 9d684cb7b3
615 changed files with 15368 additions and 6172 deletions

View File

@@ -457,4 +457,42 @@ describe('apiClient', () => {
},
});
});
it('uses api error details.message as ApiClientError message', async () => {
setStoredAccessToken('details-message-token', { emit: false });
fetchMock.mockResolvedValueOnce(
createResponseMock({
status: 400,
body: JSON.stringify({
ok: false,
data: null,
error: {
code: 'BAD_REQUEST',
message: '请求参数不合法',
details: {
provider: 'dashscope',
message: '拼图图片生成失败:请求参数不合法',
},
},
meta: {},
}),
headers: {
'Content-Type': 'application/json',
},
}),
);
await expect(
requestJson('/api/runtime/puzzle/agent/sessions/test/actions', {
method: 'POST',
}, '执行拼图操作失败。'),
).rejects.toMatchObject({
message: '拼图图片生成失败:请求参数不合法',
status: 400,
code: 'BAD_REQUEST',
details: {
provider: 'dashscope',
},
});
});
});

View File

@@ -14,7 +14,7 @@ vi.mock('../apiClient', async (importOriginal) => {
};
});
import { listBigFishGallery } from './bigFishGalleryClient';
import { likeBigFishGalleryWork, listBigFishGallery } from './bigFishGalleryClient';
beforeEach(() => {
requestJsonMock.mockReset();
@@ -42,3 +42,15 @@ test('listBigFishGallery keeps non-gallery-read errors visible', async () => {
await expect(listBigFishGallery()).rejects.toBe(error);
});
test('likeBigFishGalleryWork posts to authenticated like route', async () => {
requestJsonMock.mockResolvedValueOnce({ items: [] });
await likeBigFishGalleryWork('big-fish-session-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/big-fish/gallery/big-fish-session-1/like',
expect.objectContaining({ method: 'POST' }),
'点赞大鱼吃小鱼作品失败',
);
});

View File

@@ -50,7 +50,21 @@ export async function remixBigFishGalleryWork(sessionId: string) {
);
}
/**
* 点赞公开大鱼吃小鱼作品,后端按当前登录用户做幂等计数。
*/
export async function likeBigFishGalleryWork(sessionId: string) {
return requestJson<BigFishWorksResponse>(
`${BIG_FISH_GALLERY_API_BASE}/${encodeURIComponent(sessionId)}/like`,
{
method: 'POST',
},
'点赞大鱼吃小鱼作品失败',
);
}
export const bigFishGalleryClient = {
like: likeBigFishGalleryWork,
list: listBigFishGallery,
remix: remixBigFishGalleryWork,
};

View File

@@ -1,5 +1,6 @@
export {
bigFishGalleryClient,
likeBigFishGalleryWork,
listBigFishGallery,
remixBigFishGalleryWork,
} from './bigFishGalleryClient';

View File

@@ -65,7 +65,7 @@ describe('miniGameDraftGenerationProgress', () => {
);
});
test('puzzle generation anchors expose only title and picture description', () => {
test('puzzle generation anchors expose form payload as the display source', () => {
const entries = buildPuzzleGenerationAnchorEntries({
sessionId: 'puzzle-session-1',
currentTurn: 1,
@@ -110,13 +110,24 @@ describe('miniGameDraftGenerationProgress', () => {
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-04-29T00:00:00.000Z',
}, {
seedText: '表单作品名',
workTitle: '暖灯猫街',
workDescription: '一套雨夜猫街主题拼图。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
});
expect(entries).toEqual([
{
id: 'puzzle-title',
label: '拼图标题',
value: '雨夜猫街',
label: '作品名称',
value: '暖灯猫街',
},
{
id: 'work-description',
label: '作品描述',
value: '一套雨夜猫街主题拼图。',
},
{
id: 'picture-description',

View File

@@ -1,5 +1,8 @@
import type { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish';
import type { PuzzleAgentSessionSnapshot } from '../../packages/shared/src/contracts/puzzleAgentSession';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../packages/shared/src/contracts/puzzleAgentSession';
import type {
CustomWorldGenerationProgress,
CustomWorldGenerationStep,
@@ -228,6 +231,7 @@ export function buildMiniGameDraftGenerationProgress(
export function buildPuzzleGenerationAnchorEntries(
session: PuzzleAgentSessionSnapshot | null | undefined,
formPayload: CreatePuzzleAgentSessionRequest | null | undefined = null,
): CustomWorldStructuredAnchorEntry[] {
if (!session) {
return [];
@@ -236,13 +240,28 @@ export function buildPuzzleGenerationAnchorEntries(
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'puzzle-title',
label: '拼图标题',
value: session.draft?.levelName || session.anchorPack.themePromise.value,
label: '作品名称',
value:
formPayload?.workTitle?.trim() ||
formPayload?.seedText?.trim() ||
session.draft?.workTitle ||
session.anchorPack.themePromise.value,
},
{
key: 'work-description',
label: '作品描述',
value:
formPayload?.workDescription?.trim() ||
session.draft?.workDescription ||
'',
},
{
key: 'picture-description',
label: '画面描述',
value: session.draft?.summary || session.anchorPack.visualSubject.value,
value:
formPayload?.pictureDescription?.trim() ||
session.draft?.levels?.[0]?.pictureDescription ||
session.anchorPack.visualSubject.value,
},
];

View File

@@ -1,5 +1,6 @@
export {
getPuzzleGalleryDetail,
likePuzzleGalleryWork,
listPuzzleGallery,
puzzleGalleryClient,
remixPuzzleGalleryWork,

View File

@@ -8,7 +8,11 @@ vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
import { getPuzzleGalleryDetail, listPuzzleGallery } from './puzzleGalleryClient';
import {
getPuzzleGalleryDetail,
likePuzzleGalleryWork,
listPuzzleGallery,
} from './puzzleGalleryClient';
beforeEach(() => {
requestJsonMock.mockReset();
@@ -50,3 +54,20 @@ test('getPuzzleGalleryDetail reads public detail without auth refresh coupling',
}),
);
});
test('likePuzzleGalleryWork posts to authenticated like route', async () => {
requestJsonMock.mockResolvedValueOnce({
item: {
profileId: 'puzzle-profile-1',
likeCount: 2,
},
});
await likePuzzleGalleryWork('puzzle-profile-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/puzzle/gallery/puzzle-profile-1/like',
expect.objectContaining({ method: 'POST' }),
'点赞拼图作品失败',
);
});

View File

@@ -50,6 +50,19 @@ export async function getPuzzleGalleryDetail(profileId: string) {
);
}
/**
* 点赞公开拼图作品,后端按当前登录用户做幂等计数。
*/
export async function likePuzzleGalleryWork(profileId: string) {
return requestJson<{ item: PuzzleWorkSummary }>(
`${PUZZLE_GALLERY_API_BASE}/${encodeURIComponent(profileId)}/like`,
{
method: 'POST',
},
'点赞拼图作品失败',
);
}
/**
* 将公开拼图作品复制为当前用户的草稿。
*/
@@ -65,6 +78,7 @@ export async function remixPuzzleGalleryWork(profileId: string) {
export const puzzleGalleryClient = {
getDetail: getPuzzleGalleryDetail,
like: likePuzzleGalleryWork,
list: listPuzzleGallery,
remix: remixPuzzleGalleryWork,
};

View File

@@ -1,3 +1,4 @@
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
PuzzleWorkDetailResponse,
PuzzleWorkMutationResponse,
@@ -52,16 +53,19 @@ export async function getPuzzleWorkDetail(profileId: string) {
/**
* 更新已发布或草稿态拼图作品的轻量字段。
* 只覆盖结果页约定的标题、摘要、标签正式图。
* 只覆盖结果页约定的作品信息、首关摘要、标签正式图与关卡列表
*/
export async function updatePuzzleWork(
profileId: string,
payload: {
workTitle?: string;
workDescription?: string;
levelName: string;
summary: string;
themeTags: string[];
coverImageSrc?: string | null;
coverAssetId?: string | null;
levels: PuzzleDraftLevel[];
},
) {
return requestJson<PuzzleWorkMutationResponse>(

View File

@@ -0,0 +1,117 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH,
readPuzzleReferenceImageAsDataUrl,
} from './puzzleReferenceImage';
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function stubFileReader(dataUrl: string) {
class MockFileReader {
result: string | null = null;
error: Error | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result = dataUrl;
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
}
function stubImage(width = 4096, height = 3072) {
class MockImage {
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
naturalWidth = width;
naturalHeight = height;
width = width;
height = height;
set src(_value: string) {
this.onload?.();
}
}
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
}
function stubCanvas(dataUrls: string[]) {
const drawImage = vi.fn();
const toDataURL = vi
.fn()
.mockImplementation(
() => dataUrls.shift() ?? 'data:image/jpeg;base64,small',
);
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
if (tagName !== 'canvas') {
return originalCreateElement(tagName);
}
return {
width: 0,
height: 0,
getContext: () => ({
drawImage,
fillRect: vi.fn(),
fillStyle: '',
imageSmoothingEnabled: false,
imageSmoothingQuality: 'low',
}),
toDataURL,
} as unknown as HTMLCanvasElement;
});
return { drawImage, toDataURL };
}
describe('readPuzzleReferenceImageAsDataUrl', () => {
test('compresses large puzzle reference images before JSON upload', async () => {
stubFileReader(`data:image/png;base64,${'A'.repeat(3 * 1024 * 1024)}`);
stubImage();
const { drawImage, toDataURL } = stubCanvas([
`data:image/jpeg;base64,${'B'.repeat(1200)}`,
`data:image/jpeg;base64,${'C'.repeat(1000)}`,
`data:image/jpeg;base64,${'D'.repeat(1400)}`,
]);
const file = new File(['x'.repeat(2 * 1024 * 1024)], 'reference.png', {
type: 'image/png',
});
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
expect(dataUrl).toBe(`data:image/jpeg;base64,${'C'.repeat(1000)}`);
expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1536, 1152);
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.84);
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.76);
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.68);
});
test('rejects reference images that still exceed the upload budget', async () => {
stubFileReader(
`data:image/png;base64,${'A'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 1)}`,
);
stubImage();
stubCanvas([
`data:image/jpeg;base64,${'B'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 1)}`,
`data:image/jpeg;base64,${'C'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 2)}`,
`data:image/jpeg;base64,${'D'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 3)}`,
]);
const file = new File(['x'.repeat(2 * 1024 * 1024)], 'reference.png', {
type: 'image/png',
});
await expect(readPuzzleReferenceImageAsDataUrl(file)).rejects.toThrow(
'参考图过大,请换一张尺寸更小的图片。',
);
});
});

View File

@@ -0,0 +1,117 @@
const PUZZLE_REFERENCE_IMAGE_MAX_EDGE = 1536;
const PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES = 1536 * 1024;
export const PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
type PuzzleReferenceImageSize = {
width: number;
height: number;
};
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('参考图读取失败,请重试。'));
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('参考图读取失败,请重试。'));
return;
}
resolve(reader.result);
};
reader.readAsDataURL(file);
});
}
function ensureReferenceImageWithinLimit(dataUrl: string) {
if (dataUrl.length > PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) {
throw new Error('参考图过大,请换一张尺寸更小的图片。');
}
return dataUrl;
}
function loadReferenceImage(dataUrl: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('参考图读取失败,请重试。'));
image.src = dataUrl;
});
}
function resolveCompressedImageSize(
image: HTMLImageElement,
): PuzzleReferenceImageSize {
const sourceWidth = image.naturalWidth || image.width;
const sourceHeight = image.naturalHeight || image.height;
if (sourceWidth <= 0 || sourceHeight <= 0) {
throw new Error('参考图读取失败,请重试。');
}
const scale = Math.min(
1,
PUZZLE_REFERENCE_IMAGE_MAX_EDGE / Math.max(sourceWidth, sourceHeight),
);
return {
width: Math.max(1, Math.round(sourceWidth * scale)),
height: Math.max(1, Math.round(sourceHeight * scale)),
};
}
function shouldCompressReferenceImage(file: File, dataUrl: string) {
return (
file.size > PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES ||
dataUrl.length > PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH
);
}
async function compressReferenceImageDataUrl(file: File, dataUrl: string) {
if (
typeof document === 'undefined' ||
typeof Image === 'undefined' ||
!shouldCompressReferenceImage(file, dataUrl)
) {
return dataUrl;
}
const image = await loadReferenceImage(dataUrl);
const size = resolveCompressedImageSize(image);
const canvas = document.createElement('canvas');
canvas.width = size.width;
canvas.height = size.height;
const context = canvas.getContext('2d');
if (!context) {
return dataUrl;
}
// 中文注释:参考图只作为生成提示,不需要保留手机原图体积;压到单边 1536 内给 JSON body 留余量。
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.fillStyle = '#ffffff';
context.fillRect(0, 0, size.width, size.height);
context.drawImage(image, 0, 0, size.width, size.height);
const candidates = [0.84, 0.76, 0.68].map((quality) =>
canvas.toDataURL('image/jpeg', quality),
);
return candidates.reduce((best, current) =>
current.length < best.length ? current : best,
);
}
export async function readPuzzleReferenceImageAsDataUrl(file: File) {
const dataUrl = await readFileAsDataUrl(file);
try {
const compressedDataUrl = await compressReferenceImageDataUrl(
file,
dataUrl,
);
return ensureReferenceImageWithinLimit(
compressedDataUrl.length < dataUrl.length ? compressedDataUrl : dataUrl,
);
} catch (error) {
if (dataUrl.length <= PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) {
return dataUrl;
}
throw error;
}
}

View File

@@ -4,6 +4,7 @@ export {
getRpgEntryWorldLibraryDetail,
listRpgEntryWorldGallery,
listRpgEntryWorldLibrary,
likeRpgEntryWorldGallery,
publishRpgEntryWorldProfile,
recordRpgEntryWorldGalleryPlay,
remixRpgEntryWorldGallery,

View File

@@ -152,6 +152,26 @@ describe('rpgEntry public custom world gallery routes', () => {
}),
);
});
it('likes public gallery detail through the authenticated mutation route', async () => {
requestJsonMock.mockResolvedValueOnce({
entry: {
ownerUserId: 'user-1',
profileId: 'profile-1',
likeCount: 2,
},
});
const { likeRpgEntryWorldGallery } = await import('./rpgEntryLibraryClient');
await likeRpgEntryWorldGallery('user-1', 'profile-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world-gallery/user-1/profile-1/like',
expect.objectContaining({ method: 'POST' }),
'点赞作品失败',
expect.anything(),
);
});
});
describe('rpgEntry save archive routes', () => {

View File

@@ -115,6 +115,23 @@ export async function recordRpgEntryWorldGalleryPlay(
return response.entry;
}
export async function likeRpgEntryWorldGallery(
ownerUserId: string,
profileId: string,
options: RuntimeRequestOptions = {},
) {
const response = await requestRpgRuntimeJson<
CustomWorldGalleryDetailResponse<CustomWorldProfile>
>(
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}/like`,
{ method: 'POST' },
'点赞作品失败',
options,
);
return response.entry;
}
export async function getRpgEntryWorldLibraryDetail(
profileId: string,
options: RuntimeRequestOptions = {},
@@ -218,6 +235,7 @@ export const rpgEntryLibraryClient = {
getWorldLibraryDetail: getRpgEntryWorldLibraryDetail,
remixWorldGallery: remixRpgEntryWorldGallery,
recordWorldGalleryPlay: recordRpgEntryWorldGalleryPlay,
likeWorldGallery: likeRpgEntryWorldGallery,
upsertWorldProfile: upsertRpgEntryWorldProfile,
deleteWorldProfile: deleteRpgEntryWorldProfile,
publishWorldProfile: publishRpgEntryWorldProfile,