fix: refresh custom world publish gate diagnostics
This commit is contained in:
@@ -173,3 +173,20 @@ Rust 首版沿用这些结论,但不要求一次性照搬旧 Node 的全部多
|
|||||||
3. `server-rs/crates/spacetime-module/src/lib.rs`
|
3. `server-rs/crates/spacetime-module/src/lib.rs`
|
||||||
4. `server-rs/crates/module-custom-world/src/lib.rs`
|
4. `server-rs/crates/module-custom-world/src/lib.rs`
|
||||||
5. `docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md`
|
5. `docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md`
|
||||||
|
|
||||||
|
## 2026-04-24 发布阻断快照刷新修正
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
- 现象:生成 foundation draft 后,结果页已经能看到玩家 premise、主线章节、第一幕场景幕,但底部仍显示旧的发布阻断项,并禁用“发布并进入世界”。
|
||||||
|
- 根因:前端结果页直接消费 `session.resultPreview.blockers`。当当前页面 profile 已经补齐结构字段,但服务端 `resultPreview` 仍停留在上一轮快照时,展示态与可点击态会被旧 blocker 锁住。
|
||||||
|
- 次要根因:进入世界前的 `sync_result_profile` 发现当前页面 profile 与 session preview 签名一致时会短路,导致旧 `publish_gate_json/result_preview_json` 没机会被强制重算。
|
||||||
|
|
||||||
|
### 落地方案
|
||||||
|
- `PlatformEntryFlowShellImpl` 在 Agent 草稿结果页按当前 `generatedCustomWorldProfile` 判断旧结构 blocker 是否已过期,文案继续沿用后端 `resultPreview.blockers`,前端不新增重复中文提示。
|
||||||
|
- 非结构类 blocker 继续继承服务端快照,避免把真实质量阻断误放行。
|
||||||
|
- `useRpgCreationResultAutosave.syncAgentDraftResultProfile` 在 `agentSession.resultPreview.publishReady === false` 时不走签名短路,发布前会调用后端 `sync_result_profile` 重建 `publish_gate_json/result_preview_json`。
|
||||||
|
- `api-server` 在读取 session 以及执行 `draft_foundation/sync_result_profile/publish_world` 后写入 `custom_world.publish_gate` 诊断日志,记录 blocker code、preview 来源与关键门禁字段是否为空;前端只显示简洁阻断数量,不展示字段细节。
|
||||||
|
|
||||||
|
### 验收点
|
||||||
|
- 生成草稿后,如果当前 profile 已包含 `playerPremise` 或 `anchorContent.playerEntryPoint`、`coreConflicts`、`chapters/sceneChapterBlueprints` 与至少一个 `acts`,结果页不再继续显示旧结构 blocker。
|
||||||
|
- 点击“发布并进入世界”前仍会同步到 SpacetimeDB reducer,由后端重新计算最终发布门禁;若仍有非结构质量 blocker,按钮仍保持阻断。
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ use spacetime_client::{
|
|||||||
CustomWorldWorkSummaryRecord, SpacetimeClientError,
|
CustomWorldWorkSummaryRecord, SpacetimeClientError,
|
||||||
};
|
};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api_response::json_success_body,
|
api_response::json_success_body,
|
||||||
@@ -471,6 +472,7 @@ pub async fn get_custom_world_agent_session(
|
|||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
||||||
})?;
|
})?;
|
||||||
|
log_custom_world_publish_gate_diagnostics("get_session", &session);
|
||||||
|
|
||||||
Ok(json_success_body(
|
Ok(json_success_body(
|
||||||
Some(&request_context),
|
Some(&request_context),
|
||||||
@@ -998,6 +1000,19 @@ pub async fn execute_custom_world_agent_action(
|
|||||||
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
if matches!(
|
||||||
|
action.as_str(),
|
||||||
|
"sync_result_profile" | "publish_world" | "draft_foundation"
|
||||||
|
) {
|
||||||
|
if let Ok(session) = state
|
||||||
|
.spacetime_client()
|
||||||
|
.get_custom_world_agent_session(session_id.clone(), owner_user_id.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log_custom_world_publish_gate_diagnostics(action.as_str(), &session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(json_success_body(
|
Ok(json_success_body(
|
||||||
Some(&request_context),
|
Some(&request_context),
|
||||||
json!({
|
json!({
|
||||||
@@ -1131,6 +1146,109 @@ fn map_custom_world_agent_session_response(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn log_custom_world_publish_gate_diagnostics(
|
||||||
|
source: &str,
|
||||||
|
session: &CustomWorldAgentSessionRecord,
|
||||||
|
) {
|
||||||
|
let draft_profile = session.draft_profile.as_object();
|
||||||
|
let preview_profile = session
|
||||||
|
.result_preview
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|preview| preview.get("preview"))
|
||||||
|
.and_then(Value::as_object);
|
||||||
|
let profile = preview_profile.or(draft_profile);
|
||||||
|
let blocker_codes = session
|
||||||
|
.publish_gate
|
||||||
|
.as_ref()
|
||||||
|
.map(|gate| {
|
||||||
|
gate.blockers
|
||||||
|
.iter()
|
||||||
|
.map(|blocker| blocker.code.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
target: "custom_world.publish_gate",
|
||||||
|
source,
|
||||||
|
session_id = %session.session_id,
|
||||||
|
stage = %session.stage,
|
||||||
|
publish_ready = session.publish_gate.as_ref().map(|gate| gate.publish_ready).unwrap_or(false),
|
||||||
|
blocker_count = session.publish_gate.as_ref().map(|gate| gate.blocker_count).unwrap_or(0),
|
||||||
|
blocker_codes = %blocker_codes,
|
||||||
|
has_draft_profile = session.draft_profile.as_object().map(|value| !value.is_empty()).unwrap_or(false),
|
||||||
|
has_result_preview = session.result_preview.is_some(),
|
||||||
|
preview_source = session.result_preview.as_ref().and_then(|value| value.get("source")).and_then(Value::as_str).unwrap_or(""),
|
||||||
|
has_world_hook = has_custom_world_publish_text(profile, &["worldHook", "creatorIntent.worldHook", "anchorContent.worldPromise.hook", "settingText"]),
|
||||||
|
has_player_premise = has_custom_world_publish_text(profile, &["playerPremise", "creatorIntent.playerPremise", "anchorContent.playerEntryPoint.openingIdentity", "anchorContent.playerEntryPoint.openingProblem", "anchorContent.playerEntryPoint.entryMotivation"]),
|
||||||
|
has_core_conflicts = has_custom_world_non_empty_text_array(profile, "coreConflicts"),
|
||||||
|
has_main_chapter = has_custom_world_array(profile, "chapters") || has_custom_world_array(profile, "sceneChapterBlueprints") || has_custom_world_array(profile, "sceneChapters"),
|
||||||
|
has_scene_act = has_custom_world_scene_act(profile),
|
||||||
|
"custom world publish gate diagnostics"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_custom_world_publish_text(profile: Option<&Map<String, Value>>, paths: &[&str]) -> bool {
|
||||||
|
paths.iter().any(|path| {
|
||||||
|
let mut segments = path.split('.');
|
||||||
|
let Some(first_segment) = segments.next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut current = profile.and_then(|value| value.get(first_segment));
|
||||||
|
for segment in segments {
|
||||||
|
current = current.and_then(|value| value.get(segment));
|
||||||
|
}
|
||||||
|
current
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.map(|value| !value.is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_custom_world_non_empty_text_array(profile: Option<&Map<String, Value>>, key: &str) -> bool {
|
||||||
|
profile
|
||||||
|
.and_then(|value| value.get(key))
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items.iter().any(|item| {
|
||||||
|
item.as_str()
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|value| !value.is_empty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_custom_world_array(profile: Option<&Map<String, Value>>, key: &str) -> bool {
|
||||||
|
profile
|
||||||
|
.and_then(|value| value.get(key))
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| !items.is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_custom_world_scene_act(profile: Option<&Map<String, Value>>) -> bool {
|
||||||
|
profile
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.get("sceneChapterBlueprints")
|
||||||
|
.or_else(|| value.get("sceneChapters"))
|
||||||
|
})
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|chapters| {
|
||||||
|
chapters.iter().any(|chapter| {
|
||||||
|
chapter
|
||||||
|
.get("acts")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|acts| !acts.is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn map_custom_world_publish_gate_response(
|
fn map_custom_world_publish_gate_response(
|
||||||
gate: CustomWorldPublishGateRecord,
|
gate: CustomWorldPublishGateRecord,
|
||||||
) -> CustomWorldPublishGateResponse {
|
) -> CustomWorldPublishGateResponse {
|
||||||
|
|||||||
@@ -92,6 +92,147 @@ import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
|
|||||||
import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail';
|
import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail';
|
||||||
import { usePlatformEntryNavigation } from './usePlatformEntryNavigation';
|
import { usePlatformEntryNavigation } from './usePlatformEntryNavigation';
|
||||||
|
|
||||||
|
type AgentResultPublishGateView = {
|
||||||
|
blockers: string[];
|
||||||
|
publishReady: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentResultBlockerView = {
|
||||||
|
code?: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
|
||||||
|
'publish_missing_world_hook',
|
||||||
|
'publish_missing_player_premise',
|
||||||
|
'publish_missing_core_conflict',
|
||||||
|
'publish_missing_main_chapter',
|
||||||
|
'publish_missing_first_act',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function readProfileTextField(
|
||||||
|
profile: CustomWorldProfile | null,
|
||||||
|
paths: string[],
|
||||||
|
) {
|
||||||
|
for (const path of paths) {
|
||||||
|
let current: unknown = profile;
|
||||||
|
for (const segment of path.split('.')) {
|
||||||
|
if (!current || typeof current !== 'object') {
|
||||||
|
current = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = (current as Record<string, unknown>)[segment];
|
||||||
|
}
|
||||||
|
if (typeof current === 'string' && current.trim()) {
|
||||||
|
return current.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasProfileTextArray(profile: CustomWorldProfile | null, key: string) {
|
||||||
|
const value = profile
|
||||||
|
? (profile as unknown as Record<string, unknown>)[key]
|
||||||
|
: null;
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.some((entry) => typeof entry === 'string' && entry.trim())
|
||||||
|
: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasProfileArray(profile: CustomWorldProfile | null, key: string) {
|
||||||
|
const value = profile
|
||||||
|
? (profile as unknown as Record<string, unknown>)[key]
|
||||||
|
: null;
|
||||||
|
return Array.isArray(value) && value.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSceneAct(profile: CustomWorldProfile | null) {
|
||||||
|
const rawProfile = profile as unknown as Record<string, unknown> | null;
|
||||||
|
const chapters =
|
||||||
|
rawProfile &&
|
||||||
|
(Array.isArray(rawProfile.sceneChapterBlueprints)
|
||||||
|
? rawProfile.sceneChapterBlueprints
|
||||||
|
: Array.isArray(rawProfile.sceneChapters)
|
||||||
|
? rawProfile.sceneChapters
|
||||||
|
: []);
|
||||||
|
return Array.isArray(chapters)
|
||||||
|
? chapters.some((chapter) => {
|
||||||
|
const acts =
|
||||||
|
chapter && typeof chapter === 'object'
|
||||||
|
? (chapter as Record<string, unknown>).acts
|
||||||
|
: null;
|
||||||
|
return Array.isArray(acts) && acts.length > 0;
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAgentResultStructuralBlockerResolved(
|
||||||
|
profile: CustomWorldProfile,
|
||||||
|
code: string | undefined,
|
||||||
|
) {
|
||||||
|
if (!code || !AGENT_RESULT_STRUCTURAL_BLOCKER_CODES.has(code)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 'publish_missing_world_hook') {
|
||||||
|
return Boolean(
|
||||||
|
readProfileTextField(profile, [
|
||||||
|
'worldHook',
|
||||||
|
'creatorIntent.worldHook',
|
||||||
|
'anchorContent.worldPromise.hook',
|
||||||
|
'settingText',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (code === 'publish_missing_player_premise') {
|
||||||
|
return Boolean(
|
||||||
|
readProfileTextField(profile, [
|
||||||
|
'playerPremise',
|
||||||
|
'creatorIntent.playerPremise',
|
||||||
|
'anchorContent.playerEntryPoint.openingIdentity',
|
||||||
|
'anchorContent.playerEntryPoint.openingProblem',
|
||||||
|
'anchorContent.playerEntryPoint.entryMotivation',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (code === 'publish_missing_core_conflict') {
|
||||||
|
return hasProfileTextArray(profile, 'coreConflicts');
|
||||||
|
}
|
||||||
|
if (code === 'publish_missing_main_chapter') {
|
||||||
|
return (
|
||||||
|
hasProfileArray(profile, 'chapters') ||
|
||||||
|
hasProfileArray(profile, 'sceneChapterBlueprints') ||
|
||||||
|
hasProfileArray(profile, 'sceneChapters')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return hasSceneAct(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAgentResultPublishGateView(
|
||||||
|
profile: CustomWorldProfile | null,
|
||||||
|
fallbackBlockers: AgentResultBlockerView[],
|
||||||
|
fallbackPublishReady: boolean,
|
||||||
|
): AgentResultPublishGateView {
|
||||||
|
if (!profile) {
|
||||||
|
return {
|
||||||
|
blockers: fallbackBlockers.map((entry) => entry.message),
|
||||||
|
publishReady: fallbackPublishReady,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockers = fallbackBlockers
|
||||||
|
.filter(
|
||||||
|
(entry) =>
|
||||||
|
!isAgentResultStructuralBlockerResolved(profile, entry.code),
|
||||||
|
)
|
||||||
|
.map((entry) => entry.message);
|
||||||
|
|
||||||
|
return {
|
||||||
|
blockers,
|
||||||
|
publishReady: blockers.length === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const CustomWorldGenerationView = lazy(async () => {
|
const CustomWorldGenerationView = lazy(async () => {
|
||||||
const module = await import('../CustomWorldGenerationView');
|
const module = await import('../CustomWorldGenerationView');
|
||||||
return {
|
return {
|
||||||
@@ -318,9 +459,22 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const agentResultPreview =
|
const agentResultPreview =
|
||||||
sessionController.agentSession?.resultPreview ?? null;
|
sessionController.agentSession?.resultPreview ?? null;
|
||||||
const agentResultPreviewBlockers = useMemo(
|
const agentResultPreviewBlockers = useMemo(
|
||||||
() => agentResultPreview?.blockers?.map((entry) => entry.message) ?? [],
|
() => agentResultPreview?.blockers ?? [],
|
||||||
[agentResultPreview],
|
[agentResultPreview],
|
||||||
);
|
);
|
||||||
|
const agentResultPublishGateView = useMemo(
|
||||||
|
() =>
|
||||||
|
buildAgentResultPublishGateView(
|
||||||
|
sessionController.generatedCustomWorldProfile,
|
||||||
|
agentResultPreviewBlockers,
|
||||||
|
Boolean(agentResultPreview?.publishReady),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
agentResultPreview?.publishReady,
|
||||||
|
agentResultPreviewBlockers,
|
||||||
|
sessionController.generatedCustomWorldProfile,
|
||||||
|
],
|
||||||
|
);
|
||||||
const agentResultPreviewQualityFindings = useMemo(
|
const agentResultPreviewQualityFindings = useMemo(
|
||||||
() => agentResultPreview?.qualityFindings ?? [],
|
() => agentResultPreview?.qualityFindings ?? [],
|
||||||
[agentResultPreview],
|
[agentResultPreview],
|
||||||
@@ -1928,12 +2082,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
publishReady={
|
publishReady={
|
||||||
sessionController.isAgentDraftResultView
|
sessionController.isAgentDraftResultView
|
||||||
? Boolean(agentResultPreview?.publishReady)
|
? agentResultPublishGateView.publishReady
|
||||||
: true
|
: true
|
||||||
}
|
}
|
||||||
publishBlockers={
|
publishBlockers={
|
||||||
sessionController.isAgentDraftResultView
|
sessionController.isAgentDraftResultView
|
||||||
? agentResultPreviewBlockers
|
? agentResultPublishGateView.blockers
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
qualityFindings={
|
qualityFindings={
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { Character, CustomWorldProfile } from '../../types';
|
import type { Character, CustomWorldProfile } from '../../types';
|
||||||
import {
|
import {
|
||||||
@@ -201,6 +201,23 @@ export function RpgCreationResultView({
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!error && compactAgentResultMode && previewSourceLabel ? (
|
||||||
|
<div className="platform-banner platform-banner--info mt-3 rounded-2xl text-sm leading-6">
|
||||||
|
当前结果页数据源:{previewSourceLabel}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!error && compactAgentResultMode && publishBlockers.length > 0 ? (
|
||||||
|
<div className="platform-banner platform-banner--warning mt-3 rounded-2xl text-sm leading-6">
|
||||||
|
{publishReady
|
||||||
|
? '当前世界已满足发布门槛。'
|
||||||
|
: `当前还有 ${publishBlockers.length} 个发布阻断项,请先补齐后再进入世界。`}
|
||||||
|
{!publishReady ? (
|
||||||
|
<div className="mt-2 text-xs text-[var(--platform-text-muted)]">
|
||||||
|
详细诊断已记录到后端日志。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{!error &&
|
{!error &&
|
||||||
compactAgentResultMode &&
|
compactAgentResultMode &&
|
||||||
publishBlockers.length <= 0 &&
|
publishBlockers.length <= 0 &&
|
||||||
|
|||||||
@@ -192,8 +192,14 @@ export function useRpgCreationResultAutosave(
|
|||||||
const latestSessionProfileSignature = latestSessionProfile
|
const latestSessionProfileSignature = latestSessionProfile
|
||||||
? stringifyAgentBackedProfile(latestSessionProfile)
|
? stringifyAgentBackedProfile(latestSessionProfile)
|
||||||
: '';
|
: '';
|
||||||
|
const shouldRefreshPublishGate = Boolean(
|
||||||
|
agentSession?.resultPreview && !agentSession.resultPreview.publishReady,
|
||||||
|
);
|
||||||
|
|
||||||
if (latestSessionProfileSignature === profileSignature) {
|
if (
|
||||||
|
latestSessionProfileSignature === profileSignature &&
|
||||||
|
!shouldRefreshPublishGate
|
||||||
|
) {
|
||||||
latestAgentResultSyncSignatureRef.current = profileSignature;
|
latestAgentResultSyncSignatureRef.current = profileSignature;
|
||||||
return {
|
return {
|
||||||
session: agentSession,
|
session: agentSession,
|
||||||
@@ -201,7 +207,10 @@ export function useRpgCreationResultAutosave(
|
|||||||
} satisfies SyncedAgentDraftResult;
|
} satisfies SyncedAgentDraftResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (latestAgentResultSyncSignatureRef.current === profileSignature) {
|
if (
|
||||||
|
latestAgentResultSyncSignatureRef.current === profileSignature &&
|
||||||
|
!shouldRefreshPublishGate
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
session: agentSession,
|
session: agentSession,
|
||||||
profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),
|
profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),
|
||||||
|
|||||||
Reference in New Issue
Block a user