diff --git a/.codex/skills/spacetimedb-cli/SKILL.md b/.codex/skills/spacetimedb-cli/SKILL.md index a3d892458..73dac7168 100644 --- a/.codex/skills/spacetimedb-cli/SKILL.md +++ b/.codex/skills/spacetimedb-cli/SKILL.md @@ -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. diff --git a/.codex/skills/spacetimedb-concepts/SKILL.md b/.codex/skills/spacetimedb-concepts/SKILL.md index c17575687..e671603dd 100644 --- a/.codex/skills/spacetimedb-concepts/SKILL.md +++ b/.codex/skills/spacetimedb-concepts/SKILL.md @@ -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` 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 diff --git a/.codex/skills/spacetimedb-rust/SKILL.md b/.codex/skills/spacetimedb-rust/SKILL.md index 5750ada65..ef0a239d6 100644 --- a/.codex/skills/spacetimedb-rust/SKILL.md +++ b/.codex/skills/spacetimedb-rust/SKILL.md @@ -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}; diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 1536699b8..3b4cf7c4d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -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', diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 85f029e37..97ec8bbb6 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -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', { diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 17e3ba244..e3697bb90 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -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('/admin/api/feature-gates', { + token, + }); +} + +export function upsertAdminFeatureGateConfig( + token: string, + payload: AdminUpsertFeatureGateConfigRequest, +) { + return request('/admin/api/feature-gates', { + method: 'PUT', + token, + body: payload, + }); +} + export function getAdminEditorGenerationPricing(token: string) { return request( '/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( + '/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, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 65c416d50..3900ab01d 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -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 | 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; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 1ee731fc7..de33c8761 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -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' ? ( + + ) : null} {activeRouteId === 'redeem' ? ( { ); }); +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', diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index faf97b083..b3bad3b50 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -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' }, diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx new file mode 100644 index 000000000..9e93be572 --- /dev/null +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx @@ -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 ? ( + {alt} + ) : ( +
+ ); +} + +export function AdminEditorAssetPreviewDialog({ + entry, + token, + onClose, +}: { + entry: AdminPreviewableEditorAsset; + token: string; + onClose: () => void; +}) { + return ( +
+
+
+
+

{entry.label || entry.assetId}

+ {entry.assetId} +
+ +
+ +
+
+ ); +} + +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 ( +
+ {`音频封面:${label}`} + {mediaSrc ? ( +