Merge remote-tracking branch 'origin/master' into editor-agent-refactored
# Conflicts: # server-rs/crates/api-server/src/editor_generation_queue.rs # src/components/image-editor/ImageCanvasEditorView.test.tsx # src/components/image-editor/ImageCanvasEditorView.tsx
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: spacetimedb-cli
|
||||
description: SpacetimeDB 2.5 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.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.
|
||||
---
|
||||
|
||||
# SpacetimeDB CLI
|
||||
@@ -61,7 +61,7 @@ spacetime describe my-db table users --server http://127.0.0.1:3101 --json
|
||||
# Reducer/procedure calls. Arguments are positional JSON values.
|
||||
spacetime call --server http://127.0.0.1:3101 my-db my_reducer '"value"' '123'
|
||||
|
||||
# 2.5 accepts hex strings for Identity arguments without full JSON tuple syntax.
|
||||
# 2.5+ accepts hex strings for Identity arguments without full JSON tuple syntax.
|
||||
spacetime call --server http://127.0.0.1:3101 my-db reducer_needing_identity 0xabc123...
|
||||
|
||||
# Subscribe from CLI
|
||||
@@ -102,7 +102,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.5 prefer 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 +146,6 @@ pid="$(systemctl show spacetimedb.service -p MainPID --value)"
|
||||
|
||||
## Notes
|
||||
|
||||
- Procedure calls are stable in 2.5; module HTTP handlers/webhooks, unstable view features, and RLS remain behind unstable gates per release notes.
|
||||
- 2.5 fixes `publish --delete-data` config fallback so `spacetime.json` can provide the database name.
|
||||
- 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.
|
||||
- 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.5 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.6 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.5**: they can use explicit transactions and outgoing HTTP via `ctx.http`.
|
||||
3. **Procedures are stable in 2.6**: 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.5. 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.6. 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, unstable view features, and RLS `client_visibility_filter` remain gated behind unstable according to the 2.5 release notes.
|
||||
Module HTTP handlers/webhooks and RLS `client_visibility_filter` remain subject to their documented gates in 2.6.
|
||||
|
||||
## Views
|
||||
|
||||
Views expose computed read-only data. In 2.4.1 Rust and TypeScript gained primary key support for procedural views; in 2.5 C# gained the same. 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.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.
|
||||
|
||||
## 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.5 adds 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.
|
||||
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.
|
||||
|
||||
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/2.5 release notes document primary-key-backed update callbacks for procedural views, not event tables.
|
||||
Official 2.4.1 through 2.6 release notes document primary-key-backed update callbacks for procedural views, not event tables.
|
||||
|
||||
## Subscriptions
|
||||
|
||||
@@ -78,7 +78,7 @@ Best practices:
|
||||
- Avoid overlapping queries that duplicate row delivery.
|
||||
- Use indexes for subscribed filters.
|
||||
|
||||
## 2.2.0 to 2.5.0 Delta
|
||||
## 2.2.0 to 2.6.0 Delta
|
||||
|
||||
Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
|
||||
|
||||
@@ -87,6 +87,7 @@ Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
|
||||
- **2.4.0**: unstable module HTTP handlers/webhooks, faster synchronous WASM reducer runtime, commitlog resume truncation fix for silent data loss risk, better commitlog decode context, V8 heap metrics for procedure workers, JS execution-time billing regression reverted.
|
||||
- **2.4.1**: Rust and TypeScript procedural views can declare primary keys, enabling `OnUpdate` events for subscribed views; fixed index schema from ST tables.
|
||||
- **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.
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: spacetimedb-rust
|
||||
description: Develop SpacetimeDB 2.5 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.6 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
|
||||
@@ -28,7 +28,7 @@ ctx.db.player.find(id) // Use ctx.db.player().id().find(&id)
|
||||
ctx.sender // Use ctx.sender()
|
||||
ctx.db.user().name().update(..) // Update by primary key only
|
||||
|
||||
spacetimedb = { version = "...", features = ["unstable"] } // Not needed for procedures in 2.5
|
||||
spacetimedb = { version = "...", features = ["unstable"] } // Not needed for procedures since 2.5
|
||||
```
|
||||
|
||||
## Required Patterns
|
||||
@@ -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.5, 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.
|
||||
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.
|
||||
|
||||
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/2.5 release notes tie primary-key-backed update callbacks to procedural views, not event tables.
|
||||
Official 2.4.1 through 2.6 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 are stable in 2.5 and no longer require the `unstable` feature.
|
||||
Procedures remain stable in 2.6 and no longer require the `unstable` feature.
|
||||
|
||||
```rust
|
||||
use spacetimedb::{procedure, ProcedureContext};
|
||||
|
||||
@@ -104,6 +104,8 @@ WECHAT_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/sns/oauth2/access_token"
|
||||
WECHAT_USER_INFO_ENDPOINT="https://api.weixin.qq.com/sns/userinfo"
|
||||
WECHAT_JS_CODE_SESSION_ENDPOINT="https://api.weixin.qq.com/sns/jscode2session"
|
||||
WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/cgi-bin/stable_token"
|
||||
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT="https://api.weixin.qq.com/xpay/query_order"
|
||||
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT="https://api.weixin.qq.com/xpay/notify_provide_goods"
|
||||
WECHAT_PHONE_NUMBER_ENDPOINT="https://api.weixin.qq.com/wxa/business/getuserphonenumber"
|
||||
WECHAT_STATE_TTL_MINUTES="15"
|
||||
WECHAT_MOCK_USER_ID="wx-mock-user"
|
||||
|
||||
@@ -764,6 +764,11 @@ function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
|
||||
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
|
||||
params.set('limit', String(query.limit));
|
||||
}
|
||||
if (typeof query.page === 'number' && Number.isFinite(query.page)) {
|
||||
params.set('page', String(query.page));
|
||||
}
|
||||
appendQueryParam(params, 'sortColumn', query.sortColumn);
|
||||
appendQueryParam(params, 'sortDirection', query.sortDirection);
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
@@ -150,8 +150,11 @@ export interface AdminDatabaseTableListResponse {
|
||||
|
||||
export interface AdminDatabaseTableRowsQuery {
|
||||
limit?: number;
|
||||
page?: number;
|
||||
search?: string;
|
||||
filters?: string;
|
||||
sortColumn?: string;
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface AdminDatabaseTableRowPayload {
|
||||
@@ -165,6 +168,11 @@ export interface AdminDatabaseTableRowsResponse {
|
||||
rows: AdminDatabaseTableRowPayload[];
|
||||
totalReturned: number;
|
||||
limit: number;
|
||||
page: number;
|
||||
totalMatched: number;
|
||||
scannedCount: number;
|
||||
scanLimit: number;
|
||||
scanLimitReached: boolean;
|
||||
}
|
||||
|
||||
export interface AdminDatabaseTableStatPayload {
|
||||
|
||||
@@ -19,6 +19,36 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const referralRows = [
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-02T00:00:00Z',
|
||||
invitee_user_id: 'u-b',
|
||||
invite_code: 'INV-1001',
|
||||
inviter_user_id: 'u-a',
|
||||
},
|
||||
raw: ['u-b', 'u-a', 'INV-1001', '2026-05-02T00:00:00Z'],
|
||||
},
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-01T00:00:00Z',
|
||||
invitee_user_id: 'u-a',
|
||||
invite_code: 'INV-1002',
|
||||
inviter_user_id: 'u-c',
|
||||
},
|
||||
raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'],
|
||||
},
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-03T00:00:00Z',
|
||||
invitee_user_id: 'u-c',
|
||||
invite_code: 'INV-1003',
|
||||
inviter_user_id: 'u-a',
|
||||
},
|
||||
raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
window.location.hash = '#tables?table=profile_referral_relation';
|
||||
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
|
||||
@@ -28,41 +58,72 @@ beforeEach(() => {
|
||||
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
|
||||
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
|
||||
limit: 100,
|
||||
rows: [
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-02T00:00:00Z',
|
||||
invitee_user_id: 'u-b',
|
||||
invite_code: 'INV-1001',
|
||||
inviter_user_id: 'u-a',
|
||||
},
|
||||
raw: ['u-b', 'u-a', 'INV-1001', '2026-05-02T00:00:00Z'],
|
||||
},
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-01T00:00:00Z',
|
||||
invitee_user_id: 'u-a',
|
||||
invite_code: 'INV-1002',
|
||||
inviter_user_id: 'u-c',
|
||||
},
|
||||
raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'],
|
||||
},
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-03T00:00:00Z',
|
||||
invitee_user_id: 'u-c',
|
||||
invite_code: 'INV-1003',
|
||||
inviter_user_id: 'u-a',
|
||||
},
|
||||
raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'],
|
||||
},
|
||||
],
|
||||
rows: referralRows,
|
||||
page: 1,
|
||||
scannedCount: 3,
|
||||
scanLimit: 50000,
|
||||
scanLimitReached: false,
|
||||
tableName: 'profile_referral_relation',
|
||||
totalMatched: 3,
|
||||
totalReturned: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('后台表查询页支持宽表滚动容器和表头排序', async () => {
|
||||
test('后台表查询页通过页面级固定栏翻页并提示扫描结果可能不完整', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
|
||||
columns: ['invitee_user_id'],
|
||||
limit: 100,
|
||||
page: 1,
|
||||
rows: [
|
||||
{
|
||||
cells: { invitee_user_id: 'u-b' },
|
||||
raw: ['u-b'],
|
||||
},
|
||||
],
|
||||
scannedCount: 50000,
|
||||
scanLimit: 50000,
|
||||
scanLimitReached: true,
|
||||
tableName: 'profile_referral_relation',
|
||||
totalMatched: 250,
|
||||
totalReturned: 1,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('第 1 / 3 页,共 250 条')).toBeTruthy();
|
||||
const pagination = screen.getByRole('navigation', { name: '表查询分页' });
|
||||
expect(pagination.classList.contains('admin-database-pagination')).toBe(true);
|
||||
expect(pagination.closest('.admin-panel')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('.admin-database-tables-page')?.lastElementChild,
|
||||
).toBe(pagination);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'当前表超过 50000 条扫描与浏览上限,本次已扫描 50000 条;当前分页结果和匹配总数可能不完整。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.type(screen.getByRole('textbox', { name: '关键词' }), '未执行条件');
|
||||
await user.click(screen.getByRole('button', { name: '下一页' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
|
||||
'admin-token',
|
||||
'profile_referral_relation',
|
||||
expect.objectContaining({
|
||||
filters: '',
|
||||
limit: 100,
|
||||
page: 2,
|
||||
search: '',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('后台表查询页把表头排序交给后端并从第一页展示排序结果', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
@@ -89,13 +150,55 @@ test('后台表查询页支持宽表滚动容器和表头排序', async () => {
|
||||
).toBe('原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。');
|
||||
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']);
|
||||
|
||||
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
|
||||
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
|
||||
limit: 100,
|
||||
page: 1,
|
||||
rows: [referralRows[0]!, referralRows[2]!, referralRows[1]!],
|
||||
scannedCount: 3,
|
||||
scanLimit: 50000,
|
||||
scanLimitReached: false,
|
||||
tableName: 'profile_referral_relation',
|
||||
totalMatched: 3,
|
||||
totalReturned: 3,
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
|
||||
await waitFor(() => {
|
||||
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
|
||||
'admin-token',
|
||||
'profile_referral_relation',
|
||||
expect.objectContaining({
|
||||
page: 1,
|
||||
sortColumn: 'inviter_user_id',
|
||||
sortDirection: 'asc',
|
||||
}),
|
||||
);
|
||||
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-c', 'u-a']);
|
||||
});
|
||||
|
||||
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
|
||||
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
|
||||
limit: 100,
|
||||
page: 1,
|
||||
rows: [referralRows[1]!, referralRows[0]!, referralRows[2]!],
|
||||
scannedCount: 3,
|
||||
scanLimit: 50000,
|
||||
scanLimitReached: false,
|
||||
tableName: 'profile_referral_relation',
|
||||
totalMatched: 3,
|
||||
totalReturned: 3,
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
|
||||
await waitFor(() => {
|
||||
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
|
||||
'admin-token',
|
||||
'profile_referral_relation',
|
||||
expect.objectContaining({
|
||||
page: 1,
|
||||
sortColumn: 'inviter_user_id',
|
||||
sortDirection: 'desc',
|
||||
}),
|
||||
);
|
||||
expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,21 @@ import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
FormEvent,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
getAdminDatabaseTableRows,
|
||||
@@ -30,6 +39,14 @@ export function AdminDatabaseTablesPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: AdminDatabaseTablesPageProps) {
|
||||
const pageRef = useRef<HTMLElement>(null);
|
||||
const appliedQueryRef = useRef({
|
||||
search: '',
|
||||
filters: '',
|
||||
limit: '100',
|
||||
sortColumn: '',
|
||||
sortDirection: 'asc' as SortDirection,
|
||||
});
|
||||
const [tables, setTables] = useState<string[]>([]);
|
||||
const [tableName, setTableName] = useState(() => readHashTableName());
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -61,7 +78,6 @@ export function AdminDatabaseTablesPage({
|
||||
const tableFromHash = readHashTableName();
|
||||
if (tableFromHash) {
|
||||
setTableName(tableFromHash);
|
||||
void refreshRows(tableFromHash);
|
||||
}
|
||||
};
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
@@ -82,6 +98,45 @@ export function AdminDatabaseTablesPage({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tableName]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const pageElement = pageRef.current;
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
const bottomNav = document.querySelector<HTMLElement>('.admin-bottom-nav');
|
||||
const pagination = pageElement.querySelector<HTMLElement>(
|
||||
'.admin-database-pagination',
|
||||
);
|
||||
const updateFixedBarHeights = () => {
|
||||
pageElement.style.setProperty(
|
||||
'--admin-bottom-nav-height',
|
||||
`${bottomNav?.getBoundingClientRect().height ?? 0}px`,
|
||||
);
|
||||
pageElement.style.setProperty(
|
||||
'--admin-database-pagination-height',
|
||||
`${pagination?.getBoundingClientRect().height ?? 0}px`,
|
||||
);
|
||||
};
|
||||
updateFixedBarHeights();
|
||||
window.addEventListener('resize', updateFixedBarHeights);
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(updateFixedBarHeights)
|
||||
: null;
|
||||
if (resizeObserver) {
|
||||
if (bottomNav) {
|
||||
resizeObserver.observe(bottomNav);
|
||||
}
|
||||
if (pagination) {
|
||||
resizeObserver.observe(pagination);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', updateFixedBarHeights);
|
||||
};
|
||||
}, [result]);
|
||||
|
||||
const visibleColumns = useMemo(() => {
|
||||
const columns = result?.columns ?? [];
|
||||
if (columns.length) {
|
||||
@@ -117,27 +172,11 @@ export function AdminDatabaseTablesPage({
|
||||
[tableName, visibleColumns],
|
||||
);
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
const rows = result?.rows ?? [];
|
||||
if (!sortColumn || !visibleColumns.includes(sortColumn)) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
return [...rows]
|
||||
.map((row, index) => ({ index, row }))
|
||||
.sort((left, right) => {
|
||||
const comparison = compareTableCellValues(
|
||||
left.row.cells[sortColumn],
|
||||
right.row.cells[sortColumn],
|
||||
sortDirection,
|
||||
);
|
||||
if (comparison !== 0) {
|
||||
return comparison;
|
||||
}
|
||||
return left.index - right.index;
|
||||
})
|
||||
.map(({ row }) => row);
|
||||
}, [result, sortColumn, sortDirection, visibleColumns]);
|
||||
const currentPage = result?.page ?? 1;
|
||||
const totalMatched = result?.totalMatched ?? result?.totalReturned ?? 0;
|
||||
const totalPages = result
|
||||
? Math.max(1, Math.ceil(totalMatched / Math.max(1, result.limit)))
|
||||
: 1;
|
||||
|
||||
async function loadTables() {
|
||||
setIsLoadingTables(true);
|
||||
@@ -161,6 +200,9 @@ export function AdminDatabaseTablesPage({
|
||||
search?: string;
|
||||
filters?: string;
|
||||
limit?: string;
|
||||
page?: number;
|
||||
sortColumn?: string;
|
||||
sortDirection?: SortDirection;
|
||||
} = {},
|
||||
) {
|
||||
const normalizedTableName = nextTableName.trim();
|
||||
@@ -170,6 +212,8 @@ export function AdminDatabaseTablesPage({
|
||||
const querySearch = options.search ?? search;
|
||||
const queryFilters = options.filters ?? filters;
|
||||
const queryLimit = options.limit ?? limit;
|
||||
const querySortColumn = options.sortColumn ?? sortColumn;
|
||||
const querySortDirection = options.sortDirection ?? sortDirection;
|
||||
setIsLoadingRows(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
@@ -180,8 +224,20 @@ export function AdminDatabaseTablesPage({
|
||||
search: querySearch,
|
||||
filters: queryFilters,
|
||||
limit: parseLimit(queryLimit),
|
||||
page: options.page ?? 1,
|
||||
sortColumn: querySortColumn || undefined,
|
||||
sortDirection: querySortColumn ? querySortDirection : undefined,
|
||||
},
|
||||
);
|
||||
appliedQueryRef.current = {
|
||||
search: querySearch,
|
||||
filters: queryFilters,
|
||||
limit: queryLimit,
|
||||
sortColumn: querySortColumn,
|
||||
sortDirection: querySortDirection,
|
||||
};
|
||||
setSortColumn(querySortColumn);
|
||||
setSortDirection(querySortDirection);
|
||||
setResult(response);
|
||||
setCopyMessage('');
|
||||
} catch (error: unknown) {
|
||||
@@ -193,10 +249,12 @@ export function AdminDatabaseTablesPage({
|
||||
|
||||
function handleSearch(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void refreshRows();
|
||||
void refreshRows(tableName, { page: 1 });
|
||||
}
|
||||
|
||||
function handleTableChange(nextTableName: string) {
|
||||
setSortColumn('');
|
||||
setSortDirection('asc');
|
||||
setTableName(nextTableName);
|
||||
const nextHash = `#tables?table=${encodeURIComponent(nextTableName)}`;
|
||||
if (window.location.hash !== nextHash) {
|
||||
@@ -208,19 +266,31 @@ export function AdminDatabaseTablesPage({
|
||||
setSearch('');
|
||||
setFilters('');
|
||||
setLimit('100');
|
||||
void refreshRows(tableName, { search: '', filters: '', limit: '100' });
|
||||
setSortColumn('');
|
||||
setSortDirection('asc');
|
||||
void refreshRows(tableName, {
|
||||
search: '',
|
||||
filters: '',
|
||||
limit: '100',
|
||||
page: 1,
|
||||
sortColumn: '',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
void refreshRows(tableName, { ...appliedQueryRef.current, page });
|
||||
}
|
||||
|
||||
function handleSortColumn(column: string) {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection((currentDirection) =>
|
||||
currentDirection === 'asc' ? 'desc' : 'asc',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSortColumn(column);
|
||||
setSortDirection('asc');
|
||||
const nextDirection =
|
||||
sortColumn === column && sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
void refreshRows(tableName, {
|
||||
...appliedQueryRef.current,
|
||||
page: 1,
|
||||
sortColumn: column,
|
||||
sortDirection: nextDirection,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCopyDetailJson() {
|
||||
@@ -242,7 +312,10 @@ export function AdminDatabaseTablesPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<section
|
||||
ref={pageRef}
|
||||
className="admin-page admin-page-wide admin-database-tables-page"
|
||||
>
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>表查询</h2>
|
||||
@@ -262,7 +335,7 @@ export function AdminDatabaseTablesPage({
|
||||
className="admin-primary-button"
|
||||
disabled={!tableName || isLoadingRows}
|
||||
type="button"
|
||||
onClick={() => void refreshRows()}
|
||||
onClick={() => void refreshRows(tableName, { page: 1 })}
|
||||
>
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<span>{isLoadingRows ? '查询中' : '查询'}</span>
|
||||
@@ -302,7 +375,7 @@ export function AdminDatabaseTablesPage({
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field admin-field-compact">
|
||||
<span>条数</span>
|
||||
<span>每页条数</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={limit}
|
||||
@@ -342,12 +415,21 @@ export function AdminDatabaseTablesPage({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result?.scanLimitReached ? (
|
||||
<section className="admin-panel admin-panel-warning" role="status">
|
||||
当前表超过 {result.scanLimit} 条扫描与浏览上限,本次已扫描{' '}
|
||||
{result.scannedCount} 条;当前分页结果和匹配总数可能不完整。
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3 title={resultTableHeader.description}>
|
||||
{resultTableHeader.label}
|
||||
</h3>
|
||||
<span>{result?.totalReturned ?? 0} 条</span>
|
||||
<span>
|
||||
匹配 {totalMatched} 条,本页 {result?.totalReturned ?? 0} 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-wide admin-database-table">
|
||||
@@ -371,6 +453,7 @@ export function AdminDatabaseTablesPage({
|
||||
data-active={isSorted ? 'true' : 'false'}
|
||||
title={description}
|
||||
type="button"
|
||||
disabled={isLoadingRows}
|
||||
onClick={() => handleSortColumn(column)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
@@ -391,8 +474,8 @@ export function AdminDatabaseTablesPage({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.length ? (
|
||||
sortedRows.map((row, rowIndex) => (
|
||||
{result?.rows.length ? (
|
||||
result.rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={buildRowKey(row, rowIndex)}
|
||||
data-clickable="true"
|
||||
@@ -441,6 +524,41 @@ export function AdminDatabaseTablesPage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{result ? (
|
||||
<nav
|
||||
className="admin-action-row admin-database-pagination"
|
||||
aria-label="表查询分页"
|
||||
>
|
||||
<span className="admin-database-pagination-info">
|
||||
第 {currentPage} / {totalPages} 页,共 {totalMatched} 条
|
||||
</span>
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
aria-label="上一页"
|
||||
disabled={isLoadingRows || currentPage <= 1}
|
||||
title="上一页"
|
||||
type="button"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
<span>上一页</span>
|
||||
</button>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
aria-label="下一页"
|
||||
disabled={isLoadingRows || currentPage >= totalPages}
|
||||
title="下一页"
|
||||
type="button"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
>
|
||||
<span>下一页</span>
|
||||
<ChevronRight size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
{detailRow ? (
|
||||
<div className="admin-confirm-backdrop" role="presentation">
|
||||
<section
|
||||
@@ -580,74 +698,6 @@ function getDatabaseTableColumnDescription(
|
||||
return `原始字段名:${column}。${description}。点击可按此列排序。`;
|
||||
}
|
||||
|
||||
function compareTableCellValues(
|
||||
leftValue: unknown,
|
||||
rightValue: unknown,
|
||||
sortDirection: SortDirection,
|
||||
) {
|
||||
const direction = sortDirection === 'asc' ? 1 : -1;
|
||||
const left = normalizeTableCellSortValue(leftValue);
|
||||
const right = normalizeTableCellSortValue(rightValue);
|
||||
|
||||
if (left.kind === 'empty' && right.kind === 'empty') {
|
||||
return 0;
|
||||
}
|
||||
if (left.kind === 'empty') {
|
||||
return 1;
|
||||
}
|
||||
if (right.kind === 'empty') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (left.kind !== right.kind) {
|
||||
return (
|
||||
direction * (getSortKindOrder(left.kind) - getSortKindOrder(right.kind))
|
||||
);
|
||||
}
|
||||
|
||||
let comparison = 0;
|
||||
switch (left.kind) {
|
||||
case 'number':
|
||||
comparison = left.value - getSortableNumberValue(right);
|
||||
break;
|
||||
case 'boolean':
|
||||
comparison = Number(left.value) - Number(getSortableBooleanValue(right));
|
||||
break;
|
||||
case 'text':
|
||||
comparison = tableSortCollator.compare(
|
||||
left.value,
|
||||
getSortableTextValue(right),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return direction * comparison;
|
||||
}
|
||||
|
||||
function normalizeTableCellSortValue(value: unknown): SortableTableCellValue {
|
||||
if (value === null || typeof value === 'undefined' || value === '') {
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return { kind: 'number', value };
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return { kind: 'boolean', value };
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
return { kind: 'text', value: trimmed };
|
||||
}
|
||||
|
||||
return { kind: 'text', value: stringifyUnknownValue(value) };
|
||||
}
|
||||
|
||||
function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) {
|
||||
const firstValue = Object.values(row.cells)[0];
|
||||
return `${rowIndex}-${String(firstValue ?? '')}`;
|
||||
@@ -797,27 +847,6 @@ function stringifyUnknownValue(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSortKindOrder(kind: SortableTableCellValue['kind']): number {
|
||||
switch (kind) {
|
||||
case 'number':
|
||||
return 0;
|
||||
case 'boolean':
|
||||
return 1;
|
||||
case 'text':
|
||||
return 2;
|
||||
case 'empty':
|
||||
return 3;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
type SortableTableCellValue =
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'number'; value: number }
|
||||
| { kind: 'boolean'; value: boolean }
|
||||
| { kind: 'text'; value: string };
|
||||
|
||||
interface DatabaseTableHeader {
|
||||
name: string;
|
||||
label: string;
|
||||
@@ -830,11 +859,6 @@ interface FormattedTableCellValue {
|
||||
fullText: string;
|
||||
}
|
||||
|
||||
const tableSortCollator = new Intl.Collator('zh-CN', {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
const databaseTableColumnLabelMap: Record<string, string> = {
|
||||
id: 'ID',
|
||||
table_name: '表名',
|
||||
@@ -1489,35 +1513,3 @@ const databaseTableDescriptionMap: Record<string, string> = {
|
||||
ai_result_reference: 'AI 结果引用表',
|
||||
ai_task_event: 'AI 任务事件表',
|
||||
};
|
||||
|
||||
function getSortableNumberValue(value: SortableTableCellValue) {
|
||||
return isSortableNumberValue(value) ? value.value : 0;
|
||||
}
|
||||
|
||||
function getSortableBooleanValue(value: SortableTableCellValue) {
|
||||
return isSortableBooleanValue(value) ? value.value : false;
|
||||
}
|
||||
|
||||
function getSortableTextValue(value: SortableTableCellValue) {
|
||||
return isSortableTextValue(value)
|
||||
? value.value
|
||||
: stringifyUnknownValue(value);
|
||||
}
|
||||
|
||||
function isSortableNumberValue(
|
||||
value: SortableTableCellValue,
|
||||
): value is Extract<SortableTableCellValue, { kind: 'number' }> {
|
||||
return value.kind === 'number';
|
||||
}
|
||||
|
||||
function isSortableBooleanValue(
|
||||
value: SortableTableCellValue,
|
||||
): value is Extract<SortableTableCellValue, { kind: 'boolean' }> {
|
||||
return value.kind === 'boolean';
|
||||
}
|
||||
|
||||
function isSortableTextValue(
|
||||
value: SortableTableCellValue,
|
||||
): value is Extract<SortableTableCellValue, { kind: 'text' }> {
|
||||
return value.kind === 'text';
|
||||
}
|
||||
|
||||
@@ -49,10 +49,10 @@ export function AdminRechargeProductPage({
|
||||
const [priceCents, setPriceCents] = useState('600');
|
||||
const [kind, setKind] = useState<ProfileRechargeProductKind>('points');
|
||||
const [pointsAmount, setPointsAmount] = useState('60');
|
||||
const [bonusPoints, setBonusPoints] = useState('60');
|
||||
const [bonusPoints, setBonusPoints] = useState('0');
|
||||
const [durationDays, setDurationDays] = useState('0');
|
||||
const [badgeLabel, setBadgeLabel] = useState('首充双倍');
|
||||
const [description, setDescription] = useState('首充送60泥点');
|
||||
const [badgeLabel, setBadgeLabel] = useState('');
|
||||
const [description, setDescription] = useState('60泥点');
|
||||
const [tier, setTier] = useState<ProfileMembershipTier>('normal');
|
||||
const [membershipPeriodPoints, setMembershipPeriodPoints] = useState('0');
|
||||
const [membershipPeriodDays, setMembershipPeriodDays] = useState('0');
|
||||
|
||||
@@ -1220,6 +1220,30 @@ button:disabled {
|
||||
max-width: 112px;
|
||||
}
|
||||
|
||||
.admin-database-tables-page {
|
||||
padding-bottom: calc(var(--admin-database-pagination-height, 68px) + 16px);
|
||||
}
|
||||
|
||||
.admin-database-pagination {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 232px;
|
||||
z-index: 18;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #eaded2;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 -8px 24px rgba(112, 57, 30, 0.08);
|
||||
padding: 12px 24px calc(12px + env(safe-area-inset-bottom));
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.admin-database-pagination-info {
|
||||
color: #755a49;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-table-sort-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1588,6 +1612,20 @@ button:disabled {
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.admin-database-pagination {
|
||||
right: 0;
|
||||
bottom: var(--admin-bottom-nav-height, 64px);
|
||||
left: 0;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.admin-database-tables-page {
|
||||
padding-bottom: calc(
|
||||
var(--admin-bottom-nav-height, 64px) +
|
||||
var(--admin-database-pagination-height, 58px) + 16px
|
||||
);
|
||||
}
|
||||
|
||||
.admin-bottom-nav-button {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -1604,6 +1642,18 @@ button:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.admin-database-pagination .admin-secondary-button {
|
||||
width: 38px;
|
||||
min-width: 38px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-database-pagination .admin-secondary-button span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.admin-login-panel,
|
||||
.admin-panel {
|
||||
@@ -1660,4 +1710,29 @@ button:disabled {
|
||||
.admin-icon-button span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-database-pagination,
|
||||
.admin-database-pagination .admin-action-row {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.admin-database-pagination {
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.admin-database-pagination .admin-action-row {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-database-pagination .admin-secondary-button {
|
||||
gap: 4px;
|
||||
min-height: 38px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.admin-database-pagination-info {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht
|
||||
|
||||
## 构建工具链
|
||||
|
||||
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.4.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.6.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 凭据写入仓库。
|
||||
|
||||
## 启动与验证
|
||||
|
||||
@@ -127,7 +127,7 @@ npm run container:worker-smoke -- status
|
||||
npm run container:worker-smoke -- smoke --force
|
||||
```
|
||||
|
||||
`container:worker-smoke` 默认会把本机 `spacetime` 2.4.1 CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 必须拉取官方大镜像;普通 `npm run container:*` 压测仍默认使用 `clockworklabs/spacetime:v2.4.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.6.0 CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 必须拉取官方大镜像;普通 `npm run container:*` 压测默认使用 `clockworklabs/spacetime:v2.6.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.4.1}
|
||||
image: ${GENARRATIVE_CONTAINER_SPACETIME_IMAGE:-clockworklabs/spacetime:v2.6.0}
|
||||
user: root
|
||||
command:
|
||||
[
|
||||
|
||||
@@ -215,8 +215,18 @@ http {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# BEGIN GENARRATIVE MAIN SPA ROUTES
|
||||
location = / {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
location ~* "^/(?:bark-battle|big-fish|child-motion-demo|creation|creation/baby-object-match|creation/baby-object-match/generating|creation/baby-object-match/result|creation/bark-battle|creation/bark-battle/generating|creation/bark-battle/result|creation/big-fish|creation/big-fish/generating|creation/big-fish/result|creation/creative-agent|creation/jump-hop|creation/jump-hop/generating|creation/jump-hop/result|creation/match3d|creation/match3d/generating|creation/match3d/result|creation/puzzle|creation/puzzle-clear|creation/puzzle-clear/generating|creation/puzzle-clear/result|creation/puzzle/generating|creation/puzzle/result|creation/rpg|creation/rpg/agent|creation/rpg/generating|creation/rpg/result|creation/square-hole|creation/square-hole/generating|creation/square-hole/result|creation/visual-novel|creation/visual-novel/generating|creation/visual-novel/result|creation/wooden-fish|creation/wooden-fish/generating|creation/wooden-fish/result|editor/canvas|gallery/jump-hop/detail|gallery/puzzle/detail|gallery/visual-novel/detail|match3d|project|puzzle|runtime/baby-love-drawing|runtime/baby-object-match|runtime/bark-battle|runtime/big-fish|runtime/jump-hop|runtime/match3d|runtime/puzzle|runtime/puzzle-clear|runtime/rpg/adventure|runtime/rpg/characters|runtime/square-hole|runtime/visual-novel|runtime/wooden-fish|works/detail|worlds/detail)/?$" {
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
# END GENARRATIVE MAIN SPA ROUTES
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,19 @@ limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s
|
||||
limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s;
|
||||
limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s;
|
||||
|
||||
# 维护期间允许真实 TCP 内网来源继续访问整站;不信任请求头伪造的客户端地址。
|
||||
geo $remote_addr $genarrative_internal_client {
|
||||
default 0;
|
||||
127.0.0.0/8 1;
|
||||
10.0.0.0/8 1;
|
||||
172.16.0.0/12 1;
|
||||
192.168.0.0/16 1;
|
||||
169.254.0.0/16 1;
|
||||
::1 1;
|
||||
fc00::/7 1;
|
||||
fe80::/10 1;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name genarrative.example.com;
|
||||
@@ -70,10 +83,22 @@ server {
|
||||
}
|
||||
|
||||
location = /admin {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
return 301 /admin/;
|
||||
}
|
||||
|
||||
location ^~ /admin/assets/ {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
@@ -249,6 +274,28 @@ server {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# BEGIN GENARRATIVE MAIN SPA ROUTES
|
||||
location = / {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
location ~* "^/(?:bark-battle|big-fish|child-motion-demo|creation|creation/baby-object-match|creation/baby-object-match/generating|creation/baby-object-match/result|creation/bark-battle|creation/bark-battle/generating|creation/bark-battle/result|creation/big-fish|creation/big-fish/generating|creation/big-fish/result|creation/creative-agent|creation/jump-hop|creation/jump-hop/generating|creation/jump-hop/result|creation/match3d|creation/match3d/generating|creation/match3d/result|creation/puzzle|creation/puzzle-clear|creation/puzzle-clear/generating|creation/puzzle-clear/result|creation/puzzle/generating|creation/puzzle/result|creation/rpg|creation/rpg/agent|creation/rpg/generating|creation/rpg/result|creation/square-hole|creation/square-hole/generating|creation/square-hole/result|creation/visual-novel|creation/visual-novel/generating|creation/visual-novel/result|creation/wooden-fish|creation/wooden-fish/generating|creation/wooden-fish/result|editor/canvas|gallery/jump-hop/detail|gallery/puzzle/detail|gallery/visual-novel/detail|match3d|project|puzzle|runtime/baby-love-drawing|runtime/baby-object-match|runtime/bark-battle|runtime/big-fish|runtime/jump-hop|runtime/match3d|runtime/puzzle|runtime/puzzle-clear|runtime/rpg/adventure|runtime/rpg/characters|runtime/square-hole|runtime/visual-novel|runtime/wooden-fish|works/detail|worlds/detail)/?$" {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
# END GENARRATIVE MAIN SPA ROUTES
|
||||
|
||||
location / {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
@@ -256,6 +303,6 @@ server {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files $uri $uri/ /index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,19 @@ limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s
|
||||
limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s;
|
||||
limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s;
|
||||
|
||||
# 维护期间允许真实 TCP 内网来源继续访问整站;不信任请求头伪造的客户端地址。
|
||||
geo $remote_addr $genarrative_internal_client {
|
||||
default 0;
|
||||
127.0.0.0/8 1;
|
||||
10.0.0.0/8 1;
|
||||
172.16.0.0/12 1;
|
||||
192.168.0.0/16 1;
|
||||
169.254.0.0/16 1;
|
||||
::1 1;
|
||||
fc00::/7 1;
|
||||
fe80::/10 1;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name genarrative.example.com;
|
||||
@@ -90,10 +103,22 @@ server {
|
||||
}
|
||||
|
||||
location = /admin {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
return 301 /admin/;
|
||||
}
|
||||
|
||||
location ^~ /admin/assets/ {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
@@ -269,6 +294,28 @@ server {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# BEGIN GENARRATIVE MAIN SPA ROUTES
|
||||
location = / {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
location ~* "^/(?:bark-battle|big-fish|child-motion-demo|creation|creation/baby-object-match|creation/baby-object-match/generating|creation/baby-object-match/result|creation/bark-battle|creation/bark-battle/generating|creation/bark-battle/result|creation/big-fish|creation/big-fish/generating|creation/big-fish/result|creation/creative-agent|creation/jump-hop|creation/jump-hop/generating|creation/jump-hop/result|creation/match3d|creation/match3d/generating|creation/match3d/result|creation/puzzle|creation/puzzle-clear|creation/puzzle-clear/generating|creation/puzzle-clear/result|creation/puzzle/generating|creation/puzzle/result|creation/rpg|creation/rpg/agent|creation/rpg/generating|creation/rpg/result|creation/square-hole|creation/square-hole/generating|creation/square-hole/result|creation/visual-novel|creation/visual-novel/generating|creation/visual-novel/result|creation/wooden-fish|creation/wooden-fish/generating|creation/wooden-fish/result|editor/canvas|gallery/jump-hop/detail|gallery/puzzle/detail|gallery/visual-novel/detail|match3d|project|puzzle|runtime/baby-love-drawing|runtime/baby-object-match|runtime/bark-battle|runtime/big-fish|runtime/jump-hop|runtime/match3d|runtime/puzzle|runtime/puzzle-clear|runtime/rpg/adventure|runtime/rpg/characters|runtime/square-hole|runtime/visual-novel|runtime/wooden-fish|works/detail|worlds/detail)/?$" {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
# END GENARRATIVE MAIN SPA ROUTES
|
||||
|
||||
location / {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
@@ -276,6 +323,6 @@ server {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files $uri $uri/ /index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# 维护模式由发布脚本或人工运维通过固定文件控制。
|
||||
# 文件存在时,普通页面展示维护页,管理 API 返回 503。
|
||||
# 文件存在时,公网页面展示维护页、API 返回 503;真实 TCP 内网来源仍可访问整站。
|
||||
set $genarrative_maintenance 0;
|
||||
if (-f /var/lib/genarrative/maintenance/enabled) {
|
||||
set $genarrative_maintenance 1;
|
||||
}
|
||||
|
||||
if ($genarrative_internal_client) {
|
||||
set $genarrative_maintenance 0;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
root /srv/genarrative/web;
|
||||
add_header Cache-Control "no-store";
|
||||
|
||||
@@ -359,17 +359,93 @@
|
||||
},
|
||||
{
|
||||
"id": "web_spa_fallback",
|
||||
"samplePath": "/some/spa/path",
|
||||
"samplePath": "/creation/puzzle/result",
|
||||
"expect": {
|
||||
"kind": "static",
|
||||
"root": "web",
|
||||
"mode": "spa_fallback"
|
||||
},
|
||||
"nginx": {
|
||||
"production": ["location /", "try_files $uri $uri/ /index.html;"],
|
||||
"development": ["location /", "try_files $uri $uri/ /index.html;"]
|
||||
"production": [
|
||||
"# BEGIN GENARRATIVE MAIN SPA ROUTES",
|
||||
"try_files $uri /index.html =404;"
|
||||
],
|
||||
"development": [
|
||||
"# BEGIN GENARRATIVE MAIN SPA ROUTES",
|
||||
"try_files $uri /index.html =404;"
|
||||
]
|
||||
},
|
||||
"docs": ["其它路径", "失败回退 `/index.html`"]
|
||||
"docs": ["主站 SPA allowlist", "失败回退 `/index.html`"]
|
||||
},
|
||||
{
|
||||
"id": "web_spa_case_trailing_slash",
|
||||
"samplePath": "/CREATION/PUZZLE/RESULT/",
|
||||
"expect": {
|
||||
"kind": "static",
|
||||
"root": "web",
|
||||
"mode": "spa_fallback"
|
||||
},
|
||||
"nginx": {
|
||||
"production": ["location ~*", "try_files $uri /index.html =404;"],
|
||||
"development": ["location ~*", "try_files $uri /index.html =404;"]
|
||||
},
|
||||
"docs": ["大小写不敏感", "一个尾部斜杠"]
|
||||
},
|
||||
{
|
||||
"id": "web_unknown_path_exact",
|
||||
"samplePath": "/some/spa/path",
|
||||
"expect": {
|
||||
"kind": "static",
|
||||
"root": "web",
|
||||
"mode": "exact"
|
||||
},
|
||||
"nginx": {
|
||||
"production": ["location /", "try_files $uri $uri/ =404;"],
|
||||
"development": ["location /", "try_files $uri $uri/ =404;"]
|
||||
},
|
||||
"docs": ["其它 Web 路径", "缺失时返回真实 404"]
|
||||
},
|
||||
{
|
||||
"id": "creation_unknown_path_exact",
|
||||
"samplePath": "/creation/not-exist",
|
||||
"expect": {
|
||||
"kind": "static",
|
||||
"root": "web",
|
||||
"mode": "exact"
|
||||
},
|
||||
"nginx": {
|
||||
"production": ["try_files $uri $uri/ =404;"],
|
||||
"development": ["try_files $uri $uri/ =404;"]
|
||||
},
|
||||
"docs": ["`/creation/not-exist`"]
|
||||
},
|
||||
{
|
||||
"id": "runtime_unknown_path_exact",
|
||||
"samplePath": "/runtime/not-exist",
|
||||
"expect": {
|
||||
"kind": "static",
|
||||
"root": "web",
|
||||
"mode": "exact"
|
||||
},
|
||||
"nginx": {
|
||||
"production": ["try_files $uri $uri/ =404;"],
|
||||
"development": ["try_files $uri $uri/ =404;"]
|
||||
},
|
||||
"docs": ["`/runtime/not-exist`"]
|
||||
},
|
||||
{
|
||||
"id": "puzzle_unknown_path_exact",
|
||||
"samplePath": "/puzzle/not-exist",
|
||||
"expect": {
|
||||
"kind": "static",
|
||||
"root": "web",
|
||||
"mode": "exact"
|
||||
},
|
||||
"nginx": {
|
||||
"production": ["try_files $uri $uri/ =404;"],
|
||||
"development": ["try_files $uri $uri/ =404;"]
|
||||
},
|
||||
"docs": ["`/puzzle/not-exist`"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
## 快速入口
|
||||
|
||||
- [Agent 工作入口与执行准则](./%E3%80%90%E5%8D%8F%E4%BD%9C%E8%A7%84%E8%8C%83%E3%80%91Agent%E5%B7%A5%E4%BD%9C%E5%85%A5%E5%8F%A3%E4%B8%8E%E6%89%A7%E8%A1%8C%E5%87%86%E5%88%99-2026-06-22.md):复杂任务前的 Agent 阅读顺序、执行边界、技能路由、文档规则和验证口径。
|
||||
- [官网 SEO 地基实施约定](./technical/【SEO】官网SEO地基实施约定-2026-07-10.md):首页基础 head、robots/sitemap、唯一 H1、精确 SPA 路由与未知路径 404 的长期技术边界。
|
||||
- [经验沉淀](./experience/README.md):项目开发经验、UI 交接、历史实现经验。
|
||||
- [审计与复盘](./audits/README.md):工程审查、文本/乱码审计、专项落地审计。
|
||||
- [系统设计](./design/README.md):玩法、关系、物品与对话设计。
|
||||
|
||||
@@ -23,6 +23,38 @@
|
||||
- 验证方式:`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_agent`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent`、`npm run test -- src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx src/services/image-editor/editorAgentClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md`。
|
||||
|
||||
## 2026-07-12 泥点充值收敛为四档并统一资产入口
|
||||
|
||||
- 背景:主站与图片画板的泥点余额入口、余额明细和充值弹窗存在不同实现,旧充值口径仍展示六档泥点、首充双倍和会员购买 / 升级入口,容易让展示、商品资格与后端余额真相发生漂移。
|
||||
- 决策:主站与图片画板统一复用公共泥点资产入口,收起态展示总额与充值,展开态只展示不限时泥点、每日免费泥点和使用详情;充值中心 BFF 继续统一下发总额、三桶余额、限时到期时间、每日免费基础重置额及下次重置时间,前端不得自行相减推算,但会员周期限时泥点仅用于存量兼容和后端结算,当前版本不在前台展示。默认泥点商品收敛为 `60 / ¥6`、`180 + 90 / ¥18`、`300 + 150 / ¥30`、`680 + 340 / ¥68` 四档,`60` 档无赠送,后三档按现有 `user_id + product_id` 独立资格规则首次购买加赠 `50%`。当前版本关闭会员购买页签、会员商品和购买 / 升级入口。
|
||||
- 影响范围:`profile_recharge_product_config` 默认商品、充值中心 read model、共享前后端契约、主站与图片画板泥点资产入口、充值弹窗、后台充值商品默认值。
|
||||
- 验证方式:充值与统一入口定向前端测试、`npm run typecheck`、充值商品定向 Rust 测试、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 2026-07-12 每日免费泥点独立于任务并按北京时间日切
|
||||
|
||||
- 背景:现有“每日免费泥点”实际只是每日登录任务领取奖励,领取后进入普通永久余额,既不是独立余额桶,也不会在次日失效;同时主站仍展示每日任务卡片和任务中心入口,与新的产品口径不一致。
|
||||
- 决策:新增 `profile_daily_free_points` 作为每日免费泥点事实源,基础额度固定为 `20`,以北京时间 `day_key` 为业务日;跨日后的首次余额读取或扣费原子清除昨日剩余及退款叠加量,并把当日额度重置为 `20`,对外语义始终视为北京时间 `00:00` 已重置。扣费按“每日免费 -> 会员周期限时 -> 永久”顺序;资产退款在同一业务日恢复原每日免费额度,跨业务日时把原每日免费消费部分叠加到退款当日每日免费桶,当日允许超过 `20`,下一业务日仍统一重置为 `20`。每日任务系统和 `daily_task_reward` 保留为普通永久奖励,但主站隐藏每日任务卡片及任务中心入口。
|
||||
- 影响范围:`profile_daily_free_points`、`profile_wallet_ledger`、个人资金 read model、钱包扣费和退款 metadata、主站“我的”页、SpacetimeDB 迁移与生成绑定。
|
||||
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、钱包定向 Rust 测试、个人中心定向前端测试、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 2026-07-11 SpacetimeDB 工具链统一升级到 2.6.0
|
||||
|
||||
- 背景:生产数据副本验证已使用 2.6.0 standalone,而仓库 Rust crate、本地 CLI、生成 bindings、容器与 server provision 仍锁定 2.5.0 或更早版本,继续混用会增加 BSATN / procedure 返回值与发布产物错配风险。
|
||||
- 决策:`server-rs/Cargo.toml` 的 `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 精确锁定 2.6.0;本地 CLI / standalone、Rust bindings、worker smoke、容器压测镜像和生产 provision 下载根同步对齐 2.6.0。其它 crate 恰好出现的 2.4.1 / 2.5.0 不随本决策机械替换。
|
||||
- 影响范围:Rust workspace lockfile、SpacetimeDB bindings、本地 dev 版本门禁、容器 smoke / loadtest、server provision Jenkins 与项目 SpacetimeDB skills / 文档。
|
||||
- 验证方式:核对 `spacetime --version`,运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo check` / 定向测试、`npm run test -- scripts/dev.test.ts`、server provision 工具测试、production ops / 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 持久化”只覆盖工程、素材、图层和元数据,遗漏了正式生成任务表。
|
||||
- 决策:`external_generation_job.request_payload_json` / `result_payload_json` 同样属于正式持久化边界。对于本次事故涉及的 `source_module = editor-canvas` 任务,只允许普通业务参数和 `objectKey` / `resourceId` / `assetId` 等已登记轻量引用;任意层级 `data:` / `blob:` 与超限 JSON 必须由 api-server 和 SpacetimeDB 双重拒绝。编辑器已有媒体直接传正式引用,本地红框标记图先上传 OSS 后再入队,上传目录与文件名使用同一个强唯一 ID。其它玩法现存 Data URL 请求契约不在本次事故修复中被静默禁用,后续必须先完成各自资源化再扩大 DB 门禁。用户任务列表、单任务状态和 acknowledge 只读取不含 request/result payload 的 `external_generation_job_summary` 投影;acknowledge 只更新摘要小表并保留审计事件,不为确认通知加载 / 重写主任务 payload。提示词在入队时提前提取;错误摘要统一去除内联媒体并限制为 2048 字符;列表在单次 owner 扫描中只保留固定大小 top-N,不再收集全量历史后截断。历史终态 payload 仅允许迁移操作员通过默认 dry-run、`editor-canvas + job_id` B-tree cursor 显式分批压缩,pending / running 永不压缩;cursor 选择最多读取 `limit + 1` 行,apply 再逐条主键读取。首次发布默认 fail-closed 暂停在 Stdb 与 API 之间,保持维护模式并停止旧 API/controller/worker,完成压缩和摘要回填后才由指定审批人放行 API。
|
||||
- 影响范围:编辑器生成提交 workflow、`external_generation_job`、`external_generation_job_summary`、外部生成 procedure / typed client / BFF、SpacetimeDB bindings、历史数据维护流程和图片画布文档。
|
||||
- 验证方式:覆盖编辑器嵌套内联媒体与 payload 上限拒绝、非编辑器既有任务不被本轮门禁误伤、正式任务接口类型不含 payload、终态分批压缩不修改活动任务、已有 objectKey 不转 Data URL、本地标记图先上传再提交;运行外部生成定向 Rust / Vitest、`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/project-memory/shared-memory/pitfalls.md`。
|
||||
|
||||
## 2026-07-10 BgFilter segModel 保留内部字段,不进入外部 OpenAPI
|
||||
|
||||
- 背景:`api-server` 的图片生成、图标 spritesheet 与 UI 素材提取请求仍可反序列化 `segModel`,并识别 `birefnet` / `anime-seg`,以兼容内部调用和既有任务;但 BgFilter 当前受服务进程内存与并发容量约束,不同分割模型的内存占用并非可由外部调用方自由选择的稳定契约。
|
||||
@@ -50,7 +82,7 @@
|
||||
## 2026-07-09 充值订单过期改为 SpacetimeDB scheduled 表触发
|
||||
|
||||
- 背景:旧充值过期处理使用 api-server 后台轮询 worker claim 普通 schedule 表,非 HTTP 的 external-generation-worker / controller 进程也可能启动同一过期任务;扩外部生成 worker 会意外放大微信查单 / 关单流量,并且本地过期后若微信仍可支付,容易出现“微信扣款但本地拒绝入账”的风险。
|
||||
- 决策:新建原生 scheduled 表 `profile_recharge_order_expiration_timer`,创建真实微信 pending 充值订单时写入 5 分钟 timer;scheduled reducer 到点只把仍为 `pending` 的订单改为 `expired` 并写 `expired_at`。HTTP `api-server` 订阅 `profile_recharge_order` 的 `Pending -> Expired` 更新并执行微信查单补偿;`SUCCESS` 允许 `Expired -> Paid` 入账,未支付或远端已终态只记录检查结果,本地保持 `expired`。`external-generation-worker` 和 controller 不处理充值过期。
|
||||
- 决策:新建原生 scheduled 表 `profile_recharge_order_expiration_timer`,创建真实微信 pending 充值订单时写入 5 分钟 timer;scheduled reducer 到点只把仍为 `pending` 的订单改为 `expired` 并写 `expired_at`。HTTP `api-server` 只订阅活跃 timer 表的删除事件,按 `order_id` 重新读取订单并仅对 `expired` 执行微信查单补偿;支付或主动关闭导致的 timer 删除会被状态判断忽略,断线窗口由未检查过期订单 catch-up 补齐,不订阅完整 `profile_recharge_order` 历史表。`SUCCESS` 允许 `Expired -> Paid` 入账,未支付或远端已终态只记录检查结果,本地保持 `expired`。`external-generation-worker` 和 controller 不处理充值过期。
|
||||
- 影响范围:`profile_recharge_order`、`profile_recharge_order_expiration_timer`、充值订单状态契约、`spacetime-client` bindings/facade、`api-server` 充值过期监听器、微信支付查单 / 关单、个人中心充值前端、后台表查询和运维文档。
|
||||
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、充值过期 listener / 微信支付 / shared contracts / 前端充值定向测试、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
@@ -3946,3 +3978,51 @@
|
||||
- 性能:`api-server` 只在当前判定涉及的已启用 gate 配置了用户标签白名单时读取用户标签;不因无关 gate 或纯用户 ID / 百分比灰度触发额外标签读取。
|
||||
- 影响范围:`feature_gate_config`、`spacetime-client` runtime facade、`api-server` 创作入口配置与路由熔断、`apps/admin-web` 灰度发布页。
|
||||
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-runtime --manifest-path server-rs/Cargo.toml feature_gate`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml creation_entry_feature_gate`、`npm run admin-web:typecheck`、后台灰度页 Vitest、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-07-10 官网 SEO 与主站 SPA 404 边界
|
||||
|
||||
- 背景:主站 Nginx 和 Pingora 原先会把任意未知路径回退到 `index.html`,导致 soft 404;共享 `index.html` 也缺少首页 SEO head,robots 和 sitemap 请求会落入 SPA fallback。
|
||||
- 决策:新增真实 `robots.txt` 和仅首页的 `sitemap.xml`,首页共享 head 提供基础 SEO/OG/JSON-LD 文本但不使用未确认的 image/logo URL;首页 DOM 只保留一个稳定产品定位 H1。Nginx 与 Pingora 只允许当前完整 SPA 路径回退 `index.html`,同前缀未知路径必须返回 404;`/admin` 继续走独立子应用。路由变化必须同步三套 Nginx、Pingora、route parity matrix 和自动门禁。
|
||||
- 影响范围:`index.html`、`public/robots.txt`、`public/sitemap.xml`、首页组件、三套 Nginx、Pingora 网关和路由 parity 门禁。
|
||||
- 验证方式:前端定向测试与构建、`npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`npm run check:pingora-gateway-smoke`、`npm run check:encoding`、`git diff --check`,部署后同时抽查根级未知路径和 `/creation/not-exist` 等同前缀未知路径。
|
||||
|
||||
## 2026-07-11 Jenkins Secret File 默认值与 dev 定时发布
|
||||
|
||||
- 背景:Stdb Build / Publish / Full Job 改用 Secret File 后,live Job UI 默认值为空且会被 SCM Jenkinsfile 覆盖;Full Job 的 04:00 timer 又与默认人工 rollout gate 冲突。dev 服务器不对外,允许定时完整发布。
|
||||
- 决策:三个 Jenkinsfile 将 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 默认固定为 `genarrative-spacetime-bootstrap-secret-dev-file`。Full Job 保留 04:00 timer,默认 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal`,按 Stdb → API → Web 完整发布 dev;三个下游 Build 都显式传 `PUBLISH_AFTER_BUILD=false`,防止提前发布和顺序漂移。人工维护窗口才选择 `pause-after-stdb` 并强制校验 approvers。
|
||||
- 凭据边界:Secret 原文以 Jenkins Secret File 为事实源;credential ID 与参数行为以仓库 Jenkinsfile 为事实源。旧 Secret Text 继续服务 Database Import / Export,不原地改类型或删除。
|
||||
- 影响范围:`jenkins/Jenkinsfile.production-full-build-and-deploy`、`jenkins/Jenkinsfile.production-stdb-module-build`、`jenkins/Jenkinsfile.production-stdb-module-publish`、生产运维门禁与 live Job 参数 schema。
|
||||
- 验证方式:`node --check scripts/check-production-ops-guardrails.mjs`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`;推送后用首阶段 fail-closed 运行刷新三个 live Job 参数,再只读核对 credential 默认值、`normal` 默认值与 timer。
|
||||
|
||||
## 2026-07-12 维护模式只拦截公网流量
|
||||
|
||||
- 背景:此前维护 marker 只对内网放行后台,内网排障或人工维护仍无法访问主站、普通 API 和 SpacetimeDB 路由;当前维护目标是隔离公网访问,不应阻断可信内网流量。
|
||||
- 决策:Nginx 与 Pingora 允许 IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local 来源在维护期间访问整站,包括主站页面与静态资源、普通 API、后台页面与 `/admin/api/**`、SpacetimeDB 路由。公网应用主站、普通 API、后台和 SpacetimeDB 路由继续维护响应,应用层鉴权不变。
|
||||
- 信任边界:Nginx 使用 TCP `$remote_addr`;Pingora 使用 TCP peer,只有同机 loopback Nginx 才可通过其强制覆盖的 `X-Real-IP` 传递原始地址,禁止使用 `X-Forwarded-For` 做维护放行判断。
|
||||
- 限制:网关放行不等于后端存活;`pause-after-stdb` 停止 api-server 时,内网普通 API 和后台 API 仍不可用。
|
||||
- 影响范围:生产 / dev Nginx 模板、维护 snippet、Pingora maintenance gate、Nginx 静态门禁与 Pingora smoke。
|
||||
- 验证方式:`npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml`、`npm run check:pingora-gateway-smoke`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-07-12 Full 发布显式控制成功后维护状态
|
||||
|
||||
- 背景:Full Job 只能用 `STDB_API_ROLLOUT_MODE` 控制 Stdb 与 API 之间是否暂停,但 API Deploy 在 readiness 成功后固定执行 `maintenance-off.sh`,因此无法选择完整流水线结束后继续保留维护页。
|
||||
- 决策:Full Job 新增默认勾选的 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION`,并让 Stdb Publish、API Deploy 两个下游阶段固定保持维护;Web Deploy 成功后才由独立 `Exit Maintenance` 阶段按该参数决定是否调用 current release 随包 `maintenance-off.sh`。API Deploy Job 单独使用 `KEEP_MAINTENANCE_MODE`,将其转换为随包 `production-api-deploy.sh --keep-maintenance-mode`;默认仍退出维护,失败路径继续沿用 current 切换前后既有安全语义。
|
||||
- 参数刷新:Jenkinsfile 是参数事实源。推送后必须让 Full 与 API Deploy live Job 安全加载一次新 Jenkinsfile,再只读确认两个参数已进入 `config.xml`;只在 Jenkins UI 手工加参数不是持久修复。
|
||||
- 影响范围:Full / API Deploy Jenkinsfile、API 发布脚本、生产 API deploy fixture、生产运维门禁与 live Job 参数 schema。
|
||||
- 验证方式:`bash -n scripts/deploy/production-api-deploy.sh`、`npm run check:production-api-deploy`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-07-13 数据库冷备使用 OSS Multipart 上传并在清理前验真
|
||||
|
||||
- 背景:SpacetimeDB 冷备归档已经超过 OSS 单次 PutObject 的 5 GiB 上限,单请求上传会稳定失败并让本地归档持续积压;网络中断还可能让 CompleteMultipartUpload 的客户端结果不确定。
|
||||
- 决策:`scripts/database-backup-to-oss.mjs` 对备份归档统一使用 OSS Multipart Upload,默认按 128 MiB 顺序分片;每个分片请求重新创建文件流、时间和 V4 签名,仅对网络错误、HTTP 408 / 429 / 5xx 做有限重试。V4 canonical query 必须同时支持无等号的 `uploads` 子资源和带值的 `partNumber` / `uploadId` 参数。
|
||||
- 验真与清理边界:Complete 后必须发送签名 HEAD,并严格核对 OSS `Content-Length` 与本地归档大小;Complete 响应不确定时也先用 HEAD 判定对象是否已经完整落盘。只有验真成功后才能把 manifest 标记为 `uploaded`,并按 `keepLocal` 决定是否删除本地归档;失败时 best-effort AbortMultipartUpload,不得提前更新 manifest 或清理本地文件。
|
||||
- 影响范围:数据库备份 OSS 上传实现、备份回归门禁、release 本地归档保留与 timer 恢复流程。
|
||||
- 验证方式:`npm run check:database-backup`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`;线上先对既有归档使用 `--upload-archive ... --keep-local`,确认 OSS 对象长度和可恢复性后再清理积压并恢复 timer。
|
||||
|
||||
## 2026-07-13 微信虚拟支付使用官方查单补偿
|
||||
|
||||
- 背景:`wechat_mp_virtual` 原先被误认为没有服务端查单能力,导致消息推送遗漏后订单只能停在 pending / expired,历史订单也无法按微信真实状态核对。
|
||||
- 决策:`platform-wechat` 按官方协议调用 `POST /xpay/query_order`,使用小程序 `access_token` 与 `HMAC-SHA256(appKey, "/xpay/query_order&" + 实际 JSON body)` 支付签名。用户确认和订单到期补偿都可查虚拟支付订单,但只在单号、支付类型 `order_type=0/7`、金额、合法 `paid_time` 一致且微信状态为 `2/3/4` 时补入账;退款类型 `1/8` 不发放权益。`short_series_goods` 的 `status=2` 在本地入账后必须补调 `/xpay/notify_provide_goods`,失败可从本地 `paid` 状态只重试发货;token 明确失效时强制刷新并最多重放一次。
|
||||
- 边界:查单使用订单所属用户的小程序 `openid`,不把 AppKey、AppSecret、access token 或 `openid` 下发前端;虚拟支付不得误用微信支付 V3 查单。
|
||||
- 历史单:升级前遗留的 pending 订单不会被 expiration catch-up 覆盖,使用 `spacetime:wechat-virtual-payment:reconcile` 逐单 dry-run,再使用当次 `applyFingerprint` 明确 `--apply`。脚本每次重读本地订单与微信查单结果,指纹漂移、非 `2/3/4`、单号/金额/支付类型 `order_type=0/7` 不一致或非官方 endpoint 时默认拒绝入账。
|
||||
- 验证方式:`cargo test -p platform-wechat --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml virtual_payment_query`、`npm run check:wechat-virtual-payment-reconcile`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
- 关联:相关文件、文档、提交或 Issue
|
||||
```
|
||||
|
||||
## 禁止 Data URL 持久化时不要漏掉异步任务 JSON
|
||||
|
||||
- 现象:工程、素材、图层和元数据都已禁止 Data URL 后,服务器仍在生成高峰出现 SpacetimeDB / api-server 内存急剧膨胀甚至 OOM;读取少量正式生成任务也会造成远大于响应体的瞬时内存增长。
|
||||
- 原因:同步接口 worker 化时把原请求整体序列化到 `external_generation_job.request_payload_json`,而前端又把已有 `objectKey` 下载成 Data URL 提交。任务表也是正式持久化边界;列表 procedure 若先收集完整任务行再截断,还会把 request/result 大字段在 SpacetimeDB、SDK mapper 和 BFF 多次持有。
|
||||
- 处理:先在事故涉及的编辑器持久任务 JSON 上由 api-server 与 SpacetimeDB 两层递归拒绝 `data:` / `blob:` 并限制字节数;已有媒体传 `objectKey` / `resourceId` / `assetId`,本地派生图先用强唯一 key 上传。其它玩法若仍以 Data URL 作为正式请求契约,必须先资源化,不能直接扩大门禁造成玩法回归。列表、详情和 acknowledge 只走无 payload 的摘要投影,ack 不能为了同步旧字段重写大任务行;摘要错误文本也必须清除内联媒体并设硬上限,列表只能维护有界 top-N,不能先收集 owner 全量历史再截断。历史只通过迁移操作员的 dry-run + B-tree cursor 分批 procedure 压缩 `editor-canvas` 终态任务,cursor 选择读取量必须受 limit 约束,绝不全表扫描、绝不处理 pending / running;dry-run 后 apply 同一批时保持输入 cursor 不变,最后一批即使 `has_more=false` 只要仍有命中也必须 apply,只有 apply 成功后才推进到返回 cursor。SpacetimeDB CLI 2.5 的 `Option<T>` 非空参数必须使用 SATS sum 编码;维护脚本要统一编码 `cursor_job_id`、`owner_user_id` 和 `completed_before_micros`,否则首批空 cursor 可运行,但第二批或带截止时间的调用会在写入前被拒绝。
|
||||
- 发布门禁:生产发布入口必须固定 `--delete-data=never` 与 scoped `--yes=migrate,break-clients`,普通 Jenkins 参数不得暴露清库开关;需要删数据的 schema 冲突必须直接阻断并重新检查 artifact/schema,不能靠裸 `--yes` 放行。
|
||||
- 验证:构造嵌套 Data URL、Blob URL 和超限 JSON 确认入队失败;检查正式 UI procedure / client record 不含 request/result payload;用 dry-run 和 apply 测试确认活动任务不变、终态普通提示词保留且内联媒体被替换;至少带一次非空 `--cursor-job-id` 与 `--completed-before-micros` 验证 CLI Option 编码,而不是只测首批空 cursor。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_generation_queue.rs`、`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/api-server/src/external_generation.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## React 测试因内部状态或实现细节正常重构就碎
|
||||
|
||||
- 现象:修改组件结构、按钮排序、图标库 class、提示文案或 hook 内部状态名后,React 测试大量失败,但真实用户流程和对外契约没有变化。
|
||||
@@ -359,6 +368,14 @@
|
||||
- 验证:`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand`;后端验证至少覆盖 `editor_image_edit_request_omits_price_mud_points` 和 `editor_image_edit_can_complete_by_replacing_target_layer`。
|
||||
- 关联:`src/services/image-editor/editorProjectClient.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`server-rs/crates/api-server/src/editor_project.rs`。
|
||||
|
||||
## 图片画布快速编辑尺寸要区分业务目标和 provider 对齐尺寸
|
||||
|
||||
- 现象:原图经过快速编辑后 Resolution 变成近似比例的 1K / 2K 预设;原图或框选标记图宽高不是 16 的倍数时,VectorEngine edits 直接拒绝请求。
|
||||
- 原因:前端已有源图精确 `originalWidth/originalHeight`,提交时却按最近常用比例和 K 档重新计算 `size`;后端又把非 16 倍数的目标尺寸和原始参考图字节直接放进 multipart,并以 provider 回图宽高落库和覆盖画布图层。
|
||||
- 处理:画布快速编辑提交源图精确尺寸作为业务目标;api-server 只在 provider 边界向右、向下复制边缘像素,把每张参考图和目标尺寸临时补齐到 16 的倍数,收到回图后裁回业务目标尺寸再持久化。若上游异常返回其他尺寸,先按目标比例裁切缩放;临时对齐尺寸不能进入 OSS 元数据、`editor_project_resource`、`editor_asset` 或画布 Resolution。前端 inline 回填也保留源图显示尺寸和 Resolution,避免旧回包再次放大图层。
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx` 覆盖 `1537x1025` 精确提交和源图回填;`cargo test -p api-server editor_image_edit_aligns_provider_images_and_restores_source_dimensions --manifest-path server-rs/Cargo.toml` 覆盖 provider `1552x1040`、额外参考图独立对齐和回图恢复。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationLayerModel.ts`、`server-rs/crates/api-server/src/editor_project.rs`。
|
||||
|
||||
## 图片画布快速编辑元数据必须记录原图引用
|
||||
|
||||
- 现象:快速编辑生成的新图可以替换画布,但打开图片信息时“生成输入”里看不到被修改的原图。
|
||||
@@ -2043,10 +2060,10 @@
|
||||
|
||||
- 现象:微信小程序支付下单能返回 `prepay_id`,但真实支付通知验签失败,或者本地实现误把商户 API 私钥当作回调验签 key。
|
||||
- 原因:商户私钥只用于商户请求微信支付和生成小程序 `paySign`;微信支付通知的 `Wechatpay-Signature` 需要使用微信支付平台公钥或平台证书公钥验签,并按通知头里的平台序列号匹配。
|
||||
- 处理:api-server 真实微信支付配置同时需要商户私钥与微信平台公钥:`WECHAT_PAY_PRIVATE_KEY_*` 用于签名,`WECHAT_PAY_PLATFORM_PUBLIC_KEY_*` 与 `WECHAT_PAY_PLATFORM_SERIAL_NO` 用于通知验签,`WECHAT_PAY_API_V3_KEY` 只用于解密通知 resource。支付成功后只通过通知里的 `out_trade_no` 确认本地 pending 订单,并保存 `transaction_id` 到 `profile_recharge_order.provider_transaction_id`。
|
||||
- 处理:api-server 真实微信支付配置同时需要商户私钥与微信平台公钥:`WECHAT_PAY_PRIVATE_KEY_*` 用于签名,`WECHAT_PAY_PLATFORM_PUBLIC_KEY_*` 与 `WECHAT_PAY_PLATFORM_SERIAL_NO` 用于通知验签,`WECHAT_PAY_API_V3_KEY` 只用于解密通知 resource。微信平台 `PUBLIC KEY` PEM 的 DER 内容是 SPKI `SubjectPublicKeyInfo`,初始化时必须解析并提取其中的 PKCS#1 `RSAPublicKey` DER 后再交给 `ring::RSA_PKCS1_2048_8192_SHA256`;不能把整段 SPKI DER 直接传给 `ring`。支付成功后只通过通知里的 `out_trade_no` 确认本地 pending 订单,并保存 `transaction_id` 到 `profile_recharge_order.provider_transaction_id`。
|
||||
- APIv3 通知成功应答使用 HTTP `204 No Content`,不要沿用 V2 XML 成功报文;失败仍返回 4XX/5XX 让微信重试。
|
||||
- 验证:mock 通知测试只能覆盖本地回调推进;真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。
|
||||
- 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。
|
||||
- 验证:mock 通知测试只能覆盖本地回调推进;`platform-wechat` 必须用标准 SPKI `PUBLIC KEY` 和匹配私钥生成真实 RSA-SHA256 签名,覆盖 SPKI 到 PKCS#1 的解析与生产验签 helper。真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。
|
||||
- 关联:`server-rs/crates/platform-wechat/src/pay.rs`、`server-rs/crates/api-server/src/wechat/pay.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 微信支付 JSAPI 下单必须显式带 User-Agent
|
||||
|
||||
@@ -2080,14 +2097,29 @@
|
||||
- 验证:执行 `cargo test -p api-server admin_database -- --nocapture`,并确认后台详情弹层的 `raw` 与表格 `cells` 都显示业务字符串。
|
||||
- 关联:`server-rs/crates/api-server/src/admin.rs`、`docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md`。
|
||||
|
||||
## 后台通用表查询不能先按每页条数截断再筛选
|
||||
|
||||
- 现象:后台“表查询”填写关键词或 JSON 条件后查不到确定存在的记录;把“条数”从 100 调到 500 只能偶尔缓解,而且页面没有继续翻页的入口。
|
||||
- 原因:旧实现先执行 `SELECT * FROM <table> LIMIT <limit>`,再对这批行做内存过滤;目标记录不在首批结果时永远无法命中,同时响应没有页码、匹配总数或扫描上限状态。
|
||||
- 处理:用户输入继续不进入通用 SQL。API Server 通过单次 `SELECT * ... LIMIT 50001` 读取哨兵行,最多保留前 50,000 条候选,先过滤,再按后端接收的列名 / 方向对完整候选集稳定排序,最后分页;`totalMatched`、`scannedCount` 和 `scanLimitReached` 都从同一份 SQL 结果计算。请求页码超过实际总页数时钳制到末页,零结果固定为第 1 页。响应统一返回 `page`、`totalMatched`、`scannedCount`、`scanLimit` 和 `scanLimitReached`,后台翻页栏固定在视口底部;达到扫描上限时明确提示结果可能不完整。32 MiB 是候选 SQL 响应体硬上限,宽表即使每页条数很小也可能整次拒绝,不返回部分结果。实时写入可能改变相邻请求之间的候选快照,精确审计使用专用业务查询。
|
||||
- 验证:执行 `cargo test -p api-server admin_database -- --nocapture`,覆盖第 101 行才命中、完整候选集排序后分页、哨兵截断、越界页码和响应体硬上限;前端测试覆盖下一页沿用已应用条件、后端排序参数 / 返回结果与扫描警告,并运行后台类型检查。
|
||||
- 关联:`server-rs/crates/api-server/src/admin.rs`、`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx`。
|
||||
|
||||
## 充值订单过期补偿不要放进外部生成 worker
|
||||
|
||||
- 现象:外部生成 worker/controller 扩容后,微信充值过期查单和关单流量也被同步放大;排查时还会误去外部生成 worker 日志里找支付过期任务。
|
||||
- 原因:支付过期是账户资金链路,不是外部内容生成队列;旧实现把充值过期轮询 worker 挂在通用后台任务启动函数里,非 HTTP 角色也会启动。
|
||||
- 处理:充值订单过期由 SpacetimeDB 原生 `profile_recharge_order_expiration_timer` 到点把 `pending` 改为 `expired`,只有 HTTP `api-server` 订阅 `profile_recharge_order` 的 `Pending -> Expired` 更新并查微信补偿。未支付终态本地保持 `expired`,不要再改写成 `closed`;微信成功支付通知或补偿查单仍可把 `Expired -> Paid` 入账。
|
||||
- 处理:充值订单过期由 SpacetimeDB 原生 `profile_recharge_order_expiration_timer` 到点把 `pending` 改为 `expired`,只有 HTTP `api-server` 订阅活跃 timer 表的删除事件,按 `order_id` 重新读取订单并仅对 `expired` 查微信补偿;支付或主动关闭导致的删除信号会被状态判断忽略,断线窗口由未检查过期订单 catch-up 补齐。未支付终态本地保持 `expired`,不要再改写成 `closed`;微信成功支付通知或补偿查单仍可把 `Expired -> Paid` 入账。
|
||||
- 验证:确认 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` / `external-generation-controller` 不启动充值过期监听;创建 pending 充值单后只由 scheduled reducer 产生 `expired`,HTTP api-server listener 记录 `expiration_checked_at` 或补入账。
|
||||
- 关联:`server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs`、`server-rs/crates/spacetime-module/src/runtime/profile.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 充值订单状态枚举不能用字符串 SQL 字面量订阅
|
||||
|
||||
- 现象:API 已 ready,但日志每 5 秒出现 `profile recharge expiration listener failed to subscribe`,并提示 `pending` 不能解析为 `profile_recharge_order.status` 的枚举类型;scheduled reducer 仍会把订单改成 `expired`,但微信查单补偿监听没有运行。
|
||||
- 原因:SpacetimeDB 2.6 不会把订阅 SQL 中的 `'pending'` / `'expired'` 字符串自动转换为生成绑定的 sum-type enum;两个按状态过滤的订阅都在应用阶段失败。
|
||||
- 处理:不要改成订阅完整 `profile_recharge_order` 历史表。后端订阅只保留活跃五分钟定时器的 `profile_recharge_order_expiration_timer`,监听 timer 删除后按 `order_id` 通过 procedure 读取订单,只处理当前状态为 `expired` 的记录;支付 / 关闭信号会被忽略,断线窗口继续由未检查过期订单 catch-up 补齐。这样既不依赖不受支持的枚举 SQL,也不会把充值历史常驻 API 客户端缓存。
|
||||
- 验证:运行 `cargo test -p spacetime-client profile_recharge_expiration --manifest-path server-rs/Cargo.toml`,发布后确认 API 日志不再出现订阅解析错误,并用真实 pending 订单验证 scheduled reducer 过期后写入 `expiration_checked_at`。
|
||||
|
||||
## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布
|
||||
|
||||
- 现象:草稿页预览模型失败并报 `GL_INVALID_ENUM: Invalid cap.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。
|
||||
@@ -2928,3 +2960,41 @@
|
||||
- 处理:产品 UI 不提供模型选择,应用内调用固定 `birefnet`;外部编辑器 OpenAPI 不声明 `segModel`,并保持相关请求 schema 的 `additionalProperties: false`,使外部请求携带该字段时被契约拒绝。只有维护 BgFilter 模型与容量的后端代码可使用该内部字段;若未来需要开放,先完成各模型的内存、并发和超时压测,再明确版本化外部契约。
|
||||
- 验证:检查 `docs/openapi/genarrative-external-v1.openapi.json` 的图片生成、图标 spritesheet 和 UI 素材提取请求 schema 均未包含 `segModel`,且均保持 `additionalProperties: false`。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`src/services/image-editor/editorProjectClient.ts`、`docs/project-memory/shared-memory/decision-log.md`。
|
||||
## SPA 路由白名单不能只按一级目录放行
|
||||
|
||||
- 现象:`/not-exist` 已返回 404,但 `/creation/not-exist`、`/runtime/not-exist` 或 `/puzzle/not-exist` 仍返回 200 首页,搜索引擎继续判定为 soft 404。
|
||||
- 原因:Nginx 或 Pingora 使用 `/creation/*`、`/runtime/*` 等宽前缀作为 SPA fallback,前端对未知路径又回到平台首页;只验收根级未知 URL 无法发现该问题。
|
||||
- 处理:SPA fallback 必须精确匹配当前真实完整路径,同时允许前端已有的大小写归一和尾部斜杠;最终 catch-all 只提供真实静态文件,失败返回 404。路由增删同步三套 Nginx、Pingora、route parity matrix 和路由门禁。
|
||||
- 验证:除全部真实 SPA 路径外,至少检查 `/not-exist`、`/creation/not-exist`、`/runtime/not-exist` 和 `/puzzle/not-exist` 均返回 404;维护模式仍保持页面 503 优先语义。
|
||||
- 关联:`src/routing/appRoutes.tsx`、`src/routing/appPageRoutes.ts`、`deploy/nginx/`、`deploy/container/nginx.conf`、`server-rs/crates/pingora-gateway/src/main.rs`。
|
||||
|
||||
## Jenkins Job UI 参数会被 SCM Jenkinsfile 覆盖
|
||||
|
||||
- 现象:在 Jenkins Job 页面给 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 配了默认值,下一次加载 Declarative Pipeline 后又变空或恢复旧描述;04:00 Full Job 还可能因默认选择 `pause-after-stdb` 且 approvers 为空而失败。
|
||||
- 原因:这些 Job 使用 Pipeline script from SCM,`parameters {}` 和 `triggers {}` 会作为 Job property 回写现场配置;只改 UI 不是持久修复。构建编排如果不显式关闭下游 `PUBLISH_AFTER_BUILD`,还会受下游默认值漂移影响。
|
||||
- 处理:credential ID 和参数默认值写回三个 Jenkinsfile;仅供开发使用的 dev 定时 Full Job 默认 `STDB_API_ROLLOUT_MODE=normal`,三路 Build 调用显式传 `PUBLISH_AFTER_BUILD=false`,再由 Full Job 统一按 Stdb → API → Web 发布。Secret 原文只放 Jenkins Secret File,旧 Secret Text 保留给 Import / Export。
|
||||
- 验证:推送后让 Full / Stdb Build 用不存在的源码分支在 checkout 阶段 fail-closed,让 Stdb Publish 用空构建版本在 Prepare 阶段 fail-closed,以安全刷新参数 schema;随后只读检查三个 live `config.xml` 的参数描述和默认值,确认 Full timer 仍为 `0 4 * * *`、rollout 默认值为 `normal`,并确认刷新运行未进入 publish / deploy stage。
|
||||
- 关联:`jenkins/Jenkinsfile.production-full-build-and-deploy`、`jenkins/Jenkinsfile.production-stdb-module-build`、`jenkins/Jenkinsfile.production-stdb-module-publish`、`scripts/check-production-ops-guardrails.mjs`。
|
||||
|
||||
## 维护模式内网全站放行不能信任 X-Forwarded-For
|
||||
|
||||
- 现象:维护期间希望让内网继续访问整站,如果直接按 `X-Forwarded-For: 192.168.x.x` 放行,公网请求可伪造该头绕过维护闸;如果仍按路径只放行后台,又会让内网主站和普通 API 继续返回 503。
|
||||
- 原因:XFF 是客户端可提交的普通请求头,当前 Nginx 的 `$proxy_add_x_forwarded_for` 还会保留已有前缀;维护放行属于授权判断,必须建立在不可伪造的网络来源边界上,并在路由分类前按来源统一决定是否绕过维护闸。
|
||||
- 处理:Nginx 按 TCP `$remote_addr` 判断内网;Pingora 按 TCP peer 判断,只有 peer 为 loopback 的同机 Nginx 时才接受 Nginx 强制覆盖的 `X-Real-IP`。可信内网来源绕过整站维护响应,公网应用主站、普通 API、后台和 SpacetimeDB 路由仍保持维护响应;绝不能用 `X-Forwarded-For` 做放行判断。
|
||||
- 验证:Pingora smoke 同时覆盖公网主站、普通 API、后台为 503,以及内网对应路由为 200;Rust 单测覆盖 IPv4 / IPv6 内网、公网和空来源;Nginx 静态门禁反查两份模板的内网来源定义与全局维护变量清零逻辑。
|
||||
- 限制:如果发布门禁已经停止 api-server,网关放行后普通 API 和后台 API 仍会失败;需要调用后端时应确保对应服务仍运行,不能把维护页绕过误当作服务可用性保证。
|
||||
|
||||
## Full 结束后保持维护不能只加一个 UI 参数
|
||||
|
||||
- 现象:Full Job 参数页没有“完整发布成功后是否退出维护”选项,或者补了选项后 API readiness 一通过仍自动撤掉维护。
|
||||
- 原因:维护退出发生在随 API artifact 发布的 `production-api-deploy.sh` 内;Full、API Deploy Job 和脚本任一层没有透传,最终都会回到固定执行 `maintenance-off.sh`。Declarative Pipeline 参数还要等 live Job 加载新版 Jenkinsfile 后才会刷新。
|
||||
- 处理:Full 使用 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 表达产品选择,Stdb Publish 和 API Deploy 全程固定保持维护,Web Deploy 成功后才进入独立最终退出阶段;API Deploy 的独立 `KEEP_MAINTENANCE_MODE` 再转换为脚本 `--keep-maintenance-mode`。API deploy 还必须把 `production-api-deploy.sh`、`maintenance-on.sh` 和 `maintenance-off.sh` 从同一 build artifact 复制进 current release,否则 Full 最终阶段即使有选项也找不到随包退出脚本。默认值仍在 Full 结束时退出维护,避免定时 dev 发布行为变化。
|
||||
- 验证:API deploy fixture 必须覆盖成功发布并保留 marker,还要断言 current release 中三个部署 / 维护脚本存在;生产运维静态门禁同时反查 Full 参数、下游透传、API Deploy 参数和脚本 flag。推送后用 fail-closed 首阶段运行刷新 live Job 参数,再核对 `config.xml`,不能只看仓库文件。
|
||||
|
||||
## 遮罩点击关闭必须校验完整指针序列
|
||||
|
||||
- 现象:在弹窗内容内按下鼠标,拖到弹窗外的遮罩上松开时,弹窗被误关闭。
|
||||
- 原因:只在 `click` 阶段判断 `event.target === event.currentTarget` 不足以确认用户点击了遮罩;跨弹窗边界松开时,浏览器可能把合成点击的目标归到弹窗和遮罩的共同祖先。
|
||||
- 处理:共享弹窗统一记录 `pointerdown` 与 `pointerup` 的目标,只有按下和松开都发生在遮罩自身时才允许关闭。新增弹窗优先复用 `UnifiedModal`,不要继续复制只判断最终 `click` 目标的手写遮罩逻辑。
|
||||
- 验证:回归测试同时覆盖“弹窗内按下、遮罩松开不关闭”和“遮罩按下、遮罩松开正常关闭”。
|
||||
- 关联:`src/components/common/UnifiedModal.tsx`、`src/components/common/UnifiedModal.test.tsx`、`src/components/auth/PlatformAuthModalShell.test.tsx`。
|
||||
|
||||
@@ -36,7 +36,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A
|
||||
server-rs + Axum + SpacetimeDB
|
||||
```
|
||||
|
||||
当前 SpacetimeDB crate、SDK、CLI / standalone、生成 bindings 和容器压测镜像统一按 `2.5.0` 对齐;遇到版本不匹配时先升级到 `server-rs/Cargo.toml` 锁定版本,升级后重启对应 SpacetimeDB 进程再重试。
|
||||
当前 SpacetimeDB crate、SDK、CLI / standalone、生成 bindings 和容器压测镜像统一按 `2.6.0` 对齐;遇到版本不匹配时先升级到 `server-rs/Cargo.toml` 锁定版本,升级后重启对应 SpacetimeDB 进程再重试。
|
||||
|
||||
职责边界:
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# 官网 SEO 地基实施约定
|
||||
|
||||
更新时间:`2026-07-10`
|
||||
|
||||
## 本轮边界
|
||||
|
||||
本轮只建立陶泥儿中文首页的 SEO 基础设施,不新增英文站、长尾落地页、SSR、动态 sitemap、前端访问埋点或分享图。
|
||||
|
||||
- `public/robots.txt` 是真实静态文件;非 SEO 产品路径本轮阻止抓取,但不把 `Disallow` 解释为保证不收录。
|
||||
- `public/sitemap.xml` 本轮只包含 `https://www.genarrative.world/`,不写难以持续维护的 `lastmod`。
|
||||
- `index.html` 提供首页 title、description、canonical、robots、基础 OG/Twitter 文本和 JSON-LD;没有正式分享图时不写 `og:image`、`twitter:image` 或 JSON-LD `logo`。
|
||||
- 桌面首页与 `/creation` 创作主页渲染后只有一个稳定、可见的产品定位 H1,固定为 `陶泥儿 · 开启全民精品游戏创作`;小眉题、产品说明、权益提示、动态作品名、卡片标题、按钮内部标题、弹窗标题和隐藏 Tab 不作为 H1。
|
||||
- H1 下方真实展示游戏美术 AI 工作台、美术 Agent、无限画布以及角色、场景、UI、宣发素材说明,不使用透明、极小、屏幕外或页面底部堆词文本;`游戏美术 AI 创作工具` 使用 H2,能力卡标题使用 H3。
|
||||
- 本轮可见首页文案调整不得删除或改写已经验收的 title、description、canonical、robots、OG/Twitter 文本和 JSON-LD。
|
||||
- Nginx 和 Pingora 只对当前已知完整 SPA 路径回退 `index.html`;未知路径以及 `/creation/not-exist` 等同前缀未知路径返回 HTTP 404。
|
||||
- Nginx access log 继续使用现有 `$http_referer`,本轮不新增前端 pageview 或用户身份采集。
|
||||
|
||||
## 路由事实源
|
||||
|
||||
主站 SPA 路由以以下源码为事实源:
|
||||
|
||||
- `src/routing/appRoutes.tsx`
|
||||
- `src/routing/appPageRoutes.ts`
|
||||
|
||||
`/creation/rpg/agent` 仍被现有刷新恢复链路使用,当前作为兼容深链保留。新增或删除前端路由时,必须同步三套 Nginx 配置、Pingora 路由、`deploy/pingora/nginx-route-parity.matrix.json` 和对应自动门禁。不得把 `/creation/*`、`/runtime/*` 等一级目录整体设为 SPA fallback。
|
||||
|
||||
后续新增 SEO 落地页时,还必须同时满足:返回 200、不被 robots.txt 阻止抓取、加入 sitemap,并提供独立 title、description、canonical、H1、正文和内链入口。纯 SPA 页面需要独立 head 时,应评估构建时静态 HTML、预渲染或 SSR。
|
||||
|
||||
## 验收口径
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run typecheck
|
||||
npm run check:nginx-spa-routes
|
||||
npm run check:pingora-route-parity
|
||||
npm run check:pingora-gateway-smoke
|
||||
npm run check:encoding
|
||||
git diff --check
|
||||
```
|
||||
|
||||
部署后至少验证:
|
||||
|
||||
```bash
|
||||
curl -I https://www.genarrative.world/robots.txt
|
||||
curl -I https://www.genarrative.world/sitemap.xml
|
||||
curl -I https://www.genarrative.world/not-exist-test
|
||||
curl -I https://www.genarrative.world/creation/not-exist-test
|
||||
curl -I https://www.genarrative.world/runtime/not-exist-test
|
||||
curl -I https://www.genarrative.world/puzzle/not-exist-test
|
||||
```
|
||||
|
||||
真实 SPA 路径不得误 404;以上未知路径必须返回 404。首页浏览器 DOM 应只有一个 H1,并确认桌面和移动端布局、导航、推荐流和创作入口没有因 SEO 文案变形。
|
||||
@@ -9,7 +9,7 @@
|
||||
- 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。
|
||||
- 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=<projectId>`。
|
||||
- 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。
|
||||
- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧常驻展示当前账号泥点余额,样式对齐创作主页顶部钱包 chip,点击后复用创作主页右上角泥点入口的账户充值 / 兑换码弹窗与支付确认反馈。
|
||||
- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口,并以充值中心 `mudPointBalance` 为余额真相源。余额区只展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。
|
||||
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
|
||||
- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
|
||||
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
|
||||
@@ -86,8 +86,8 @@
|
||||
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,生成成功后 api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=<screenColor>`、`seg_model=<segModel>` 生成透明 PNG。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。
|
||||
- `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后由 api-server 解析为图片文件并转发到 BiRefNet 去背景服务;请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径;响应返回 `imageSrc`、`objectKey`、`assetObjectId`、`width`、`height`、`taskId`、`elapsedMs`、`provider` 和可选 `project` 快照。服务地址由 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL` 配置,令牌只在服务端通过 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 注入。
|
||||
- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端保存透明 spritesheet project resource / 账号素材,并随响应返回对应快照。
|
||||
- `POST /api/editor/ui-designs/assets/extractions`:以前端已绘入红色框选轮廓的 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet,并按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。
|
||||
- `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据;接口能力仍可接收明确参考图,但图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。
|
||||
- `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet,并按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。
|
||||
- `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 调用 VectorEngine edits,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。画布快速编辑必须把源图精确 `originalWidth x originalHeight` 作为业务目标 `size` 提交,不能重新映射为近似比例或 1K / 2K 预设;api-server 在 VectorEngine provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后恢复到业务目标精确尺寸,再落 OSS、project resource、账号素材和画布快照。16 对齐尺寸不得泄漏到响应、持久化资源或图层 Resolution。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。
|
||||
- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `asset` 快照,基础响应返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。
|
||||
- `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`:按音效 / 背景音乐参数生成音频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `resource` / `asset` 快照,基础响应返回 `audioSrc`、prompt、model、provider、taskId、duration、歌词和 `priceMudPoints`。
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
- 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。
|
||||
- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 代理远端 BiRefNet 服务并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词;生成的透明 spritesheet 原图和拆分后的独立素材都作为画布图层保留。
|
||||
- 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。
|
||||
- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。
|
||||
- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;只有尚未登记的浏览器本地图片才先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。
|
||||
- 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。
|
||||
- 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。普通上传默认落入“上传素材”文件夹;素材库缺少该文件夹时,前端在首次普通上传前创建一次并复用,拖到指定文件夹或点击指定文件夹上传时仍进入目标文件夹。
|
||||
- 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;上传视频同样在创建素材和图层前读取视频 metadata 宽高,保证单层下载或 ZIP 导出的真实视频文件重新导入后仍按文件自身尺寸入画布;仅在无法解析尺寸时才使用对应媒体兜底尺寸。
|
||||
|
||||
@@ -42,6 +42,7 @@ cargo run -p pingora-gateway --manifest-path server-rs/Cargo.toml
|
||||
|
||||
```bash
|
||||
npm run check:pingora-gateway-smoke
|
||||
npm run check:nginx-spa-routes
|
||||
npm run check:pingora-route-parity
|
||||
npm run check:nginx-pingora-canary
|
||||
npm run check:pingora-canary-docker
|
||||
@@ -59,9 +60,11 @@ npm run check:pingora-cutover-evidence-audit
|
||||
npm run check:pingora-release-readiness
|
||||
```
|
||||
|
||||
`check:pingora-gateway-smoke` 会临时启动 mock `api-server`、mock SpacetimeDB、mock Gitea 和 `pingora-gateway`,覆盖 SPA fallback、后台静态路由、HTML / 普通静态资源 `no-cache`、Vite 指纹静态资源 immutable 缓存、静态 `ETag` / `Last-Modified` 与 `304` 协商缓存、静态 `HEAD` 响应、静态 Range、静态 access log method/path/status 对账、gzip 最小长度、小响应不压缩、图片资源不压缩、大响应压缩、ACME、TLS 直连、HTTP/2 ALPN、HTTP 到 HTTPS 重定向、内部路由拒绝、shadow probe、API 代理头(`Host` / `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP` / `X-Forwarded-For`)、Gitea Host 整站转发、请求体上限、429 接流保护、上游断连 / 超时 JSON 错误、维护模式、维护模式不拦截 Gitea Host 和 SpacetimeDB WebSocket Upgrade,并复用 `check-pingora-direct-live.mjs` 对临时 HTTPS / HTTP redirect / WSS subscribe 入口做 live smoke。该本地 fixture 会让首页同时引用普通静态资源和 Vite 指纹静态资源,direct live JSON 必须确认指纹资源 GET / HEAD / `Range: bytes=0-0` 以及 access log method/path/status 证据,避免正式直连前只证明普通静态读取。排查失败时可追加 `-- --verbose` 输出网关 stderr / stdout;已确认二进制无需重编时可追加 `-- --skip-build`。
|
||||
`check:pingora-gateway-smoke` 会临时启动 mock `api-server`、mock SpacetimeDB、mock Gitea 和 `pingora-gateway`,覆盖精确主站 SPA fallback、大小写与尾部斜杠兼容、同前缀未知路径真实 404、后台静态路由、HTML / 普通静态资源 `no-cache`、Vite 指纹静态资源 immutable 缓存、静态 `ETag` / `Last-Modified` 与 `304` 协商缓存、静态 `HEAD` 响应、静态 Range、静态 access log method/path/status 对账、gzip 最小长度、小响应不压缩、图片资源不压缩、大响应压缩、ACME、TLS 直连、HTTP/2 ALPN、HTTP 到 HTTPS 重定向、内部路由拒绝、shadow probe、API 代理头(`Host` / `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP` / `X-Forwarded-For`)、Gitea Host 整站转发、请求体上限、429 接流保护、上游断连 / 超时 JSON 错误、维护模式、维护模式不拦截 Gitea Host 和 SpacetimeDB WebSocket Upgrade,并复用 `check-pingora-direct-live.mjs` 对临时 HTTPS / HTTP redirect / WSS subscribe 入口做 live smoke。该本地 fixture 会让首页同时引用普通静态资源和 Vite 指纹静态资源,direct live JSON 必须确认指纹资源 GET / HEAD / `Range: bytes=0-0` 以及 access log method/path/status 证据,避免正式直连前只证明普通静态读取。排查失败时可追加 `-- --verbose` 输出网关 stderr / stdout;已确认二进制无需重编时可追加 `-- --skip-build`。
|
||||
|
||||
`check:pingora-route-parity` 读取 `deploy/pingora/nginx-route-parity.matrix.json`,静态确认生产 / 开发 Nginx 模板、Pingora Rust 路由单测和本文档都覆盖同一组核心路由。`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml matches_nginx_route_parity_matrix` 会读取同一份矩阵,逐条断言 `classify_path` 的路由结果、body limit 和接流保护分组。
|
||||
`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` / `APP_RUNTIME_ROUTES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。
|
||||
|
||||
`check:pingora-route-parity` 会先执行同一 Nginx SPA 路由门禁,再读取 `deploy/pingora/nginx-route-parity.matrix.json`,静态确认生产 / 开发 Nginx 模板、Pingora Rust 路由 allowlist / 单测和本文档都覆盖同一组核心路由。`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml matches_nginx_route_parity_matrix` 会读取同一份矩阵,逐条断言 `classify_path` 的路由结果、body limit 和接流保护分组。
|
||||
|
||||
`check:nginx-pingora-canary` 会静态校验 `deploy/nginx/snippets/genarrative-pingora-canary.conf` 的本机来源限制、handoff 响应头、probe token 占位、前缀 rewrite、低缓冲和 WebSocket Upgrade 设置,也会校验 `deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf` 只能作为独立 loopback `server` 片段使用、默认监听 `127.0.0.1:18083`、写独立 access log、没有 rewrite、覆盖真实 `/api` / `/v1` / `/assets` 代表路径。本机安装了 Nginx 时脚本会额外把两个 snippet 包进临时 `http {}` 执行 `nginx -t`;需要在 CI / 目标 agent 上强制要求真实 Nginx 语法检查时执行 `node scripts/check-nginx-pingora-canary.mjs --require-nginx`。
|
||||
|
||||
@@ -539,9 +542,10 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清
|
||||
| `/v1/database/{db}/subscribe`、`/v1/identity*` | 转发到 SpacetimeDB,保留 WebSocket Upgrade 头。 |
|
||||
| `/__genarrative_pingora/healthz` | 仅在携带 `X-Genarrative-Pingora-Probe` 且匹配配置 token 时返回 shadow JSON,否则 404。 |
|
||||
| `/v1/*`、`/generated-*`、`/healthz*`、`/readyz*` | 返回 404,保持生产公网不暴露口径。 |
|
||||
| 其它路径 | 先读取静态文件或目录 index,失败回退 `/index.html`,HTML 默认 `no-cache`,并支持条件请求返回 `304` 与单段 `Range: bytes=` 返回 `206` / 越界返回 `416`。 |
|
||||
| 主站 SPA allowlist | 只对当前前端完整路由及兼容恢复路径 `/creation/rpg/agent` 失败回退 `/index.html`;匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。 |
|
||||
| 其它 Web 路径 | 只读取真实静态文件或目录 index,缺失时返回真实 404;`/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 不进入 SPA fallback。 |
|
||||
|
||||
维护模式下,API-like 路由返回 JSON `503`,Web 静态路由优先返回 `maintenance.html`,不存在时返回纯文本 `503`。
|
||||
维护模式下,公网 API-like 路由返回 JSON `503`,公网 Web 静态路由优先返回 `maintenance.html`,不存在时返回纯文本 `503`。IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local 来源绕过整站维护闸,主站页面与静态资源、普通 API、后台页面与后台 API、SpacetimeDB 路由均按非维护状态继续处理;应用层登录、管理员鉴权和其它业务鉴权保持不变。Pingora 直连按 TCP peer 判定来源;仅当 peer 是 loopback 的同机 Nginx 时才接受 Nginx 强制覆盖的 `X-Real-IP`,绝不使用客户端可伪造的 `X-Forwarded-For` 做维护放行。该放行只绕过网关维护响应;若 `pause-after-stdb` 已停止 api-server,内网普通 API 和后台 API 仍不可用。
|
||||
代理失败时,API / SpacetimeDB 等代理路由返回统一 JSON 网关错误;本地静态路由仍保持对应 HTTP 错误状态。
|
||||
静态 `Range` 只支持单段 bytes range;多段 range 暂按完整文件返回,避免在正式替换前引入 multipart 响应面。`If-None-Match` / `If-Modified-Since` 优先于 `Range` 判定,命中时仍返回 `304`;`If-Range` 日期匹配时继续返回 `206`,日期旧于文件或弱 ETag 校验器时回完整 `200`;`206` / `304` / `416` 不做 gzip 压缩,避免 `Content-Range` 语义被响应体改写破坏。Gateway smoke 会用固定 `X-Request-Id` 对账静态 `304`、`405`、`206`、`416` 的 Pingora access log 行,确认本地响应状态也进入正式切换证据链。
|
||||
静态路由只允许 `GET` / `HEAD` 读取;其它方法在确认命中静态候选后返回 `405` 并写入 `Allow: GET, HEAD`,缺失文件仍返回 `404`,避免直连后错误客户端把静态入口当作可写接口。
|
||||
|
||||
@@ -16,7 +16,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.5.0`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `server-rs/Cargo.toml` 锁定版本对齐,避免 BSATN / procedure result 反序列化错配。遇到版本不匹配时,不继续沿着业务超时排查,先把 CLI / standalone 直接升级到锁定版本并重启后再重试。
|
||||
SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 统一锁定 `2.6.0`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `server-rs/Cargo.toml` 锁定版本对齐,避免 BSATN / procedure result 反序列化错配。遇到版本不匹配时,不继续沿着业务超时排查,先把 CLI / standalone 直接升级到锁定版本并重启后再重试。
|
||||
|
||||
当前主要 crate:
|
||||
|
||||
@@ -189,18 +189,20 @@ npm run check:server-rs-ddd
|
||||
## 账户充值数据契约
|
||||
|
||||
1. `profile_recharge_product_config` 是泥点和会员商品配置真相源,默认商品只在表为空时由 SpacetimeDB 播种。`module-runtime` 中的默认商品 helper 只作为空库种子和兼容入口,不再作为运行期业务真相。
|
||||
2. 后台通过 `/admin/api/profile/recharge-products` 读写充值商品配置;字段覆盖 `productId`、标题、商品类型、金额分、基础泥点、首充赠送泥点、会员天数、徽标、说明、会员层级、会员每周期限时泥点、周期天数、队列上限、折扣率、启用状态和排序。
|
||||
3. 充值中心、下单校验和支付确认入账都读取 `profile_recharge_product_config`。历史订单保留下单时写入的商品标题、金额、渠道、状态和 provider transaction id,不随配置改动回写。
|
||||
4. 泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断。某个档位已支付后,只隐藏该档位的首充赠送;其它未购买档位仍展示和结算首充赠送。
|
||||
5. `hasPointsRecharged` 只保留为账号是否发生过任一泥点充值的兼容字段,不得驱动所有商品展示隐藏或结算金额计算。前端只渲染后端返回的商品快照。
|
||||
6. 默认会员商品为空库播种时使用 `Starter / Basic / Pro / Ultimate` 四档,默认有效期均为 30 天,每周期限时泥点分别为 `200 / 800 / 2500 / 6000`,队列上限分别为 `2 / 2 / 5 / 10`。
|
||||
7. 会员有效期和周期重置时间是两条独立时间线。`expires_at` 只决定会员是否生效;`cycle_resets_at` 只决定当前周期限时泥点何时重置。会员升级只更新档位并补齐当前周期限时泥点差额,不延长 `expires_at`,不移动 `cycle_resets_at` 和周期天数。同级会员购买只从当前 `expires_at` 延长有效期,不发放额外当前周期泥点,也不移动重置时间。
|
||||
8. 会员周期刷新发生在个人中心、充值中心、任务中心、账单读取和钱包扣费入口:到达 `cycle_resets_at` 时先清除上周期剩余限时泥点,再发放当前会员档位周期额度;会员过期时清除剩余限时泥点并把状态降为普通。周期发放和重置流水分别使用 `membership_period_grant`、`membership_period_reset`。
|
||||
9. `paymentChannel` 缺失、未知或冒用小程序支付设备时必须拒绝;真实微信渠道只允许 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5`、`wechat_native`,生产配置不得把真实支付静默降级为 `mock`。
|
||||
10. access JWT 只携带最小设备快照 `device.client_type`、`device.client_runtime`、`device.client_platform`。充值下单按该快照拦截小程序渠道:小程序只允许 `wechat_mp` / `wechat_mp_virtual`;移动网页和微信内 H5 走 `wechat_h5`;桌面网页和桌面微信走 `wechat_native`;`wechat_jsapi` 仅保留后端能力,未接微信开放平台前不由前端自动选择。历史普通 Web 登录态若缺少设备快照也允许继续进入 JSAPI / H5 / Native 渠道的后续支付配置校验,但不放宽小程序虚拟支付。
|
||||
11. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。
|
||||
12. 微信 JSAPI / H5 / 小程序 / Native 下单统一显式传 5 分钟 `time_expire`,格式为 RFC3339 秒级时间;Native 额外通过 `wechatNativePayment.expiresAt` 下发给前端二维码弹窗展示。
|
||||
13. 真实微信渠道的新建 pending 充值订单会写入 SpacetimeDB 原生 scheduled 表 `profile_recharge_order_expiration_timer`。到期 reducer 只做数据库内状态转换:订单仍为 `pending` 时更新为 `expired` 并写 `expired_at`,同时删除 timer。HTTP `api-server` 订阅 `profile_recharge_order` 的 `Pending -> Expired` 更新并执行微信查单补偿:`SUCCESS` 可把 `expired` 补确认成 `paid` 入账;`NOTPAY` 会调用微信关单并把本地订单保持为 `expired`;`CLOSED` / `REVOKED` / `PAYERROR` / `ORDER_NOT_EXIST` 只记录检查结果。`external-generation-worker` / controller 不处理充值过期;`wechat_mp_virtual` 到期只记录虚拟渠道不可查,后续真实支付通知仍允许 `Expired -> Paid`。
|
||||
2. 默认泥点商品固定为四档:`points_60 = 60 泥点 / 600 分`、`points_180 = 180 + 90 泥点 / 1800 分`、`points_300 = 300 + 150 泥点 / 3000 分`、`points_680 = 680 + 340 泥点 / 6800 分`。`points_60` 的首充赠送为 `0`;后三档首次购买分别赠送基础泥点的 `50%`。
|
||||
3. 后台通过 `/admin/api/profile/recharge-products` 读写充值商品配置;字段覆盖 `productId`、标题、商品类型、金额分、基础泥点、首充赠送泥点、会员天数、徽标、说明、会员层级、会员每周期限时泥点、周期天数、队列上限、折扣率、启用状态和排序。
|
||||
4. 充值中心、下单校验和支付确认入账都读取 `profile_recharge_product_config`。充值中心 BFF 还必须在 `mudPointBalance` 下发 `totalPoints`、`permanentPoints`、`limitedPoints`、`limitedExpiresAt`、`dailyFreePoints`、`dailyFreeResetPoints` 和 `dailyFreeResetsAt`;前端以该 read model 为真相源,不得自行用总额相减推算余额桶。当前版本公开 UI 只渲染不限时泥点和每日免费泥点,`limitedPoints` 与 `limitedExpiresAt` 仅保留给存量兼容和后端结算。历史订单保留下单时写入的商品标题、金额、渠道、状态和 provider transaction id,不随配置改动回写。
|
||||
5. 泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断。某个档位已支付后,只隐藏该档位的首充赠送;其它未购买档位仍展示和结算首充赠送。
|
||||
6. `hasPointsRecharged` 只保留为账号是否发生过任一泥点充值的兼容字段,不得驱动所有商品展示隐藏或结算金额计算。前端只渲染后端返回的商品快照。
|
||||
7. 当前版本公开充值 UI 只展示泥点商品,不渲染会员购买页签、会员商品、购买会员或升级会员入口。充值中心响应中的会员商品兼容字段、默认会员商品、`profile_membership` 和周期刷新逻辑继续保留;存量会员的 `cycle_remaining_points` 仍通过充值中心 read model 下发用于兼容和结算,但不作为限时泥点在当前版本前台展示。
|
||||
8. 默认会员商品为空库播种时使用 `Starter / Basic / Pro / Ultimate` 四档,默认有效期均为 30 天,每周期限时泥点分别为 `200 / 800 / 2500 / 6000`,队列上限分别为 `2 / 2 / 5 / 10`。
|
||||
9. 会员有效期和周期重置时间是两条独立时间线。`expires_at` 只决定会员是否生效;`cycle_resets_at` 只决定当前周期限时泥点何时重置。会员升级只更新档位并补齐当前周期限时泥点差额,不延长 `expires_at`,不移动 `cycle_resets_at` 和周期天数。同级会员购买只从当前 `expires_at` 延长有效期,不发放额外当前周期泥点,也不移动重置时间。
|
||||
10. 会员周期刷新发生在个人中心、充值中心、任务中心、账单读取和钱包扣费入口:到达 `cycle_resets_at` 时先清除上周期剩余限时泥点,再发放当前会员档位周期额度;会员过期时清除剩余限时泥点并把状态降为普通。周期发放和重置流水分别使用 `membership_period_grant`、`membership_period_reset`。
|
||||
11. `paymentChannel` 缺失、未知或冒用小程序支付设备时必须拒绝;真实微信渠道只允许 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5`、`wechat_native`,生产配置不得把真实支付静默降级为 `mock`。
|
||||
12. access JWT 只携带最小设备快照 `device.client_type`、`device.client_runtime`、`device.client_platform`。充值下单按该快照拦截小程序渠道:小程序只允许 `wechat_mp` / `wechat_mp_virtual`;移动网页和微信内 H5 走 `wechat_h5`;桌面网页和桌面微信走 `wechat_native`;`wechat_jsapi` 仅保留后端能力,未接微信开放平台前不由前端自动选择。历史普通 Web 登录态若缺少设备快照也允许继续进入 JSAPI / H5 / Native 渠道的后续支付配置校验,但不放宽小程序虚拟支付。
|
||||
13. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。
|
||||
14. 微信 JSAPI / H5 / 小程序 / Native 下单统一显式传 5 分钟 `time_expire`,格式为 RFC3339 秒级时间;Native 额外通过 `wechatNativePayment.expiresAt` 下发给前端二维码弹窗展示。
|
||||
15. 真实微信渠道的新建 pending 充值订单会写入 SpacetimeDB 原生 scheduled 表 `profile_recharge_order_expiration_timer`。到期 reducer 只做数据库内状态转换:订单仍为 `pending` 时更新为 `expired` 并写 `expired_at`,同时删除 timer。HTTP `api-server` 只订阅这张活跃 timer 表的删除事件,收到 `order_id` 后通过 procedure 重新读取订单,只有状态确认为 `expired` 才执行微信查单补偿;支付或主动关闭同样会删除 timer,但会被状态判断忽略。监听断线期间遗漏的删除事件由未检查过期订单 catch-up 补齐,不订阅完整 `profile_recharge_order` 历史表。普通微信支付查单中 `SUCCESS` 可把 `expired` 补确认成 `paid` 入账;`NOTPAY` 会调用微信关单并把本地订单保持为 `expired`;`CLOSED` / `REVOKED` / `PAYERROR` / `ORDER_NOT_EXIST` 只记录检查结果。`wechat_mp_virtual` 使用小程序 `access_token` 和虚拟支付 AppKey 调用官方 `/xpay/query_order`,只在返回单号、支付类型 `order_type=0/7`、金额、合法 `paid_time` 与本地契约一致且状态为 `2/3/4` 时补入账;退款类型 `1/8` 不得触发充值,其余已知状态只记录检查结果。`short_series_goods` 从 `status=2` 恢复时先幂等入账,再调用 `/xpay/notify_provide_goods`,失败后允许基于本地 `paid` 状态只重试发货;`short_series_coin` 不调用现金单发货接口。`external-generation-worker` / controller 不处理充值过期。
|
||||
|
||||
## 创作入口泥点扣费契约
|
||||
|
||||
@@ -215,13 +217,14 @@ npm run check:server-rs-ddd
|
||||
## 用户钱包与编辑器生成扣费契约
|
||||
|
||||
1. 新用户账号完成注册并成功同步正式认证表后,注册赠送金额读取 `profile_wallet_config.initial_mud_points`;后台通过 `/admin/api/profile/wallet-config` 维护“账号初始泥点数”。未写入配置时默认仍为 `100` 泥点。流水原因仍使用 `new_user_registration_reward`,流水 ID 继续保持幂等,重复发放请求不得叠加余额。
|
||||
2. 用户钱包余额对外仍暴露为一个总余额,但后端扣费时优先消耗 `profile_membership.cycle_remaining_points` 中的会员周期限时泥点,再消耗普通永久泥点;扣费流水 `metadata_json` 会记录 `membershipPeriodPointsDelta`、`permanentPointsDelta` 和限时泥点所属 `cycleResetsAtMicros`,退款会按原消费流水优先恢复同一周期的限时泥点,前端不得自行决定扣费桶。
|
||||
3. 每日免费泥点当前由每日任务体系发放,流水来源为 `daily_task_reward`,任务进度和可领取状态按北京时间每日刷新;它不参与会员 `cycle_resets_at`,也不由前端合并进会员周期泥点。
|
||||
4. 编辑器画板所有会调用外部生成 provider 的入口都不从前端请求接收 `priceMudPoints`;同步请求以 SpacetimeDB `editor_generation_pricing_config` 当前全局配置计算,外部生成队列则以 `external_generation_job.price_mud_points` 保存的入队价格为准,worker 的扣费、退款、响应和资产成本不得按执行时配置重算。前端按钮泥点只作为展示。
|
||||
5. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
|
||||
6. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。
|
||||
7. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
|
||||
8. 编辑器图片生成、图片修改、图标 spritesheet 和 UI 设计图提取素材的参考图可以提交 Data URL 或已登记的 generated objectKey;objectKey 必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,后端通过归属校验后才签名读取 OSS。图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;图片快速编辑当前不开放额外参考图,只提交原图或红框序号标注图作为 `sourceImageSrc`。UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,图标素材上限 8 张额外参考图。
|
||||
2. 用户钱包余额对外仍暴露为一个总余额,但后端扣费必须按“每日免费泥点 -> 会员周期限时泥点 -> 普通永久泥点”的顺序消耗,前端不得自行决定扣费桶。扣费流水 `metadata_json` 必须记录 `dailyFreePointsDelta`、`dailyFreeDayKey`、`membershipPeriodPointsDelta`、`permanentPointsDelta` 和会员限时泥点所属 `cycleResetsAtMicros`;资产退款中的会员限时泥点只在原周期仍有效时恢复会员额度,其余会员部分进入普通永久泥点。原每日免费消费部分在同一业务日退款时恢复原当日额度;跨北京时间业务日退款时叠加到退款当日每日免费桶,不进入普通永久泥点,当日 `granted_points` 与 `remaining_points` 均可因此超过 `20`。原永久泥点消费部分无论是否跨业务日,均按退款流水中的 `permanentPointsDelta` 退回普通永久泥点。
|
||||
3. 每日免费泥点是独立于每日任务和会员周期的正式余额额度,基础发放量固定为 `20`,不得由前端或后台任务配置改写。`profile_daily_free_points` 保存当前北京时间业务日、当日基础发放及跨日退款叠加后的总额度和剩余额度;北京时间每日 `00:00` 作为业务日边界,个人中心、充值中心、账单读取和钱包扣费入口在首次触达新业务日时原子清除昨日剩余及退款叠加量,并把今日 `granted_points`、`remaining_points` 重置为 `20`。首次初始化使用 `daily_free_grant` 流水,跨日重置使用 `daily_free_reset` 流水。惰性落库不能改变“北京时间 00:00 后读取即为新日额度”的对外语义。
|
||||
4. 每日任务奖励继续使用 `daily_task_reward` 流水并进入普通永久泥点,但主站隐藏每日任务卡片和任务中心入口,不再把每日登录任务描述为“每日免费泥点”。任务配置、进度、领取记录和后台管理能力暂时保留,除非后续需求明确删除。
|
||||
5. 编辑器画板所有会调用外部生成 provider 的入口都不从前端请求接收 `priceMudPoints`;同步请求以 SpacetimeDB `editor_generation_pricing_config` 当前全局配置计算,外部生成队列则以 `external_generation_job.price_mud_points` 保存的入队价格为准,worker 的扣费、退款、响应和资产成本不得按执行时配置重算。前端按钮泥点只作为展示。
|
||||
6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
|
||||
7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。
|
||||
8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
|
||||
9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,只允许提交已登记的 generated `objectKey`、`resourceId` 或 `assetId`;任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。objectKey 必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,后端通过归属校验后才签名读取 OSS。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;图片快速编辑当前不开放额外参考图。UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,图标素材上限 8 张额外参考图。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。
|
||||
|
||||
## 外部服务与资产
|
||||
|
||||
@@ -266,7 +269,15 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`ExternalGenerationJob`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
|
||||
- 用途:外部生成 worker 的持久任务队列和用户可见生成任务列表;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。队列行同时保存 `price_mud_points`、`refund_ledger_id` 和 `notification_acknowledged_at`,BFF 通过 `GET /api/runtime/external-generation/jobs` 返回当前账号的正式生成任务列表、价格、状态和未确认终态数量;前端只能展示该后端事实,完成 / 失败提示展示后后台调用 `POST /api/runtime/external-generation/jobs/acknowledge` 由后端写确认时间,关闭按钮只收起本地弹窗,未确认终态任务会在下次登录后再次集中弹出。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
||||
- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,但用户可见任务列表、价格、状态、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
||||
- 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用;其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行和受控维护读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得再返回或解析这两个 payload。
|
||||
|
||||
### `external_generation_job_summary`
|
||||
|
||||
- Rust 结构体:`ExternalGenerationJobSummary`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
|
||||
- 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、价格、有界错误摘要、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误摘要统一拒绝内联媒体并限制为 2048 字符;列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。
|
||||
- 正式读取 procedure 为 `get_external_generation_job_summary_and_return`、`list_external_generation_job_summaries_and_return` 和 `acknowledge_external_generation_job_summaries_and_return`。历史维护 procedure 为 `compact_external_generation_job_payloads_and_return` 与 `backfill_external_generation_job_summaries_and_return`,仅 migration operator 可调用;运维入口统一使用 `npm run spacetime:external-generation:maintain -- ...`,默认 dry-run、单批最多 25 条。B-tree cursor 选择阶段最多反序列化 `limit + 1` 行,apply 再按主键逐条读取选中行;怀疑存在单行异常巨型 JSON 时必须先使用 `--limit 1`。payload 压缩额外固定使用 `source_module = editor-canvas` 的复合 cursor 索引,不得静默改写其它玩法历史任务。
|
||||
|
||||
### `external_generation_job_event`
|
||||
|
||||
@@ -683,6 +694,12 @@ npm run check:server-rs-ddd
|
||||
- Rust 结构体:`ProfileDashboardState`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
|
||||
### `profile_daily_free_points`
|
||||
|
||||
- Rust 结构体:`ProfileDailyFreePoints`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 作用:每日免费泥点事实源。`day_key` 使用北京时间业务日,基础发放量固定为 `20`,`remaining_points` 保存当日剩余额度;跨业务日退款的每日免费消费部分会叠加到退款当日,使 `granted_points` 和 `remaining_points` 可暂时超过 `20`,下一业务日首次触达时旧余额与叠加量一并失效并重置为 `20`。
|
||||
|
||||
### `profile_feedback_submission`
|
||||
|
||||
- Rust 结构体:`ProfileFeedbackSubmission`
|
||||
@@ -709,7 +726,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`ProfileRechargeProductConfig`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 作用:泥点和会员充值商品配置真相源,供充值中心展示、下单校验、支付确认和后台“充值商品”页维护。
|
||||
- 作用:泥点和会员充值商品配置真相源,供充值中心展示、下单校验、支付确认和后台“充值商品”页维护;当前公开充值中心只展示四档泥点商品,会员商品配置留存但不公开购买或升级入口。
|
||||
- 字段补充:会员商品追加 `membership_period_points`、`membership_period_days`、`membership_queue_limit`、`membership_discount_bps`;泥点商品这些字段必须为 `0`。
|
||||
|
||||
### `profile_played_world`
|
||||
@@ -774,7 +791,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`ProfileWalletLedger`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 说明:账号钱包流水表。`metadata_json` 为可选 JSON 对象字符串,旧行缺失时读取层按 `{}` 归一;外部生成扣费 / 退款写入 `externalGenerationJobId`,使退款记录可以追溯到对应 `external_generation_job`。
|
||||
- 说明:账号钱包流水表。`created_at` 表示钱包事务实际结算时间,列表先按当前余额反向校验 `balance_after - amount_delta` 的结算链,再以该时间倒序兜底,避免支付回调或退款重放延迟时出现余额顺序倒置;支付平台确认时间继续保存在充值订单 `paid_at`。`metadata_json` 为可选 JSON 对象字符串,旧行缺失时读取层按 `{}` 归一;外部生成扣费 / 退款写入 `externalGenerationJobId`,使退款记录可以追溯到对应 `external_generation_job`。
|
||||
|
||||
### `asset_operation_wallet_settlement`
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user