Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5906ac26e | |||
| 25ee537e39 | |||
| 38c5a1a11a | |||
| d38e8c913e | |||
| 60a4548524 | |||
| a2ee879fc8 | |||
| 00da0dd1c8 | |||
| 24b10f0660 | |||
| 44748b7846 | |||
| b72e2163d7 | |||
| a7d492eea6 | |||
| 080dca8c4e | |||
| 5d6a426013 | |||
| 2c134d87da | |||
| 7c9edca923 | |||
| 9023d6df1e | |||
| 9bb4942ae7 | |||
| aba65d49f8 | |||
| f4f7d8ac20 | |||
| 2e02c28d0a | |||
| 52ade75007 | |||
| ae9502d9ed | |||
| 29369e6376 | |||
| 31a93c6842 | |||
| 7e58fd2195 | |||
| 6bed37542c | |||
| 7072e52a25 | |||
| 48c9ee2fae | |||
| 8bdc728fd3 | |||
| 8ed0e6ffa8 | |||
| 2f9ad4b11c | |||
| f748dc72c3 | |||
| 1d17698619 | |||
| 06c12f40b6 | |||
| b49bf9c984 | |||
| f9e6ae40db | |||
| ee3948b491 | |||
| a4b3898ff4 | |||
| fc8813a8ed | |||
| 8965a53fb8 | |||
| eb608ac6f2 | |||
| a5669602c0 | |||
| f1f2332e61 | |||
| b7c36074b9 | |||
| 7ea463ed08 | |||
| 0c04bbbea3 | |||
| d465e9b66c | |||
| 4ceb91cd48 | |||
| 6926e5ecb1 | |||
| bc76b1327d | |||
| 9c9c8f468a | |||
| 47f3297e84 | |||
| f3ab2f2ec0 | |||
| c56da8dc26 | |||
| f625fe8e8b | |||
| a5ae283561 | |||
| 5e512a6b23 | |||
| e9c3dc1120 | |||
| 5468bb3212 | |||
| f5368c825f |
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: gpt-image-2-apimart
|
||||
description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs gpt-image-2 request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
|
||||
description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow with gpt-image-2-c fallback. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
|
||||
---
|
||||
|
||||
# gpt-image-2 VectorEngine
|
||||
|
||||
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine `gpt-image-2` path. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
|
||||
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine image path. Keep the product/price model identifier and primary provider request as `gpt-image-2`, then fall back once to `gpt-image-2-c` for eligible provider failures. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -65,9 +65,9 @@ size=1024x1024
|
||||
image=@reference.png
|
||||
```
|
||||
|
||||
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
|
||||
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths prefer `gpt-image-2`; on an eligible upstream/model failure they retry with `gpt-image-2-c`. Do not fall back for authentication, local validation, request-budget exhaustion, uncertain send/connection failure, content-safety rejection, or a generated image URL download failure. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
|
||||
|
||||
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine GPT-image-2 currently returns synchronously; do not poll APIMart task endpoints.
|
||||
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints.
|
||||
|
||||
## Environment
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..', '..', '..');
|
||||
const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations');
|
||||
const defaultTimeoutMs = 1000000;
|
||||
const preferredImageModel = 'gpt-image-2';
|
||||
const fallbackImageModel = 'gpt-image-2-c';
|
||||
|
||||
const prompts = [
|
||||
{
|
||||
@@ -165,6 +167,25 @@ function extractBase64Images(payload) {
|
||||
return values;
|
||||
}
|
||||
|
||||
function decodeStrictBase64Image(raw) {
|
||||
const normalized = String(raw || '').trim();
|
||||
if (
|
||||
!normalized ||
|
||||
normalized.length % 4 !== 0 ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
|
||||
normalized,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const bytes = Buffer.from(normalized, 'base64');
|
||||
return bytes.length > 0 &&
|
||||
bytes.toString('base64') === normalized &&
|
||||
inferExtensionFromBytes(bytes)
|
||||
? bytes
|
||||
: null;
|
||||
}
|
||||
|
||||
function inferExtensionFromContentType(contentType) {
|
||||
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
||||
if (normalized === 'image/png') {
|
||||
@@ -192,7 +213,13 @@ function inferExtensionFromBytes(bytes) {
|
||||
) {
|
||||
return 'webp';
|
||||
}
|
||||
return 'png';
|
||||
if (
|
||||
bytes.subarray(0, 6).toString('ascii') === 'GIF87a' ||
|
||||
bytes.subarray(0, 6).toString('ascii') === 'GIF89a'
|
||||
) {
|
||||
return 'gif';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options, timeoutMs) {
|
||||
@@ -205,9 +232,20 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
const error = new Error(
|
||||
`VectorEngine ${response.status}: ${text.slice(0, 600)}`,
|
||||
);
|
||||
error.vectorEngineStatus = response.status;
|
||||
error.vectorEngineBody = text;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
error.vectorEngineResponseParse = true;
|
||||
error.vectorEngineBody = text;
|
||||
throw error;
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
@@ -218,6 +256,83 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldFallbackImageModel(error) {
|
||||
const raw = `${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
|
||||
if (error?.vectorEngineResponseParse) {
|
||||
return !containsContentRejection(raw);
|
||||
}
|
||||
const status = Number(error?.vectorEngineStatus || 0);
|
||||
if (status === 408 || status >= 500) {
|
||||
return true;
|
||||
}
|
||||
if (status === 429) {
|
||||
return !containsContentRejection(raw);
|
||||
}
|
||||
const mentionsImageModel =
|
||||
raw.includes('model') ||
|
||||
raw.includes('模型') ||
|
||||
raw.includes(preferredImageModel) ||
|
||||
raw.includes(fallbackImageModel);
|
||||
return (
|
||||
[400, 404, 422].includes(status) &&
|
||||
mentionsImageModel &&
|
||||
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
|
||||
raw,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function containsContentRejection(raw) {
|
||||
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
|
||||
raw,
|
||||
);
|
||||
}
|
||||
|
||||
async function requestImagePayload(env, entry) {
|
||||
for (const model of [preferredImageModel, fallbackImageModel]) {
|
||||
const requestBody = {
|
||||
model,
|
||||
prompt: buildPrompt(entry),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
|
||||
if (
|
||||
extractImageUrls(payload)[0] ||
|
||||
base64Image
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
const error = new Error(`VectorEngine returned no image for ${entry.id}`);
|
||||
error.vectorEngineResponseParse = true;
|
||||
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
|
||||
throw error;
|
||||
} catch (error) {
|
||||
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`VectorEngine returned no image for ${entry.id}`);
|
||||
}
|
||||
|
||||
async function downloadUrl(url, timeoutMs) {
|
||||
const abortController = new AbortController();
|
||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
@@ -244,25 +359,7 @@ async function downloadUrl(url, timeoutMs) {
|
||||
}
|
||||
|
||||
async function generateOne(env, entry, outDir) {
|
||||
const requestBody = {
|
||||
model: 'gpt-image-2',
|
||||
prompt: buildPrompt(entry),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
const payload = await requestImagePayload(env, entry);
|
||||
|
||||
const urls = extractImageUrls(payload);
|
||||
const b64Images = extractBase64Images(payload);
|
||||
@@ -271,7 +368,10 @@ async function generateOne(env, entry, outDir) {
|
||||
if (urls[0]) {
|
||||
image = await downloadUrl(urls[0], env.timeoutMs);
|
||||
} else if (b64Images[0]) {
|
||||
const bytes = Buffer.from(b64Images[0], 'base64');
|
||||
const bytes = decodeStrictBase64Image(b64Images[0]);
|
||||
if (!bytes) {
|
||||
throw new Error(`VectorEngine returned invalid base64 image for ${entry.id}`);
|
||||
}
|
||||
image = {
|
||||
bytes,
|
||||
extension: inferExtensionFromBytes(bytes),
|
||||
@@ -304,8 +404,9 @@ if (dryRun) {
|
||||
requests: selectedPrompts.map((entry) => ({
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
fallbackModel: fallbackImageModel,
|
||||
body: {
|
||||
model: 'gpt-image-2',
|
||||
model: preferredImageModel,
|
||||
prompt: buildPrompt(entry),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
|
||||
@@ -14,6 +14,8 @@ const promptsPath = path.join(
|
||||
);
|
||||
const defaultOutDir = path.join(repoRoot, 'public', 'puzzle-creation-templates');
|
||||
const defaultTimeoutMs = 1000000;
|
||||
const preferredImageModel = 'gpt-image-2';
|
||||
const fallbackImageModel = 'gpt-image-2-c';
|
||||
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
@@ -131,6 +133,25 @@ function extractBase64Images(payload) {
|
||||
return values;
|
||||
}
|
||||
|
||||
function decodeStrictBase64Image(raw) {
|
||||
const normalized = String(raw || '').trim();
|
||||
if (
|
||||
!normalized ||
|
||||
normalized.length % 4 !== 0 ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
|
||||
normalized,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const bytes = Buffer.from(normalized, 'base64');
|
||||
return bytes.length > 0 &&
|
||||
bytes.toString('base64') === normalized &&
|
||||
inferExtensionFromBytes(bytes)
|
||||
? bytes
|
||||
: null;
|
||||
}
|
||||
|
||||
function inferExtensionFromContentType(contentType) {
|
||||
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
||||
if (normalized === 'image/png') {
|
||||
@@ -158,7 +179,13 @@ function inferExtensionFromBytes(bytes) {
|
||||
) {
|
||||
return 'webp';
|
||||
}
|
||||
return 'png';
|
||||
if (
|
||||
bytes.subarray(0, 6).toString('ascii') === 'GIF87a' ||
|
||||
bytes.subarray(0, 6).toString('ascii') === 'GIF89a'
|
||||
) {
|
||||
return 'gif';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options, timeoutMs) {
|
||||
@@ -171,9 +198,20 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
const error = new Error(
|
||||
`VectorEngine ${response.status}: ${text.slice(0, 600)}`,
|
||||
);
|
||||
error.vectorEngineStatus = response.status;
|
||||
error.vectorEngineBody = text;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
error.vectorEngineResponseParse = true;
|
||||
error.vectorEngineBody = text;
|
||||
throw error;
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
@@ -184,6 +222,83 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldFallbackImageModel(error) {
|
||||
const raw = `${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
|
||||
if (error?.vectorEngineResponseParse) {
|
||||
return !containsContentRejection(raw);
|
||||
}
|
||||
const status = Number(error?.vectorEngineStatus || 0);
|
||||
if (status === 408 || status >= 500) {
|
||||
return true;
|
||||
}
|
||||
if (status === 429) {
|
||||
return !containsContentRejection(raw);
|
||||
}
|
||||
const mentionsImageModel =
|
||||
raw.includes('model') ||
|
||||
raw.includes('模型') ||
|
||||
raw.includes(preferredImageModel) ||
|
||||
raw.includes(fallbackImageModel);
|
||||
return (
|
||||
[400, 404, 422].includes(status) &&
|
||||
mentionsImageModel &&
|
||||
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
|
||||
raw,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function containsContentRejection(raw) {
|
||||
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
|
||||
raw,
|
||||
);
|
||||
}
|
||||
|
||||
async function requestImagePayload(env, template) {
|
||||
for (const model of [preferredImageModel, fallbackImageModel]) {
|
||||
const requestBody = {
|
||||
model,
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
|
||||
if (
|
||||
extractImageUrls(payload)[0] ||
|
||||
base64Image
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
const error = new Error(`VectorEngine returned no image for ${template.id}`);
|
||||
error.vectorEngineResponseParse = true;
|
||||
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
|
||||
throw error;
|
||||
} catch (error) {
|
||||
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`VectorEngine returned no image for ${template.id}`);
|
||||
}
|
||||
|
||||
async function downloadUrl(url, timeoutMs) {
|
||||
const abortController = new AbortController();
|
||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
@@ -210,25 +325,7 @@ async function downloadUrl(url, timeoutMs) {
|
||||
}
|
||||
|
||||
async function generateOne(env, template, outDir) {
|
||||
const requestBody = {
|
||||
model: 'gpt-image-2',
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
const payload = await requestImagePayload(env, template);
|
||||
|
||||
const urls = extractImageUrls(payload);
|
||||
const b64Images = extractBase64Images(payload);
|
||||
@@ -237,7 +334,10 @@ async function generateOne(env, template, outDir) {
|
||||
if (urls[0]) {
|
||||
image = await downloadUrl(urls[0], env.timeoutMs);
|
||||
} else if (b64Images[0]) {
|
||||
const bytes = Buffer.from(b64Images[0], 'base64');
|
||||
const bytes = decodeStrictBase64Image(b64Images[0]);
|
||||
if (!bytes) {
|
||||
throw new Error(`VectorEngine returned invalid base64 image for ${template.id}`);
|
||||
}
|
||||
image = {
|
||||
bytes,
|
||||
extension: inferExtensionFromBytes(bytes),
|
||||
@@ -274,8 +374,9 @@ if (dryRun) {
|
||||
requests: selectedTemplates.map((template) => ({
|
||||
id: template.id,
|
||||
title: template.title,
|
||||
fallbackModel: fallbackImageModel,
|
||||
body: {
|
||||
model: 'gpt-image-2',
|
||||
model: preferredImageModel,
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: spacetimedb-cli
|
||||
description: SpacetimeDB 2.6 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification.
|
||||
description: SpacetimeDB 2.7 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification.
|
||||
---
|
||||
|
||||
# SpacetimeDB CLI
|
||||
@@ -68,6 +68,31 @@ spacetime call --server http://127.0.0.1:3101 my-db reducer_needing_identity 0xa
|
||||
spacetime subscribe my-db "SELECT * FROM users" --num-updates 10 --server http://127.0.0.1:3101
|
||||
```
|
||||
|
||||
## Standalone MCP Endpoint (2.7)
|
||||
|
||||
SpacetimeDB 2.7 standalone exposes an authenticated JSON-RPC MCP endpoint at
|
||||
`POST /v1/database/{name_or_identity}/mcp`. It advertises `ping`, `get_schema`,
|
||||
`sql`, and `call`. The SQL and reducer tools execute with the bearer token's
|
||||
identity, so keep routine smoke checks read-only.
|
||||
|
||||
```bash
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer ${SPACETIME_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"genarrative-smoke","version":"1.0.0"}}}' \
|
||||
http://127.0.0.1:3101/v1/database/my-db/mcp
|
||||
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer ${SPACETIME_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ping","arguments":{"message":"genarrative"}}}' \
|
||||
http://127.0.0.1:3101/v1/database/my-db/mcp
|
||||
```
|
||||
|
||||
For repository upgrade validation, also call `tools/list` and the read-only
|
||||
`get_schema` tool against an isolated local database. Do not use `sql` or `call`
|
||||
for writes unless that mutation is explicitly in scope.
|
||||
|
||||
## Server & Auth
|
||||
|
||||
```bash
|
||||
@@ -102,7 +127,7 @@ curl -fsS http://127.0.0.1:3101/v1/ping
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--server`, `-s` | Target server nickname, host, or URL |
|
||||
| `--yes`, `-y` | Non-interactive prompt skipping; in 2.6 use scoped values |
|
||||
| `--yes`, `-y` | Non-interactive prompt skipping; in 2.6+ use scoped values |
|
||||
| `--delete-data`, `-c` | Publish data policy: `always`, `on-conflict`, or `never` |
|
||||
| `--module-path`, `-p` | Module project path |
|
||||
| `--bin-path`, `-b` | Publish/generate from compiled wasm |
|
||||
@@ -146,6 +171,8 @@ pid="$(systemctl show spacetimedb.service -p MainPID --value)"
|
||||
|
||||
## Notes
|
||||
|
||||
- Procedure calls remain stable in 2.6; module HTTP handlers/webhooks and RLS capabilities still require their documented gates.
|
||||
- 2.5 fixed `publish --delete-data` config fallback; 2.6 keeps that behavior and improves CLI binary distribution.
|
||||
- Procedure calls remain stable in 2.7; module HTTP handlers/webhooks and RLS capabilities still require their documented gates.
|
||||
- 2.5 fixed `publish --delete-data` config fallback; 2.6 kept that behavior and improved CLI binary distribution; 2.7 adds `spacetime sql --format json` and database `lock` / `unlock`.
|
||||
- The official 2.7.0 Linux release archives and container image currently use the `v2.7.0-hotfix3` asset tag while binaries report `2.7.0`; keep the asset tag distinct from the runtime version check.
|
||||
- Do not assume `spacetime version install 2.7.0` selected hotfix3: stale updater metadata can install bare-tag commit `a08663c7...`. For the current release, verify CLI commit `d220349a...` and use the official hotfix3 archive or repository provision flow when it differs.
|
||||
- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on CLI defaults.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: spacetimedb-concepts
|
||||
description: Understand SpacetimeDB 2.6 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features.
|
||||
description: Understand SpacetimeDB 2.7 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features.
|
||||
---
|
||||
|
||||
# SpacetimeDB Core Concepts
|
||||
@@ -20,7 +20,7 @@ SpacetimeDB is a relational database that also executes application logic in upl
|
||||
|
||||
1. **Reducers are transactional**: they do not return data to callers. Read through subscriptions, read models, views, or BFF endpoints.
|
||||
2. **Reducers are deterministic**: no filesystem, network, wall-clock, or external RNG. Use `ctx.timestamp`, `ctx.rng()` / `ctx.random()`, and tables.
|
||||
3. **Procedures are stable in 2.6**: they can use explicit transactions and outgoing HTTP via `ctx.http`.
|
||||
3. **Procedures are stable in 2.7**: they can use explicit transactions and outgoing HTTP via `ctx.http`.
|
||||
4. **Identity comes from context**: use `ctx.sender()` or language equivalent for authorization. Never trust identity passed as an argument.
|
||||
5. **Auto-increment IDs are not ordering guarantees**: gaps are normal. Use timestamps or explicit sequence columns for ordering.
|
||||
6. **Schema changes need migration discipline**: existing Genarrative table fields must be appended with defaults; update migration code, table catalog, generated bindings, and run `npm run check:spacetime-schema`.
|
||||
@@ -44,25 +44,25 @@ Reducers are deterministic transactional functions. They are the primary client-
|
||||
|
||||
## Procedures
|
||||
|
||||
Procedures are stable in 2.6. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`).
|
||||
Procedures are stable in 2.7. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`).
|
||||
|
||||
Genarrative default: keep external provider protocols in `platform-*` and orchestration in `api-server` unless a task explicitly moves a workflow into a module procedure.
|
||||
|
||||
Module HTTP handlers/webhooks and RLS `client_visibility_filter` remain subject to their documented gates in 2.6.
|
||||
Module HTTP handlers/webhooks and RLS `client_visibility_filter` remain subject to their documented gates in 2.7.
|
||||
|
||||
## Views
|
||||
|
||||
Views expose computed read-only data. SpacetimeDB 2.6 supports primary keys on procedural views in Rust, TypeScript, and C#. Clients can receive `OnUpdate` events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction.
|
||||
Views expose computed read-only data. SpacetimeDB 2.7 supports primary keys on procedural views in Rust, TypeScript, C#, and C++. Clients can receive update events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction.
|
||||
|
||||
## Event Tables
|
||||
|
||||
Event tables broadcast reducer/procedure-specific facts to subscribers and must be subscribed explicitly. They are excluded from `subscribe_to_all_tables()`.
|
||||
|
||||
2.6 supports broader layout-altering automigrations for event tables, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables.
|
||||
Since 2.6, event tables support broader layout-altering automigrations, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables.
|
||||
|
||||
Event-table primary keys and constraints are transaction-scoped. They can reject duplicate event rows within one transaction, but event rows are not retained in client cache, so clients observe event tables through insert callbacks only. Do not design Genarrative event tables around `OnUpdate` / `on_update` / `onUpdate`; use a persistent table or a primary-keyed procedural view when update callbacks are required.
|
||||
|
||||
Official 2.4.1 through 2.6 release notes document primary-key-backed update callbacks for procedural views, not event tables.
|
||||
Official 2.4.1 through 2.7 release notes document primary-key-backed update callbacks for procedural views, not event tables.
|
||||
|
||||
## Subscriptions
|
||||
|
||||
@@ -78,7 +78,18 @@ Best practices:
|
||||
- Avoid overlapping queries that duplicate row delivery.
|
||||
- Use indexes for subscribed filters.
|
||||
|
||||
## 2.2.0 to 2.6.1 Delta
|
||||
## Standalone MCP
|
||||
|
||||
SpacetimeDB 2.7 standalone exposes `POST /v1/database/{name_or_identity}/mcp`
|
||||
using MCP JSON-RPC protocol `2025-06-18`. Its tools are `ping`, `get_schema`,
|
||||
`sql`, and `call`; SQL and reducer calls run with the authenticated caller's
|
||||
identity. In Genarrative this is an operator/developer integration surface, not
|
||||
a replacement for `api-server` BFF routes, `spacetime-client` facades, or public
|
||||
read models. Upgrade smoke should use an isolated local database and restrict
|
||||
itself to `initialize`, `tools/list`, `ping`, and `get_schema` unless writes are
|
||||
explicitly intended.
|
||||
|
||||
## 2.2.0 to 2.7.0 Delta
|
||||
|
||||
Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
|
||||
|
||||
@@ -89,6 +100,7 @@ Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
|
||||
- **2.5.0**: procedures are stable, C# procedural views gain primary keys, event tables allow broader layout-altering automigrations, BTreeSet storage makes row insertion deterministic and avoids accidentally quadratic bulk insert behavior, `wasm_memory_bytes` billing metric semantics changed, template version constraints unified, `publish --delete-data` config fallback fixed, CLI `call` accepts hex Identity arguments.
|
||||
- **2.6.0**: procedural-view primary keys are available across Rust, TypeScript, and C#, commitlog gains `max_segment_size` / `write_buffer_size` / `preallocate_segments`, the default write buffer increases for throughput, event-table automigrations improve, and CLI binary distribution expands.
|
||||
- **2.6.1**: procedure contexts again receive the caller `Identity` and `ConnectionId`; generated TypeScript `Option<T>` fields use optional keys; `spacetime init --template` lists available templates when no template argument is supplied.
|
||||
- **2.7.0**: existing tables can add unique or primary-key constraints when current data satisfies them; standalone exposes an authenticated database MCP endpoint; Rust adds context-capability and table-accessor traits; `spacetime sql --format json` and database locking are available; view cleanup, backing-table migration, connection metrics, and memory metrics improve. Official current release assets use the `v2.7.0-hotfix3` tag while binaries report `2.7.0`.
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: spacetimedb-rust
|
||||
description: Develop SpacetimeDB 2.6 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic.
|
||||
description: Develop SpacetimeDB 2.7 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic.
|
||||
---
|
||||
|
||||
# SpacetimeDB Rust Module Development
|
||||
@@ -181,11 +181,11 @@ fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
|
||||
|
||||
Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`.
|
||||
|
||||
In 2.6, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables.
|
||||
Since 2.6, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables.
|
||||
|
||||
Event-table primary keys and constraints are enforced only within the current transaction. They do not make event rows persistent, and client SDKs expose event tables as insert-only event streams. Do not rely on `OnUpdate` / `on_update` / `onUpdate` for event tables; use a persistent table or a primary-keyed procedural view when update callbacks are required.
|
||||
|
||||
Official 2.4.1 through 2.6 release notes tie primary-key-backed update callbacks to procedural views, not event tables.
|
||||
Official 2.4.1 through 2.7 release notes tie primary-key-backed update callbacks to procedural views, not event tables.
|
||||
|
||||
## Views
|
||||
|
||||
@@ -228,7 +228,7 @@ For scheduled reducers, check `ctx.sender_auth().is_internal()` when the reducer
|
||||
|
||||
## Procedures
|
||||
|
||||
Procedures remain stable in 2.6 and no longer require the `unstable` feature.
|
||||
Procedures remain stable in 2.7 and no longer require the `unstable` feature.
|
||||
|
||||
```rust
|
||||
use spacetimedb::{procedure, ProcedureContext};
|
||||
|
||||
+13
-7
@@ -114,10 +114,6 @@ WECHAT_MOCK_DISPLAY_NAME="微信旅人"
|
||||
WECHAT_MOCK_AVATAR_URL=""
|
||||
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=""
|
||||
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=""
|
||||
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED="true"
|
||||
WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID="m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU"
|
||||
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE="formal"
|
||||
|
||||
# Model name for chat completions.
|
||||
VITE_LLM_MODEL="gpt-5.4-mini"
|
||||
GENARRATIVE_LLM_PROVIDER="openai-compatible"
|
||||
@@ -125,9 +121,6 @@ GENARRATIVE_LLM_BASE_URL="https://api.vectorengine.cn/v1"
|
||||
GENARRATIVE_LLM_API_KEY=""
|
||||
GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
|
||||
|
||||
# Optional: enable upstream web search for RPG story text generation.
|
||||
RPG_LLM_WEB_SEARCH_ENABLED="true"
|
||||
|
||||
# Server-side DashScope endpoint and API key used by the local scene-image proxy.
|
||||
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
|
||||
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
|
||||
@@ -150,6 +143,19 @@ ALIYUN_OSS_POST_EXPIRE_SECONDS="600"
|
||||
ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520"
|
||||
ALIYUN_OSS_SUCCESS_ACTION_STATUS="200"
|
||||
|
||||
# BgFilter 受限资源 worker。父 api-server / external-generation-worker 与唯一的
|
||||
# `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程必须使用同一个内部 Token。
|
||||
# `npm run dev` 与 `npm run dev:api-server` 都会自动带起并验活唯一 worker,不要再开第二个终端重复启动。
|
||||
# 只有需要脱离父 API 单独验证 worker 时才运行 `npm run dev:bgfilter-worker`;不要让 `all` 角色兼任它。
|
||||
GENARRATIVE_BGFILTER_WORKER_HOST="127.0.0.1"
|
||||
GENARRATIVE_BGFILTER_WORKER_PORT="8083"
|
||||
GENARRATIVE_BGFILTER_WORKER_BASE_URL="http://127.0.0.1:8083"
|
||||
GENARRATIVE_BGFILTER_INTERNAL_TOKEN="CHANGE_ME_FOR_LOCAL"
|
||||
GENARRATIVE_BGFILTER_WORKER_CONCURRENCY="16"
|
||||
GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS="5000"
|
||||
GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS="2048"
|
||||
GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS="2000"
|
||||
|
||||
# SpacetimeDB 数据目录备份到 OSS。备份 bucket 可与资源 bucket 分离;未设置时脚本回退使用 ALIYUN_OSS_BUCKET。
|
||||
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=""
|
||||
GENARRATIVE_DATABASE_BACKUP_WORK_DIR=""
|
||||
|
||||
+286
-8
@@ -58,6 +58,70 @@ module.exports = {
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/active-main.tsx',
|
||||
'src/ActiveApp.tsx',
|
||||
'src/AuthenticatedApp.tsx',
|
||||
'src/hooks/useGameSettings.ts',
|
||||
'src/routing/activeApp*.ts',
|
||||
'src/routing/activeApp*.tsx',
|
||||
'src/components/auth/**/*.{ts,tsx}',
|
||||
'src/components/common/**/*.{ts,tsx}',
|
||||
'src/components/creation-home/**/*.{ts,tsx}',
|
||||
'src/components/image-editor/**/*.{ts,tsx}',
|
||||
'src/components/project/**/*.{ts,tsx}',
|
||||
'src/components/platform-entry/PlatformActiveProfileView*.tsx',
|
||||
'src/components/platform-entry/PlatformEntryActiveFlowShell*.tsx',
|
||||
'src/components/platform-entry/PlatformEntryFlowShell.tsx',
|
||||
'src/components/platform-entry/PlatformProfileApiKeysModal.tsx',
|
||||
'src/components/platform-entry/PlatformProfileModalShell.tsx',
|
||||
'src/components/platform-entry/PlatformProfilePrimitives.tsx',
|
||||
'src/components/platform-entry/PlatformProfileRechargeModal.tsx',
|
||||
'src/components/platform-entry/PlatformProfileReferralModal.tsx',
|
||||
'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx',
|
||||
'src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx',
|
||||
'src/components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx',
|
||||
'src/components/platform-entry/platformActiveProfileModel.ts',
|
||||
'src/components/platform-entry/platformEntryActiveTypes.ts',
|
||||
'src/components/platform-entry/platformProfileFundsModel.ts',
|
||||
'src/components/platform-entry/platformProfileHostClipboard.ts',
|
||||
'src/components/platform-entry/usePlatformProfileCenterController.ts',
|
||||
'src/services/image-editor/**/*.{ts,tsx}',
|
||||
'src/services/platform-entry/**/*.{ts,tsx}',
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
'**/components/rpg-entry/**',
|
||||
'**/components/*-creation/**',
|
||||
'**/components/*-result/**',
|
||||
'**/components/*-runtime/**',
|
||||
'**/components/creation-agent/**',
|
||||
'**/components/creative-agent/**',
|
||||
'**/components/custom-world-*/**',
|
||||
'**/components/unified-creation/**',
|
||||
'**/services/rpg-entry/**',
|
||||
'**/services/rpg-runtime/**',
|
||||
'**/services/*-creation/**',
|
||||
'**/services/*-runtime/**',
|
||||
'**/services/*-works/**',
|
||||
'**/services/creation-agent/**',
|
||||
'**/services/creative-agent/**',
|
||||
'**/services/puzzle-*/**',
|
||||
'**/services/storyEngine/**',
|
||||
],
|
||||
message: '现役前端不得重新导入已退役的创作模板模块。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/components/game-canvas/**/*.tsx'],
|
||||
rules: {
|
||||
@@ -71,12 +135,6 @@ module.exports = {
|
||||
'simple-import-sort/exports': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/components/match3d-runtime/Match3DPhysicsBoard.tsx'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
'@typescript-eslint',
|
||||
@@ -96,22 +154,239 @@ module.exports = {
|
||||
'dist_check',
|
||||
'dist_check_monster_position',
|
||||
'coverage',
|
||||
'build',
|
||||
'output',
|
||||
'retired/**',
|
||||
'node_modules',
|
||||
'server-rs/target',
|
||||
'server-rs/target-*',
|
||||
'apps/desktop-shell/src-tauri/target',
|
||||
'target',
|
||||
'src/main.tsx',
|
||||
'src/App.tsx',
|
||||
'src/routing/appPageRoutes.ts',
|
||||
'src/routing/appPageRoutes.test.ts',
|
||||
'src/routing/appRoutes.tsx',
|
||||
'src/routing/appRoutes.test.ts',
|
||||
'src/services/appTitle.ts',
|
||||
'src/services/appTitle.test.ts',
|
||||
'src/components/CharacterAnimator.tsx',
|
||||
'src/components/*.ts',
|
||||
'src/components/*.tsx',
|
||||
'!src/components/ResolvedAssetAudio.tsx',
|
||||
'!src/components/ResolvedAssetImage.tsx',
|
||||
'!src/components/ResolvedAssetVideo.tsx',
|
||||
'src/hooks/*.ts',
|
||||
'src/hooks/*.tsx',
|
||||
'!src/hooks/useGameSettings.ts',
|
||||
'!src/hooks/useHostNavigationCanGoBack.ts',
|
||||
'!src/hooks/useHostNavigationCanGoBack.test.tsx',
|
||||
'!src/hooks/useResolvedAssetReadUrl.ts',
|
||||
'!src/hooks/useResolvedAssetReadUrl.test.tsx',
|
||||
'src/persistence/*.ts',
|
||||
'src/persistence/*.tsx',
|
||||
'!src/persistence/gameSettingsStorage.ts',
|
||||
'!src/persistence/gameSettingsStorage.test.ts',
|
||||
'!src/persistence/storage.ts',
|
||||
'!src/persistence/storage.test.ts',
|
||||
'src/routing/*.ts',
|
||||
'src/routing/*.tsx',
|
||||
'!src/routing/activeAppPageRoutes.ts',
|
||||
'!src/routing/activeAppPageRoutes.test.ts',
|
||||
'!src/routing/activeAppRoutes.tsx',
|
||||
'!src/routing/activeAppRoutes.test.ts',
|
||||
'src/services/*.ts',
|
||||
'src/services/*.tsx',
|
||||
'!src/services/activeAppTitle.ts',
|
||||
'!src/services/activeAppTitle.test.ts',
|
||||
'!src/services/apiClient.ts',
|
||||
'!src/services/apiClient.test.ts',
|
||||
'!src/services/assetReadUrlService.ts',
|
||||
'!src/services/assetReadUrlService.test.ts',
|
||||
'!src/services/authService.ts',
|
||||
'!src/services/authService.test.ts',
|
||||
'!src/services/clipboard.ts',
|
||||
'!src/services/clipboard.test.ts',
|
||||
'!src/services/frontendRuntimeConfigService.ts',
|
||||
'!src/services/frontendRuntimeConfigService.test.ts',
|
||||
'!src/services/sseStream.ts',
|
||||
'!src/services/sseStream.test.ts',
|
||||
'src/AdventurePanel.tsx',
|
||||
'src/AdventureEntityModal.tsx',
|
||||
'src/App.tsx',
|
||||
'src/App.test.tsx',
|
||||
'src/CharacterCreation.tsx',
|
||||
'src/RpgRuntimeApp.tsx',
|
||||
'src/routing/appPageRoutes.ts',
|
||||
'src/routing/appPageRoutes.test.ts',
|
||||
'src/routing/appRoutes.tsx',
|
||||
'src/routing/appRoutes.test.ts',
|
||||
'src/*PlaygroundApp.tsx',
|
||||
'src/ChildMotionDemoApp.tsx',
|
||||
'src/Match3DPlaygroundApp.tsx',
|
||||
'src/components/child-motion-demo/**',
|
||||
'src/components/asset-studio/**',
|
||||
'src/components/bark-battle-creation/**',
|
||||
'src/components/big-fish-creation/**',
|
||||
'src/components/big-fish-result/**',
|
||||
'src/components/big-fish-runtime/**',
|
||||
'src/components/creation-agent/**',
|
||||
'src/components/creative-agent/**',
|
||||
'src/components/custom-world-agent/**',
|
||||
'src/components/custom-world-home/**',
|
||||
'src/components/edutainment-creation/**',
|
||||
'src/components/edutainment-result/**',
|
||||
'src/components/edutainment-runtime/**',
|
||||
'src/components/game-canvas/**',
|
||||
'src/components/jump-hop-result/**',
|
||||
'src/components/jump-hop-runtime/**',
|
||||
'src/components/match3d-result/**',
|
||||
'src/components/match3d-runtime/**',
|
||||
'src/components/puzzle-clear-creation/**',
|
||||
'src/components/puzzle-clear-result/**',
|
||||
'src/components/puzzle-clear-runtime/**',
|
||||
'src/components/puzzle-gallery/**',
|
||||
'src/components/puzzle-result/**',
|
||||
'src/components/puzzle-runtime/**',
|
||||
'src/components/rpg-creation-asset-studio/**',
|
||||
'src/components/rpg-creation-result/**',
|
||||
'src/components/rpg-runtime-panels/**',
|
||||
'src/components/square-hole-creation/**',
|
||||
'src/components/square-hole-result/**',
|
||||
'src/components/square-hole-runtime/**',
|
||||
'src/components/unified-creation/**',
|
||||
'src/components/visual-novel-creation/**',
|
||||
'src/components/visual-novel-result/**',
|
||||
'src/components/visual-novel-runtime/**',
|
||||
'src/components/wooden-fish-result/**',
|
||||
'src/components/wooden-fish-runtime/**',
|
||||
'src/components/common/PublishShare*',
|
||||
'src/components/common/publishShare*',
|
||||
'src/components/platform-entry/PlatformEntryCreation*',
|
||||
'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx',
|
||||
'src/components/platform-entry/platformEntryTypes.ts',
|
||||
'src/components/platform-entry/PlatformEntryHome*',
|
||||
'src/components/platform-entry/PlatformEntryWorld*',
|
||||
'src/components/platform-entry/PlatformMobileHome*',
|
||||
'src/components/platform-entry/PlatformWork*',
|
||||
'src/components/platform-entry/PlatformDraft*',
|
||||
'src/components/platform-entry/PlatformTask*',
|
||||
'src/components/platform-entry/PlatformError*',
|
||||
'src/components/platform-entry/barkBattle*',
|
||||
'src/components/platform-entry/platformCreation*',
|
||||
'src/components/platform-entry/platformDialog*',
|
||||
'src/components/platform-entry/platformDraft*',
|
||||
'src/components/platform-entry/platformEdutainment*',
|
||||
'src/components/platform-entry/platformEntryCreation*',
|
||||
'src/components/platform-entry/platformExternal*',
|
||||
'src/components/platform-entry/platformGeneration*',
|
||||
'src/components/platform-entry/platformHost*',
|
||||
'src/components/platform-entry/platformMiniGame*',
|
||||
'src/components/platform-entry/platformPlayed*',
|
||||
'src/components/platform-entry/platformPublic*',
|
||||
'src/components/platform-entry/platformPuzzle*',
|
||||
'src/components/platform-entry/platformRecommend*',
|
||||
'src/components/platform-entry/platformRpg*',
|
||||
'src/components/platform-entry/platformSelection*',
|
||||
'src/components/platform-entry/puzzleDraft*',
|
||||
'src/components/platform-entry/usePlatformCreation*',
|
||||
'src/components/platform-entry/usePlatformEntry*',
|
||||
'src/components/platform-entry/PlatformEntryFlowShellImpl/**',
|
||||
'src/components/platform-entry/platformMatch3DRuntimeProfile*',
|
||||
'src/components/unified-creation/workspaces/JumpHopCreationWorkspace*',
|
||||
'src/components/unified-creation/workspaces/Match3DCreationWorkspace*',
|
||||
'src/components/rpg-creation-editor/**',
|
||||
'src/components/rpg-entry/**',
|
||||
'src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx',
|
||||
'src/components/custom-world-home/CustomWorldCreationHub.test.tsx',
|
||||
'src/components/custom-world-home/CustomWorldCreationHub.testAdapter.tsx',
|
||||
'src/components/custom-world-home/creationWorkShelf.test.ts',
|
||||
'src/components/rpg-runtime-shell/**',
|
||||
'src/hooks/rpg-runtime-story/**',
|
||||
'src/hooks/rpg-session/**',
|
||||
'src/hooks/combat/**',
|
||||
'src/hooks/useCombatFlow.ts',
|
||||
'src/hooks/useStoryOptions.ts',
|
||||
'src/prompts/customWorldPrompts.ts',
|
||||
'src/services/ai.ts',
|
||||
'src/services/appTitle.ts',
|
||||
'src/services/appTitle.test.ts',
|
||||
'src/services/miniGameDraftGenerationProgress.ts',
|
||||
'src/services/creationEntryConfigService.ts',
|
||||
'src/services/creationUrlState*',
|
||||
'src/services/customWorld*',
|
||||
'src/services/input-devices/**',
|
||||
'src/services/publicWorkCode.ts',
|
||||
'src/services/runtimeGuestAuth.ts',
|
||||
'src/services/runtimeRequest*',
|
||||
'src/services/runtimeAudioFeedback.ts',
|
||||
'src/services/useMocapInput*',
|
||||
'src/services/wechatMiniProgramSubscribe*',
|
||||
'src/services/bark-battle-creation/**',
|
||||
'src/services/bark-battle-runtime/**',
|
||||
'src/services/big-fish-creation/**',
|
||||
'src/services/big-fish-gallery/**',
|
||||
'src/services/big-fish-runtime/**',
|
||||
'src/services/big-fish-works/**',
|
||||
'src/services/creation-agent/**',
|
||||
'src/services/creation-audio/**',
|
||||
'src/services/creative-agent/**',
|
||||
'src/services/edutainment-baby-drawing/**',
|
||||
'src/services/edutainment-baby-object/**',
|
||||
'src/services/child-motion-demo/**',
|
||||
'src/services/jump-hop/**',
|
||||
'src/services/match3d-creation/**',
|
||||
'src/services/match3d-runtime/**',
|
||||
'src/services/match3d-works/**',
|
||||
'src/services/match3dGeneratedModelCache*',
|
||||
'src/services/match3dSpritesheetParser*',
|
||||
'src/services/puzzle-agent/**',
|
||||
'src/services/puzzle-gallery/**',
|
||||
'src/services/puzzle-onboarding/**',
|
||||
'src/services/puzzle-runtime/**',
|
||||
'src/services/puzzle-works/**',
|
||||
'src/services/rpg-creation/**',
|
||||
'src/services/rpg-entry/**',
|
||||
'src/services/rpg-runtime/**',
|
||||
'src/services/square-hole-creation/**',
|
||||
'src/services/square-hole-runtime/**',
|
||||
'src/services/square-hole-works/**',
|
||||
'src/services/storyEngine/**',
|
||||
'src/services/visual-novel-creation/**',
|
||||
'src/services/visual-novel-runtime/**',
|
||||
'src/services/visual-novel-works/**',
|
||||
'src/services/wooden-fish/**',
|
||||
'src/types.ts',
|
||||
'src/types/**',
|
||||
'src/uiAssets.ts',
|
||||
'scripts/loadtest/**',
|
||||
'packages/shared/src/contracts/jumpHop.ts',
|
||||
'packages/shared/src/contracts/match3dAgent.ts',
|
||||
'packages/shared/src/contracts/match3dRuntime.ts',
|
||||
'packages/shared/src/contracts/match3dWorks.ts',
|
||||
'packages/shared/src/contracts/barkBattle*',
|
||||
'packages/shared/src/contracts/bigFish*',
|
||||
'packages/shared/src/contracts/creationAgent*',
|
||||
'packages/shared/src/contracts/creationAudio*',
|
||||
'packages/shared/src/contracts/creativeAgent*',
|
||||
'packages/shared/src/contracts/customWorld*',
|
||||
'packages/shared/src/contracts/edutainment*',
|
||||
'packages/shared/src/contracts/playTypes*',
|
||||
'packages/shared/src/contracts/publicWork*',
|
||||
'packages/shared/src/contracts/puzzle*',
|
||||
'packages/shared/src/contracts/rpg*',
|
||||
'packages/shared/src/contracts/squareHole*',
|
||||
'packages/shared/src/contracts/story*',
|
||||
'packages/shared/src/contracts/visualNovel*',
|
||||
'packages/shared/src/contracts/woodenFish*',
|
||||
'scripts/export-match3d-resource-pipeline*',
|
||||
'scripts/generate-child-motion-demo-assets.mjs',
|
||||
'src/services/puzzle-clear/**',
|
||||
'src/games/**',
|
||||
'src/data/**',
|
||||
'src/prompts/**',
|
||||
'apps/admin-web/src/pages/AdminCreationEntrySwitchPage*',
|
||||
'apps/admin-web/src/pages/AdminWorkVisibilityPage*',
|
||||
'src/services/recommendedRuntimeGuestLaunch.test.ts',
|
||||
'src/data/sceneEncounterPreviews.ts',
|
||||
'public/Icons',
|
||||
@@ -130,7 +405,10 @@ module.exports = {
|
||||
rules: {
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-refresh/only-export-components': ['error', {allowConstantExport: true}],
|
||||
'react-refresh/only-export-components': [
|
||||
'error',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'simple-import-sort/imports': 'error',
|
||||
'simple-import-sort/exports': 'error',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
@@ -143,7 +421,7 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
'no-constant-condition': 'error',
|
||||
'no-console': ['error', {allow: ['warn', 'error']}],
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
'no-useless-escape': 'error',
|
||||
'prefer-const': 'error',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
name: Project CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- codex/ai-game-creator-app
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CI: 'true'
|
||||
CARGO_INCREMENTAL: '0'
|
||||
CARGO_HTTP_MULTIPLEXING: 'false'
|
||||
CARGO_NET_RETRY: '10'
|
||||
CARGO_TERM_COLOR: always
|
||||
NPM_CONFIG_AUDIT: 'false'
|
||||
NPM_CONFIG_FETCH_RETRIES: '10'
|
||||
NPM_CONFIG_FETCH_RETRY_FACTOR: '2'
|
||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: '60000'
|
||||
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: '2000'
|
||||
NPM_CONFIG_FUND: 'false'
|
||||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||||
RUSTUP_AUTO_INSTALL: '0'
|
||||
RUSTC_WRAPPER: ''
|
||||
CARGO_BUILD_RUSTC_WRAPPER: ''
|
||||
|
||||
jobs:
|
||||
repository-checks:
|
||||
name: Repository checks
|
||||
runs-on: genarrative-ci
|
||||
steps:
|
||||
- name: Checkout full history from Gitea
|
||||
env:
|
||||
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: genarrative-gitea-checkout
|
||||
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Resolve comparison base
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_ref="$(node -e '
|
||||
const fs = require("node:fs");
|
||||
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
|
||||
process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? "");
|
||||
')"
|
||||
if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then
|
||||
git cat-file -e "${base_ref}^{commit}" 2>/dev/null || {
|
||||
echo "comparison base commit is unavailable: ${base_ref}" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
|
||||
fi
|
||||
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
|
||||
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
|
||||
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run repository lint gates
|
||||
run: npm run lint
|
||||
|
||||
- name: Build web applications
|
||||
run: npm run build
|
||||
|
||||
- name: Validate content data
|
||||
run: npm run check:content
|
||||
|
||||
- name: Check committed whitespace
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_ref="${SPACETIME_SCHEMA_BASE_REF:-}"
|
||||
test -n "${base_ref}"
|
||||
git cat-file -e "${base_ref}^{commit}"
|
||||
git diff --check "${base_ref}"...HEAD
|
||||
|
||||
frontend-tests:
|
||||
name: Frontend tests
|
||||
runs-on: genarrative-ci
|
||||
steps:
|
||||
- name: Checkout source from Gitea
|
||||
env:
|
||||
GENARRATIVE_GITEA_FETCH_DEPTH: '1'
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: genarrative-gitea-checkout
|
||||
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run frontend and script tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run BgFilter worker smoke harness tests
|
||||
run: npm run bgfilter-worker:smoke-test
|
||||
|
||||
- name: Validate production health patrol behavior
|
||||
run: npm run check:production-health-patrol
|
||||
|
||||
- name: Validate production API release behavior
|
||||
run: npm run check:production-api-release
|
||||
|
||||
- name: Validate production API deploy behavior
|
||||
run: npm run check:production-api-deploy
|
||||
|
||||
backend-tests:
|
||||
name: Backend tests
|
||||
runs-on: genarrative-ci
|
||||
steps:
|
||||
- name: Checkout full history from Gitea
|
||||
env:
|
||||
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: genarrative-gitea-checkout
|
||||
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Resolve comparison base
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_ref="$(node -e '
|
||||
const fs = require("node:fs");
|
||||
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
|
||||
process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? "");
|
||||
')"
|
||||
if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then
|
||||
git cat-file -e "${base_ref}^{commit}" 2>/dev/null || {
|
||||
echo "comparison base commit is unavailable: ${base_ref}" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
|
||||
fi
|
||||
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
|
||||
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
|
||||
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check server-rs boundaries
|
||||
run: npm run check:server-rs-ddd
|
||||
|
||||
- name: Run server-rs workspace tests
|
||||
run: cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml
|
||||
|
||||
- name: Check api-server targets
|
||||
run: cargo check --locked -p api-server --all-targets --manifest-path server-rs/Cargo.toml
|
||||
|
||||
- name: Check SpacetimeDB module
|
||||
run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml
|
||||
|
||||
native-shell-tests:
|
||||
name: Native shell tests
|
||||
runs-on: genarrative-ci
|
||||
steps:
|
||||
- name: Checkout full history from Gitea
|
||||
env:
|
||||
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: genarrative-gitea-checkout
|
||||
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run native shell gates
|
||||
run: npm run check:native-shells
|
||||
|
||||
- name: Ensure native lockfile is unchanged
|
||||
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: genarrative-dev-stack-port-routing
|
||||
short_description: 修改 Genarrative 本地 dev 启动端口、代理目标、端口冲突处理时使用。
|
||||
description: 在 Genarrative 中修改 npm run dev / dev:spacetime / dev:api-server / dev:web / dev:admin-web 的本地启动端口、端口可用性探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标和后台 admin-web 启动串联时使用。
|
||||
version: 1.0.0
|
||||
description: 在 Genarrative 中修改 npm run dev / dev:spacetime / dev:api-server / dev:bgfilter-worker / dev:web / dev:admin-web 的本地启动端口、端口可用性探测、端口漂移、SpacetimeDB publish server、Rust 进程环境变量、Vite 代理目标和后台 admin-web 启动串联时使用。
|
||||
version: 1.1.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
@@ -13,7 +13,7 @@ metadata:
|
||||
|
||||
# Genarrative 本地 dev 启动端口与代理目标串联流程
|
||||
|
||||
用于维护 Genarrative 本地开发栈启动脚本,重点覆盖 `npm run dev` 与四个 `dev:*` 单模块命令的端口检查、端口漂移和后续流程目标传递。
|
||||
用于维护 Genarrative 本地开发栈启动脚本,重点覆盖 `npm run dev` 与五个 `dev:*` 单模块命令的端口检查、端口漂移和后续流程目标传递。
|
||||
|
||||
## 适用场景
|
||||
|
||||
@@ -31,40 +31,44 @@ metadata:
|
||||
2. Rust `api-server`:`8082`,健康检查为 `http://127.0.0.1:<api-port>/healthz`。
|
||||
3. SpacetimeDB standalone:`3101`,健康检查为 `http://127.0.0.1:<spacetime-port>/v1/ping`。
|
||||
4. 后台 Vite:`3102`,后台地址为 `http://127.0.0.1:<admin-web-port>/admin/`。
|
||||
5. 独立 BgFilter worker:`8083`,就绪检查为 `http://127.0.0.1:<bgfilter-worker-port>/readyz`。
|
||||
|
||||
端口不可用时,脚本会从优先端口开始向后寻找可用端口。后续流程必须以解析后的实际端口为准,不能继续使用默认端口。
|
||||
|
||||
Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 会先向系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json` 申请一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`adminWeb = start + 3`。注册表锁文件是 `/var/tmp/genarrative-dev-port-ranges/registry.lock`,可通过 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。自动分配从 `10000-10099` 起,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;当前口径是“一个用户固定占用一个段,后续启动继续复用这段并在段内漂移”;该注册表只在 Linux 上生效;Windows 继续沿用原有端口探测、漂移和复用逻辑,不读系统级注册表。
|
||||
Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 会先向系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json` 申请一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`adminWeb = start + 3`、`bgfilterWorker = start + 4`。注册表锁文件是 `/var/tmp/genarrative-dev-port-ranges/registry.lock`,可通过 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。自动分配从 `10000-10099` 起,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;当前口径是“一个用户固定占用一个段,后续启动继续复用这段并在段内漂移”;该注册表只在 Linux 上生效;Windows 继续沿用原有统一端口探测和漂移逻辑,不读系统级注册表。
|
||||
|
||||
## 实现入口
|
||||
|
||||
- `package.json`
|
||||
- `dev`:执行 `node scripts/dev.mjs`,启动完整四模块。
|
||||
- `dev:spacetime` / `dev:api-server` / `dev:web` / `dev:admin-web`:执行 `node scripts/dev.mjs <module>`。
|
||||
- `dev`:执行 `node scripts/dev.mjs`,启动完整五服务。
|
||||
- `dev:spacetime` / `dev:api-server` / `dev:bgfilter-worker` / `dev:web` / `dev:admin-web`:执行 `node scripts/dev.mjs <module>`;`dev:api-server` 会安全带起其依赖的 BgFilter worker。
|
||||
- `scripts/dev-stack-port-utils.mjs`
|
||||
- `isPortAvailable(...)`:探测端口是否可监听。
|
||||
- `findAvailablePort(...)`:从优先端口向后寻找可用端口,`0` 表示申请临时端口。
|
||||
- `resolveDevStackPorts(...)`:一次性解析 SpacetimeDB、api-server、主站 Vite、后台 Vite 端口,并避免本次解析结果互相冲突。
|
||||
- `resolveDevStackPorts(...)`:一次性解析 SpacetimeDB、api-server、主站 Vite、后台 Vite、BgFilter worker 端口,并避免本次解析结果互相冲突。
|
||||
- Linux 注册表分配:`reserveLinuxDevPortRange(...)` / `releaseLinuxDevPortRange(...)`,仅在 Linux 上启用系统级端口段登记与用户段复用,自动分配从 `10000-10099` 起。
|
||||
- CLI 模式:`node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:3101 api:127.0.0.1:8082 web:0.0.0.0:3000 adminWeb:127.0.0.1:3102`。
|
||||
- CLI 模式:`node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:3101 api:127.0.0.1:8082 web:0.0.0.0:3000 adminWeb:127.0.0.1:3102 bgfilterWorker:127.0.0.1:8083`。
|
||||
- `scripts/dev.mjs`
|
||||
- 解析 CLI 参数后统一计算 client host、端口、`SPACETIME_SERVER`、`RUST_SERVER_TARGET`。
|
||||
- 完整栈按 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 顺序启动。
|
||||
- Linux 下会先申请系统级端口段并把它映射成四个 dev 端口;自动分配从 `10000-10099` 起,Windows 则直接沿用原有参数解析与端口漂移逻辑。
|
||||
- 完整栈按 SpacetimeDB、publish、BgFilter worker readiness、api-server readiness、主站 Vite、后台 Vite 顺序启动。
|
||||
- Linux 下会先申请系统级端口段并把它映射成五个 dev 端口;自动分配从 `10000-10099` 起,Windows 则把第五个服务纳入原有统一参数解析与端口漂移逻辑。
|
||||
- 完整栈和 `dev:api-server` 把两个 Rust 进程作为同一重启单元,先全部停止,再先启动 BgFilter worker、后启动 api-server;不要为同一份 Rust 源码创建两个并发 `cargo` watcher。
|
||||
- 单模块命令复用同一套参数和 env 解析。
|
||||
|
||||
## 必须保持的传递链路
|
||||
|
||||
`npm run dev` 和四个 `dev:*` 单模块命令中端口解析后,必须同步到以下位置:
|
||||
`npm run dev` 和五个 `dev:*` 单模块命令中端口解析后,必须同步到以下位置:
|
||||
|
||||
1. SpacetimeDB 启动:`spacetime start --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}"`。
|
||||
2. SpacetimeDB 发布:`spacetime publish ... --server "${SPACETIME_SERVER}"`。
|
||||
3. Rust api-server:`GENARRATIVE_API_HOST`、`GENARRATIVE_API_PORT`、`GENARRATIVE_SPACETIME_SERVER_URL`、`GENARRATIVE_SPACETIME_DATABASE`。
|
||||
4. api-server 健康检查:`wait_for_api_server "${RUST_SERVER_TARGET}/healthz" ...`。
|
||||
5. 主站 Vite:`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET`、`ADMIN_WEB_TARGET`、`ADMIN_WEB_PORT`、`--port=${WEB_PORT}`、`--host=${WEB_HOST}`。
|
||||
6. 后台 Vite:`ADMIN_API_TARGET`、`GENARRATIVE_API_TARGET`、`GENARRATIVE_API_PORT`、`--port=${ADMIN_WEB_PORT}`。
|
||||
7. 控制台日志:`[dev:ports]` 和 `[dev] web/admin web/api-server/spacetime` 必须显示最终实际地址。
|
||||
8. Linux 端口段注册:`[dev] port-range:` 与 `[dev] port-range-registry:` 只在 Linux 输出,Windows 不应依赖系统级注册表。
|
||||
5. BgFilter worker:`GENARRATIVE_PROCESS_ROLE=bgfilter-worker`、解析后的 `HOST / PORT`、与父 API 相同的 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` / `GENARRATIVE_BGFILTER_INTERNAL_TOKEN`,以及显式有效的 `N / Q`。
|
||||
6. BgFilter worker readiness:父 API 启动前检查解析后地址的 `/readyz`。
|
||||
7. 主站 Vite:`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET`、`ADMIN_WEB_TARGET`、`ADMIN_WEB_PORT`、`--port=${WEB_PORT}`、`--host=${WEB_HOST}`。
|
||||
8. 后台 Vite:`ADMIN_API_TARGET`、`GENARRATIVE_API_TARGET`、`GENARRATIVE_API_PORT`、`--port=${ADMIN_WEB_PORT}`。
|
||||
9. 控制台日志:`[dev:ports]` 和 `[dev] web/admin web/api-server/bgfilter-worker/spacetime` 必须显示最终实际地址。
|
||||
10. Linux 端口段注册:`[dev] port-range:` 与 `[dev] port-range-registry:` 只在 Linux 输出,Windows 不应依赖系统级注册表。
|
||||
|
||||
如果只改了其中一段,通常会出现:浏览器打开的前端可用,但 `/api/*` 代理到旧端口;后台页面可用但后台 API 失败;SpacetimeDB 启动在新端口但 publish 仍发往旧端口。
|
||||
|
||||
@@ -74,7 +78,7 @@ Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range`
|
||||
- `scripts/dev-stack-port-utils.mjs`
|
||||
- `scripts/dev.mjs`
|
||||
- `scripts/dev-utils.mjs`
|
||||
- `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`
|
||||
- `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
|
||||
- `docs/project-memory/shared-memory/pitfalls.md`
|
||||
2. 优先改公共端口工具,不要把端口探测逻辑复制到多个脚本。
|
||||
3. 修改 `scripts/dev.mjs` 时确认变量顺序:先解析参数和端口,再构造 `SPACETIME_SERVER` / `RUST_SERVER_TARGET`,最后启动对应 service。
|
||||
@@ -91,21 +95,21 @@ Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range`
|
||||
node --check scripts/dev.mjs
|
||||
npm run test -- scripts/dev-stack-port-utils.test.ts
|
||||
npm run check:encoding
|
||||
node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 api:127.0.0.1:0 web:0.0.0.0:0 adminWeb:127.0.0.1:0
|
||||
node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 api:127.0.0.1:0 web:0.0.0.0:0 adminWeb:127.0.0.1:0 bgfilterWorker:127.0.0.1:0
|
||||
```
|
||||
|
||||
端口冲突回归测试建议:
|
||||
|
||||
1. 用测试或临时 Node server 占用某个优先端口。
|
||||
2. 调用 `findAvailablePort`,断言结果大于被占用端口。
|
||||
3. 调用 `resolveDevStackPorts`,断言四个结果互不相同。
|
||||
3. 调用 `resolveDevStackPorts`,断言五个结果互不相同。
|
||||
4. 如果实际启动完整栈,观察控制台:
|
||||
- `[dev:ports] ... 不可用,改用 ...`
|
||||
- `[dev] api-server: http://...:<actual-api-port>`
|
||||
- `[dev] spacetime: http://...:<actual-spacetime-port>`
|
||||
- 主站和后台 Vite 启动端口与日志一致。
|
||||
|
||||
完整启动属于长驻进程。需要 smoke 时用 background 方式启动,并另开命令检查 `/healthz`、`/v1/ping` 和页面端口;不要等待 `npm run dev` 自然退出。
|
||||
完整启动属于长驻进程。需要 smoke 时用 background 方式启动,并另开命令检查 api-server `/healthz`、BgFilter worker `/readyz`、SpacetimeDB `/v1/ping` 和两个页面端口;不要等待 `npm run dev` 自然退出。检查地址必须取 `.app/dev-stack.json` 或启动日志中的实际端口,不能假定 worker 一定停在 `8083`。
|
||||
|
||||
## 常见坑
|
||||
|
||||
@@ -122,7 +126,8 @@ node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 ap
|
||||
- [ ] Linux 注册表分配、同用户复用固定段并继续漂移、自动分配从 `10000-10099` 起、Windows bypass 都有测试覆盖。
|
||||
- [ ] `scripts/dev.mjs` 通过 `node --check`。
|
||||
- [ ] `npm run dev` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。
|
||||
- [ ] BgFilter worker 在 api-server 前 ready,父子共享实际 base URL / Token,Rust watch 只触发一次组合重启。
|
||||
- [ ] `npm run dev:web` 在主站端口不可用时能切换到可用端口。
|
||||
- [ ] 文档同步更新 `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`。
|
||||
- [ ] 文档同步更新 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
- [ ] 长期踩坑同步更新 `docs/project-memory/shared-memory/pitfalls.md`。
|
||||
- [ ] 修改中文文件后运行 `npm run check:encoding`。
|
||||
|
||||
@@ -44,10 +44,10 @@ npm run dev
|
||||
|
||||
补充说明:
|
||||
|
||||
- `npm run dev` 会启动 SpacetimeDB standalone、Rust `api-server`、主站 Vite 与后台 Vite,适合完整联调。
|
||||
- `npm run dev` 会启动 SpacetimeDB standalone、独立 `bgfilter-worker`、Rust `api-server`、主站 Vite 与后台 Vite,适合完整联调;内部 worker ready 后才启动 API。
|
||||
- 主站默认地址是 `http://127.0.0.1:3000`,后台可从 `http://127.0.0.1:3000/admin/` 进入,也可直连 `http://127.0.0.1:3102`。
|
||||
- 四个模块可独立启动:`npm run dev:spacetime`、`npm run dev:api-server`、`npm run dev:web`、`npm run dev:admin-web`。
|
||||
- 如需自动刷新后端模块,使用 `npm run dev -- --watch`;其中 `spacetime-module` 改动后只会重新发布模块,不会重启 standalone,`api-server` 改动后会重启 Rust 进程。主站和后台前端源码变化交给 Vite 自身 HMR,不由外层 watcher 重启。非 watch 模式下可在 `npm run dev` 终端输入 `rs api-server`、`rs web`、`rs admin-web`、`rs spacetime` 或 `rs all`,其中 `rs spacetime` 也是只重新发布模块。
|
||||
- 五个模块可独立启动:`npm run dev:spacetime`、`npm run dev:api-server`、`npm run dev:bgfilter-worker`、`npm run dev:web`、`npm run dev:admin-web`;其中 `dev:api-server` 会安全带起同 runner 的 BgFilter worker 依赖。
|
||||
- 如需自动刷新后端模块,使用 `npm run dev -- --watch`;其中 `spacetime-module` 改动后只会重新发布模块,不会重启 standalone,Rust 源码改动会把 `api-server` 与 `bgfilter-worker` 作为一个组合单元重启。主站和后台前端源码变化交给 Vite 自身 HMR,不由外层 watcher 重启。非 watch 模式下可在 `npm run dev` 终端输入 `rs api-server`、`rs bgfilter-worker`、`rs web`、`rs admin-web`、`rs spacetime` 或 `rs all`,其中 `rs spacetime` 也是只重新发布模块。
|
||||
|
||||
构建生产包:
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ import { afterEach, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
createAdminAccount,
|
||||
executeAdminRechargeRefund,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
listAdminRechargeOrders,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
updateAdminAccount,
|
||||
uploadAdminEditorShowcaseCampaignImage,
|
||||
upsertAdminFeatureGateConfig,
|
||||
} from './adminApiClient';
|
||||
|
||||
afterEach(() => {
|
||||
@@ -16,7 +19,7 @@ afterEach(() => {
|
||||
test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({account: {accountId: 'member-1'}}), {
|
||||
new Response(JSON.stringify({ account: { accountId: 'member-1' } }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
@@ -40,7 +43,7 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({Authorization: 'Bearer owner-token'}),
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer owner-token' }),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1');
|
||||
@@ -56,6 +59,132 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度配置读写只使用通用 feature-gates 管理接口', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ gates: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await getAdminFeatureGateConfig('gray-token');
|
||||
await upsertAdminFeatureGateConfig('gray-token', {
|
||||
gateKey: 'image-editor:agent-sidebar',
|
||||
enabled: true,
|
||||
rolloutPercent: 25,
|
||||
allowUserIds: ['user-1'],
|
||||
allowUserTags: ['beta'],
|
||||
denyUserIds: ['blocked-1'],
|
||||
description: '画布 Agent 入口灰度',
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('/admin/api/feature-gates');
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer gray-token' }),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/feature-gates');
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer gray-token' }),
|
||||
body: JSON.stringify({
|
||||
gateKey: 'image-editor:agent-sidebar',
|
||||
enabled: true,
|
||||
rolloutPercent: 25,
|
||||
allowUserIds: ['user-1'],
|
||||
allowUserTags: ['beta'],
|
||||
denyUserIds: ['blocked-1'],
|
||||
description: '画布 Agent 入口灰度',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('活动卡图片上传成功后先确认正式私有对象再返回图片引用', async () => {
|
||||
const closeBitmap = vi.fn();
|
||||
vi.stubGlobal(
|
||||
'createImageBitmap',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ width: 1024, height: 1536, close: closeBitmap }),
|
||||
);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
upload: {
|
||||
bucket: 'genarrative-release',
|
||||
host: 'https://genarrative-release.oss.example.com',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/showcase-campaign/current/card.png',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/showcase-campaign/current/card.png',
|
||||
contentType: 'image/png',
|
||||
formFields: { key: 'campaign-key', policy: 'signed-policy' },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response('', { status: 200 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ assetObject: { assetObjectId: 'assetobj-1' } }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const file = new File(['image-bytes'], 'card.png', { type: 'image/png' });
|
||||
|
||||
const uploaded = await uploadAdminEditorShowcaseCampaignImage(
|
||||
'admin-token',
|
||||
file,
|
||||
);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'/admin/api/editor-showcase/campaign/image-upload-ticket',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
'https://genarrative-release.oss.example.com',
|
||||
);
|
||||
expect(fetchMock.mock.calls[2]?.[0]).toBe(
|
||||
'/admin/api/editor-showcase/campaign/image-upload-confirm',
|
||||
);
|
||||
expect(fetchMock.mock.calls[2]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
||||
body: JSON.stringify({
|
||||
bucket: 'genarrative-release',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/showcase-campaign/current/card.png',
|
||||
contentType: 'image/png',
|
||||
contentLength: file.size,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(uploaded).toEqual({
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/showcase-campaign/current/card.png',
|
||||
imageObjectKey:
|
||||
'generated-character-drafts/editor/showcase-campaign/current/card.png',
|
||||
imageWidth: 1024,
|
||||
imageHeight: 1536,
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/showcase-campaign/current/card.png',
|
||||
});
|
||||
expect(closeBitmap).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('充值订单查询按后台契约序列化筛选参数', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ entries: [] }), {
|
||||
@@ -110,13 +239,11 @@ test('用户详情只发送实际提供的用户定位字段', async () => {
|
||||
});
|
||||
|
||||
test('退款执行使用独立 execute 管理员路由', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await executeAdminRechargeRefund('token-1', {
|
||||
@@ -143,13 +270,11 @@ test('退款执行使用独立 execute 管理员路由', async () => {
|
||||
});
|
||||
|
||||
test('退款人工复核使用独立 resolve 管理员路由', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await resolveAdminRechargeRefundManualReview('token-1', {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
AdminAccountListResponse,
|
||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||
AdminCreateAccountRequest,
|
||||
AdminCreateAccountResponse,
|
||||
AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
|
||||
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse,
|
||||
AdminCreationEntryConfigResponse,
|
||||
AdminDashboardQuery,
|
||||
AdminDashboardResponse,
|
||||
AdminDatabaseTableListResponse,
|
||||
@@ -40,11 +40,7 @@ import type {
|
||||
AdminTrackingEventListResponse,
|
||||
AdminUpdateAccountRequest,
|
||||
AdminUpdateAccountResponse,
|
||||
AdminUpdateWorkVisibilityRequest,
|
||||
AdminUpdateWorkVisibilityResponse,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertCreationEntryEventBannersRequest,
|
||||
AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertFeatureGateConfigRequest,
|
||||
AdminUpsertProfileInviteCodeRequest,
|
||||
@@ -52,12 +48,10 @@ import type {
|
||||
AdminUpsertProfileRedeemCodeRequest,
|
||||
AdminUpsertProfileTaskConfigRequest,
|
||||
AdminUpsertProfileWalletConfigRequest,
|
||||
AdminUpsertPublicWorkInteractionConfigRequest,
|
||||
AdminUserDetailQuery,
|
||||
AdminUserDetailResponse,
|
||||
AdminWalletRestrictionRequest,
|
||||
AdminWalletRestrictionResponse,
|
||||
AdminWorkVisibilityListResponse,
|
||||
ApiErrorEnvelope,
|
||||
ApiMeta,
|
||||
ApiSuccessEnvelope,
|
||||
@@ -194,7 +188,7 @@ export function getAdminMe(token: string) {
|
||||
}
|
||||
|
||||
export function listAdminAccounts(token: string) {
|
||||
return request<AdminAccountListResponse>('/admin/api/accounts', {token});
|
||||
return request<AdminAccountListResponse>('/admin/api/accounts', { token });
|
||||
}
|
||||
|
||||
export function createAdminAccount(
|
||||
@@ -215,7 +209,7 @@ export function updateAdminAccount(
|
||||
) {
|
||||
return request<AdminUpdateAccountResponse>(
|
||||
`/admin/api/accounts/${encodeURIComponent(accountId)}`,
|
||||
{method: 'PUT', token, body: payload},
|
||||
{ method: 'PUT', token, body: payload },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,57 +286,6 @@ export function upsertAdminFeatureGateConfig(
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminCreationEntryConfig(token: string) {
|
||||
return request<AdminCreationEntryConfigResponse>(
|
||||
'/admin/api/creation-entry/config',
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertAdminCreationEntryConfig(
|
||||
token: string,
|
||||
payload: AdminUpsertCreationEntryTypeConfigRequest,
|
||||
) {
|
||||
return request<AdminCreationEntryConfigResponse>(
|
||||
'/admin/api/creation-entry/config',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存创作入口公告表单序列化后的后端传输字段。 */
|
||||
export function upsertAdminCreationEntryBanners(
|
||||
token: string,
|
||||
payload: AdminUpsertCreationEntryEventBannersRequest,
|
||||
) {
|
||||
return request<AdminCreationEntryConfigResponse>(
|
||||
'/admin/api/creation-entry/config/banners',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存公开作品详情页点赞 / 改造能力配置。 */
|
||||
export function upsertAdminPublicWorkInteractions(
|
||||
token: string,
|
||||
payload: AdminUpsertPublicWorkInteractionConfigRequest,
|
||||
) {
|
||||
return request<AdminCreationEntryConfigResponse>(
|
||||
'/admin/api/creation-entry/config/interactions',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminEditorGenerationPricing(token: string) {
|
||||
return request<EditorGenerationPricingConfigPayload>(
|
||||
'/admin/api/editor-generation-pricing',
|
||||
@@ -364,27 +307,6 @@ export function upsertAdminEditorGenerationPricing(
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminWorkVisibility(token: string) {
|
||||
return request<AdminWorkVisibilityListResponse>(
|
||||
'/admin/api/works/visibility',
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function updateAdminWorkVisibility(
|
||||
token: string,
|
||||
payload: AdminUpdateWorkVisibilityRequest,
|
||||
) {
|
||||
return request<AdminUpdateWorkVisibilityResponse>(
|
||||
'/admin/api/works/visibility',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminAssetReadUrl(
|
||||
token: string,
|
||||
query: AdminAssetReadUrlQuery,
|
||||
@@ -471,20 +393,34 @@ export async function uploadAdminEditorShowcaseCampaignImage(
|
||||
): Promise<AdminUploadedEditorShowcaseCampaignImage> {
|
||||
const contentType = resolveAdminImageContentType(file);
|
||||
const dimensions = await readAdminImageFileDimensions(file);
|
||||
const response = await request<AdminCreateEditorShowcaseCampaignImageUploadTicketResponse>(
|
||||
'/admin/api/editor-showcase/campaign/image-upload-ticket',
|
||||
const response =
|
||||
await request<AdminCreateEditorShowcaseCampaignImageUploadTicketResponse>(
|
||||
'/admin/api/editor-showcase/campaign/image-upload-ticket',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
fileName: file.name.trim() || 'showcase-campaign.png',
|
||||
contentType,
|
||||
contentLength: file.size,
|
||||
} satisfies AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
|
||||
},
|
||||
);
|
||||
await postAdminDirectUploadFile(response.upload, file);
|
||||
const objectKey = response.upload.objectKey.trim().replace(/^\/+/u, '');
|
||||
await request<unknown>(
|
||||
'/admin/api/editor-showcase/campaign/image-upload-confirm',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
fileName: file.name.trim() || 'showcase-campaign.png',
|
||||
bucket: response.upload.bucket,
|
||||
objectKey,
|
||||
contentType,
|
||||
contentLength: file.size,
|
||||
} satisfies AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
|
||||
} satisfies AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||
},
|
||||
);
|
||||
await postAdminDirectUploadFile(response.upload, file);
|
||||
const objectKey = response.upload.objectKey.trim().replace(/^\/+/u, '');
|
||||
return {
|
||||
imageSrc: objectKey ? `/${objectKey}` : response.upload.legacyPublicPath,
|
||||
imageObjectKey: objectKey,
|
||||
@@ -630,17 +566,14 @@ export function listAdminRechargeOrders(
|
||||
) {
|
||||
return request<AdminRechargeOrderListResponse>(
|
||||
`/admin/api/profile/recharge-orders${buildAdminRechargeOrderListQuery(query)}`,
|
||||
{token},
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminUserDetail(
|
||||
token: string,
|
||||
query: AdminUserDetailQuery,
|
||||
) {
|
||||
export function getAdminUserDetail(token: string, query: AdminUserDetailQuery) {
|
||||
return request<AdminUserDetailResponse>(
|
||||
`/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`,
|
||||
{token},
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -650,7 +583,7 @@ export function previewAdminRechargeRefund(
|
||||
) {
|
||||
return request<AdminRechargeRefundPreviewResponse>(
|
||||
'/admin/api/profile/recharge-refunds/preview',
|
||||
{method: 'POST', token, body: payload},
|
||||
{ method: 'POST', token, body: payload },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -660,7 +593,7 @@ export function executeAdminRechargeRefund(
|
||||
) {
|
||||
return request<AdminRechargeRefundActionResponse>(
|
||||
'/admin/api/profile/recharge-refunds/execute',
|
||||
{method: 'POST', token, body: payload},
|
||||
{ method: 'POST', token, body: payload },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -670,7 +603,7 @@ export function registerAdminRechargeRefund(
|
||||
) {
|
||||
return request<AdminRechargeRefundActionResponse>(
|
||||
'/admin/api/profile/recharge-refunds/register',
|
||||
{method: 'POST', token, body: payload},
|
||||
{ method: 'POST', token, body: payload },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -680,7 +613,7 @@ export function resolveAdminRechargeRefundManualReview(
|
||||
) {
|
||||
return request<AdminRechargeRefundActionResponse>(
|
||||
'/admin/api/profile/recharge-refunds/manual-review/resolve',
|
||||
{method: 'POST', token, body: payload},
|
||||
{ method: 'POST', token, body: payload },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -690,7 +623,7 @@ export function updateAdminWalletRestriction(
|
||||
) {
|
||||
return request<AdminWalletRestrictionResponse>(
|
||||
'/admin/api/profile/wallet-restriction',
|
||||
{method: 'POST', token, body: payload},
|
||||
{ method: 'POST', token, body: payload },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -710,10 +643,7 @@ function buildAssetReadUrlQuery(query: AdminAssetReadUrlQuery) {
|
||||
if (objectKey) {
|
||||
params.set('objectKey', objectKey);
|
||||
} else if (legacyPublicPath) {
|
||||
params.set(
|
||||
'legacyPublicPath',
|
||||
`/${legacyPublicPath.replace(/^\/+/u, '')}`,
|
||||
);
|
||||
params.set('legacyPublicPath', `/${legacyPublicPath.replace(/^\/+/u, '')}`);
|
||||
}
|
||||
if (
|
||||
typeof query.expireSeconds === 'number' &&
|
||||
@@ -731,7 +661,10 @@ function resolveAdminImageContentType(file: File) {
|
||||
if (declaredType.startsWith('image/')) {
|
||||
return declaredType;
|
||||
}
|
||||
const extension = file.name.trim().toLowerCase().match(/\.([a-z0-9]+)$/u)?.[1];
|
||||
const extension = file.name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.match(/\.([a-z0-9]+)$/u)?.[1];
|
||||
if (extension === 'jpg' || extension === 'jpeg') {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
@@ -306,78 +306,6 @@ export type AdminUpsertFeatureGateConfigRequest = Omit<
|
||||
'updatedAt'
|
||||
>;
|
||||
|
||||
/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */
|
||||
export interface AdminCreationEntryConfigResponse {
|
||||
entries: AdminCreationEntryTypeConfigPayload[];
|
||||
eventBanners: AdminCreationEntryEventBannerPayload[];
|
||||
publicWorkInteractions: PublicWorkInteractionConfigPayload[];
|
||||
}
|
||||
|
||||
/** 后台创作入口公告位配置项;旧结构化 banner 字段仅保留兼容。 */
|
||||
export interface AdminCreationEntryEventBannerPayload {
|
||||
title: string;
|
||||
description: string;
|
||||
coverImageSrc: string;
|
||||
prizePoolMudPoints: number;
|
||||
startsAtText: string;
|
||||
endsAtText: string;
|
||||
renderMode: 'structured' | 'html';
|
||||
htmlCode?: string | null;
|
||||
}
|
||||
|
||||
/** 后台单个创作模板入口配置,公告不再绑定在某一个入口上。 */
|
||||
export interface AdminCreationEntryTypeConfigPayload {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
imageSrc: string;
|
||||
visible: boolean;
|
||||
open: boolean;
|
||||
sortOrder: number;
|
||||
categoryId: string;
|
||||
categoryLabel: string;
|
||||
categorySortOrder: number;
|
||||
updatedAtMicros: number;
|
||||
unifiedCreationSpec?: UnifiedCreationSpecPayload | null;
|
||||
}
|
||||
|
||||
/** 后台保存创作模板入口开关与统一创作契约的请求体。 */
|
||||
export interface AdminUpsertCreationEntryTypeConfigRequest {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
imageSrc: string;
|
||||
visible: boolean;
|
||||
open: boolean;
|
||||
sortOrder: number;
|
||||
categoryId: string;
|
||||
categoryLabel: string;
|
||||
categorySortOrder: number;
|
||||
unifiedCreationSpec?: UnifiedCreationSpecPayload | null;
|
||||
}
|
||||
|
||||
/** 后台保存创作入口公告表单序列化结果的请求体。 */
|
||||
export interface AdminUpsertCreationEntryEventBannersRequest {
|
||||
/** 传输字段沿用后端契约,内容由后台表单生成。 */
|
||||
eventBannersJson: string;
|
||||
}
|
||||
|
||||
/** 后台公开作品详情页互动能力配置项。 */
|
||||
export interface PublicWorkInteractionConfigPayload {
|
||||
sourceType: string;
|
||||
likeEnabled: boolean;
|
||||
remixEnabled: boolean;
|
||||
likeDisabledMessage: string;
|
||||
remixDisabledMessage: string;
|
||||
}
|
||||
|
||||
/** 后台保存公开作品点赞 / 改造能力配置请求体。 */
|
||||
export interface AdminUpsertPublicWorkInteractionConfigRequest {
|
||||
publicWorkInteractions: PublicWorkInteractionConfigPayload[];
|
||||
}
|
||||
|
||||
/** 图片画布生成模型泥点定价配置。 */
|
||||
export type EditorGenerationPricingUnitPayload = 'perGeneration' | 'perSecond';
|
||||
|
||||
@@ -391,55 +319,6 @@ export interface EditorGenerationPricingConfigPayload {
|
||||
models: Record<string, EditorGenerationModelPricingPayload>;
|
||||
}
|
||||
|
||||
/** 后台统一创作工作台契约表单的传输结构。 */
|
||||
export interface UnifiedCreationSpecPayload {
|
||||
playId: string;
|
||||
title: string;
|
||||
mudPointCost: number;
|
||||
workspaceStage: string;
|
||||
generationStage: string;
|
||||
resultStage: string;
|
||||
fields: UnifiedCreationFieldPayload[];
|
||||
}
|
||||
|
||||
/** 后台统一创作字段契约,保存前会校验字段类型和必填标记。 */
|
||||
export interface UnifiedCreationFieldPayload {
|
||||
id: string;
|
||||
kind: 'text' | 'select' | 'image' | 'audio';
|
||||
label: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface AdminWorkVisibilityEntryPayload {
|
||||
sourceType: string;
|
||||
workId: string;
|
||||
profileId: string;
|
||||
sourceSessionId?: string | null;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
coverImageSrc?: string | null;
|
||||
visible: boolean;
|
||||
publishedAtMicros?: number | null;
|
||||
updatedAtMicros: number;
|
||||
}
|
||||
|
||||
export interface AdminWorkVisibilityListResponse {
|
||||
entries: AdminWorkVisibilityEntryPayload[];
|
||||
}
|
||||
|
||||
export interface AdminUpdateWorkVisibilityRequest {
|
||||
sourceType: string;
|
||||
profileId: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export interface AdminUpdateWorkVisibilityResponse {
|
||||
entry: AdminWorkVisibilityEntryPayload;
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetListQuery {
|
||||
cursor?: string | null;
|
||||
ownerUserId?: string | null;
|
||||
@@ -513,6 +392,7 @@ export interface AdminEditorShowcaseAssetPayload {
|
||||
taskId?: string | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: Record<string, unknown> | null;
|
||||
thumbnailSrc?: string | null;
|
||||
generationCostMudPoints: number;
|
||||
refundMudPoints: number;
|
||||
reviewStatus: 'pending' | 'approved' | 'rejected' | string;
|
||||
@@ -600,6 +480,13 @@ export interface AdminCreateEditorShowcaseCampaignImageUploadTicketResponse {
|
||||
upload: AdminDirectUploadTicketPayload;
|
||||
}
|
||||
|
||||
export interface AdminConfirmEditorShowcaseCampaignImageUploadRequest {
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
contentType: string;
|
||||
contentLength: number;
|
||||
}
|
||||
|
||||
export interface AdminUploadedEditorShowcaseCampaignImage {
|
||||
imageSrc: string;
|
||||
imageObjectKey: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
formatAdminApiError,
|
||||
@@ -17,33 +17,31 @@ import {
|
||||
getStoredAdminToken,
|
||||
setStoredAdminToken,
|
||||
} from '../auth/adminAuthStore';
|
||||
import {AdminAccountsPage} from '../pages/AdminAccountsPage';
|
||||
import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage';
|
||||
import {AdminDashboardPage} from '../pages/AdminDashboardPage';
|
||||
import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
|
||||
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
|
||||
import {AdminEditorAssetQueryPage} from '../pages/AdminEditorAssetQueryPage';
|
||||
import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage';
|
||||
import {AdminEditorShowcaseReviewPage} from '../pages/AdminEditorShowcaseReviewPage';
|
||||
import {AdminGrayReleaseConfigPage} from '../pages/AdminGrayReleaseConfigPage';
|
||||
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
|
||||
import {AdminLoginPage} from '../pages/AdminLoginPage';
|
||||
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
|
||||
import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage';
|
||||
import {AdminRechargeOrderPage} from '../pages/AdminRechargeOrderPage';
|
||||
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
|
||||
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
|
||||
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
|
||||
import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage';
|
||||
import {AdminWorkVisibilityPage} from '../pages/AdminWorkVisibilityPage';
|
||||
import type {AdminRouteId} from './adminRoutes';
|
||||
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
||||
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
||||
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
||||
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
||||
import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
|
||||
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
|
||||
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
|
||||
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
|
||||
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
|
||||
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
|
||||
import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage';
|
||||
import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage';
|
||||
import { AdminTaskConfigPage } from '../pages/AdminTaskConfigPage';
|
||||
import { AdminTrackingEventsPage } from '../pages/AdminTrackingEventsPage';
|
||||
import type { AdminRouteId } from './adminRoutes';
|
||||
import {
|
||||
getAccessibleAdminRoutes,
|
||||
resolveAccessibleAdminRoute,
|
||||
resolveAdminRoute,
|
||||
routeHash,
|
||||
} from './adminRoutes';
|
||||
import {AdminShell} from './AdminShell';
|
||||
import { AdminShell } from './AdminShell';
|
||||
|
||||
type SessionStatus = 'checking' | 'guest' | 'authenticated';
|
||||
|
||||
@@ -157,17 +155,20 @@ export function AdminApp() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLogin = useCallback(async (username: string, password: string) => {
|
||||
const response = await loginAdmin(username, password);
|
||||
setStoredAdminToken(response.token);
|
||||
setToken(response.token);
|
||||
setAdmin(response.admin);
|
||||
setTaskConfigResult(null);
|
||||
setProfileWalletConfigResult(null);
|
||||
setRechargeProductResult(null);
|
||||
setLoginNotice('');
|
||||
setStatus('authenticated');
|
||||
}, []);
|
||||
const handleLogin = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
const response = await loginAdmin(username, password);
|
||||
setStoredAdminToken(response.token);
|
||||
setToken(response.token);
|
||||
setAdmin(response.admin);
|
||||
setTaskConfigResult(null);
|
||||
setProfileWalletConfigResult(null);
|
||||
setRechargeProductResult(null);
|
||||
setLoginNotice('');
|
||||
setStatus('authenticated');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleUnauthorized = useCallback(
|
||||
(message = '登录状态已失效') => {
|
||||
@@ -245,25 +246,6 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'creation-announcement' ? (
|
||||
<AdminCreationEntrySwitchPage
|
||||
mode="announcements"
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'creation-entry' ? (
|
||||
<AdminCreationEntrySwitchPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'work-visibility' ? (
|
||||
<AdminWorkVisibilityPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'tasks' ? (
|
||||
<AdminTaskConfigPage
|
||||
result={taskConfigResult}
|
||||
@@ -313,10 +295,7 @@ export function AdminApp() {
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'accounts' ? (
|
||||
<AdminAccountsPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
<AdminAccountsPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
</AdminShell>
|
||||
);
|
||||
|
||||
@@ -4,16 +4,13 @@ import {
|
||||
Bug,
|
||||
Coins,
|
||||
Database,
|
||||
Eye,
|
||||
GitBranch,
|
||||
Images,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
ReceiptText,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Star,
|
||||
Table2,
|
||||
TicketCheck,
|
||||
@@ -21,10 +18,10 @@ import {
|
||||
Users,
|
||||
WalletCards,
|
||||
} from 'lucide-react';
|
||||
import type {ReactNode} from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type {AdminSessionPayload} from '../api/adminApiTypes';
|
||||
import type {AdminRouteDefinition, AdminRouteId} from './adminRoutes';
|
||||
import type { AdminSessionPayload } from '../api/adminApiTypes';
|
||||
import type { AdminRouteDefinition, AdminRouteId } from './adminRoutes';
|
||||
|
||||
interface AdminShellProps {
|
||||
admin: AdminSessionPayload;
|
||||
@@ -51,9 +48,6 @@ const routeIcons = {
|
||||
'editor-generation-pricing': Coins,
|
||||
'editor-showcase': Star,
|
||||
'editor-assets': Images,
|
||||
'creation-announcement': Megaphone,
|
||||
'creation-entry': SlidersHorizontal,
|
||||
'work-visibility': Eye,
|
||||
accounts: Users,
|
||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {expect, test} from 'vitest';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
adminRoutes,
|
||||
@@ -19,19 +19,6 @@ test('后台默认进入 Dashboard', () => {
|
||||
expect(routeHash('dashboard')).toBe('#dashboard');
|
||||
});
|
||||
|
||||
// 中文注释:后台入口公告必须作为独立导航存在,避免公告表单被误藏在入口开关页。
|
||||
test('后台入口公告路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'creation-announcement',
|
||||
label: '入口公告',
|
||||
hash: '#creation-announcement',
|
||||
});
|
||||
expect(resolveAdminRoute('#creation-announcement')).toBe(
|
||||
'creation-announcement',
|
||||
);
|
||||
expect(routeHash('creation-announcement')).toBe('#creation-announcement');
|
||||
});
|
||||
|
||||
test('后台模型定价路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'editor-generation-pricing',
|
||||
@@ -56,6 +43,12 @@ test('后台灰度发布路由可通过导航和 hash 访问', () => {
|
||||
expect(routeHash('gray-release')).toBe('#gray-release');
|
||||
});
|
||||
|
||||
test('后台不再暴露旧创作模板管理路由', () => {
|
||||
expect(resolveAdminRoute('#creation-entry')).toBe('dashboard');
|
||||
expect(resolveAdminRoute('#creation-announcement')).toBe('dashboard');
|
||||
expect(resolveAdminRoute('#work-visibility')).toBe('dashboard');
|
||||
});
|
||||
|
||||
test('后台素材查询路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'editor-assets',
|
||||
@@ -92,7 +85,7 @@ test('owner 可访问全部业务 Tab 和账号管理', () => {
|
||||
tabPermissions: [],
|
||||
});
|
||||
expect(routes).toEqual(adminRoutes);
|
||||
expect(routes.at(-1)).toMatchObject({id: 'accounts', ownerOnly: true});
|
||||
expect(routes.at(-1)).toMatchObject({ id: 'accounts', ownerOnly: true });
|
||||
});
|
||||
|
||||
test('member 只访问已分配 Tab 且无权 hash 回落到第一项', () => {
|
||||
@@ -110,6 +103,17 @@ test('member 只访问已分配 Tab 且无权 hash 回落到第一项', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('member 可单独获得灰度发布 Tab 权限', () => {
|
||||
const routes = getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['gray-release'],
|
||||
});
|
||||
expect(routes.map((route) => route.id)).toEqual(['gray-release']);
|
||||
expect(resolveAccessibleAdminRoute('#gray-release', routes)).toBe(
|
||||
'gray-release',
|
||||
);
|
||||
});
|
||||
|
||||
test('零权限 member 不回落到 Dashboard', () => {
|
||||
const routes = getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 后台单页应用可导航的路由标识,入口公告独立于入口开关维护。 */
|
||||
/** 后台单页应用可导航的路由标识。 */
|
||||
export type AdminRouteId =
|
||||
| 'dashboard'
|
||||
| 'overview'
|
||||
@@ -15,9 +15,6 @@ export type AdminRouteId =
|
||||
| 'editor-generation-pricing'
|
||||
| 'editor-showcase'
|
||||
| 'editor-assets'
|
||||
| 'creation-announcement'
|
||||
| 'creation-entry'
|
||||
| 'work-visibility'
|
||||
| 'accounts';
|
||||
|
||||
export type AdminTabPermission = Exclude<AdminRouteId, 'accounts'>;
|
||||
@@ -31,25 +28,26 @@ export interface AdminRouteDefinition {
|
||||
}
|
||||
|
||||
export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'dashboard', label: 'Dashboard', hash: '#dashboard'},
|
||||
{id: 'overview', label: '服务总览', hash: '#overview'},
|
||||
{id: 'tables', label: '表查询', hash: '#tables'},
|
||||
{id: 'debug', label: 'API 调试', hash: '#debug'},
|
||||
{id: 'tracking', label: '埋点数据', hash: '#tracking'},
|
||||
{id: 'gray-release', label: '灰度发布', hash: '#gray-release'},
|
||||
{id: 'redeem', label: '兑换码', hash: '#redeem'},
|
||||
{id: 'invite', label: '邀请码', hash: '#invite'},
|
||||
{id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet'},
|
||||
{id: 'tasks', label: '任务配置', hash: '#tasks'},
|
||||
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
|
||||
{id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders'},
|
||||
{id: 'editor-generation-pricing', label: '模型定价', hash: '#editor-generation-pricing'},
|
||||
{id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase'},
|
||||
{id: 'editor-assets', label: '素材查询', hash: '#editor-assets'},
|
||||
{id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'},
|
||||
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
|
||||
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
|
||||
{id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true},
|
||||
{ id: 'dashboard', label: 'Dashboard', hash: '#dashboard' },
|
||||
{ id: 'overview', label: '服务总览', hash: '#overview' },
|
||||
{ id: 'tables', label: '表查询', hash: '#tables' },
|
||||
{ id: 'debug', label: 'API 调试', hash: '#debug' },
|
||||
{ id: 'tracking', label: '埋点数据', hash: '#tracking' },
|
||||
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
|
||||
{ id: 'redeem', label: '兑换码', hash: '#redeem' },
|
||||
{ id: 'invite', label: '邀请码', hash: '#invite' },
|
||||
{ id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet' },
|
||||
{ id: 'tasks', label: '任务配置', hash: '#tasks' },
|
||||
{ id: 'recharge-products', label: '充值商品', hash: '#recharge-products' },
|
||||
{ id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders' },
|
||||
{
|
||||
id: 'editor-generation-pricing',
|
||||
label: '模型定价',
|
||||
hash: '#editor-generation-pricing',
|
||||
},
|
||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||
];
|
||||
|
||||
export interface AdminRouteAccess {
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
|
||||
import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient';
|
||||
|
||||
const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300;
|
||||
const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40;
|
||||
const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const;
|
||||
const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px';
|
||||
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
|
||||
let adminAssetReadDispatchTail = Promise.resolve();
|
||||
|
||||
export interface AdminPreviewableEditorAsset {
|
||||
assetId: string;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
assetKind?: string | null;
|
||||
thumbnailSrc?: string | null;
|
||||
}
|
||||
|
||||
export function AdminEditorAssetThumbnail({
|
||||
entry,
|
||||
token,
|
||||
altPrefix = '素材',
|
||||
}: {
|
||||
entry: AdminPreviewableEditorAsset;
|
||||
token: string;
|
||||
altPrefix?: string;
|
||||
}) {
|
||||
const thumbnailSource = resolveAdminAssetThumbnailSource(entry);
|
||||
const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility();
|
||||
const imageSrc = useAdminResolvedAssetUrl(
|
||||
token,
|
||||
thumbnailSource.src,
|
||||
thumbnailSource.objectKey,
|
||||
shouldLoad,
|
||||
);
|
||||
const alt = `${altPrefix}:${entry.label || entry.assetId}`;
|
||||
|
||||
return imageSrc ? (
|
||||
<img
|
||||
ref={observeElement}
|
||||
alt={alt}
|
||||
className="admin-asset-query-thumb"
|
||||
src={imageSrc}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={observeElement}
|
||||
className="admin-asset-query-thumb admin-asset-query-thumb-placeholder"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminEditorAssetPreviewDialog({
|
||||
entry,
|
||||
token,
|
||||
onClose,
|
||||
}: {
|
||||
entry: AdminPreviewableEditorAsset;
|
||||
token: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-confirm-backdrop" role="presentation">
|
||||
<section
|
||||
aria-label="素材预览"
|
||||
className="admin-detail-panel admin-asset-query-preview-dialog"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="admin-panel-heading">
|
||||
<div>
|
||||
<h3>{entry.label || entry.assetId}</h3>
|
||||
<span>{entry.assetId}</span>
|
||||
</div>
|
||||
<button
|
||||
aria-label="关闭素材预览"
|
||||
className="admin-ghost-button"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<AdminEditorAssetPreviewMedia entry={entry} token={token} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminEditorAssetPreviewMedia({
|
||||
entry,
|
||||
token,
|
||||
}: {
|
||||
entry: AdminPreviewableEditorAsset;
|
||||
token: string;
|
||||
}) {
|
||||
const mediaKind = resolveAdminAssetMediaKind(entry);
|
||||
const isAudio = mediaKind === 'audio';
|
||||
const isVideo = mediaKind === 'video';
|
||||
const mediaSrc = useAdminResolvedAssetUrl(
|
||||
token,
|
||||
entry.imageSrc,
|
||||
entry.objectKey,
|
||||
);
|
||||
const posterSrc = useAdminResolvedAssetUrl(
|
||||
token,
|
||||
isVideo ? (entry.thumbnailSrc ?? '') : '',
|
||||
null,
|
||||
);
|
||||
const label = entry.label || entry.assetId;
|
||||
|
||||
if (isAudio) {
|
||||
return (
|
||||
<div className="admin-asset-query-preview-audio">
|
||||
<img
|
||||
alt={`音频封面:${label}`}
|
||||
className="admin-asset-query-preview-cover"
|
||||
src={AUDIO_ASSET_COVER_SRC}
|
||||
/>
|
||||
{mediaSrc ? (
|
||||
<audio
|
||||
aria-label={`音频预览:${label}`}
|
||||
className="admin-asset-query-preview-player"
|
||||
controls
|
||||
preload="metadata"
|
||||
src={mediaSrc}
|
||||
/>
|
||||
) : (
|
||||
<div className="admin-asset-query-preview-placeholder" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
return mediaSrc ? (
|
||||
<video
|
||||
aria-label={`视频预览:${label}`}
|
||||
className="admin-asset-query-preview-media"
|
||||
controls
|
||||
playsInline
|
||||
poster={posterSrc || undefined}
|
||||
preload="metadata"
|
||||
src={mediaSrc}
|
||||
/>
|
||||
) : (
|
||||
<div className="admin-asset-query-preview-placeholder" />
|
||||
);
|
||||
}
|
||||
|
||||
return mediaSrc ? (
|
||||
<img
|
||||
alt={`图片预览:${label}`}
|
||||
className="admin-asset-query-preview-media"
|
||||
src={mediaSrc}
|
||||
/>
|
||||
) : (
|
||||
<div className="admin-asset-query-preview-placeholder" />
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAdminAssetThumbnailSource(entry: AdminPreviewableEditorAsset) {
|
||||
const mediaKind = resolveAdminAssetMediaKind(entry);
|
||||
if (mediaKind === 'audio') {
|
||||
return { src: AUDIO_ASSET_COVER_SRC, objectKey: null };
|
||||
}
|
||||
if (mediaKind === 'video') {
|
||||
return { src: entry.thumbnailSrc || '', objectKey: null };
|
||||
}
|
||||
if (entry.thumbnailSrc?.trim()) {
|
||||
return {
|
||||
src: entry.thumbnailSrc,
|
||||
objectKey: adminAssetPathsMatch(entry.thumbnailSrc, entry.imageSrc)
|
||||
? entry.objectKey
|
||||
: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
src: entry.imageSrc,
|
||||
objectKey: entry.objectKey,
|
||||
};
|
||||
}
|
||||
|
||||
function useAdminAssetThumbnailVisibility() {
|
||||
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||
const [shouldLoad, setShouldLoad] = useState(false);
|
||||
const observeElement = useCallback((nextElement: HTMLElement | null) => {
|
||||
setElement(nextElement);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldLoad || !element) {
|
||||
return;
|
||||
}
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setShouldLoad(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setShouldLoad(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN },
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [element, shouldLoad]);
|
||||
|
||||
return { observeElement, shouldLoad };
|
||||
}
|
||||
|
||||
type AdminAssetMediaKind = 'image' | 'audio' | 'video';
|
||||
|
||||
function resolveAdminAssetMediaKind(
|
||||
entry: AdminPreviewableEditorAsset,
|
||||
): AdminAssetMediaKind {
|
||||
const pathMediaKind =
|
||||
resolveAdminAssetMediaKindFromPath(entry.imageSrc) ??
|
||||
resolveAdminAssetMediaKindFromPath(entry.objectKey ?? '');
|
||||
if (pathMediaKind) {
|
||||
return pathMediaKind;
|
||||
}
|
||||
|
||||
const assetKind = entry.assetKind?.trim() ?? '';
|
||||
if (
|
||||
assetKind === 'sound-effect' ||
|
||||
assetKind === 'background-music' ||
|
||||
assetKind === 'editor_uploaded_audio'
|
||||
) {
|
||||
return 'audio';
|
||||
}
|
||||
if (
|
||||
assetKind === 'video' ||
|
||||
assetKind === 'editor_video' ||
|
||||
assetKind === 'editor-video' ||
|
||||
assetKind === 'editor_uploaded_video'
|
||||
) {
|
||||
return 'video';
|
||||
}
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function resolveAdminAssetMediaKindFromPath(
|
||||
value: string,
|
||||
): AdminAssetMediaKind | null {
|
||||
const normalizedValue = value.trim();
|
||||
if (/^data:image\//iu.test(normalizedValue)) {
|
||||
return 'image';
|
||||
}
|
||||
if (/^data:audio\//iu.test(normalizedValue)) {
|
||||
return 'audio';
|
||||
}
|
||||
if (/^data:video\//iu.test(normalizedValue)) {
|
||||
return 'video';
|
||||
}
|
||||
if (
|
||||
/\.(?:avif|bmp|gif|jpe?g|png|svg|webp)(?:$|[?#])/iu.test(normalizedValue)
|
||||
) {
|
||||
return 'image';
|
||||
}
|
||||
if (/\.(?:aac|flac|m4a|mp3|ogg|opus|wav)(?:$|[?#])/iu.test(normalizedValue)) {
|
||||
return 'audio';
|
||||
}
|
||||
if (/\.(?:m4v|mov|mp4|ogv|webm)(?:$|[?#])/iu.test(normalizedValue)) {
|
||||
return 'video';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function useAdminResolvedAssetUrl(
|
||||
token: string,
|
||||
imageSrc: string | null | undefined,
|
||||
objectKey: string | null | undefined,
|
||||
enabled = true,
|
||||
) {
|
||||
const normalizedImageSrc = imageSrc?.trim() ?? '';
|
||||
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
|
||||
const normalizedLegacyPublicPath = isGeneratedLegacyPath(normalizedImageSrc)
|
||||
? normalizedImageSrc
|
||||
: resolveAdminGeneratedLegacyPathFromUrl(normalizedImageSrc);
|
||||
const shouldResolve =
|
||||
Boolean(normalizedObjectKey) || Boolean(normalizedLegacyPublicPath);
|
||||
const [resolvedImageSrc, setResolvedImageSrc] = useState(
|
||||
shouldResolve ? '' : normalizedImageSrc,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedImageSrc && !normalizedObjectKey) {
|
||||
setResolvedImageSrc('');
|
||||
return;
|
||||
}
|
||||
if (!shouldResolve) {
|
||||
setResolvedImageSrc(normalizedImageSrc);
|
||||
return;
|
||||
}
|
||||
if (!enabled) {
|
||||
setResolvedImageSrc('');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let retryIndex = 0;
|
||||
const dispatchController = new AbortController();
|
||||
setResolvedImageSrc('');
|
||||
|
||||
const resolveReadUrl = async () => {
|
||||
try {
|
||||
await waitForAdminAssetReadDispatch(dispatchController.signal);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const response = await getAdminAssetReadUrl(
|
||||
token,
|
||||
normalizedObjectKey
|
||||
? {
|
||||
objectKey: normalizedObjectKey,
|
||||
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
|
||||
}
|
||||
: {
|
||||
legacyPublicPath: normalizedLegacyPublicPath,
|
||||
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
|
||||
},
|
||||
);
|
||||
if (!cancelled) {
|
||||
setResolvedImageSrc(resolveAdminAssetReadSignedUrl(response));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const retryDelay = ADMIN_ASSET_READ_RETRY_DELAYS_MS[retryIndex];
|
||||
if (
|
||||
isAdminApiError(error) &&
|
||||
error.status === 429 &&
|
||||
typeof retryDelay === 'number'
|
||||
) {
|
||||
retryIndex += 1;
|
||||
retryTimer = setTimeout(() => void resolveReadUrl(), retryDelay);
|
||||
return;
|
||||
}
|
||||
setResolvedImageSrc('');
|
||||
}
|
||||
};
|
||||
|
||||
void resolveReadUrl();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
dispatchController.abort();
|
||||
if (retryTimer !== null) {
|
||||
clearTimeout(retryTimer);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
normalizedImageSrc,
|
||||
normalizedLegacyPublicPath,
|
||||
normalizedObjectKey,
|
||||
shouldResolve,
|
||||
token,
|
||||
]);
|
||||
|
||||
return resolvedImageSrc;
|
||||
}
|
||||
|
||||
async function waitForAdminAssetReadDispatch(signal: AbortSignal) {
|
||||
const dispatch = adminAssetReadDispatchTail.then(
|
||||
() => waitForAdminAssetReadDispatchSpacing(signal),
|
||||
() => waitForAdminAssetReadDispatchSpacing(signal),
|
||||
);
|
||||
adminAssetReadDispatchTail = dispatch.catch(() => undefined);
|
||||
await dispatch;
|
||||
}
|
||||
|
||||
async function waitForAdminAssetReadDispatchSpacing(signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('The operation was aborted.', 'AbortError');
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
resolve();
|
||||
}, ADMIN_ASSET_READ_DISPATCH_SPACING_MS);
|
||||
|
||||
function handleAbort() {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
}
|
||||
|
||||
signal.addEventListener('abort', handleAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAdminObjectKey(value: string | null | undefined) {
|
||||
return value?.trim().replace(/^\/+/u, '') ?? '';
|
||||
}
|
||||
|
||||
function adminAssetPathsMatch(left: string, right: string) {
|
||||
return (
|
||||
left.trim().replace(/^\/+|[?#].*$/gu, '') ===
|
||||
right.trim().replace(/^\/+|[?#].*$/gu, '')
|
||||
);
|
||||
}
|
||||
|
||||
function isGeneratedLegacyPath(value: string) {
|
||||
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
|
||||
}
|
||||
|
||||
function resolveAdminGeneratedLegacyPathFromUrl(value: string) {
|
||||
try {
|
||||
const parsedUrl = new URL(value);
|
||||
if (
|
||||
parsedUrl.protocol !== 'https:' ||
|
||||
!/^[^.]+\.oss-[^.]+\.aliyuncs\.com$/iu.test(parsedUrl.hostname)
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
const legacyPublicPath = decodeURIComponent(parsedUrl.pathname);
|
||||
return isGeneratedLegacyPath(legacyPublicPath) ? legacyPublicPath : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAdminAssetReadSignedUrl(response: AdminAssetReadUrlResponse) {
|
||||
const read = response.read ?? response;
|
||||
return typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
|
||||
}
|
||||
@@ -21,8 +21,8 @@ describe('admin tracking event definitions', () => {
|
||||
expect(keys).toContain('auth_login_options_view');
|
||||
expect(keys).toContain('task_center_view');
|
||||
expect(keys).toContain('asset_upload_ticket_create');
|
||||
expect(keys).toContain('creative_agent_route_success');
|
||||
expect(keys).toContain('work_play_start');
|
||||
expect(keys).not.toContain('creative_agent_route_success');
|
||||
expect(keys).not.toContain('work_play_start');
|
||||
});
|
||||
|
||||
test('任务配置候选只开放适合个人任务的事件', () => {
|
||||
@@ -49,13 +49,15 @@ describe('admin tracking event definitions', () => {
|
||||
]);
|
||||
|
||||
expect(options.find(({ key }) => key === 'work_play_start')?.title).toBe(
|
||||
'作品开始游玩',
|
||||
'work_play_start',
|
||||
);
|
||||
expect(options.find(({ key }) => key === 'unknown_event')?.title).toBe(
|
||||
'未知事件',
|
||||
);
|
||||
expect(
|
||||
filterAdminTrackingEventKeyOptions(options, '作品').map(({ key }) => key),
|
||||
filterAdminTrackingEventKeyOptions(options, 'work_play').map(
|
||||
({ key }) => key,
|
||||
),
|
||||
).toEqual(['work_play_start']);
|
||||
});
|
||||
|
||||
@@ -65,7 +67,7 @@ describe('admin tracking event definitions', () => {
|
||||
).toEqual(['asset_upload_ticket_create']);
|
||||
expect(
|
||||
filterAdminTrackingEventDefinitions('work_play').map(({ key }) => key),
|
||||
).toEqual(['work_play_start']);
|
||||
).toEqual([]);
|
||||
expect(findAdminTrackingEventDefinition(' daily_login ')?.title).toBe(
|
||||
'每日登录',
|
||||
);
|
||||
|
||||
@@ -148,42 +148,6 @@ export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
|
||||
scopeKind: 'user',
|
||||
remark: '领取个人任务奖励成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'save_archive_list_view',
|
||||
title: '存档列表查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取个人存档列表成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'save_archive_detail_view',
|
||||
title: '存档详情查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取个人存档详情成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'browse_history_view',
|
||||
title: '浏览历史查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取浏览历史成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'browse_history_record',
|
||||
title: '浏览历史写入',
|
||||
scopeKind: 'user',
|
||||
remark: '记录浏览历史成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'browse_history_clear',
|
||||
title: '浏览历史清空',
|
||||
scopeKind: 'user',
|
||||
remark: '清空浏览历史成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'play_stats_view',
|
||||
title: '游玩统计查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取个人游玩统计成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'profile_analytics_metric_view',
|
||||
title: '个人指标查看',
|
||||
@@ -352,62 +316,6 @@ export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
|
||||
scopeKind: 'user',
|
||||
remark: '更新运行设置成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'runtime_snapshot_view',
|
||||
title: '运行快照查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取运行快照成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'runtime_snapshot_save',
|
||||
title: '运行快照保存',
|
||||
scopeKind: 'user',
|
||||
remark: '保存运行快照成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'runtime_snapshot_delete',
|
||||
title: '运行快照删除',
|
||||
scopeKind: 'user',
|
||||
remark: '删除运行快照成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'puzzle_route_success',
|
||||
title: '拼图路由成功',
|
||||
scopeKind: 'user',
|
||||
remark: '拼图运行或创作接口成功响应后兜底记录;GET 入口可能按 site 统计。',
|
||||
},
|
||||
{
|
||||
key: 'match3d_route_success',
|
||||
title: '抓大鹅路由成功',
|
||||
scopeKind: 'user',
|
||||
remark:
|
||||
'抓大鹅创作或运行接口成功响应后兜底记录;GET 入口可能按 site 统计。',
|
||||
},
|
||||
{
|
||||
key: 'square_hole_route_success',
|
||||
title: '方洞路由成功',
|
||||
scopeKind: 'user',
|
||||
remark: '方洞创作或运行接口成功响应后兜底记录;GET 入口可能按 site 统计。',
|
||||
},
|
||||
{
|
||||
key: 'custom_world_route_success',
|
||||
title: '自定义世界路由成功',
|
||||
scopeKind: 'user',
|
||||
remark: '自定义世界运行接口成功响应后兜底记录;GET 入口可能按 site 统计。',
|
||||
},
|
||||
{
|
||||
key: 'creative_agent_route_success',
|
||||
title: '创意 Agent 路由成功',
|
||||
scopeKind: 'user',
|
||||
remark: '创意 Agent 接口成功响应后兜底记录;GET 入口可能按 site 统计。',
|
||||
},
|
||||
{
|
||||
key: 'work_play_start',
|
||||
title: '作品开始游玩',
|
||||
scopeKind: 'work',
|
||||
remark:
|
||||
'拼图、抓大鹅、方洞、自定义世界、大鱼吃小鱼、Visual Novel 正式开始游玩时记录。',
|
||||
},
|
||||
];
|
||||
|
||||
export const adminProfileTaskTrackingEventDefinitions =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,14 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminAssetReadUrl,
|
||||
@@ -23,6 +24,13 @@ import { AdminEditorShowcaseReviewPage } from './AdminEditorShowcaseReviewPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
getAdminAssetReadUrl: vi.fn(),
|
||||
isAdminApiError: vi.fn(
|
||||
(error: unknown) =>
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'status' in error &&
|
||||
typeof error.status === 'number',
|
||||
),
|
||||
getAdminEditorShowcaseCampaign: vi.fn(),
|
||||
listAdminEditorShowcaseAssets: vi.fn(),
|
||||
reviewAdminEditorShowcaseAsset: vi.fn(),
|
||||
@@ -31,8 +39,87 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
upsertAdminEditorShowcaseCampaign: vi.fn(),
|
||||
}));
|
||||
|
||||
interface MockIntersectionObserverController {
|
||||
enter: (target: Element) => void;
|
||||
isObserved: (target: Element) => boolean;
|
||||
}
|
||||
|
||||
function installIntersectionObserverMock(): MockIntersectionObserverController {
|
||||
const observed = new Map<
|
||||
Element,
|
||||
{
|
||||
callback: IntersectionObserverCallback;
|
||||
observer: IntersectionObserver;
|
||||
}
|
||||
>();
|
||||
|
||||
class MockIntersectionObserver implements IntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin: string;
|
||||
readonly thresholds = [0];
|
||||
private readonly targets = new Set<Element>();
|
||||
|
||||
constructor(
|
||||
private readonly callback: IntersectionObserverCallback,
|
||||
options: IntersectionObserverInit = {},
|
||||
) {
|
||||
this.rootMargin = options.rootMargin ?? '0px';
|
||||
}
|
||||
|
||||
observe(target: Element) {
|
||||
this.targets.add(target);
|
||||
observed.set(target, {
|
||||
callback: this.callback,
|
||||
observer: this as unknown as IntersectionObserver,
|
||||
});
|
||||
}
|
||||
|
||||
unobserve(target: Element) {
|
||||
this.targets.delete(target);
|
||||
observed.delete(target);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.targets.forEach((target) => observed.delete(target));
|
||||
this.targets.clear();
|
||||
}
|
||||
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
|
||||
return {
|
||||
enter(target) {
|
||||
const record = observed.get(target);
|
||||
if (!record) {
|
||||
throw new Error('目标精选缩略图尚未进入 IntersectionObserver');
|
||||
}
|
||||
act(() => {
|
||||
record.callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
target,
|
||||
} as IntersectionObserverEntry,
|
||||
],
|
||||
record.observer,
|
||||
);
|
||||
});
|
||||
},
|
||||
isObserved(target) {
|
||||
return observed.has(target);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('../components/AdminUserReferenceButton', () => ({
|
||||
AdminUserReferenceButton: ({ userId, publicUserCode }: {
|
||||
AdminUserReferenceButton: ({
|
||||
userId,
|
||||
publicUserCode,
|
||||
}: {
|
||||
userId?: string;
|
||||
publicUserCode?: string | null;
|
||||
}) => (
|
||||
@@ -160,6 +247,10 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('后台精选审核展示待审核素材和活动卡配置', async () => {
|
||||
render(
|
||||
<AdminEditorShowcaseReviewPage
|
||||
@@ -184,10 +275,114 @@ test('后台精选审核展示待审核素材和活动卡配置', async () => {
|
||||
submittedBefore: null,
|
||||
limit: 80,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
||||
objectKey: 'generated-character-drafts/editor/spec.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('后台精选审核缩略图进入视口后换签并可打开图片预览', async () => {
|
||||
const observer = installIntersectionObserverMock();
|
||||
render(
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('角色形象 1')).toBeTruthy();
|
||||
const previewButton = screen.getByTitle('预览素材');
|
||||
const thumbnail = previewButton.querySelector('.admin-asset-query-thumb');
|
||||
expect(thumbnail).not.toBeNull();
|
||||
await waitFor(() => expect(observer.isObserved(thumbnail!)).toBe(true));
|
||||
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
|
||||
|
||||
observer.enter(thumbnail!);
|
||||
const image = await screen.findByRole('img', {
|
||||
name: '精选素材:角色形象 1',
|
||||
});
|
||||
expect(image.getAttribute('src')).toBe('https://signed.example.com/spec.png');
|
||||
|
||||
fireEvent.click(previewButton);
|
||||
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
|
||||
const previewImage = await within(dialog).findByRole('img', {
|
||||
name: '图片预览:角色形象 1',
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(previewImage.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/spec.png',
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: '精选素材详情' })).toBeNull();
|
||||
});
|
||||
|
||||
test('后台精选审核将无 objectKey 的绝对 OSS 图片地址换签后预览', async () => {
|
||||
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
...pendingShowcaseAsset,
|
||||
imageSrc:
|
||||
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-character-drafts/editor/absolute.png?x-oss-process=image/resize,w_320',
|
||||
objectKey: null,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
||||
read: {
|
||||
objectKey: 'generated-character-drafts/editor/absolute.png',
|
||||
signedUrl: 'https://signed.example.com/absolute.png',
|
||||
expiresAt: '2026-07-04T11:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const image = await screen.findByRole('img', {
|
||||
name: '精选素材:角色形象 1',
|
||||
});
|
||||
expect(image.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/absolute.png',
|
||||
);
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
|
||||
objectKey: 'generated-character-drafts/editor/spec.png',
|
||||
legacyPublicPath: '/generated-character-drafts/editor/absolute.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle('预览素材'));
|
||||
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
|
||||
expect(
|
||||
await within(dialog).findByRole('img', {
|
||||
name: '图片预览:角色形象 1',
|
||||
}),
|
||||
).toHaveProperty('src', 'https://signed.example.com/absolute.png');
|
||||
});
|
||||
|
||||
test('后台精选审核详情中的缩略图也可打开素材预览', async () => {
|
||||
render(
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '详情' }));
|
||||
const detail = await screen.findByRole('dialog', { name: '精选素材详情' });
|
||||
fireEvent.click(within(detail).getByTitle('预览素材'));
|
||||
|
||||
const preview = await screen.findByRole('dialog', { name: '素材预览' });
|
||||
expect(
|
||||
await within(preview).findByRole('img', {
|
||||
name: '图片预览:角色形象 1',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('后台精选审核格式化微秒时间并显示素材名', async () => {
|
||||
|
||||
@@ -2,9 +2,7 @@ import { Eye, FileText, RefreshCcw, Upload, X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
|
||||
import {
|
||||
getAdminAssetReadUrl,
|
||||
getAdminEditorShowcaseCampaign,
|
||||
listAdminEditorShowcaseAssets,
|
||||
reviewAdminEditorShowcaseAsset,
|
||||
@@ -17,6 +15,10 @@ import type {
|
||||
AdminEditorShowcaseCampaignPayload,
|
||||
AdminEditorShowcaseListQuery,
|
||||
} from '../api/adminApiTypes';
|
||||
import {
|
||||
AdminEditorAssetPreviewDialog,
|
||||
AdminEditorAssetThumbnail,
|
||||
} from '../components/AdminEditorAssetMedia';
|
||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
@@ -25,9 +27,6 @@ interface AdminEditorShowcaseReviewPageProps {
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
const ADMIN_SHOWCASE_READ_EXPIRE_SECONDS = 300;
|
||||
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
|
||||
|
||||
const showcaseCategoryOptions = [
|
||||
{ value: 'characters', label: '角色' },
|
||||
{ value: 'ui', label: 'UI' },
|
||||
@@ -58,6 +57,8 @@ export function AdminEditorShowcaseReviewPage({
|
||||
const [reviewNotes, setReviewNotes] = useState<Record<string, string>>({});
|
||||
const [detailEntry, setDetailEntry] =
|
||||
useState<AdminEditorShowcaseAssetPayload | null>(null);
|
||||
const [previewEntry, setPreviewEntry] =
|
||||
useState<AdminEditorShowcaseAssetPayload | null>(null);
|
||||
const [promptPreview, setPromptPreview] = useState<{
|
||||
title: string;
|
||||
prompt: string;
|
||||
@@ -342,11 +343,15 @@ export function AdminEditorShowcaseReviewPage({
|
||||
<td>
|
||||
<button
|
||||
className="admin-asset-query-thumb-button"
|
||||
title="查看详情"
|
||||
title="预览素材"
|
||||
type="button"
|
||||
onClick={() => setDetailEntry(entry)}
|
||||
onClick={() => setPreviewEntry(entry)}
|
||||
>
|
||||
<AdminShowcaseThumbnail entry={entry} token={token} />
|
||||
<AdminEditorAssetThumbnail
|
||||
entry={entry}
|
||||
token={token}
|
||||
altPrefix="精选素材"
|
||||
/>
|
||||
</button>
|
||||
<small>{entry.label || '-'}</small>
|
||||
</td>
|
||||
@@ -355,7 +360,9 @@ export function AdminEditorShowcaseReviewPage({
|
||||
<div className="admin-inline-identity">
|
||||
<div>
|
||||
{authorDisplayName(entry)}
|
||||
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
|
||||
<small>
|
||||
{entry.authorPublicUserCode?.trim() || '-'}
|
||||
</small>
|
||||
</div>
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
@@ -611,6 +618,7 @@ export function AdminEditorShowcaseReviewPage({
|
||||
token={token}
|
||||
onUnauthorized={onUnauthorized}
|
||||
onClose={() => setDetailEntry(null)}
|
||||
onPreview={(entry) => setPreviewEntry(entry)}
|
||||
onPromptPreview={(entry, prompt) =>
|
||||
setPromptPreview({
|
||||
title: entry.label || entry.showcaseId,
|
||||
@@ -620,6 +628,14 @@ export function AdminEditorShowcaseReviewPage({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{previewEntry ? (
|
||||
<AdminEditorAssetPreviewDialog
|
||||
entry={previewEntry}
|
||||
token={token}
|
||||
onClose={() => setPreviewEntry(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{promptPreview ? (
|
||||
<div className="admin-confirm-backdrop" role="presentation">
|
||||
<section
|
||||
@@ -652,48 +668,18 @@ export function AdminEditorShowcaseReviewPage({
|
||||
);
|
||||
}
|
||||
|
||||
function AdminShowcaseThumbnail({
|
||||
entry,
|
||||
token,
|
||||
}: {
|
||||
entry: AdminEditorShowcaseAssetPayload;
|
||||
token: string;
|
||||
}) {
|
||||
const isAudio = isAdminShowcaseAudioAsset(entry);
|
||||
const imageSrc = useAdminResolvedAssetImageSrc(
|
||||
token,
|
||||
isAudio ? AUDIO_ASSET_COVER_SRC : entry.imageSrc,
|
||||
isAudio ? null : entry.objectKey,
|
||||
);
|
||||
const alt = `精选素材:${entry.label || entry.showcaseId}`;
|
||||
|
||||
return imageSrc ? (
|
||||
<img alt={alt} className="admin-asset-query-thumb" src={imageSrc} />
|
||||
) : (
|
||||
<div className="admin-asset-query-thumb admin-asset-query-thumb-placeholder" />
|
||||
);
|
||||
}
|
||||
|
||||
function isAdminShowcaseAudioAsset(entry: AdminEditorShowcaseAssetPayload) {
|
||||
const assetKind = entry.assetKind?.trim() ?? '';
|
||||
return (
|
||||
assetKind === 'sound-effect' ||
|
||||
assetKind === 'background-music' ||
|
||||
assetKind === 'editor_uploaded_audio' ||
|
||||
/\.(?:mp3|wav|m4a|aac|ogg)(?:$|[?#])/iu.test(entry.imageSrc.trim())
|
||||
);
|
||||
}
|
||||
|
||||
function AdminShowcaseDetailDialog({
|
||||
entry,
|
||||
token,
|
||||
onClose,
|
||||
onPreview,
|
||||
onPromptPreview,
|
||||
onUnauthorized,
|
||||
}: {
|
||||
entry: AdminEditorShowcaseAssetPayload;
|
||||
token: string;
|
||||
onClose: () => void;
|
||||
onPreview: (entry: AdminEditorShowcaseAssetPayload) => void;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
onPromptPreview: (
|
||||
entry: AdminEditorShowcaseAssetPayload,
|
||||
@@ -723,7 +709,18 @@ function AdminShowcaseDetailDialog({
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-asset-query-detail-layout">
|
||||
<AdminShowcaseThumbnail entry={entry} token={token} />
|
||||
<button
|
||||
className="admin-asset-query-thumb-button admin-asset-query-detail-thumb-button"
|
||||
title="预览素材"
|
||||
type="button"
|
||||
onClick={() => onPreview(entry)}
|
||||
>
|
||||
<AdminEditorAssetThumbnail
|
||||
entry={entry}
|
||||
token={token}
|
||||
altPrefix="精选素材"
|
||||
/>
|
||||
</button>
|
||||
<dl className="admin-info-list admin-detail-list">
|
||||
<AdminInfoItem label="作者">
|
||||
<div className="admin-inline-identity">
|
||||
@@ -815,77 +812,6 @@ function AdminInfoItem({
|
||||
);
|
||||
}
|
||||
|
||||
function useAdminResolvedAssetImageSrc(
|
||||
token: string,
|
||||
imageSrc: string | null | undefined,
|
||||
objectKey: string | null | undefined,
|
||||
) {
|
||||
const normalizedImageSrc = imageSrc?.trim() ?? '';
|
||||
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
|
||||
const shouldResolve =
|
||||
Boolean(normalizedObjectKey) || isGeneratedLegacyPath(normalizedImageSrc);
|
||||
const [resolvedImageSrc, setResolvedImageSrc] = useState(
|
||||
shouldResolve ? '' : normalizedImageSrc,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedImageSrc && !normalizedObjectKey) {
|
||||
setResolvedImageSrc('');
|
||||
return;
|
||||
}
|
||||
if (!shouldResolve) {
|
||||
setResolvedImageSrc(normalizedImageSrc);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setResolvedImageSrc('');
|
||||
|
||||
void getAdminAssetReadUrl(
|
||||
token,
|
||||
normalizedObjectKey
|
||||
? {
|
||||
objectKey: normalizedObjectKey,
|
||||
expireSeconds: ADMIN_SHOWCASE_READ_EXPIRE_SECONDS,
|
||||
}
|
||||
: {
|
||||
legacyPublicPath: normalizedImageSrc,
|
||||
expireSeconds: ADMIN_SHOWCASE_READ_EXPIRE_SECONDS,
|
||||
},
|
||||
)
|
||||
.then(resolveAdminAssetReadSignedUrl)
|
||||
.then((signedUrl) => {
|
||||
if (!cancelled) {
|
||||
setResolvedImageSrc(signedUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setResolvedImageSrc('');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [normalizedImageSrc, normalizedObjectKey, shouldResolve, token]);
|
||||
|
||||
return resolvedImageSrc;
|
||||
}
|
||||
|
||||
function normalizeAdminObjectKey(value: string | null | undefined) {
|
||||
return value?.trim().replace(/^\/+/u, '') ?? '';
|
||||
}
|
||||
|
||||
function isGeneratedLegacyPath(value: string) {
|
||||
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
|
||||
}
|
||||
|
||||
function resolveAdminAssetReadSignedUrl(response: AdminAssetReadUrlResponse) {
|
||||
const read = response.read ?? response;
|
||||
return typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
|
||||
}
|
||||
|
||||
function mergeShowcaseEntries(
|
||||
current: AdminEditorShowcaseAssetPayload[],
|
||||
incoming: AdminEditorShowcaseAssetPayload[],
|
||||
|
||||
@@ -5,21 +5,16 @@ import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminCreationEntryConfig,
|
||||
getAdminFeatureGateConfig,
|
||||
upsertAdminFeatureGateConfig,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminCreationEntryConfigResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import type { AdminFeatureGateConfigResponse } from '../api/adminApiTypes';
|
||||
import { AdminGrayReleaseConfigPage } from './AdminGrayReleaseConfigPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
getAdminCreationEntryConfig: vi.fn(),
|
||||
getAdminFeatureGateConfig: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
upsertAdminFeatureGateConfig: vi.fn(),
|
||||
@@ -50,48 +45,8 @@ const configResponse: AdminFeatureGateConfigResponse = {
|
||||
],
|
||||
};
|
||||
|
||||
const creationEntryResponse: AdminCreationEntryConfigResponse = {
|
||||
entries: [
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '',
|
||||
badge: '',
|
||||
imageSrc: '',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
categoryId: 'default',
|
||||
categoryLabel: '默认',
|
||||
categorySortOrder: 0,
|
||||
updatedAtMicros: 0,
|
||||
unifiedCreationSpec: null,
|
||||
},
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '3D 消除',
|
||||
subtitle: '',
|
||||
badge: '',
|
||||
imageSrc: '',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 20,
|
||||
categoryId: 'default',
|
||||
categoryLabel: '默认',
|
||||
categorySortOrder: 0,
|
||||
updatedAtMicros: 0,
|
||||
unifiedCreationSpec: null,
|
||||
},
|
||||
],
|
||||
eventBanners: [],
|
||||
publicWorkInteractions: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(
|
||||
creationEntryResponse,
|
||||
);
|
||||
vi.mocked(getAdminFeatureGateConfig).mockResolvedValue(configResponse);
|
||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValue(configResponse);
|
||||
});
|
||||
@@ -109,7 +64,6 @@ test('灰度发布页加载并展示 gate 列表', async () => {
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('25%')).toBeTruthy();
|
||||
expect(getAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token');
|
||||
expect(getAdminCreationEntryConfig).toHaveBeenCalledWith('admin-token');
|
||||
});
|
||||
|
||||
test('灰度发布页可选择已有 gate 编辑', async () => {
|
||||
@@ -152,12 +106,11 @@ test('灰度发布页选择新 target 时重置旧 gate 规则', async () => {
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' }),
|
||||
);
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
||||
'creation-entry',
|
||||
'image-editor',
|
||||
]);
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 目标'), ['match3d']);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'creation-entry:match3d',
|
||||
'image-editor:agent-sidebar',
|
||||
);
|
||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||
false,
|
||||
@@ -175,27 +128,7 @@ test('灰度发布页选择新 target 时重置旧 gate 规则', async () => {
|
||||
(screen.getByLabelText('拒绝用户 ID') as HTMLTextAreaElement).value,
|
||||
).toBe('');
|
||||
expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe(
|
||||
'3D 消除创作入口灰度',
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页可通过创作入口生成 Gate Key', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
||||
'creation-entry',
|
||||
]);
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 目标'), ['puzzle']);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'creation-entry:puzzle',
|
||||
);
|
||||
expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe(
|
||||
'拼图创作入口灰度',
|
||||
'画布 Agent 入口灰度',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -281,7 +214,6 @@ test('灰度发布页保存时转换数组和百分比', async () => {
|
||||
test('灰度发布页无 token 时不请求配置', () => {
|
||||
render(<AdminGrayReleaseConfigPage token="" onUnauthorized={vi.fn()} />);
|
||||
|
||||
expect(getAdminCreationEntryConfig).not.toHaveBeenCalled();
|
||||
expect(getAdminFeatureGateConfig).not.toHaveBeenCalled();
|
||||
expect(upsertAdminFeatureGateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user