Merge branch 'master' into editor-agent-more-tools
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: spacetimedb-cli
|
||||
description: SpacetimeDB 2.6 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification.
|
||||
description: SpacetimeDB 2.7 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification.
|
||||
---
|
||||
|
||||
# SpacetimeDB CLI
|
||||
@@ -68,6 +68,31 @@ spacetime call --server http://127.0.0.1:3101 my-db reducer_needing_identity 0xa
|
||||
spacetime subscribe my-db "SELECT * FROM users" --num-updates 10 --server http://127.0.0.1:3101
|
||||
```
|
||||
|
||||
## Standalone MCP Endpoint (2.7)
|
||||
|
||||
SpacetimeDB 2.7 standalone exposes an authenticated JSON-RPC MCP endpoint at
|
||||
`POST /v1/database/{name_or_identity}/mcp`. It advertises `ping`, `get_schema`,
|
||||
`sql`, and `call`. The SQL and reducer tools execute with the bearer token's
|
||||
identity, so keep routine smoke checks read-only.
|
||||
|
||||
```bash
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer ${SPACETIME_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"genarrative-smoke","version":"1.0.0"}}}' \
|
||||
http://127.0.0.1:3101/v1/database/my-db/mcp
|
||||
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer ${SPACETIME_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ping","arguments":{"message":"genarrative"}}}' \
|
||||
http://127.0.0.1:3101/v1/database/my-db/mcp
|
||||
```
|
||||
|
||||
For repository upgrade validation, also call `tools/list` and the read-only
|
||||
`get_schema` tool against an isolated local database. Do not use `sql` or `call`
|
||||
for writes unless that mutation is explicitly in scope.
|
||||
|
||||
## Server & Auth
|
||||
|
||||
```bash
|
||||
@@ -102,7 +127,7 @@ curl -fsS http://127.0.0.1:3101/v1/ping
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--server`, `-s` | Target server nickname, host, or URL |
|
||||
| `--yes`, `-y` | Non-interactive prompt skipping; in 2.6 use scoped values |
|
||||
| `--yes`, `-y` | Non-interactive prompt skipping; in 2.6+ use scoped values |
|
||||
| `--delete-data`, `-c` | Publish data policy: `always`, `on-conflict`, or `never` |
|
||||
| `--module-path`, `-p` | Module project path |
|
||||
| `--bin-path`, `-b` | Publish/generate from compiled wasm |
|
||||
@@ -146,6 +171,8 @@ pid="$(systemctl show spacetimedb.service -p MainPID --value)"
|
||||
|
||||
## Notes
|
||||
|
||||
- Procedure calls remain stable in 2.6; module HTTP handlers/webhooks and RLS capabilities still require their documented gates.
|
||||
- 2.5 fixed `publish --delete-data` config fallback; 2.6 keeps that behavior and improves CLI binary distribution.
|
||||
- Procedure calls remain stable in 2.7; module HTTP handlers/webhooks and RLS capabilities still require their documented gates.
|
||||
- 2.5 fixed `publish --delete-data` config fallback; 2.6 kept that behavior and improved CLI binary distribution; 2.7 adds `spacetime sql --format json` and database `lock` / `unlock`.
|
||||
- The official 2.7.0 Linux release archives and container image currently use the `v2.7.0-hotfix3` asset tag while binaries report `2.7.0`; keep the asset tag distinct from the runtime version check.
|
||||
- Do not assume `spacetime version install 2.7.0` selected hotfix3: stale updater metadata can install bare-tag commit `a08663c7...`. For the current release, verify CLI commit `d220349a...` and use the official hotfix3 archive or repository provision flow when it differs.
|
||||
- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on CLI defaults.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: spacetimedb-concepts
|
||||
description: Understand SpacetimeDB 2.6 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features.
|
||||
description: Understand SpacetimeDB 2.7 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features.
|
||||
---
|
||||
|
||||
# SpacetimeDB Core Concepts
|
||||
@@ -20,7 +20,7 @@ SpacetimeDB is a relational database that also executes application logic in upl
|
||||
|
||||
1. **Reducers are transactional**: they do not return data to callers. Read through subscriptions, read models, views, or BFF endpoints.
|
||||
2. **Reducers are deterministic**: no filesystem, network, wall-clock, or external RNG. Use `ctx.timestamp`, `ctx.rng()` / `ctx.random()`, and tables.
|
||||
3. **Procedures are stable in 2.6**: they can use explicit transactions and outgoing HTTP via `ctx.http`.
|
||||
3. **Procedures are stable in 2.7**: they can use explicit transactions and outgoing HTTP via `ctx.http`.
|
||||
4. **Identity comes from context**: use `ctx.sender()` or language equivalent for authorization. Never trust identity passed as an argument.
|
||||
5. **Auto-increment IDs are not ordering guarantees**: gaps are normal. Use timestamps or explicit sequence columns for ordering.
|
||||
6. **Schema changes need migration discipline**: existing Genarrative table fields must be appended with defaults; update migration code, table catalog, generated bindings, and run `npm run check:spacetime-schema`.
|
||||
@@ -44,25 +44,25 @@ Reducers are deterministic transactional functions. They are the primary client-
|
||||
|
||||
## Procedures
|
||||
|
||||
Procedures are stable in 2.6. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`).
|
||||
Procedures are stable in 2.7. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`).
|
||||
|
||||
Genarrative default: keep external provider protocols in `platform-*` and orchestration in `api-server` unless a task explicitly moves a workflow into a module procedure.
|
||||
|
||||
Module HTTP handlers/webhooks and RLS `client_visibility_filter` remain subject to their documented gates in 2.6.
|
||||
Module HTTP handlers/webhooks and RLS `client_visibility_filter` remain subject to their documented gates in 2.7.
|
||||
|
||||
## Views
|
||||
|
||||
Views expose computed read-only data. SpacetimeDB 2.6 supports primary keys on procedural views in Rust, TypeScript, and C#. Clients can receive `OnUpdate` events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction.
|
||||
Views expose computed read-only data. SpacetimeDB 2.7 supports primary keys on procedural views in Rust, TypeScript, C#, and C++. Clients can receive update events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction.
|
||||
|
||||
## Event Tables
|
||||
|
||||
Event tables broadcast reducer/procedure-specific facts to subscribers and must be subscribed explicitly. They are excluded from `subscribe_to_all_tables()`.
|
||||
|
||||
2.6 supports broader layout-altering automigrations for event tables, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables.
|
||||
Since 2.6, event tables support broader layout-altering automigrations, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables.
|
||||
|
||||
Event-table primary keys and constraints are transaction-scoped. They can reject duplicate event rows within one transaction, but event rows are not retained in client cache, so clients observe event tables through insert callbacks only. Do not design Genarrative event tables around `OnUpdate` / `on_update` / `onUpdate`; use a persistent table or a primary-keyed procedural view when update callbacks are required.
|
||||
|
||||
Official 2.4.1 through 2.6 release notes document primary-key-backed update callbacks for procedural views, not event tables.
|
||||
Official 2.4.1 through 2.7 release notes document primary-key-backed update callbacks for procedural views, not event tables.
|
||||
|
||||
## Subscriptions
|
||||
|
||||
@@ -78,7 +78,18 @@ Best practices:
|
||||
- Avoid overlapping queries that duplicate row delivery.
|
||||
- Use indexes for subscribed filters.
|
||||
|
||||
## 2.2.0 to 2.6.1 Delta
|
||||
## Standalone MCP
|
||||
|
||||
SpacetimeDB 2.7 standalone exposes `POST /v1/database/{name_or_identity}/mcp`
|
||||
using MCP JSON-RPC protocol `2025-06-18`. Its tools are `ping`, `get_schema`,
|
||||
`sql`, and `call`; SQL and reducer calls run with the authenticated caller's
|
||||
identity. In Genarrative this is an operator/developer integration surface, not
|
||||
a replacement for `api-server` BFF routes, `spacetime-client` facades, or public
|
||||
read models. Upgrade smoke should use an isolated local database and restrict
|
||||
itself to `initialize`, `tools/list`, `ping`, and `get_schema` unless writes are
|
||||
explicitly intended.
|
||||
|
||||
## 2.2.0 to 2.7.0 Delta
|
||||
|
||||
Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
|
||||
|
||||
@@ -89,6 +100,7 @@ Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
|
||||
- **2.5.0**: procedures are stable, C# procedural views gain primary keys, event tables allow broader layout-altering automigrations, BTreeSet storage makes row insertion deterministic and avoids accidentally quadratic bulk insert behavior, `wasm_memory_bytes` billing metric semantics changed, template version constraints unified, `publish --delete-data` config fallback fixed, CLI `call` accepts hex Identity arguments.
|
||||
- **2.6.0**: procedural-view primary keys are available across Rust, TypeScript, and C#, commitlog gains `max_segment_size` / `write_buffer_size` / `preallocate_segments`, the default write buffer increases for throughput, event-table automigrations improve, and CLI binary distribution expands.
|
||||
- **2.6.1**: procedure contexts again receive the caller `Identity` and `ConnectionId`; generated TypeScript `Option<T>` fields use optional keys; `spacetime init --template` lists available templates when no template argument is supplied.
|
||||
- **2.7.0**: existing tables can add unique or primary-key constraints when current data satisfies them; standalone exposes an authenticated database MCP endpoint; Rust adds context-capability and table-accessor traits; `spacetime sql --format json` and database locking are available; view cleanup, backing-table migration, connection metrics, and memory metrics improve. Official current release assets use the `v2.7.0-hotfix3` tag while binaries report `2.7.0`.
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: spacetimedb-rust
|
||||
description: Develop SpacetimeDB 2.6 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic.
|
||||
description: Develop SpacetimeDB 2.7 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic.
|
||||
---
|
||||
|
||||
# SpacetimeDB Rust Module Development
|
||||
@@ -181,11 +181,11 @@ fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
|
||||
|
||||
Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`.
|
||||
|
||||
In 2.6, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables.
|
||||
Since 2.6, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables.
|
||||
|
||||
Event-table primary keys and constraints are enforced only within the current transaction. They do not make event rows persistent, and client SDKs expose event tables as insert-only event streams. Do not rely on `OnUpdate` / `on_update` / `onUpdate` for event tables; use a persistent table or a primary-keyed procedural view when update callbacks are required.
|
||||
|
||||
Official 2.4.1 through 2.6 release notes tie primary-key-backed update callbacks to procedural views, not event tables.
|
||||
Official 2.4.1 through 2.7 release notes tie primary-key-backed update callbacks to procedural views, not event tables.
|
||||
|
||||
## Views
|
||||
|
||||
@@ -228,7 +228,7 @@ For scheduled reducers, check `ctx.sender_auth().is_internal()` when the reducer
|
||||
|
||||
## Procedures
|
||||
|
||||
Procedures remain stable in 2.6 and no longer require the `unstable` feature.
|
||||
Procedures remain stable in 2.7 and no longer require the `unstable` feature.
|
||||
|
||||
```rust
|
||||
use spacetimedb::{procedure, ProcedureContext};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3,11 +3,13 @@ import { afterEach, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
createAdminAccount,
|
||||
executeAdminRechargeRefund,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
listAdminRechargeOrders,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
updateAdminAccount,
|
||||
uploadAdminEditorShowcaseCampaignImage,
|
||||
upsertAdminFeatureGateConfig,
|
||||
} from './adminApiClient';
|
||||
|
||||
afterEach(() => {
|
||||
@@ -57,6 +59,52 @@ 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(
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
AdminEditorShowcaseListQuery,
|
||||
AdminEditorShowcaseListResponse,
|
||||
AdminEditorShowcaseReviewRequest,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
@@ -41,6 +42,7 @@ import type {
|
||||
AdminUpdateAccountResponse,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertFeatureGateConfigRequest,
|
||||
AdminUpsertProfileInviteCodeRequest,
|
||||
AdminUpsertProfileRechargeProductRequest,
|
||||
AdminUpsertProfileRedeemCodeRequest,
|
||||
@@ -267,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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
[
|
||||
|
||||
@@ -263,6 +263,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 持久化”只覆盖工程、素材、图层和元数据,遗漏了正式生成任务表。
|
||||
@@ -4408,3 +4416,11 @@
|
||||
- 动态与可用性边界:`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`。
|
||||
|
||||
@@ -3313,3 +3313,11 @@
|
||||
- 验证与回滚:重启后先跑真实 PR 的四个 job,再清理旧镜像。失败时先把 workflow `runs-on` 改回 `ubuntu-latest`,再恢复 runner config 备份并重启;不在 Git、共享文档或日志中记录 config 备份路径、注册信息或 token。
|
||||
- 重启边界:`docker restart --timeout 660` 只设置容器停止宽限,不能替代 Runner drain。rootless DinD supervisor 可能与 runner 同时停止内层 dockerd,使仍在收尾的 job 因连接关闭被标记失败;切换前必须同时确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。误触发时只重跑受影响的失败 job,不重跑已成功项。
|
||||
- 关联:`deploy/container/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/project-memory/shared-memory/development-workflow.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 可以创建、修改、启停 member;member 保存在 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 id;member 返回数据库中的实时规范化数组。`GET /admin/api/me` 同样执行逐请求校验并返回实时权限,供刷新页面后恢复导航。
|
||||
owner 返回全部 15 个 permission id;member 返回数据库中的实时规范化数组。`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 id;member 返回数据库中的实时规
|
||||
| `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 id;member 返回数据库中的实时规
|
||||
| `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 id;member 返回数据库中的实时规
|
||||
| `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 的源码目录;它们仅用于历史追溯,不属于任何正式入口或编译目标。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 2026-07-18 状态更新:旧创作入口、全部模板业务 API/worker/运行态及 SpacetimeDB 业务逻辑已退役。本文逐玩法路由、流程和 DTO 章节仅作为历史设计记录;相关持久化表仍按原结构作为最小 schema 数据壳编译,当前编译与运行边界以 `server-rs/Cargo.toml`、`server-rs/crates/api-server/src/app.rs` 和 `docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md` 为准。
|
||||
|
||||
更新时间:`2026-07-21`
|
||||
更新时间:`2026-07-23`
|
||||
|
||||
## 后端主线
|
||||
|
||||
@@ -18,7 +18,7 @@ server-rs + Axum + SpacetimeDB
|
||||
|
||||
`server-rs/Cargo.toml` 是 workspace 事实源。默认构建成员为 `crates/api-server`;第三方依赖版本和 workspace 内 crate path 统一放在 `[workspace.dependencies]`。
|
||||
|
||||
SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 统一锁定 `2.6.1`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `server-rs/Cargo.toml` 锁定版本对齐,避免 BSATN / procedure result 反序列化错配。遇到版本不匹配时,不继续沿着业务超时排查,先把 CLI / standalone 直接升级到锁定版本并重启后再重试。2.6.1 还修复了 procedure context 中调用者 `Identity` / `ConnectionId` 丢失问题,因此依赖调用者身份的 procedure 不得继续运行在 2.6.0 standalone 上。
|
||||
SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 统一锁定 `2.7.0`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `server-rs/Cargo.toml` 锁定版本对齐,避免 BSATN / procedure result 反序列化错配。2.7.0 官方 CLI / standalone 发行包与容器镜像使用 `v2.7.0-hotfix3` 资产标签,二进制版本仍为 `2.7.0`;不得回退使用缺少后续 backing-view 迁移修复的裸 tag 构建。遇到版本不匹配时,不继续沿着业务超时排查,先把 CLI / standalone 直接升级到锁定版本并重启后再重试。2.6.1 还修复了 procedure context 中调用者 `Identity` / `ConnectionId` 丢失问题,因此依赖调用者身份的 procedure 不得继续运行在 2.6.0 standalone 上。
|
||||
|
||||
当前主要 crate:
|
||||
|
||||
@@ -59,6 +59,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- 健康检查:`GET /healthz`、`GET /readyz`。
|
||||
- 后台管理:`/admin/api/*`,现役路由包括登录与账号管理、Dashboard / 概览、HTTP debug、埋点、表查询、通用 feature gate、编辑器定价与素材 / 精选管理,以及账号侧兑换码、邀请码、任务、钱包、充值与退款管理;不再挂载旧创作入口配置、旧作品互动或旧玩法运营路由。环境变量管理员固定作为 owner,持久化 member 每次请求按当前 `enabled`、`token_version` 和一级 Tab 权限实时校验;账号管理仅 owner 可访问,未登记权限映射的新后台路由对 member 默认拒绝。完整权限矩阵见 [`docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md`](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md),Dashboard 指标口径见 [`docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。
|
||||
- 通用灰度控制面固定为后台 `#gray-release` 与 `GET/PUT /admin/api/feature-gates`;页面固定目标只登记现役功能,不读取旧 `/admin/api/creation-entry/config`,也不恢复 `creation-entry:*` 动态目标。
|
||||
- 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
|
||||
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
|
||||
- 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 本地开发验证与生产运维
|
||||
|
||||
更新时间:`2026-07-21`
|
||||
更新时间:`2026-07-23`
|
||||
|
||||
## 标准开发流程
|
||||
|
||||
@@ -151,7 +151,7 @@ spacetime sql <database> "SELECT * FROM runtime_setting LIMIT 1" --server http:/
|
||||
|
||||
本地 `npm run dev:spacetime` 发布模块时必须显式忽略仓库根目录的 `spacetime.json`,由脚本固定追加 `--no-config` 并使用命令参数里传入的数据库名和 `--server http://127.0.0.1:3101`。否则 CLI 可能把发布目标改写到配置文件里的其他数据库,导致 `dev:spacetime` 启动后又因发布失败自动退出,浏览器随后会在 `ws://127.0.0.1:3101/v1/database/.../subscribe` 看到连接拒绝。
|
||||
|
||||
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.6.1`。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为现役 settings、editor project 或 profile procedure 超时。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;遇到版本不匹配时直接执行 `spacetime version install <version> && spacetime version use <version>`,或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。
|
||||
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.7.0`。官方发行包位于 `v2.7.0-hotfix3` 资产标签,二进制仍报告 `2.7.0`;运行态和 provision 必须使用该 hotfix 构建,不得只按裸 `v2.7.0` tag 下载。当前 updater 元数据可能让 `spacetime version install 2.7.0` 装到裸 tag commit `a08663c7...`,所以 2.7.0 安装后必须核对 `spacetime --version` 的 commit 为 hotfix3 `d220349a...`;不一致时改用官方 hotfix3 archive 或仓库 provision 流程。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为现役 settings、editor project 或 profile procedure 超时。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;其它版本可执行 `spacetime version install <version> && spacetime version use <version>`,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会把 tool version 和 commit 一起写入 `dev-spacetime-tool-version`,启动新 standalone 与复用已有本地进程时都要求 `2.7.0 + d220349a...` 同时匹配;旧单行版本记录会拒绝复用并要求重启。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。
|
||||
|
||||
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 VectorEngine 生成链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 VectorEngine provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
|
||||
|
||||
@@ -247,7 +247,7 @@ SpacetimeDB bindings:
|
||||
npm run spacetime:generate
|
||||
```
|
||||
|
||||
后台账号 procedure 的 identity、唯一索引和版本事务使用隔离 smoke 验证;脚本会在随机本机端口启动临时 SpacetimeDB 2.6、发布当前 module,结束后自动关闭并清理临时数据:
|
||||
后台账号 procedure 的 identity、唯一索引和版本事务使用隔离 smoke 验证;脚本会在随机本机端口启动仓库锁定版本的临时 SpacetimeDB、发布当前 module,结束后自动关闭并清理临时数据:
|
||||
|
||||
```bash
|
||||
npm run check:admin-account-procedures
|
||||
@@ -609,7 +609,7 @@ worker 被硬杀或断电后,lease 过期任务只有尚未耗尽 `max_attempt
|
||||
- `api-server` 正常运行时 `/healthz` 只返回进程存活状态,`/readyz` 会同时检查进程是否仍接收新流量和 SpacetimeDB 连接租约是否健康;收到 `SIGINT` / `SIGTERM` 后会先把 readiness 标记为不可用,再让 Axum 停止接新连接并等待已有 HTTP 请求排空。systemd 仍以 `KillSignal=SIGINT` 停服务,`TimeoutStopSec=90` 作为长请求排空上限。
|
||||
- SpacetimeDB 健康检查默认使用 `GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS=2` 的短等待窗口,和业务 procedure 的 `GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS` 分开。`/readyz` 失败时 `details.spacetime.stage` 会标出当前卡住阶段:`pool_acquire`、`connect_build`、`connect_handshake`、`read_model_subscribe`、`procedure_result`、`reducer_result` 或 `read_cache`;`elapsedMs` / `timeoutMs` 用于确认是否命中健康检查窗口。业务请求日志也会写入 `operation_kind`、`operation_name`、`spacetime_stage` 和 `elapsed_ms`,后续 45 秒超时不再只靠 Nginx `request_time=45s` 推断。
|
||||
- `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax -p TimeoutStopUSec` 和 `cat /proc/$(pidof api-server)/limits` 核对。
|
||||
- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转 SpacetimeDB / otelcol 工具包;Linux build 节点只负责从内网 Git 源准备 provision 脚本和配置并上传给目标 agent。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:版本已满足时直接复用目标机现有文件生成 `provision-tools/`,只有缺失或版本不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备 SpacetimeDB `2.6.1` / `otelcol-contrib 0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。
|
||||
- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转 SpacetimeDB / otelcol 工具包;Linux build 节点只负责从内网 Git 源准备 provision 脚本和配置并上传给目标 agent。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:SpacetimeDB 必须同时匹配运行版本 `2.7.0` 和 hotfix3 commit `d220349a...` 才能复用,裸 tag `a08663c7...` 即使版本号相同也必须拒绝;只有缺失或版本 / commit 不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备官方 `v2.7.0-hotfix3` 资产。`SPACETIME_EXPECTED_COMMIT` 与下载根必须成对调整,安装结果也执行同一 commit 门禁。otelcol-contrib 当前锁定 `0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。
|
||||
- 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源,目标部署 agent 也不再需要访问源码 Git remote。
|
||||
- `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。该服务必须存在系统用户 / 组 `otelcol`,并且 `/etc/otelcol/genarrative-debug.yaml` 已安装到目标机;若看到 `status=217/USER` 或 `Failed to determine user credentials`,优先检查 `getent passwd otelcol`,再补齐 `/etc/otelcol` 配置目录并重启服务。
|
||||
- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;通用 API 使用 `genarrative_api_rps`,后台 API 使用 `genarrative_admin_rps`。通用 `/api` location 保留 `client_max_body_size 64m` 作为编辑器图片、视频和文档请求的反代兜底,真实大小仍由路由与业务校验负责。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先核对 release 模板与实际媒体大小。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效。
|
||||
|
||||
@@ -25,7 +25,8 @@ pipeline {
|
||||
string(name: 'PROVISION_DOWNLOADS_DIR', defaultValue: 'provision-tool-downloads', description: '目标服务器工作区内暂存 SpacetimeDB/otelcol 安装包的相对目录')
|
||||
string(name: 'PROVISION_TOOLS_DIR', defaultValue: 'provision-tools', description: '目标机工作区内由已下载安装包生成的工具包目录')
|
||||
string(name: 'PROVISION_DOWNLOAD_PROXY', defaultValue: '', description: '可选,目标服务器下载 SpacetimeDB 和 otelcol-contrib 时使用的代理地址,例如 http://127.0.0.1:7890;留空不设置代理')
|
||||
string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.6.1', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址;默认固定到项目锁定版本')
|
||||
string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.7.0-hotfix3', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址;默认固定到项目锁定版本的官方 hotfix 资产标签')
|
||||
string(name: 'SPACETIME_EXPECTED_COMMIT', defaultValue: 'd220349adb7af7eefa810eb08a185609356b83f6', description: 'SpacetimeDB CLI 预期构建 commit;用于拒绝同版本号但缺少 hotfix 的旧二进制')
|
||||
string(name: 'SPACETIME_TARGET_HOST', defaultValue: 'x86_64-unknown-linux-gnu', description: 'SpacetimeDB 预编译包 host triple,development/release Linux amd64 使用默认值')
|
||||
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
|
||||
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
|
||||
@@ -95,6 +96,9 @@ pipeline {
|
||||
if (!(params.SPACETIME_DOWNLOAD_ROOT?.trim() ==~ /^https?:\/\/\S+$/)) {
|
||||
error('SPACETIME_DOWNLOAD_ROOT 不能为空。')
|
||||
}
|
||||
if (!(params.SPACETIME_EXPECTED_COMMIT?.trim() ==~ /^[0-9a-f]{40}$/)) {
|
||||
error('SPACETIME_EXPECTED_COMMIT 必须是 40 位小写十六进制 commit。')
|
||||
}
|
||||
if (!(params.SPACETIME_TARGET_HOST?.trim() ==~ /^[0-9A-Za-z._-]+$/)) {
|
||||
error("SPACETIME_TARGET_HOST 只能包含字母、数字、点号、下划线和短横线: ${params.SPACETIME_TARGET_HOST}")
|
||||
}
|
||||
@@ -209,7 +213,8 @@ BASH
|
||||
OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \
|
||||
PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \
|
||||
PROVISION_DOWNLOAD_PROXY="${PROVISION_DOWNLOAD_PROXY:-}" \
|
||||
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.6.1}" \
|
||||
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.7.0-hotfix3}" \
|
||||
SPACETIME_EXPECTED_COMMIT="${SPACETIME_EXPECTED_COMMIT:-d220349adb7af7eefa810eb08a185609356b83f6}" \
|
||||
SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \
|
||||
SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}" \
|
||||
scripts/prepare-server-provision-tools.sh
|
||||
|
||||
@@ -20,7 +20,7 @@ const repoRoot = path.resolve(
|
||||
'..',
|
||||
);
|
||||
const database = 'admin-account-smoke';
|
||||
const expectedSpacetimeVersion = '2.6.1';
|
||||
const expectedSpacetimeVersion = '2.7.0';
|
||||
const commandTimeoutMs = 5 * 60 * 1000;
|
||||
|
||||
function assert(condition, message) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user