合并最新 master 分支
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Failing after 58s
Project CI / Native shell tests (pull_request) Successful in 2m32s

带入 SpacetimeDB 2.7.0(hotfix3)工具链升级、后台灰度发布控制面恢复、
精选顺序分列瀑布流及后台素材预览统一。冲突仅三处文档:decision-log 与
pitfalls 双方条目并存,运维文档更新时间取新值。BgFilter 分支代码与部署
脚本零冲突;依赖升级后 bgfilter 46 测试全过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 07:02:17 +00:00
187 changed files with 11561 additions and 883 deletions
+31 -4
View File
@@ -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.
+20 -8
View File
@@ -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
+4 -4
View File
@@ -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};
-1
View File
@@ -386,7 +386,6 @@ module.exports = {
'src/data/**',
'src/prompts/**',
'apps/admin-web/src/pages/AdminCreationEntrySwitchPage*',
'apps/admin-web/src/pages/AdminGrayReleaseConfigPage*',
'apps/admin-web/src/pages/AdminWorkVisibilityPage*',
'src/services/recommendedRuntimeGuestLaunch.test.ts',
'src/data/sceneEncounterPreviews.ts',
+141 -16
View File
@@ -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', {
+33
View File
@@ -1,5 +1,6 @@
import type {
AdminAccountListResponse,
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
@@ -22,6 +23,7 @@ import type {
AdminEditorShowcaseListQuery,
AdminEditorShowcaseListResponse,
AdminEditorShowcaseReviewRequest,
AdminFeatureGateConfigResponse,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
@@ -40,6 +42,7 @@ import type {
AdminUpdateAccountResponse,
AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRechargeProductRequest,
AdminUpsertProfileRedeemCodeRequest,
@@ -266,6 +269,23 @@ export function listAdminTrackingEventKeys(token: string) {
);
}
export function getAdminFeatureGateConfig(token: string) {
return request<AdminFeatureGateConfigResponse>('/admin/api/feature-gates', {
token,
});
}
export function upsertAdminFeatureGateConfig(
token: string,
payload: AdminUpsertFeatureGateConfigRequest,
) {
return request<AdminFeatureGateConfigResponse>('/admin/api/feature-gates', {
method: 'PUT',
token,
body: payload,
});
}
export function getAdminEditorGenerationPricing(token: string) {
return request<EditorGenerationPricingConfigPayload>(
'/admin/api/editor-generation-pricing',
@@ -388,6 +408,19 @@ export async function uploadAdminEditorShowcaseCampaignImage(
);
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: {
bucket: response.upload.bucket,
objectKey,
contentType,
contentLength: file.size,
} satisfies AdminConfirmEditorShowcaseCampaignImageUploadRequest,
},
);
return {
imageSrc: objectKey ? `/${objectKey}` : response.upload.legacyPublicPath,
imageObjectKey: objectKey,
+28
View File
@@ -286,6 +286,26 @@ export interface AdminTrackingEventListQuery {
exportAll?: boolean;
}
export interface AdminFeatureGateConfigPayload {
gateKey: string;
enabled: boolean;
rolloutPercent: number;
allowUserIds: string[];
allowUserTags: string[];
denyUserIds: string[];
description: string;
updatedAt: string;
}
export interface AdminFeatureGateConfigResponse {
gates: AdminFeatureGateConfigPayload[];
}
export type AdminUpsertFeatureGateConfigRequest = Omit<
AdminFeatureGateConfigPayload,
'updatedAt'
>;
/** 图片画布生成模型泥点定价配置。 */
export type EditorGenerationPricingUnitPayload = 'perGeneration' | 'perSecond';
@@ -372,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;
@@ -459,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;
+7
View File
@@ -24,6 +24,7 @@ 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';
@@ -227,6 +228,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'gray-release' ? (
<AdminGrayReleaseConfigPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'redeem' ? (
<AdminRedeemCodePage
token={token}
+2
View File
@@ -4,6 +4,7 @@ import {
Bug,
Coins,
Database,
GitBranch,
Images,
LayoutDashboard,
ListChecks,
@@ -37,6 +38,7 @@ const routeIcons = {
tables: Database,
debug: Bug,
tracking: Table2,
'gray-release': GitBranch,
redeem: TicketPercent,
invite: TicketCheck,
'profile-wallet': WalletCards,
+22 -1
View File
@@ -33,12 +33,22 @@ test('后台模型定价路由可通过导航和 hash 访问', () => {
);
});
test('后台灰度发布路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'gray-release',
label: '灰度发布',
hash: '#gray-release',
});
expect(resolveAdminRoute('#gray-release')).toBe('gray-release');
expect(routeHash('gray-release')).toBe('#gray-release');
});
test('后台不再暴露旧创作模板管理路由', () => {
expect(resolveAdminRoute('#creation-entry')).toBe('dashboard');
expect(resolveAdminRoute('#creation-announcement')).toBe('dashboard');
expect(resolveAdminRoute('#gray-release')).toBe('dashboard');
expect(resolveAdminRoute('#work-visibility')).toBe('dashboard');
});
test('后台素材查询路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'editor-assets',
@@ -93,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',
+3 -1
View File
@@ -1,10 +1,11 @@
/** 后台单页应用可导航的路由标识,入口公告独立于入口开关维护。 */
/** 后台单页应用可导航的路由标识。 */
export type AdminRouteId =
| 'dashboard'
| 'overview'
| 'tables'
| 'debug'
| 'tracking'
| 'gray-release'
| 'redeem'
| 'invite'
| 'profile-wallet'
@@ -32,6 +33,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ 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' },
@@ -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() : '';
}
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();
});
@@ -1,13 +1,11 @@
import { Plus, RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
import { type FormEvent, useEffect, useState } from 'react';
import {
getAdminCreationEntryConfig,
getAdminFeatureGateConfig,
upsertAdminFeatureGateConfig,
} from '../api/adminApiClient';
import type {
AdminCreationEntryTypeConfigPayload,
AdminFeatureGateConfigPayload,
AdminUpsertFeatureGateConfigRequest,
} from '../api/adminApiTypes';
@@ -28,7 +26,6 @@ interface GateTargetOption {
}
const GATE_PREFIX_LABELS: Record<string, string> = {
'creation-entry': '创作入口',
'image-editor': '画布',
};
@@ -47,9 +44,6 @@ export function AdminGrayReleaseConfigPage({
onUnauthorized,
}: AdminGrayReleaseConfigPageProps) {
const [gates, setGates] = useState<AdminFeatureGateConfigPayload[]>([]);
const [creationEntries, setCreationEntries] = useState<
AdminCreationEntryTypeConfigPayload[]
>([]);
const [selectedGateKey, setSelectedGateKey] = useState('');
const [gatePrefix, setGatePrefix] = useState('');
const [gateKey, setGateKey] = useState('');
@@ -74,7 +68,6 @@ export function AdminGrayReleaseConfigPage({
const requestToken = token.trim();
if (!requestToken) {
setGates([]);
setCreationEntries([]);
setListErrorMessage('');
setIsLoading(false);
return;
@@ -83,12 +76,8 @@ export function AdminGrayReleaseConfigPage({
setIsLoading(true);
setListErrorMessage('');
try {
const [featureGateResponse, creationEntryResponse] = await Promise.all([
getAdminFeatureGateConfig(requestToken),
getAdminCreationEntryConfig(requestToken),
]);
const featureGateResponse = await getAdminFeatureGateConfig(requestToken);
setGates(featureGateResponse.gates);
setCreationEntries(creationEntryResponse.entries);
const selectedGate = featureGateResponse.gates.find(
(gate) => gate.gateKey === selectedGateKey,
);
@@ -240,7 +229,7 @@ export function AdminGrayReleaseConfigPage({
const canSave =
gateKey.trim().length > 0 && isRolloutPercentInputValid(rolloutPercent);
const gateTargetOptions = buildGateTargetOptions(creationEntries);
const gateTargetOptions = FIXED_GATE_TARGETS;
const gatePrefixOptions = buildGatePrefixOptions(gateTargetOptions);
const gateTargetsForPrefix = gateTargetOptions.filter(
(option) => option.prefix === gatePrefix,
@@ -466,27 +455,6 @@ function isRolloutPercentInputValid(value: string) {
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 100;
}
function creationEntryGateKey(entryId: string) {
return `creation-entry:${entryId.trim()}`;
}
function buildGateTargetOptions(
creationEntries: AdminCreationEntryTypeConfigPayload[],
): GateTargetOption[] {
return [
...creationEntries.map((entry) => ({
prefix: 'creation-entry',
suffix: entry.id,
key: creationEntryGateKey(entry.id),
label: entry.title.trim() || entry.id,
description: entry.title.trim()
? `${entry.title.trim()}创作入口灰度`
: '创作入口灰度',
})),
...FIXED_GATE_TARGETS,
];
}
function buildGatePrefixOptions(gateTargetOptions: GateTargetOption[]) {
const seen = new Set<string>();
return gateTargetOptions.flatMap((option) => {
-2
View File
@@ -19,8 +19,6 @@
"exclude": [
"src/pages/AdminCreationEntrySwitchPage.tsx",
"src/pages/AdminCreationEntrySwitchPage.test.tsx",
"src/pages/AdminGrayReleaseConfigPage.tsx",
"src/pages/AdminGrayReleaseConfigPage.test.tsx",
"src/pages/AdminWorkVisibilityPage.tsx"
]
}
+2 -2
View File
@@ -56,7 +56,7 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht
## 构建工具链
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.6.1` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。镜像构建阶段会同时复制 `public/`,用于满足 API 二进制里 `include_bytes!` 引用的内置素材;不要把 `public/generated-*` 放入镜像上下文。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.7.0` 依赖链继续兼容 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。镜像构建阶段会同时复制 `public/`,用于满足 API 二进制里 `include_bytes!` 引用的内置素材;不要把 `public/generated-*` 放入镜像上下文。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
### Gitea CI 预构建 Job 镜像
@@ -156,7 +156,7 @@ npm run container:worker-smoke -- status
npm run container:worker-smoke -- smoke --force
```
`container:worker-smoke` 默认会把本机 `spacetime` 2.6.1 CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 必须拉取官方大镜像;普通 `npm run container:*` 压测默认使用 `clockworklabs/spacetime:v2.6.1`。如果 Docker build 阶段在容器内拉取 crates.io 依赖不稳定,可让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入临时 smoke 镜像。该模式默认使用 `rust:1.93-bookworm` 作为 builder、Debian bookworm smoke runtime 承载构建产物;需要换 builder 镜像时设置 `GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE`,需要换运行时基础镜像时设置 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE`
`container:worker-smoke` 默认会把本机 `spacetime` 2.7.0 CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 必须拉取官方大镜像;普通 `npm run container:*` 压测默认使用 `clockworklabs/spacetime:v2.7.0-hotfix3`(容器内二进制报告 2.7.0。如果 Docker build 阶段在容器内拉取 crates.io 依赖不稳定,可让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入临时 smoke 镜像。该模式默认使用 `rust:1.93-bookworm` 作为 builder、Debian bookworm smoke runtime 承载构建产物;需要换 builder 镜像时设置 `GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE`,需要换运行时基础镜像时设置 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE`
```bash
npm run container:worker-smoke -- smoke --local-binary
+1 -1
View File
@@ -2,7 +2,7 @@ name: genarrative-container-loadtest
services:
spacetimedb:
image: ${GENARRATIVE_CONTAINER_SPACETIME_IMAGE:-clockworklabs/spacetime:v2.6.1}
image: ${GENARRATIVE_CONTAINER_SPACETIME_IMAGE:-clockworklabs/spacetime:v2.7.0-hotfix3}
user: root
command:
[
@@ -281,6 +281,14 @@
- 验证方式:核对 `spacetime --version`,运行 `npm run spacetime:generate``npm run check:spacetime-schema``cargo check` / 定向测试、server provision 工具测试、encoding / diff 门禁。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-07-23 SpacetimeDB 工具链统一升级到 2.7.0
- 背景:SpacetimeDB 2.7.0 增加满足数据约束时的 unique / primary-key 非破坏迁移、Rust SDK capability traits、standalone MCP endpoint、SQL JSON 输出和更多连接/视图/内存指标,并修复旧 procedural-view backing table 的自动迁移。官方当前发行资产位于 `v2.7.0-hotfix3` 标签,二进制和 Rust crates 版本仍为 2.7.0。
- 决策:`server-rs/Cargo.toml``spacetimedb``spacetimedb-sdk``spacetimedb-lib` 精确锁定 2.7.0;本地 CLI / standalone 与 Rust bindings 使用官方 2.7.0 hotfix3 构建,worker smoke 本地镜像按运行版本标记 2.7.0,官方容器和生产 provision 下载根固定到 `v2.7.0-hotfix3`。provision 从 hotfix 资产标签解析运行版本时必须得到 2.7.0,并同时核对 CLI commit 为 `d220349a...`;裸 tag `a08663c7...` 不得因版本号相同而被复用,下载 / 安装结果也必须通过同一 commit 门禁。
- 影响范围:Rust workspace lockfile、SpacetimeDB bindings、本地 dev 版本门禁、容器 smoke / loadtest、server provision Jenkins 与项目 SpacetimeDB skills / 文档;现役 module 没有 procedural view,本次不修改 schema 或 migration。
- 验证方式:核对 CLI 版本和 commit,重新生成 Rust bindings,运行 `npm run check:spacetime-schema`、相关 Cargo check、server provision 工具测试、容器配置、Rust 1.93 兼容检查、standalone `/v1/ping`、encoding 和 diff 门禁。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-07-10 外部生成任务只持久化轻量媒体引用并独立维护摘要投影
- 背景:编辑器 worker 化后直接把同步接口 payload 序列化进 `external_generation_job.request_payload_json`;前端又把已有 OSS `objectKey` 下载成 Data URL 再提交,导致单个任务 JSON 膨胀到数 MB,正式任务列表读取 20 条任务时同时搬运约 65 MB payload,并放大为 SpacetimeDB 与 api-server 的瞬时内存峰值。此前“禁止 Data URL 持久化”只覆盖工程、素材、图层和元数据,遗漏了正式生成任务表。
@@ -4433,3 +4441,20 @@
- 决策:`production-api-deploy.sh` 继续随 release 安装默认命名的 BgFilter、external-generation worker 和 controller unit,但安装前必须用本次部署参数渲染临时文件;新增 `--controller-env-file` 补齐 controller 专属 env 输入。release 内模板保持默认路径,供 provision 和 deploy 共同作为单一模板来源;自定义服务名仍由目标机自行管理,不强制覆盖。
- 影响范围:`scripts/deploy/production-api-deploy.sh``scripts/check-production-api-deploy.mjs``jenkins/Jenkinsfile.production-api-deploy``jenkins/Jenkinsfile.production-full-build-and-deploy``scripts/check-production-ops-guardrails.mjs`、生产运维文档和 worker systemd 发布契约。
- 验证方式:`bash -n scripts/deploy/production-api-deploy.sh``node --check scripts/check-production-api-deploy.mjs``npm run check:production-api-deploy``npm run check:production-ops``npm run check:encoding``git diff --check`
## 2026-07-22 陶泥儿精选改为顺序循环分列 Masonry
- 背景:CSS multi-column 会按纵向高度平衡卡片,少量素材或活动卡与普通素材高度差较大时,桌面首行会只放一到两张,后续卡片提前从左侧下一段开始,无法满足“每行填满三张再换行”。
- 决策:`/creation` 陶泥儿精选保持平面 DOM 顺序,按当前列数将第 `index` 张循环分配到 `index % columns` 列。三列下第 1/2/3 张分别进入第 1/2/3 列,第 4/5/6 张再分别接续三列;不采用最短列贪心排序,避免同一组多张连续进入同一列。
- 宽度与高度边界:列数由精选容器实际宽度、0.92rem computed gap 和 288px 首选最小列宽共同决定,最多三列。卡片先获得目标列宽,再按真实 preview aspect ratio 和内容测量高度;同列紧凑堆叠,不拉伸、裁切或等待其它列高卡。循环分列只消除列内空洞,较短列在整个容器底部仍可有尾部高度差。
- 动态与可用性边界:`useLayoutEffect` 首次同步测量,`ResizeObserver + requestAnimationFrame` 在容器变宽、卡高变化、筛选重排和 cursor 追加后全量重排。只有当宽度、卡数和所有高度完整时才进入 absolute ready;否则保留 Grid fallback,防止卡片重叠和分页 sentinel 提前触发。DOM/Tab/读屏顺序始终不变,容器与卡片显式为 list/listitem。
- 兼容边界:保留现有 `.creation-landing__asset-waterfall` 类名、筛选、排序、cursor 分页、预览与点赞链路;只替换布局算法。该决策覆盖 2026-07-07 multi-column 及本日早先 row-major Grid 的布局部分,不改变精选仍是动态素材流的产品定位。
- 验证方式:纯函数测试锁定容器临界宽度、循环列序、列内 top 和容器高度;`src/index.test.ts` 锁定 Grid fallback 与 Masonry ready。Playwright 在同一 viewport 中变更容器宽度,核对 3/2/1 列、每列 gap、容器高度、DOM 顺序、无重叠/横溢出和 console/page error。
## 2026-07-23 恢复通用灰度发布后台控制面
- 背景:旧创作模板退役时,后台灰度页因同时加载 `creation-entry:*` 动态目标与现役 `image-editor:agent-sidebar` 固定目标,被整页从路由、TypeScript、ESLint 和 Vitest 编译链摘除;通用 feature gate 后端、权限和现役画布 Agent 判定仍在,形成有 API 无正式控制面的不一致。
- 决策:恢复后台 `#gray-release` 导航、member Tab 权限展示、前端 DTO/client、页面渲染和页面测试;页面只读取和写入 `GET/PUT /admin/api/feature-gates`,不再请求已退役 `/admin/api/creation-entry/config`
- 目标边界:固定目标列表只登记现役 `image-editor:agent-sidebar`;管理员仍可直接输入其他通用 Gate Key。不得恢复 `creation-entry:*` 动态目标、入口公告、入口开关、旧作品可见性页面或任何旧模板接口。
- 运行语义:环境变量继续是画布 Agent 总开关,feature gate 只在总开关开启后做黑名单、白名单、标签和稳定百分比受众限制;本次不修改 SpacetimeDB schema、灰度优先级或后端契约。
- 验证方式:后台路由与灰度页面 Vitest、`npm run admin-web:typecheck`、定向 ESLint、`npm run check:encoding``git diff --check`
+30 -13
View File
@@ -95,13 +95,13 @@
- 验证:API 测试覆盖陶泥号组合条件、未知陶泥号、可信来源归组、`seconds.microsZ` / 极值游标和真实 / 归组 Task IDSpacetimeDB 测试覆盖资源删除后稳定归组、部分失败批次不抢占根任务、重复拆分有界无丢失和 owner 索引分支;后台页面测试覆盖逐字输入防抖、请求取消、刷新失败保留结果、筛选请求乱序、旧分页响应失效和“用户 ID / 陶泥号”请求。再运行 `cargo test -p api-server admin_editor_asset --manifest-path server-rs/Cargo.toml``cargo test -p spacetime-module admin_editor_asset --manifest-path server-rs/Cargo.toml``npm run test -- apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx`
- 关联:`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx``server-rs/crates/api-server/src/admin.rs``server-rs/crates/spacetime-module/src/editor_project_storage.rs`
## 后台素材缩略图不要在首次挂载时全量换签
## 后台素材查询与精选审核缩略图不要在首次挂载时全量换签
- 现象:后台“素材查询”首批缩略图正常,继续向下滚动读取更多后长期显示占位图;api-server journald 中已到达的 `/admin/api/assets/read-url` 可能全部是 `200`
- 现象:后台“素材查询”或“精选审核”首批缩略图正常,继续向下滚动读取更多或一次加载较多审核项后长期显示占位图;api-server journald 中已到达的 `/admin/api/assets/read-url` 可能全部是 `200`
- 原因:列表一次挂载 80 条私有素材时,每个缩略图同时换签,会在同秒突发请求。production Nginx 的 `genarrative_admin_rps``30r/s burst=16`,超出部分在进入 api-server 前已返回 `429`,因此仅查 api-server 日志会漏掉失败请求。
- 处理:缩略图使用 `IntersectionObserver` 在进入视口附近时再调用管理端换签;对 `429` 使用有上限的退避重试,并在条目卸载后停止更新状态和安排重试。不得为单页突发放大 Nginx 通用管理端限流,也不得在单次限流失败后永久保留无图占位。
- 验证:前端定向测试覆盖首屏外的后续行进入可见区后才换签、“读取更多”追加行可继续显示缩略图、`429` 后有限重试恢复、卸载后不再重试;真实浏览器滚动验收时同时核对 Nginx access/error log、api-server journald 和 Network 面板,不以单一日志面判定成功。
- 关联:`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx``apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx`
- 处理:素材查询与精选审核共用缩略图和预览组件;缩略图使用 `IntersectionObserver` 在进入视口附近时再调用管理端换签;对 `429` 使用有上限的退避重试,并在条目卸载后停止更新状态和安排重试。`objectKey` 的绝对 OSS generated 地址先提取 legacy path 再换签。不得为单页突发放大 Nginx 通用管理端限流,也不得在单次限流失败后永久保留无图占位。
- 验证:前端定向测试覆盖两页共用换签组件、首屏外的后续行进入可见区后才换签、“读取更多”追加行可继续显示缩略图、`429` 后有限重试恢复、卸载后不再重试、绝对 OSS 地址换签和点击缩略图打开媒体预览;真实浏览器滚动验收时同时核对 Nginx access/error log、api-server journald 和 Network 面板,不以单一日志面判定成功。
- 关联:`apps/admin-web/src/components/AdminEditorAssetMedia.tsx``apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx``apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx` 及对应测试
## 陶泥儿精选重复先查同源同媒体画布副本
@@ -119,13 +119,14 @@
- 验证:`creationShowcaseModel.test.ts` 覆盖展示名优先、陶泥号兜底和内部 owner/user id 不展示;`editorProjectClient.test.ts` 覆盖公开精选接口客户端保留公开作者字段;若改动 SpacetimeDB read model,再运行 `npm run spacetime:generate``cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema`
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``server-rs/crates/spacetime-client/src/mapper/editor_project.rs``server-rs/crates/api-server/src/editor_project.rs``src/components/creation-home/creationShowcaseModel.ts`
## 陶泥儿精选瀑布流变宽先查 multi-column 容器宽度
## 陶泥儿精选顺序分列不要用 multi-column 或共享 Grid 行高
- 现象:release `/creation` 桌面端精选卡片明显变宽,第三列被裁到屏幕外,页面内部可横向滑动;dev 看起来正常
- 原因:精选瀑布流使用 `column-count`,当它作为 grid item 时如果没有显式 `width: 100%` / `min-width: 0`Chrome 会用多列内容的 intrinsic width 反向撑开 grid track。线上实测 1920 视口下 section 为 `1296px`waterfall 被撑到约 `2048px`,单卡宽约 `672px`
- 处理:保留 multi-column 瀑布流时,`.creation-landing__asset-waterfall` 必须显式约束 `width: 100%``min-width: 0`;不要只看单张图片天然尺寸或改卡片宽度
- 验证:Playwright / CSSOM 检查 `.creation-landing__section``.creation-landing__asset-waterfall`、首张 `.creation-landing__asset-card``getBoundingClientRect()`waterfall 宽度应等于 section 宽度
- 关联:`src/index.css``src/components/creation-home/CreationLandingView.tsx`
- 现象:`/creation` 桌面端主内容区明明能放下三张卡,首行却只出现一到两张,后续素材提前回到左侧下一段;活动卡与普通素材高度差较大时尤其明显
- 原因:`column-count` 按纵向文章列流入并平衡,三张卡可排成 `2 + 1 + 0` 列;标准 CSS Grid 虽会横向先填三张,但整行共用最高卡行轨,短卡下会留下高度差。`align-items: start` 只是不拉伸短卡,不能消除行轨空白;`grid-auto-flow: dense` 也不能填单个网格项内的剩余高度
- 处理:保留平面 DOM 和 `.creation-landing__asset-waterfall` 旧类名,按当前列数将第 `index` 张显式放入 `index % columns` 列;三列时第 1/2/3 张分别进入三列,第 4/5/6 张再分别接到三列下方。列数以容器实际宽度和 288px 首选最小列宽计算,不以 viewport 硬切;先设目标卡宽再测真实卡高,列内用 computed gap 紧凑堆叠
- 动态边界:只有当容器宽度、卡数和所有卡高有效时才进入 absolute Masonry ready;否则保留 Grid fallback。`ResizeObserver + requestAnimationFrame` 在容器变宽、图片/字体/文本改变高度、筛选重排和 cursor 追加后全量重算;cleanup 必须兼容 StrictMode,避免分页 sentinel 因容器短暂零高提前触发
- 验证:纯函数测试锁定容器宽度临界值、`index % columns` 和最高列容器高;样式契约同时锁定 Grid fallback 与 Masonry ready。Playwright 需在同一 viewport 内改容器宽度验证 3/2/1 列,检查每列相邻卡间距等于 gap、容器高等于最高列底、DOM 顺序不变且无重叠/横向溢出
- 关联:`src/index.css``src/index.test.ts``src/components/creation-home/CreationLandingView.tsx``src/components/creation-home/showcaseMasonryLayout.ts`
## 画板外部生成排队超时不是失败
@@ -155,10 +156,18 @@
- 现象:主站或 External API 只要拿到另一个账号的 generated `objectKey` 就能换签,已登记的私有对象因为 key 同时命中 legacy 前缀而被匿名读取,或者 `/api/assets/read-url` 已拒绝但 `/api/assets/read-bytes` 仍能读出原始字节;后台资源预览为解决跨账号读取又误把主站入口整体放开。
- 原因:`legacyPublicPath``objectKey` 代表两种不同信任边界。前者仅用于未登记历史公开作品兼容,后者是正式对象引用;只检查 generated 前缀、或在查询 `asset_object` metadata 前直接接受 legacy 白名单,都不能证明对象公开或属于调用方。签名 URL 和 bytes proxy 如果各写一套判断也容易漂移。
- 处理:`read-url``read-bytes` 必须共用 `authorize_asset_read_target`,先按配置 bucket / 精确 key 查询 `asset_object`metadata 一旦存在,即使 key 命中 legacy 前缀,也严格执行 `PublicRead` / owner ACL。只有 metadata 不存在且显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` 时才允许匿名兼容;任意 `objectKey` 必须登记。External read-url 使用 API Key owner。主站和 External 的 object confirm owner 必须来自认证主体,同 bucket / key 已登记后不能改变 owner,不能让请求体 owner 接管对象。后台跨 owner 换签只用于管理员资源预览,成功后以 `admin_asset_read_url` 持久化管理员 subject、请求对象和有效期,且不得记录 signed URL;不能为此把 admin 能力下沉到主站入口。无权访问统一返回不存在,避免泄露对象是否存在。
- 验证:覆盖未登记 curated legacy public path、命中 legacy 前缀但已有私有 metadata、未登记 objectKey、公开对象、本人私有对象、跨 owner、匿名私有、External owner、confirm owner 不可变和 admin-only endpoint;对 `read-url``read-bytes` 使用同一组授权矩阵,并断言 Admin 成功换签会生成不含 signed URL 的管理员主体审计事件。
- 处理:`read-url``read-bytes` 必须共用 `authorize_asset_read_target`,先按配置 bucket / 精确 key 查询 `asset_object`metadata 一旦存在,即使 key 命中 legacy 前缀,也严格执行 `PublicRead` / owner ACL。只有 metadata 不存在且显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` 时才允许匿名兼容;普通 `objectKey` 必须登记。唯一窄例外是历史精选活动卡:`global` 配置已启用、请求 key 位于 `generated-character-drafts/editor/showcase-campaign/` 且与当前 `image_object_key` 精确匹配时,可在 metadata 缺失期间派生公开读取,禁用或换图后旧 key 立即失效;新上传活动卡仍必须 confirm。External read-url 使用 API Key owner。主站和 External 的 object confirm owner 必须来自认证主体,同 bucket / key 已登记后不能改变 owner,不能让请求体 owner 接管对象。后台跨 owner 换签只用于管理员资源预览,成功后以 `admin_asset_read_url` 持久化管理员 subject、请求对象和有效期,且不得记录 signed URL;不能为此把 admin 能力下沉到主站入口。无权访问统一返回不存在,避免泄露对象是否存在。
- 验证:覆盖未登记 curated legacy public path、命中 legacy 前缀但已有私有 metadata、未登记普通 objectKey、活动卡当前/禁用/替换/越目录 exact key、公开对象、本人私有对象、跨 owner、匿名私有、External owner、confirm owner 不可变和 admin-only endpoint;对 `read-url``read-bytes` 使用同一组授权矩阵,并断言 Admin 成功换签会生成不含 signed URL 的管理员主体审计事件。
- 关联:`server-rs/crates/api-server/src/assets.rs``server-rs/crates/api-server/src/external_assets_api.rs``server-rs/crates/api-server/src/admin.rs``server-rs/crates/api-server/src/modules/admin.rs`
## 精选活动卡上传成功但网站空图先查对象确认和 exact grant
- 现象:后台精选活动卡能保存标题、作者、尺寸和图片地址,`GET /api/editor/showcase/resources` 也返回已启用 campaign,但网站卡片只有占位区域,没有 `<img>`Network 中活动卡的 `/api/assets/read-url?objectKey=...` 返回 `404 资源不存在或无权访问`
- 原因:活动卡旧上传链路只完成 signed POST 并保存 `imageSrc + imageObjectKey`,没有调用 object confirm;同时公开授权只扫描普通 `editor_showcase_asset`,没有识别当前活动卡。前端见到 `imageObjectKey` 后会优先走正式 objectKey 换签,失败时按安全规则保持空 src,不回退裸 private 路径。
- 处理:后台上传必须按 ticket -> OSS POST -> `/admin/api/editor-showcase/campaign/image-upload-confirm` -> 写回表单执行,confirm 复用统一 OSS HEAD、bucket/长度校验和 `asset_object` upsert,并由管理员会话绑定 owner、强制 private/固定 asset kind/活动卡专用目录。读取 procedure 在同一事务中对当前 enabled global campaign 的专用目录 exact key 派生授权,使历史未登记当前卡无需重新上传即可恢复;api-server 仅接受 procedure 明确返回的这一 grant,其他未登记 objectKey 继续 404。
- 验证:SpacetimeDB 测试覆盖 current key、disabled、missing key、replaced old key、越目录 key 和普通精选 grantapi-server 测试覆盖 metadata 缺失时 exact grant 可读、无 grant 仍 404、confirm 路径/MIME/大小/固定 private 约束;admin-web 测试锁定 ticket -> OSS -> confirm 顺序。真实浏览器应看到活动卡图片,Network 中 objectKey 换签返回 200,禁用或换图后旧 key 返回 404。
- 关联:`apps/admin-web/src/api/adminApiClient.ts``server-rs/crates/api-server/src/admin.rs``server-rs/crates/api-server/src/assets.rs``server-rs/crates/spacetime-module/src/asset_metadata/objects.rs``server-rs/crates/spacetime-module/src/editor_project_storage.rs`
## 编辑器生成按钮显示泥点后仍要查真实钱包预扣
- 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。
@@ -3320,3 +3329,11 @@
- 处理:API deploy 安装三个 unit 前必须按本次 current、API env 和各角色 env 参数渲染临时文件,安装后保留 release 内原始模板不变;controller 自定义 env 由 `--controller-env-file` 显式传入。API Deploy 与 Full Job 必须同步暴露并透传 controller/BgFilter env,不能让流水线回退默认路径。自定义服务名表示沿用目标机自管 unit,不进入默认 unit 安装分支。
- 验证:部署 guard 使用临时自定义绝对路径,直接读取实际安装目录中的三个 unit,核对 `WorkingDirectory``ExecStart`、共享 API env 与角色 env,不能只用 fake `systemctl is-active` 判绿。
- 关联:`scripts/deploy/production-api-deploy.sh``scripts/check-production-api-deploy.mjs``scripts/jenkins-server-provision.sh``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 通用灰度后台页不能依赖已退役业务配置
- 现象:通用 `feature_gate_config``/admin/api/feature-gates` 和现役功能 gate 仍在,但后台“灰度发布”Tab 随旧创作模板入口一起消失;Rust 权限仍可授予 `gray-release`,前端却没有对应路由。
- 原因:灰度页同时请求通用 gate 与旧 `/admin/api/creation-entry/config`,并把 `creation-entry:*` 动态目标和现役固定目标混在同一页面;按页面清理旧入口时连带摘除了通用控制面。
- 处理:灰度页只能以 `/admin/api/feature-gates` 为数据源,固定目标列表只登记现役功能;新增或退役业务 target 只修改固定目标注册,不得让通用页面依赖业务列表接口。旧 `creation-entry:*` 目标、接口和页面保持退役。
- 验证:`adminRoutes` 必须包含 `gray-release`admin-web TypeScript/ESLint/Vitest 不得排除灰度页;页面测试必须断言只请求 feature-gates,并继续覆盖现役固定 target、直接 Gate Key 保存与新 target 状态重置。
- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx``apps/admin-web/src/app/adminRoutes.ts``server-rs/crates/api-server/src/modules/admin.rs``docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`
@@ -1,6 +1,6 @@
# Genarrative 项目共享概览
更新时间:`2026-07-18`
更新时间:`2026-07-23`
## 一句话定位
@@ -31,7 +31,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A
server-rs + Axum + SpacetimeDB
```
当前 SpacetimeDB crate、SDK、CLI / standalone生成 bindings 和容器压测镜像统一按 `2.6.1` 对齐;遇到版本不匹配时先升级到 `server-rs/Cargo.toml` 锁定版本,升级后重启对应 SpacetimeDB 进程再重试。
当前 SpacetimeDB crate、SDK、CLI / standalone生成 bindings 统一按 `2.7.0` 对齐;官方 CLI / standalone 发行包与容器镜像使用 `v2.7.0-hotfix3` 资产标签,二进制仍报告 `2.7.0`遇到版本不匹配时先升级到 `server-rs/Cargo.toml` 锁定版本,升级后重启对应 SpacetimeDB 进程再重试。
职责边界:
@@ -1,6 +1,6 @@
# 后台管理多账号与 Tab 访问权限方案
更新时间:`2026-07-14`
更新时间:`2026-07-23`
## 1. 文档定位
@@ -10,12 +10,12 @@
## 2. 当前基线与目标
当前后台由 `GENARRATIVE_ADMIN_USERNAME``GENARRATIVE_ADMIN_PASSWORD` 提供唯一管理员账号,`apps/admin-web/src/app/adminRoutes.ts` 定义 18一级 Tab`server-rs/crates/api-server/src/modules/admin.rs` 中的后台路由只校验统一的管理员 JWT。
当前后台由 `GENARRATIVE_ADMIN_USERNAME``GENARRATIVE_ADMIN_PASSWORD` 提供唯一管理员账号,`apps/admin-web/src/app/adminRoutes.ts` 定义 15可分配业务 Tab,并另有 owner-only 的“账号管理”Tab`server-rs/crates/api-server/src/modules/admin.rs` 中的后台路由只校验统一的管理员 JWT。
改造后的目标如下:
1. 现有环境变量账号升级为 `owner`,仍由部署环境提供,不迁移、不复制到 SpacetimeDB。
2. owner 始终拥有全部 18 个业务 Tab 权限,并独占“账号管理”Tab 和账号管理 API。
2. owner 始终拥有全部 15 个业务 Tab 权限,并独占“账号管理”Tab 和账号管理 API。
3. owner 可以创建、修改、启停 membermember 保存在 SpacetimeDB 私有表 `admin_account`
4. member 按一级 Tab 分配权限;获得一个 Tab 权限即获得该页面内全部读写能力,页面内部二级 Tab、弹窗和操作区继承一级权限。
5. member JWT 每次请求都重新读取当前账号并校验 `enabled``token_version` 和实时权限,权限、密码或启停变更应立即让旧 JWT 失效。
@@ -26,7 +26,7 @@
- owner 用户名和密码继续读取 `GENARRATIVE_ADMIN_USERNAME``GENARRATIVE_ADMIN_PASSWORD`
- owner 是环境变量构造的虚拟账号,不写入 `admin_account`,不允许通过后台改名、改密、禁用或删除。
- owner 始终拥有本文列出的全部 18 个可分配权限,不能在前端取消,也不从数据库加载权限。
- owner 始终拥有本文列出的全部 15 个可分配权限,不能在前端取消,也不从数据库加载权限。
- “账号管理”是 owner-only 能力。它可以作为新增一级路由 `accounts` / `#accounts` 展示,但 `accounts` 不进入 `ADMIN_TAB_PERMISSIONS`,不能写入 member 的 `permissions_json`
- owner 会话返回 `accountRole = "owner"``roles = ["admin", "owner"]`;账号管理权限必须根据服务端确认的 `accountRole` 判断,不能只相信前端角色字符串。
- owner 配置缺失时,后台整体保持未启用状态;不能依赖数据库中的 member 绕过 owner 引导配置启动后台。
@@ -41,7 +41,7 @@
## 4. 权限标识
`ADMIN_TAB_PERMISSIONS` 必须是 shared-contracts 与 admin-web 共用的闭合集合,值与现有 `AdminRouteId` 一致。18 个可分配权限如下,顺序同时作为前端寻找“第一可访问项”的稳定顺序:
`ADMIN_TAB_PERMISSIONS` 必须是 shared-contracts 与 admin-web 共用的闭合集合,值与现有 `AdminRouteId` 一致。15 个可分配权限如下,顺序同时作为前端寻找“第一可访问项”的稳定顺序:
| permission id | 一级 Tab | hash |
| --- | --- | --- |
@@ -60,9 +60,6 @@
| `editor-generation-pricing` | 模型定价 | `#editor-generation-pricing` |
| `editor-showcase` | 精选审核 | `#editor-showcase` |
| `editor-assets` | 素材查询 | `#editor-assets` |
| `creation-announcement` | 入口公告 | `#creation-announcement` |
| `creation-entry` | 入口开关 | `#creation-entry` |
| `work-visibility` | 作品可见性 | `#work-visibility` |
权限数组必须去重并按上表顺序规范化后保存。保存时拒绝未知值和 `accounts`;读取旧数据时遇到未知值应忽略并记录告警,绝不能将未知值解释为全权限。空数组合法,表示 member 可以登录但没有业务页面权限。
@@ -83,7 +80,7 @@
| `username` | `String` | `unique`;登录名,创建后不可修改;按 `trim + ASCII lowercase` 规范化 |
| `display_name` | `String` | 展示名,去除首尾空白后 1 至 64 字符 |
| `password_hash` | `String` | Argon2id PHC 字符串;只在内部登录查询中返回给 api-server,永不进入 HTTP DTO、日志或前端状态 |
| `permissions_json` | `String` | 规范化后的 Tab permission JSON;只允许第 4 节 18 个值,空数组为 `[]` |
| `permissions_json` | `String` | 规范化后的 Tab permission JSON;只允许第 4 节 15 个值,空数组为 `[]` |
| `enabled` | `bool` | 是否允许登录和继续使用现有 JWT |
| `token_version` | `u64` | 初始为 `1`;权限、密码或启停状态发生有效变化时加 `1` |
| `created_by` | `String` | 创建者后台 subject;当前只能是 owner subject |
@@ -163,7 +160,7 @@ accountRole: "owner" | "member"
tabPermissions: string[]
```
owner 返回全部 18 个 permission idmember 返回数据库中的实时规范化数组。`GET /admin/api/me` 同样执行逐请求校验并返回实时权限,供刷新页面后恢复导航。
owner 返回全部 15 个 permission idmember 返回数据库中的实时规范化数组。`GET /admin/api/me` 同样执行逐请求校验并返回实时权限,供刷新页面后恢复导航。
后台所有面向运营展示的管理员身份统一使用 `displayName`。审计表继续保存稳定 subject,例如 owner subject 或 `admin-account-<uuid>`api-server 在返回兑换码、邀请码等操作记录时,按 owner 运行态和 `admin_account` 批量解析显示名称,同时兼容历史用户名记录。已无法解析的历史主体统一展示“已停用管理员”,前端不得直接渲染 `operatorUserId`、账号 ID 或登录用户名代替显示名称。对写接口,显示名目录必须在主事务前加载,或在主事务成功后降级为占位文案;不得因二次读取失败把已提交写入伪装成失败。
@@ -196,10 +193,6 @@ owner 返回全部 18 个 permission idmember 返回数据库中的实时规
| `GET` | `/admin/api/tracking/event-keys` | `tracking OR tasks` |
| `GET` | `/admin/api/database/tables` | `tables` |
| `GET` | `/admin/api/database/tables/{table_name}/rows` | `tables` |
| `GET` | `/admin/api/creation-entry/config` | `gray-release OR creation-announcement OR creation-entry` |
| `POST` | `/admin/api/creation-entry/config` | `creation-entry` |
| `POST` | `/admin/api/creation-entry/config/banners` | `creation-announcement` |
| `POST` | `/admin/api/creation-entry/config/interactions` | `creation-entry` |
| `GET` | `/admin/api/feature-gates` | `gray-release` |
| `PUT` | `/admin/api/feature-gates` | `gray-release` |
| `GET` | `/admin/api/editor-generation-pricing` | `editor-generation-pricing` |
@@ -212,8 +205,6 @@ owner 返回全部 18 个 permission idmember 返回数据库中的实时规
| `GET` | `/admin/api/editor-showcase/campaign` | `editor-showcase` |
| `POST` | `/admin/api/editor-showcase/campaign` | `editor-showcase` |
| `POST` | `/admin/api/editor-showcase/campaign/image-upload-ticket` | `editor-showcase` |
| `GET` | `/admin/api/works/visibility` | `work-visibility` |
| `POST` | `/admin/api/works/visibility` | `work-visibility` |
| `GET` | `/admin/api/profile/redeem-codes` | `redeem` |
| `POST` | `/admin/api/profile/redeem-codes` | `redeem` |
| `POST` | `/admin/api/profile/redeem-codes/disable` | `redeem` |
@@ -231,17 +222,18 @@ owner 返回全部 18 个 permission idmember 返回数据库中的实时规
| `POST` | `/admin/api/profile/recharge-refunds/execute` | `recharge-orders` |
| `POST` | `/admin/api/profile/recharge-refunds/register` | `recharge-orders` |
| `POST` | `/admin/api/profile/recharge-refunds/manual-review/resolve` | `recharge-orders` |
| `GET` | `/admin/api/profile/users/detail` | `tables OR tracking OR recharge-orders OR editor-showcase OR editor-assets OR work-visibility` |
| `GET` | `/admin/api/profile/users/detail` | `tables OR tracking OR recharge-orders OR editor-showcase OR editor-assets` |
| `POST` | `/admin/api/profile/wallet-restriction` | `recharge-orders` |
| `GET` | `/admin/api/accounts` | owner-only |
| `POST` | `/admin/api/accounts` | owner-only |
| `PUT` | `/admin/api/accounts/{account_id}` | owner-only |
个共享读取接口必须按 OR 规则实现,不能为了复用简单中间件扩大成“任意 member 可访问”:
个共享读取接口必须按 OR 规则实现,不能为了复用简单中间件扩大成“任意 member 可访问”:
- `/admin/api/assets/read-url` 只服务素材查询和精选审核。
- `/admin/api/profile/users/detail` 只服务当前实际包含用户详情入口的表查询、埋点数据、充值管理、精选审核素材查询和作品可见性页面。
- `GET /admin/api/creation-entry/config` 同时为灰度发布、入口公告和入口开关提供页面初始化数据;写操作仍按具体页面单独收紧。
- `/admin/api/profile/users/detail` 只服务当前实际包含用户详情入口的表查询、埋点数据、充值管理、精选审核素材查询页面。
`gray-release` 页面只调用 `GET/PUT /admin/api/feature-gates`;旧 `/admin/api/creation-entry/config*` 已退役,不能再作为灰度页面初始化依赖。页面固定目标只登记现役功能,其他通用 gate 仍可通过 Gate Key 直接管理。
## 10. 账号管理 HTTP 契约
@@ -303,7 +295,7 @@ accounts: Array<{
### 11.1 路由与导航
- `adminRoutes` 增加权限元数据;18 个业务路由使用同名 permission id。
- `adminRoutes` 增加权限元数据;15 个业务路由使用同名 permission id。
- `accounts` 路由只在 `admin.accountRole === "owner"` 时加入侧栏和移动底栏,不属于 member 可分配列表。
- member 导航只渲染 `admin.tabPermissions` 包含的业务路由。页面组件也必须只在当前路由已授权时挂载,避免隐藏导航后仍发起无权限 API。
- owner 渲染全部业务路由和账号管理路由。
@@ -315,13 +307,13 @@ accounts: Array<{
1. 当前 hash 对应可访问路由时保持不变。
2. hash 未知、属于无权限业务 Tab,或 member 访问 `#accounts` 时,使用 `replaceState` 回落到按第 4 节顺序找到的第一可访问业务 Tab。
3. member 权限为空时,不回落 Dashboard;渲染独立的零权限空态,只保留账号信息和退出登录,不挂载任何业务页,也不发起业务 API。
4. owner 的默认项仍可保持 Dashboard;账号管理不改变 18 个业务路由的排序。
4. owner 的默认项仍可保持 Dashboard;账号管理不改变 15 个业务路由的排序。
后端返回 `403` 时,前端重新请求 `/me` 获取实时权限并执行上述回落。即使前端状态陈旧或被篡改,后端权限 middleware 仍必须拒绝越权请求。
### 11.3 账号管理页
- 权限编辑器展示 18 个明确的 checkbox,每项使用现有 Tab 中文名称;不能展示或提交 `accounts`
- 权限编辑器展示 15 个明确的 checkbox,每项使用现有 Tab 中文名称;不能展示或提交 `accounts`
- 创建和编辑使用独立弹窗或抽屉,不在列表下方追加表单。
- 编辑时密码字段默认空,空表示请求中省略 `password`;页面永不展示现有密码或 hash。
- 停用使用开关并二次确认。保存成功后以 API 返回 account snapshot 更新列表。
@@ -391,12 +383,12 @@ spacetime publish <database> \
- 权限、密码、启停更新各自会递增 `token_version`;同一次请求修改多项只递增一次;仅改展示名不递增。
- member 被停用、改密或改权限后,旧 JWT 下一次请求返回 401;重新登录后获得实时权限。
- API-to-Tab 矩阵逐路由覆盖 `modules/admin.rs`,每条路由至少测试 owner 成功、具备权限的 member 成功、缺权限 member 返回 403。
- 个共享读取接口分别覆盖每个允许 permission 的成功用例,以及无关 permission 的 403 用例。
- 个共享读取接口分别覆盖每个允许 permission 的成功用例,以及无关 permission 的 403 用例。
- owner-only 账号 API 对任意 member 都返回 403,即使其 `permissions_json` 被污染为包含 `accounts`
### 14.2 前端
- owner 看到 18 个业务 Tab 和账号管理;member 只看到被分配的业务 Tab。
- owner 看到 15 个业务 Tab 和账号管理;member 只看到被分配的业务 Tab。
- 每个一级 Tab 内的二级 Tab、弹窗和写操作继承一级权限并正常使用,不出现“页面可见但内部 API 403”的错误映射。
- 直接输入无权限 hash 自动替换为第一可访问项,不短暂挂载无权限页面。
- 当前 Tab 权限被 owner 收回后,下一请求触发重新登录;新会话恢复后落到第一可访问项。
@@ -25,6 +25,7 @@
- `migration.rs` 中相关表的迁移白名单、表名兼容和字段目录。
- 为历史审计、迁移、资产归属核对所必需的最小只读表定义;不得借兼容读取重新暴露旧创作、发布、公开详情或运行接口。
- 编辑器、项目、账号、钱包、资产、HostBridge、运维和安全等平台公共能力。
- 通用 `feature_gate_config``GET/PUT /admin/api/feature-gates` 与后台 `#gray-release` 控制页。灰度页只读取通用 gate,不再请求旧 `/admin/api/creation-entry/config`,固定目标只登记现役功能;不得恢复 `creation-entry:*` 动态目标。
- 新版 `/creation` 创作工具主页、`/project` 项目入口、稳定的 `/profile` 个人页路由、`creation-home` 展示组件与现役静态资产。桌面端保留“创作 / 项目 / 我的”公共侧边栏,移动端保留同样三项的底部 dock;“我的”保留头像 / 昵称编辑、陶泥号复制、钱包与账单、统计、充值、兑换码、玩家社区、反馈、通用设置、开发者 API Key 和法律信息,不恢复旧模板入口、旧作品架或生成队列。
- `runtime_setting` 是账号级公共设置事实,不属于旧模板运行态。原表结构和数据不变,继续由鉴权后的 `GET/PUT /api/runtime/settings``get_runtime_setting_or_default``upsert_runtime_setting_and_return` procedure 支撑音乐音量和平台主题读写。
- 旧页面、测试、素材、handler、service、worker、生成 bindings 和纯业务 crate 的源码目录;它们仅用于历史追溯,不属于任何正式入口或编译目标。

Some files were not shown because too many files have changed in this diff Show More