修复序列帧后处理结果应用与存储幂等
视频直接转换和序列帧去背景按 operation 隔离对象存储路径 队列后处理失败统一返回脱敏用户文案 inline 转换和去背景结果直接应用服务端项目快照 修复去背景加载状态和相关前端回归测试
This commit is contained in:
@@ -1235,7 +1235,7 @@ pub(crate) async fn remove_editor_character_animation_background_for_owner(
|
||||
// 同一 job 的重放必须复用对象路径;新的去背景 job 必须生成一套新序列对象。
|
||||
// 不能使用 request fingerprint:用户再次对同一源序列执行去背景时 fingerprint 相同,
|
||||
// 会复用上一任务已登记的 object location,并与新 job 的稳定 asset_object id 冲突。
|
||||
let storage_task_id = editor_character_animation_background_removal_storage_task_id(
|
||||
let storage_task_id = editor_character_animation_operation_storage_task_id(
|
||||
owner_user_id.as_str(),
|
||||
task_id.as_str(),
|
||||
);
|
||||
@@ -2281,12 +2281,11 @@ pub(crate) async fn convert_editor_character_animation_video_for_owner(
|
||||
let operation = editor_generation_operation(&caller)
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
let task_id = operation.operation_id.clone();
|
||||
// operation_id 用于持久化幂等;帧对象路径还绑定请求 fingerprint。相同 request id
|
||||
// 的不同请求不会互相覆盖,重试同一请求仍能重用路径。
|
||||
let storage_task_id = format!(
|
||||
"{}-{}",
|
||||
sanitize_storage_segment(owner_user_id.as_str(), "owner"),
|
||||
operation.operation_fingerprint
|
||||
// 同一 operation 的重放必须复用对象路径;新的转换 operation 即使 payload 相同,
|
||||
// 也必须生成一套新序列对象,避免旧 object location 与新 asset_object id 冲突。
|
||||
let storage_task_id = editor_character_animation_operation_storage_task_id(
|
||||
owner_user_id.as_str(),
|
||||
task_id.as_str(),
|
||||
);
|
||||
if let Some(reporter) = caller.phase_reporter.as_ref() {
|
||||
reporter
|
||||
@@ -5665,7 +5664,7 @@ fn editor_inline_generation_caller<T: Serialize>(
|
||||
})
|
||||
}
|
||||
|
||||
fn editor_character_animation_background_removal_storage_task_id(
|
||||
fn editor_character_animation_operation_storage_task_id(
|
||||
owner_user_id: &str,
|
||||
operation_id: &str,
|
||||
) -> String {
|
||||
@@ -9906,7 +9905,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
assert!(body.contains("price_mud_points: 0"));
|
||||
assert!(body.contains("let storage_task_id = format!"));
|
||||
assert!(body.contains("editor_character_animation_operation_storage_task_id"));
|
||||
assert!(body.contains("storage_task_id.as_str(),"));
|
||||
assert!(body.contains("cleanup_uncommitted_character_animation_objects"));
|
||||
}
|
||||
@@ -9982,13 +9981,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_background_removal_storage_path_is_task_scoped() {
|
||||
let first =
|
||||
editor_character_animation_background_removal_storage_task_id("user-1", "task-1");
|
||||
let replay =
|
||||
editor_character_animation_background_removal_storage_task_id("user-1", "task-1");
|
||||
let next_job =
|
||||
editor_character_animation_background_removal_storage_task_id("user-1", "task-2");
|
||||
fn editor_character_animation_operation_storage_path_is_task_scoped() {
|
||||
let first = editor_character_animation_operation_storage_task_id("user-1", "task-1");
|
||||
let replay = editor_character_animation_operation_storage_task_id("user-1", "task-1");
|
||||
let next_job = editor_character_animation_operation_storage_task_id("user-1", "task-2");
|
||||
|
||||
assert_eq!(first, replay);
|
||||
assert_ne!(first, next_job);
|
||||
|
||||
@@ -21,7 +21,9 @@ use spacetime_client::{
|
||||
|
||||
use crate::editor_generation_queue::{
|
||||
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
|
||||
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
|
||||
EDITOR_CHARACTER_ANIMATION_BACKGROUND_REMOVAL_JOB_KIND,
|
||||
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
|
||||
EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
|
||||
};
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
@@ -226,6 +228,12 @@ pub(crate) fn user_visible_external_generation_error(
|
||||
if job_kind == EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND && error.is_some() {
|
||||
return Some("角色动作生成失败,请稍后重试。".to_string());
|
||||
}
|
||||
if job_kind == EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_JOB_KIND && error.is_some() {
|
||||
return Some("视频直接转换失败,请稍后重试。".to_string());
|
||||
}
|
||||
if job_kind == EDITOR_CHARACTER_ANIMATION_BACKGROUND_REMOVAL_JOB_KIND && error.is_some() {
|
||||
return Some("序列帧去背景失败,请稍后重试。".to_string());
|
||||
}
|
||||
// 音效失败文本来自 ElevenLabs / reqwest,会带上请求端点、底层错误链和上游状态码。
|
||||
if job_kind == EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND && error.is_some() {
|
||||
return Some("音效生成失败,请稍后重试。".to_string());
|
||||
@@ -511,6 +519,33 @@ mod tests {
|
||||
assert!(!lower.contains("provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_postprocessing_failures_hide_internal_details_from_owner() {
|
||||
for (job_kind, expected) in [
|
||||
(
|
||||
EDITOR_CHARACTER_ANIMATION_VIDEO_CONVERSION_JOB_KIND,
|
||||
"视频直接转换失败,请稍后重试。",
|
||||
),
|
||||
(
|
||||
EDITOR_CHARACTER_ANIMATION_BACKGROUND_REMOVAL_JOB_KIND,
|
||||
"序列帧去背景失败,请稍后重试。",
|
||||
),
|
||||
] {
|
||||
let message = user_visible_external_generation_error(
|
||||
job_kind,
|
||||
Some("provider=aliyun-oss objectKey=users/user-1/private.png HTTP 502".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(message.as_deref(), Some(expected));
|
||||
let lower = message
|
||||
.expect("失败任务应返回稳定文案")
|
||||
.to_ascii_lowercase();
|
||||
for forbidden in ["aliyun", "objectkey", "users/user-1", "http"] {
|
||||
assert!(!lower.contains(forbidden));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_effect_failure_hides_provider_and_transport_details_from_owner() {
|
||||
let job = ExternalGenerationJobSummaryRecord {
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
);
|
||||
expect(
|
||||
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-group'),
|
||||
).toHaveLength(12);
|
||||
).toHaveLength(13);
|
||||
expect(
|
||||
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-divider'),
|
||||
).toHaveLength(2);
|
||||
|
||||
@@ -3452,7 +3452,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
const characterLayer = await screen.findByAltText('画布图片:市场老妇人');
|
||||
fireEvent.click(characterLayer.closest('button')!);
|
||||
expect(screen.getByText('角色')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
|
||||
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Spine序列帧动画生成' }),
|
||||
|
||||
@@ -8,9 +8,9 @@ import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorCharacterAnimationBackgroundColor,
|
||||
EditorCharacterAnimationFrameCount,
|
||||
EditorCharacterAnimationResult,
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
EditorCharacterAnimationResult,
|
||||
EditorImageGenerationStyle,
|
||||
EditorImageSequenceFrameResult,
|
||||
EditorPixelArtSnapInput,
|
||||
|
||||
@@ -497,6 +497,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
assetObjectId: undefined,
|
||||
resourceId: 'resource-source',
|
||||
sourceAssetId: undefined,
|
||||
assetKind: 'character',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
},
|
||||
|
||||
@@ -2393,8 +2393,6 @@ export function appendGenerationReference(
|
||||
}
|
||||
: dialog;
|
||||
}
|
||||
const hasVideoMotionReference =
|
||||
dialog.generationReferences?.[0]?.mediaType === 'video';
|
||||
if (mediaType === 'audio') {
|
||||
return dialog;
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ import {
|
||||
calculateEditorUiDesignPrice,
|
||||
calculateEditorVideoPrice,
|
||||
canOpenRedrawPanel,
|
||||
createCanvasLayerReference,
|
||||
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
|
||||
CHARACTER_ANIMATION_MODEL,
|
||||
createCanvasLayerReference,
|
||||
decodeCanvasGenerationInputs,
|
||||
DEFAULT_IMAGE_MODEL,
|
||||
DEFAULT_SOUND_EFFECT_MODEL,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasHistoryAction,
|
||||
@@ -4532,12 +4533,21 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('submits direct video conversion without prompt or model compatibility fields', async () => {
|
||||
it('applies the inline direct-conversion project without creating a duplicate local layer', async () => {
|
||||
const appendedLayers = vi.fn();
|
||||
const frames = [
|
||||
{ imageSrc: '/converted/frame-1.png', width: 640, height: 360 },
|
||||
{ imageSrc: '/converted/frame-2.png', width: 640, height: 360 },
|
||||
];
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
const backendProject: EditorProjectSnapshot = {
|
||||
projectId: 'editor-project-1',
|
||||
title: '视频转换项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-08-15T00:00:00.000Z',
|
||||
};
|
||||
convertEditorCharacterAnimationVideoMock.mockResolvedValueOnce({
|
||||
resultType: 'video-conversion',
|
||||
taskId: 'conversion-task-1',
|
||||
@@ -4561,6 +4571,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 2_500,
|
||||
},
|
||||
project: backendProject,
|
||||
});
|
||||
const sourceReference = {
|
||||
id: 'conversion-source',
|
||||
@@ -4574,6 +4585,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
projectId="editor-project-1"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
onAppendCanvasLayers={appendedLayers}
|
||||
initialDialog={{
|
||||
id: 'dialog-video-conversion',
|
||||
@@ -4626,18 +4638,10 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(request).not.toHaveProperty('prompt');
|
||||
expect(request).not.toHaveProperty('promptText');
|
||||
expect(request).not.toHaveProperty('model');
|
||||
await waitFor(() => expect(appendedLayers).toHaveBeenCalledTimes(1));
|
||||
const layer = appendedLayers.mock.calls[0]?.[0]?.[0];
|
||||
expect(layer).toMatchObject({
|
||||
taskId: 'conversion-task-1',
|
||||
provider: 'FFmpeg',
|
||||
imageSequenceDurationMs: 2_500,
|
||||
assetKind: 'character-animation',
|
||||
mediaType: 'image-sequence',
|
||||
await waitFor(() => {
|
||||
expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject);
|
||||
});
|
||||
expect(layer.prompt).toBeUndefined();
|
||||
expect(layer.actualPrompt).toBeUndefined();
|
||||
expect(layer.model).toBeUndefined();
|
||||
expect(appendedLayers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reuse animation lineage as the direct conversion source resource', async () => {
|
||||
|
||||
@@ -2809,6 +2809,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (result.project && applyProjectSnapshot) {
|
||||
applyProjectSnapshot(result.project);
|
||||
if (result.asset) {
|
||||
upsertGeneratedAsset?.(result.asset);
|
||||
}
|
||||
setCharacterAnimationPanel((currentPanel) =>
|
||||
currentPanel
|
||||
? { ...currentPanel, status: 'completed', result }
|
||||
: currentPanel,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (canvasDialog) {
|
||||
addCharacterAnimationResultLayer(
|
||||
result,
|
||||
@@ -3070,6 +3082,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
onWalletBalanceMayHaveChanged,
|
||||
onQueuedGenerationTask,
|
||||
onGenerationWarning,
|
||||
upsertGeneratedAsset,
|
||||
]);
|
||||
|
||||
return useMemo(
|
||||
|
||||
@@ -52,6 +52,7 @@ const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
||||
const splitEditorCharacterAnimationFramesMock = vi.hoisted(() => vi.fn());
|
||||
const removeEditorCharacterAnimationBackgroundMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
const renderCropExpandImageMock = vi.hoisted(() => vi.fn());
|
||||
@@ -91,6 +92,8 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
splitEditorIconSpritesheet: splitEditorIconSpritesheetMock,
|
||||
splitEditorCharacterAnimationFrames:
|
||||
splitEditorCharacterAnimationFramesMock,
|
||||
removeEditorCharacterAnimationBackground:
|
||||
removeEditorCharacterAnimationBackgroundMock,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -941,6 +944,21 @@ function GenerationWorkflowHarness({
|
||||
? '拆帧中'
|
||||
: '空闲'}
|
||||
</output>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void workflow.removeSelectedCharacterAnimationBackground(layers[0]!)
|
||||
}
|
||||
>
|
||||
去除序列帧背景
|
||||
</button>
|
||||
<output aria-label="序列帧去背景状态">
|
||||
{workflow.removingBackgroundCharacterAnimationLayerIds.has(
|
||||
layers[0]!.id,
|
||||
)
|
||||
? '去背景中'
|
||||
: '空闲'}
|
||||
</output>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -2988,6 +3006,89 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('applies an inline character-animation background-removal project and publishes removal state', async () => {
|
||||
const deferred = createDeferred<{
|
||||
taskId: string;
|
||||
frames: Array<{ imageSrc: string; width: number; height: number }>;
|
||||
frameCount: number;
|
||||
durationMs: number;
|
||||
frameWidth: number;
|
||||
frameHeight: number;
|
||||
priceMudPoints: number;
|
||||
project: EditorProjectSnapshot;
|
||||
}>();
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined);
|
||||
const backendProject: EditorProjectSnapshot = {
|
||||
projectId: 'project-1',
|
||||
title: '序列帧去背景项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-source',
|
||||
resourceId: 'resource-source',
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-08-15T00:00:00.000Z',
|
||||
};
|
||||
removeEditorCharacterAnimationBackgroundMock.mockReturnValueOnce(
|
||||
deferred.promise,
|
||||
);
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
refreshAssetLibrary={refreshAssetLibrary}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
title: '勇者奔跑',
|
||||
assetKind: 'character-animation',
|
||||
mediaType: 'image-sequence',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '去除序列帧背景' }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('status', { name: '序列帧去背景状态' }).textContent,
|
||||
).toBe('去背景中');
|
||||
expect(removeEditorCharacterAnimationBackgroundMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: 'project-1',
|
||||
sourceLayerId: 'layer-source',
|
||||
sourceResourceId: 'resource-source',
|
||||
}),
|
||||
);
|
||||
|
||||
deferred.resolve({
|
||||
taskId: 'background-removal-task',
|
||||
frames: [
|
||||
{ imageSrc: '/frames/1.png', width: 320, height: 240 },
|
||||
{ imageSrc: '/frames/2.png', width: 320, height: 240 },
|
||||
],
|
||||
frameCount: 2,
|
||||
durationMs: 2_000,
|
||||
frameWidth: 320,
|
||||
frameHeight: 240,
|
||||
priceMudPoints: 0,
|
||||
project: backendProject,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject, {
|
||||
type: 'remove-background',
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
expect(refreshAssetLibrary).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
screen.getByRole('status', { name: '序列帧去背景状态' }).textContent,
|
||||
).toBe('空闲');
|
||||
});
|
||||
|
||||
it('queues background removal for private character images', async () => {
|
||||
resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce(
|
||||
'data:image/png;base64,resolved-character',
|
||||
|
||||
@@ -3730,7 +3730,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
sourceLayer,
|
||||
),
|
||||
});
|
||||
await applyQueuedEditorGenerationProject(
|
||||
const queued = await applyQueuedEditorGenerationProject(
|
||||
result,
|
||||
projectId,
|
||||
(project) =>
|
||||
@@ -3745,6 +3745,15 @@ export function useImageCanvasGenerationWorkflow({
|
||||
(project) =>
|
||||
preserveSourceLayerInProjectSnapshot(project, sourceLayer),
|
||||
);
|
||||
if (!queued && result.project && applyProjectSnapshot) {
|
||||
applyProjectSnapshot(
|
||||
preserveSourceLayerInProjectSnapshot(result.project, sourceLayer),
|
||||
{
|
||||
type: 'remove-background',
|
||||
count: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
try {
|
||||
await Promise.resolve(refreshAssetLibrary?.());
|
||||
} catch {
|
||||
@@ -3870,9 +3879,6 @@ export function useImageCanvasGenerationWorkflow({
|
||||
let rejectedCharacterReference: 'video' | 'character' | null = null;
|
||||
setGenerateDialog((currentDialog) => {
|
||||
if (currentDialog?.mode === 'character-animation') {
|
||||
const isVideoAppearanceReference =
|
||||
currentDialog.characterAnimationWorkflow !== 'video-conversion' &&
|
||||
currentDialog.generationReferences?.[0]?.mediaType === 'video';
|
||||
const isAllowedCharacterAnimationReference =
|
||||
currentDialog.characterAnimationWorkflow === 'video-conversion'
|
||||
? layer.mediaType === 'video' ||
|
||||
@@ -4995,6 +5001,8 @@ export function useImageCanvasGenerationWorkflow({
|
||||
splittingIconSpritesheetLayerIds,
|
||||
splitSelectedCharacterAnimationFrames,
|
||||
splittingCharacterAnimationLayerIds,
|
||||
removeSelectedCharacterAnimationBackground,
|
||||
removingBackgroundCharacterAnimationLayerIds,
|
||||
submitCharacterAnimation,
|
||||
submitCropExpand,
|
||||
submitIconSpritesheetGeneration,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json';
|
||||
import {
|
||||
completeEditorBackgroundMusicPrompt,
|
||||
convertEditorCharacterAnimationVideo,
|
||||
createEditorAsset,
|
||||
createEditorAssetFolder,
|
||||
createEditorProject,
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
EDITOR_SOUND_EFFECT_PROMPT_OPTIMIZE_TIMEOUT_MS,
|
||||
extractEditorUiDesignAssets,
|
||||
generateEditorBackgroundMusic,
|
||||
convertEditorCharacterAnimationVideo,
|
||||
generateEditorCharacterAnimation,
|
||||
generateEditorIconSpec,
|
||||
generateEditorIconSpritesheet,
|
||||
|
||||
Reference in New Issue
Block a user