Merge remote-tracking branch 'origin/master' into editor-agent-refactored

This commit is contained in:
2026-07-16 11:13:44 +08:00
76 changed files with 3742 additions and 434 deletions
+31 -6
View File
@@ -14,20 +14,40 @@ if (hookInput && !isGitCommitCommand(extractShellCommand(hookInput))) {
}
const validationSteps = [
{
label: 'Rust format check',
command: npmCommand,
args:
process.platform === 'win32'
? ['/d', '/s', '/c', 'npm run check:rustfmt']
: ['run', 'check:rustfmt'],
},
{
label: 'TypeScript typecheck',
command: npmCommand,
args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run typecheck'] : ['run', 'typecheck'],
args:
process.platform === 'win32'
? ['/d', '/s', '/c', 'npm run typecheck']
: ['run', 'typecheck'],
},
{
label: 'Admin web typecheck',
command: npmCommand,
args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run admin-web:typecheck'] : ['run', 'admin-web:typecheck'],
args:
process.platform === 'win32'
? ['/d', '/s', '/c', 'npm run admin-web:typecheck']
: ['run', 'admin-web:typecheck'],
},
{
label: 'Rust api-server compile check',
command: 'cargo',
args: ['check', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'],
args: [
'check',
'-p',
'api-server',
'--manifest-path',
'server-rs/Cargo.toml',
],
},
];
@@ -66,7 +86,9 @@ function runStep(step) {
}
if (result.error) {
console.error(`[codex-hook] ${step.label} 启动失败:${result.error.message}`);
console.error(
`[codex-hook] ${step.label} 启动失败:${result.error.message}`,
);
return { ok: false, status: 1 };
}
@@ -104,12 +126,15 @@ function extractShellCommand(input) {
input?.command,
];
const command = candidates.find(value => typeof value === 'string' && value.trim().length > 0);
const command = candidates.find(
(value) => typeof value === 'string' && value.trim().length > 0,
);
if (command) {
return command;
}
const shellCommand = input?.tool_input?.cmd ?? input?.toolInput?.cmd ?? input?.arguments?.cmd;
const shellCommand =
input?.tool_input?.cmd ?? input?.toolInput?.cmd ?? input?.arguments?.cmd;
if (Array.isArray(shellCommand)) {
return shellCommand.join(' ');
}
+2 -1
View File
@@ -398,10 +398,11 @@ export function getAdminAssetReadUrl(
export function listAdminEditorAssets(
token: string,
query: AdminEditorAssetListQuery = {},
signal?: AbortSignal,
) {
return request<AdminEditorAssetListResponse>(
`/admin/api/editor-assets${buildEditorAssetListQuery(query)}`,
{ token },
{ token, signal },
);
}
+8
View File
@@ -128,6 +128,7 @@ export interface AdminDashboardMetricsPayload {
consumedMudPoints: number;
totalRegisteredUsers: number;
newRegisteredUsers: number;
newUserPaymentConversion: AdminDashboardPaymentConversionPayload;
day1Retention: AdminDashboardRetentionMetricPayload;
day7Retention: AdminDashboardRetentionMetricPayload;
visitUsers: number;
@@ -137,6 +138,12 @@ export interface AdminDashboardMetricsPayload {
currentUsers: number;
}
export interface AdminDashboardPaymentConversionPayload {
paidUsers: number;
newRegisteredUsers: number;
rateBasisPoints: number;
}
export interface AdminDashboardRetentionMetricPayload {
eligibleUsers: number;
retainedUsers: number;
@@ -460,6 +467,7 @@ export interface AdminEditorAssetPayload {
model?: string | null;
provider?: string | null;
taskId?: string | null;
groupTaskId?: string | null;
assetKind?: string | null;
generationInputs?: Record<string, unknown> | null;
sourceResourceId?: string | null;
@@ -35,6 +35,11 @@ const dashboardResponse: AdminDashboardResponse = {
consumedMudPoints: 88,
totalRegisteredUsers: 1200,
newRegisteredUsers: 16,
newUserPaymentConversion: {
paidUsers: 5,
newRegisteredUsers: 16,
rateBasisPoints: 3125,
},
day1Retention: {
eligibleUsers: 12,
retainedUsers: 3,
@@ -101,7 +106,20 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
expect(screen.getByText('本日生产素材数')).toBeTruthy();
expect(screen.getByText('总注册用户')).toBeTruthy();
expect(screen.getByText('本日新增用户数')).toBeTruthy();
expect(screen.getByText('新增用户留存')).toBeTruthy();
expect(screen.getByText('新增用户转化与留存')).toBeTruthy();
const paymentRateCard = screen
.getByText('本日新增用户付费率')
.closest('article');
expect(paymentRateCard).toBeTruthy();
expect(
within(paymentRateCard as HTMLElement).getByText('31.25%'),
).toBeTruthy();
expect(
within(paymentRateCard as HTMLElement).getByText('付费人数 / 新增人数'),
).toBeTruthy();
expect(
within(paymentRateCard as HTMLElement).getByText('5 / 16 人'),
).toBeTruthy();
expect(screen.getByText('次日留存')).toBeTruthy();
expect(screen.getByText('25%')).toBeTruthy();
expect(screen.getByText('3 / 12 人')).toBeTruthy();
@@ -218,6 +236,33 @@ test('Dashboard 手动选择起止日期时使用本时段查询', async () => {
});
});
expect(screen.getByText('本时段新增用户数')).toBeTruthy();
expect(screen.getByText('本时段新增用户付费率')).toBeTruthy();
});
test('Dashboard 新增用户付费率分母为零时显示横线', async () => {
vi.mocked(getAdminDashboard).mockResolvedValue({
...dashboardResponse,
metrics: {
...dashboardResponse.metrics,
newRegisteredUsers: 0,
newUserPaymentConversion: {
paidUsers: 0,
newRegisteredUsers: 0,
rateBasisPoints: 0,
},
},
});
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
const paymentRateCard = (
await screen.findByText('本日新增用户付费率')
).closest('article');
expect(paymentRateCard).toBeTruthy();
expect(within(paymentRateCard as HTMLElement).getByText('-')).toBeTruthy();
expect(
within(paymentRateCard as HTMLElement).getByText('0 / 0 人'),
).toBeTruthy();
});
test('Dashboard 手动选择日期时不允许查询北京时间今天之后', async () => {
+48 -13
View File
@@ -6,7 +6,6 @@ import type {
AdminDashboardBreakdownRowPayload,
AdminDashboardChartPayload,
AdminDashboardGranularity,
AdminDashboardRetentionMetricPayload,
AdminDashboardResponse,
} from '../api/adminApiTypes';
import { handlePageError } from './pageUtils';
@@ -292,12 +291,38 @@ export function AdminDashboardPage({
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>新增用户留存</h3>
<h3>新增用户转化与留存</h3>
<span>{dashboard?.range.periodLabel ?? '-'}</span>
</div>
<div className="admin-dashboard-retention-grid">
<RetentionCard label="次日留存" metric={metrics?.day1Retention} />
<RetentionCard label="七日留存" metric={metrics?.day7Retention} />
<RateCard
label={`${rangePrefix(granularity)}新增用户付费率`}
numeratorLabel="付费人数"
denominatorLabel="新增人数"
numerator={metrics?.newUserPaymentConversion?.paidUsers}
denominator={
metrics?.newUserPaymentConversion?.newRegisteredUsers
}
rateBasisPoints={
metrics?.newUserPaymentConversion?.rateBasisPoints
}
/>
<RateCard
label="次日留存"
numeratorLabel="留存人数"
denominatorLabel="可观察新增人数"
numerator={metrics?.day1Retention.retainedUsers}
denominator={metrics?.day1Retention.eligibleUsers}
rateBasisPoints={metrics?.day1Retention.rateBasisPoints}
/>
<RateCard
label="七日留存"
numeratorLabel="留存人数"
denominatorLabel="可观察新增人数"
numerator={metrics?.day7Retention.retainedUsers}
denominator={metrics?.day7Retention.eligibleUsers}
rateBasisPoints={metrics?.day7Retention.rateBasisPoints}
/>
</div>
</section>
@@ -406,27 +431,37 @@ function MetricCard({
);
}
function RetentionCard({
function RateCard({
label,
metric,
numeratorLabel,
denominatorLabel,
numerator,
denominator,
rateBasisPoints,
}: {
label: string;
metric?: AdminDashboardRetentionMetricPayload;
numeratorLabel: string;
denominatorLabel: string;
numerator?: number;
denominator?: number;
rateBasisPoints?: number;
}) {
const hasEligibleUsers = Boolean(metric?.eligibleUsers);
const hasDenominator = Boolean(denominator);
return (
<article className="admin-dashboard-retention-card">
<span>{label}</span>
<strong>
{hasEligibleUsers && metric
? formatRateBasisPoints(metric.rateBasisPoints)
{hasDenominator && rateBasisPoints !== undefined
? formatRateBasisPoints(rateBasisPoints)
: '-'}
</strong>
<div>
<small>留存人数 / 可观察新增人数</small>
<small>
{numeratorLabel} / {denominatorLabel}
</small>
<b>
{metric
? `${formatNumber(metric.retainedUsers)} / ${formatNumber(metric.eligibleUsers)} 人`
{numerator !== undefined && denominator !== undefined
? `${formatNumber(numerator)} / ${formatNumber(denominator)} 人`
: '-'}
</b>
</div>
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { ChevronDown, ChevronRight, Eye, FileText, RefreshCcw, X } from 'lucide-react';
import { Fragment, type ReactNode } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
getAdminAssetReadUrl,
@@ -24,6 +24,7 @@ const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300;
const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40;
const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const;
const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px';
const ADMIN_EDITOR_ASSET_FILTER_DEBOUNCE_MS = 300;
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
let adminAssetReadDispatchTail = Promise.resolve();
@@ -37,6 +38,9 @@ export function AdminEditorAssetQueryPage({
const [createdAfter, setCreatedAfter] = useState('');
const [createdBefore, setCreatedBefore] = useState('');
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [committedQueryKey, setCommittedQueryKey] = useState<string | null>(
null,
);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
@@ -51,49 +55,147 @@ export function AdminEditorAssetQueryPage({
title: string;
prompt: string;
} | null>(null);
const listRequestGenerationRef = useRef(0);
const listRequestAbortControllerRef = useRef<AbortController | null>(null);
const automaticQueryInitializedRef = useRef(false);
const automaticQueryTokenRef = useRef(token);
const activeQuery = buildListQuery();
const activeQueryKey = editorAssetListQueryKey(token, activeQuery);
const hasCurrentQuerySnapshot = committedQueryKey === activeQueryKey;
const visibleEntries = hasCurrentQuerySnapshot ? entries : [];
const visibleNextCursor = hasCurrentQuerySnapshot ? nextCursor : null;
useEffect(() => {
void refreshPage();
const runImmediately =
!automaticQueryInitializedRef.current ||
automaticQueryTokenRef.current !== token;
automaticQueryInitializedRef.current = true;
automaticQueryTokenRef.current = token;
if (runImmediately) {
void refreshPage();
return;
}
invalidateListRequest();
setIsLoading(true);
setIsLoadingMore(false);
setErrorMessage('');
const timer = setTimeout(
() => void refreshPage(),
ADMIN_EDITOR_ASSET_FILTER_DEBOUNCE_MS,
);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, ownerUserId, keyword, createdAfter, createdBefore]);
useEffect(
() => () => {
automaticQueryInitializedRef.current = false;
invalidateListRequest();
},
[],
);
async function refreshPage() {
const query = buildListQuery();
const queryKey = editorAssetListQueryKey(token, query);
const { requestGeneration, abortController } = beginListRequest();
setIsLoading(true);
setIsLoadingMore(false);
setErrorMessage('');
try {
const response = await listAdminEditorAssets(token, buildListQuery());
const response = await listAdminEditorAssets(
token,
query,
abortController.signal,
);
if (listRequestGenerationRef.current !== requestGeneration) {
return;
}
setEntries(response.entries);
setNextCursor(response.nextCursor ?? null);
setCommittedQueryKey(queryKey);
} catch (error: unknown) {
if (
listRequestGenerationRef.current !== requestGeneration ||
isAbortError(error)
) {
return;
}
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
if (listRequestGenerationRef.current === requestGeneration) {
if (listRequestAbortControllerRef.current === abortController) {
listRequestAbortControllerRef.current = null;
}
setIsLoading(false);
}
}
}
async function loadMore() {
if (!nextCursor || isLoadingMore) {
if (!visibleNextCursor || isLoading || isLoadingMore) {
return;
}
const query = buildListQuery();
const queryKey = editorAssetListQueryKey(token, query);
if (committedQueryKey !== queryKey) {
return;
}
const cursor = visibleNextCursor;
const { requestGeneration, abortController } = beginListRequest();
setIsLoadingMore(true);
setErrorMessage('');
try {
const response = await listAdminEditorAssets(token, {
...buildListQuery(),
cursor: nextCursor,
});
const response = await listAdminEditorAssets(
token,
{
...query,
cursor,
},
abortController.signal,
);
if (listRequestGenerationRef.current !== requestGeneration) {
return;
}
setEntries((current) => mergeAssetEntries(current, response.entries));
setNextCursor(response.nextCursor ?? null);
} catch (error: unknown) {
if (
listRequestGenerationRef.current !== requestGeneration ||
isAbortError(error)
) {
return;
}
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoadingMore(false);
if (listRequestGenerationRef.current === requestGeneration) {
if (listRequestAbortControllerRef.current === abortController) {
listRequestAbortControllerRef.current = null;
}
setIsLoadingMore(false);
}
}
}
function beginListRequest() {
invalidateListRequest();
const abortController = new AbortController();
listRequestAbortControllerRef.current = abortController;
const requestGeneration = listRequestGenerationRef.current;
return { requestGeneration, abortController };
}
function invalidateListRequest() {
listRequestGenerationRef.current += 1;
listRequestAbortControllerRef.current?.abort();
listRequestAbortControllerRef.current = null;
}
function buildListQuery(): AdminEditorAssetListQuery {
return {
ownerUserId: ownerUserId || null,
ownerUserId: ownerUserId.trim() || null,
keyword: keyword.trim() || null,
createdAfter: dateInputToStartRfc3339(createdAfter),
createdBefore: dateInputToEndRfc3339(createdBefore),
@@ -143,7 +245,7 @@ export function AdminEditorAssetQueryPage({
/>
</label>
<label className="admin-field">
<span>用户 ID</span>
<span>用户 ID / 陶泥号</span>
<input
value={ownerUserId}
onChange={(event) => setOwnerUserId(event.target.value)}
@@ -173,7 +275,7 @@ export function AdminEditorAssetQueryPage({
</tr>
</thead>
<tbody>
{entries.map((entry) => {
{visibleEntries.map((entry) => {
const promptText = entry.prompt || entry.actualPrompt || '-';
const expanded = expandedTaskIds.has(entry.assetId);
const children = entry.children ?? [];
@@ -318,13 +420,13 @@ export function AdminEditorAssetQueryPage({
</table>
</div>
{!isLoading && entries.length === 0 ? (
{!isLoading && !errorMessage && visibleEntries.length === 0 ? (
<div className="admin-empty-state">暂无生成素材</div>
) : null}
{nextCursor ? (
{visibleNextCursor ? (
<button
className="admin-secondary-button"
disabled={isLoadingMore}
disabled={isLoading || isLoadingMore}
type="button"
onClick={loadMore}
>
@@ -645,6 +747,12 @@ function AdminAssetDetailDialog({
{entry.provider || '-'}
</AdminInfoItem>
<AdminInfoItem label="Task ID">{entry.taskId || '-'}</AdminInfoItem>
{entry.groupTaskId?.trim() &&
entry.groupTaskId.trim() !== entry.taskId?.trim() ? (
<AdminInfoItem label="归组 Task ID">
{entry.groupTaskId.trim()}
</AdminInfoItem>
) : null}
<AdminInfoItem label="Object Key">
{entry.objectKey || '-'}
</AdminInfoItem>
@@ -988,6 +1096,29 @@ function mergeAssetEntries(
);
}
function editorAssetListQueryKey(
token: string,
query: AdminEditorAssetListQuery,
) {
return JSON.stringify([
token.trim(),
query.ownerUserId?.trim() || null,
query.keyword?.trim() || null,
query.createdAfter?.trim() || null,
query.createdBefore?.trim() || null,
query.limit ?? null,
]);
}
function isAbortError(error: unknown) {
return (
typeof error === 'object' &&
error !== null &&
'name' in error &&
error.name === 'AbortError'
);
}
function dateInputToStartRfc3339(value: string) {
return value ? `${value}T00:00:00+08:00` : null;
}
+9 -1
View File
@@ -363,7 +363,7 @@ button:disabled {
.admin-dashboard-retention-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
@@ -2120,6 +2120,14 @@ button:disabled {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-dashboard-retention-card:nth-child(odd) {
border-left: 0;
}
.admin-dashboard-retention-card:nth-child(n + 3) {
border-top: 1px solid #ead8ca;
}
.admin-field-compact {
max-width: none;
}
@@ -184,6 +184,7 @@
- 决策:`陶泥儿精选` 的公开事实改为独立 `editor_showcase_asset` 审核表。生成素材默认不公开;用户在账号级素材库对 `sourceType="generated"` 且有媒体内容的素材提交审核,后端快照素材信息并写入 `pending`。后台审核通过后写入 `approved`,但默认 `display_enabled=false` 且 `showcase_category=null`,运营可按前台具体 Tab 手动设置分类并开启展示;未设置分类的素材展示开启后进入前台“全部”,但不进入角色 / UI / 音乐 / 美宣具体分类。审核通过时按 `generation_cost_mud_points` 返还 50% 泥点;拒绝后写入 `rejected`。公开接口 `GET /api/editor/showcase/resources` 返回已通过、展示开启且媒体非空的快照,按通过时间和 `showcaseId` 倒序分页,并可携带后台配置的固定活动卡。旧 `editor_project_resource.public_showcase_enabled` 和旧 PATCH 接口只保留兼容,不再驱动精选公开。
- 影响范围:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`spacetime-client` 绑定与 mapper、`api-server` 编辑器和后台路由、admin-web 精选审核页、素材库右键菜单、`/creation` 精选瀑布流、图片画布文档和后端表目录。
- 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo check --manifest-path server-rs/Cargo.toml -p spacetime-module -p spacetime-client -p api-server`、前端 / 后台 typecheck 与精选相关组件测试,确认默认不公开、提交后 pending、审核通过后展示和返还、展示开关与点赞生效。
- 后续修正:精选批准、确定性返还流水和返还完成标记必须由同一个 SpacetimeDB procedure 在单事务内落地,失败时不得先留下 `approved`;已公开精选私有对象通过同 owner 的精确 `assetObjectId` / `objectKey` 派生匿名读取授权,不把 `generated-*` 前缀整体公开。
- 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
## 2026-07-03 外部编辑器 API 生成默认写入画布与素材库
@@ -4127,6 +4128,13 @@
- 影响范围:SpacetimeDB Dashboard 聚合 procedure、`spacetime-client` facade、`/admin/api/dashboard` 与 shared contracts、`apps/admin-web` Dashboard 页面和运营文档。
- 验证方式:SpacetimeDB 聚合与 api-server 定向 Rust 测试、Dashboard Vitest、`npm run admin-web:typecheck`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:encoding`、`git diff --check`,并用桌面 / 移动浏览器核对留存、日期、每日 UV、零值与滚动同步。
## 2026-07-15 后台 Dashboard 增加新增用户付费率
- 背景:Dashboard 已能展示筛选时段的新增用户数和 D1 / D7 留存,但缺少同一新增 cohort 的真实付费转化指标。
- 决策:新增用户付费率的分母为筛选期内 `profile_dashboard_state.created_at` 归属的新增用户,分子为其中截至查询时至少有一笔 `profile_recharge_order.paid_at` 的去重用户;已退款仍代表曾发生付费转化,未支付订单不计。
- 聚合边界:继续扩展仅 runtime service identity 可调用的 `get_admin_dashboard_stats_and_return` procedure,不新增持久化表或字段,前端只展示 BFF 返回的付费人数、新增用户数和基点率。
- 验证方式:SpacetimeDB Dashboard 聚合测试、api-server admin 测试、Dashboard Vitest、`npm run admin-web:typecheck`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:encoding`、`git diff --check`,并用桌面 / 移动浏览器核对付费率卡片。
## 2026-07-14 SpacetimeDB 调用池与缓存读连接分离
- 背景:HTTP 角色原先在每个 `GENARRATIVE_SPACETIME_POOL_SIZE` 槽位首次建连时订阅同一批 read model;release 配置为 8 时会保存 8 份相同行缓存和 subscription handles,放大 api-server 内存。
@@ -173,6 +173,19 @@ RAG 主要供 Agent 检索项目上下文,开发者仍按 `AGENTS.md`、`docs/
## 常用检查命令
Rust 格式检查:
```bash
npm run check:rustfmt
```
仓库通过根目录 `rust-toolchain.toml` 固定 Rust `1.96.0` 和 `rustfmt` 组件,
并通过 `rustfmt.toml` 固定 Edition 2024 格式口径。需要修复格式时运行:
```bash
cargo fmt --all --manifest-path server-rs/Cargo.toml
```
- 后端通用用户行为埋点统一通过 `record_tracking_event_and_return` procedure、`SpacetimeRuntimeClient::record_tracking_event(...)` 与 api-server `tracking` 中间件写入 `tracking_event` / `tracking_daily_stat`;后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 默认排除;作品级游玩埋点统一使用 `work_play_start`,详细事件清单见 `docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md`。
编码检查:
@@ -47,6 +47,14 @@
- 验证:`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`。
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/editor_project.rs`、`server-rs/crates/api-server/src/admin.rs`。
## 后台素材查询要在分页前归一用户、游标和派生任务
- 现象:输入 `SY-*` 陶泥号查不到已有素材;无筛选时只显示 80 个任务且没有“读取更多”;手动重拆图集后,`素材 N` 被单独当成零成本父任务,原图集又显示成另一组。
- 原因:`editor_asset.owner_user_id` 保存内部 `user_id`,不保存公开陶泥号;作者陶泥号在 procedure 返回后才映射,不能直接参与素材表过滤。`spacetime-client` 的统一时间文本是 `seconds.microsZ`,若 cursor 只按 RFC3339 解析,第 80 个任务无法生成 `nextCursor`。手动图集拆分使用独立 `editor-atlas-split-*` 任务号,但切片项目资源通过 `source_resource_id` 指回原图集资源,只按当前 `task_id` 分组会割裂同一条素材生产链。
- 处理:`api-server` 在调用素材 procedure 前把“用户 ID / 陶泥号”字段或精确 `SY-*` keyword 解析成内部 `user_id`;游标解析同时接受整数微秒、统一 `seconds.microsZ` 和 RFC3339,编码失败必须返回服务错误,不能伪装成末页,客户端传入的非法 cursor 必须返回 `400`,不能静默回到第一页。手动拆分素材保留真实 `task_id`,通过 `editor_asset_group_source_provenance` 按原 resource、asset object 或 Object Key 查找服务端生成账号素材的可信来源任务,再把来源任务写入 `editor_asset.group_task_id`;跨项目复用时同时携带当前和原始 source resource,首次历史回溯命中后补写 provenance,后续不再扫描账号全量素材。没有可信来源的新批次显式以自己的拆分任务作为 `group_task_id`,不得落回可读取用户资源元数据的 legacy 分支。每片保存 `group_task_expected_asset_count`,全部切片落库后再写不可逆的 `editor_asset_group_cohort` 完成事实;read model 依据完成事实判断批次资格,不用当前剩余行数猜测初始是否完整,因此用户后来删除切片不会让批次脱组。部分失败批次没有完成事实,始终保留为独立拆分任务。历史行兼容沿资源链回溯,删除项目时只固化直接引用待删资源且尚未固化的历史切片;固化始终保存真实来源任务,有界展示分组不得反写覆盖来源。read model 只让同根任务的一个完整拆分批次并入原任务,重复批次按真实拆分任务独立分页。带 owner 条件时先走 `by_editor_asset_owner_user_id`,不要为单用户查询扫描全站素材。后台筛选输入使用防抖并取消旧 transport;手动刷新同一查询失败时保留已有结果和游标,筛选已变化时不展示旧查询结果。
- 验证:API 测试覆盖陶泥号组合条件、未知陶泥号、可信来源归组、`seconds.microsZ` / 极值游标和真实 / 归组 Task ID;SpacetimeDB 测试覆盖资源删除后稳定归组、部分失败批次不抢占根任务、重复拆分有界无丢失和 owner 索引分支;后台页面测试覆盖逐字输入防抖、请求取消、刷新失败保留结果、筛选请求乱序、旧分页响应失效和“用户 ID / 陶泥号”请求。再运行 `cargo test -p api-server admin_editor_asset --manifest-path server-rs/Cargo.toml`、`cargo test -p spacetime-module admin_editor_asset --manifest-path server-rs/Cargo.toml` 和 `npm run test -- apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx`。
- 关联:`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx`、`server-rs/crates/api-server/src/admin.rs`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`。
## 后台素材缩略图不要在首次挂载时全量换签
- 现象:后台“素材查询”首批缩略图正常,继续向下滚动或读取更多后长期显示占位图;api-server journald 中已到达的 `/admin/api/assets/read-url` 可能全部是 `200`。
File diff suppressed because one or more lines are too long
@@ -25,6 +25,7 @@
- 消耗泥点数:`profile_wallet_ledger` 中 `source_type = asset_operation_consume` 且 `amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日。
- 总注册用户:`profile_dashboard_state` 行数。
- 新增用户数:`profile_dashboard_state` 中 `created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月快捷日期范围。
- 新增用户付费率:分母为当前筛选时间窗内的新增用户,分子为这些用户中截至本次查询时已至少完成一次真实支付的去重人数。真实支付以 `profile_recharge_order.paid_at` 存在且不晚于本次查询时刻为准;只创建订单或未支付订单不计,已退款订单仍表示曾经发生过付费转化,因此保留在分子。返回付费人数、新增用户数和四舍五入后的基点率;分母为 0 时 DTO 返回 0,前端百分比显示 `-`。
- 访问次数:`tracking_daily_stat` 中 `scope_kind = site` 的日聚合次数。它表示站点级成功路由 / 站点级事件,不把用户级钱包、任务、生成等业务操作混入访问次数。
- 访问人数:当前数据只具备登录用户维度,按 `tracking_daily_stat` 中 `scope_kind = user`、`count > 0` 且 `scope_id` 非空、非 `anonymous` 的用户去重;匿名访问人数需要未来补充稳定 visitor id 后才能统计。时段指标按整个筛选范围跨日去重,趋势图按 `day_key + scope_id` 每日去重,不能把时段 UV 塞到终止日,也不能把每日 UV 之和当成时段 UV。
- 近 5 分钟活跃用户:以请求时刻为终点,最近 5 分钟内 `tracking_event` 中有 `user_id` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。这是滚动窗口,不是每 5 分钟才采样一次。
File diff suppressed because one or more lines are too long
@@ -233,7 +233,7 @@ npm run codegraph:index
Codex 项目级 hook 已放在 `.codex/config.toml` 与 `.codex/hooks/`:
- `PreToolUse` hook 会在 Codex 准备执行 `git commit` 前运行 `node .codex/hooks/pre-submit-compile-check.mjs`,依次执行 `npm run typecheck`、`npm run admin-web:typecheck`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`,发现编译错误会阻止本次提交。
- `PreToolUse` hook 会在 Codex 准备执行 `git commit` 前运行 `node .codex/hooks/pre-submit-compile-check.mjs`,依次执行 `npm run check:rustfmt`、`npm run typecheck`、`npm run admin-web:typecheck`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`,发现格式或编译错误会阻止本次提交。
- `PostToolUse` hook 会在 Codex 工具修改文件后运行 `node .codex/hooks/post-edit-codegraph-sync.mjs`,执行 `npm run codegraph:sync` 刷新本地语义索引。
- 如果某个 Codex 客户端版本尚未自动加载项目级 hook,可先手动运行 `node .codex/hooks/pre-submit-compile-check.mjs` 与 `node .codex/hooks/post-edit-codegraph-sync.mjs`;个人模型、token、MCP server 仍放在个人 `~/.codex/config.toml`,不要提交。
@@ -243,6 +243,7 @@ Codex 项目级 hook 已放在 `.codex/config.toml` 与 `.codex/hooks/`:
后端代码修改后,按变更范围选择:
- `npm run check:rustfmt`
- `cargo test -p <crate> --manifest-path server-rs/Cargo.toml`
- `cargo test -p platform-image --manifest-path server-rs/Cargo.toml`
- `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
@@ -265,6 +266,12 @@ npm run spacetime:generate
npm run check:spacetime-schema
```
仓库根目录的 `rust-toolchain.toml` 固定 Rust `1.96.0` 并要求 `rustfmt` 组件,
`rustfmt.toml` 固定 Edition 2024 的格式化口径。Rust 源码统一使用
`cargo fmt --all --manifest-path server-rs/Cargo.toml` 格式化,并用
`npm run check:rustfmt` 做只读校验;Codex 提交前门禁、API 生产构建和
SpacetimeDB module 生产构建都会执行同一检查,避免不同开发机或构建节点反复产生格式差异。
## 前端改动验收
前端修改后,根据范围选择:
@@ -75,7 +75,7 @@
`陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营可在后台按前台具体 Tab 设置分类(角色、UI、音乐、美宣)并开启展示,未设置分类的素材不会隐藏,会进入前台“全部”。旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才可进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。
精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营可在后台按前台具体 Tab 设置分类(角色、UI、音乐、美宣)并开启展示,未设置分类的素材不会隐藏,会进入前台“全部”。旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才可进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等;批准状态、钱包返还流水和 `refund_completed_at` 必须在同一个 SpacetimeDB 事务中完成,任一步失败都保持待审核,历史已通过但未返还记录重复批准时按同一流水补齐且不得重复入账。已通过、已开启展示且返还完成的精选快照必须按同 owner 的精确 `assetObjectId` 或 `objectKey` 派生匿名读取授权,不能放开整个 `generated-*` 前缀。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。
瀑布流通过 `GET /api/editor/showcase/resources` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有瀑布流,而不是固定只展示首屏数量。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选。
@@ -12,7 +12,7 @@
旧库或旧迁移包没有 `event_banners_json` 时,后端读取层必须把 `eventBanners` 归一到 `module-runtime` 默认公告数组,不能把旧结构化 `eventBanner` 当成前端优先数组下发。默认公告引用的背景图必须指向 `public/` 下真实存在的站内静态资源,当前默认使用 `/creation-type-references/puzzle.webp`,避免创作入口顶部 banner 出现失效图片。
创作页和草稿页顶栏右上角统一复用公共泥点资产入口,不再把余额区本身作为直接充值按钮。余额区展开后只展示不限时泥点、每日免费泥点及重置口径;会员周期限时泥点仅由后端保留用于存量兼容和结算,当前版本不在前台展示。独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。主站各位置必须保持同一组件、数据口径和交互语义,头像 / 设置等账号入口继续保留各自语义。
创作页和草稿页顶栏右上角统一复用公共泥点资产入口,不再把余额区本身作为直接充值按钮。顶部总余额优先展示会随扣费、退回和到账刷新的 `profileDashboard.walletBalance`,充值中心的 `mudPointBalance` 只承接展开面板中的不限时 / 每日免费泥点明细,不得用已缓存的明细总数覆盖已刷新的顶部余额。余额区展开后只展示不限时泥点、每日免费泥点及重置口径;会员周期限时泥点仅由后端保留用于存量兼容和结算,当前版本不在前台展示。独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。主站各位置必须保持同一组件、数据口径和交互语义,头像 / 设置等账号入口继续保留各自语义。
创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。平台入口刷新直达时,路径到玩法恢复目标、四个 query 归一化、生成页标记、大鱼吃小鱼 workId 兜底、作品 / 草稿身份匹配和跳一跳 / 敲木鱼恢复阶段落点统一由 `platformCreationUrlStateModel.ts` 解析,壳层只执行读取作品、恢复草稿和切换阶段等副作用。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。
+1
View File
@@ -125,6 +125,7 @@ pipeline {
echo "[api-build] 未找到 sccache,改用 rustc 直接构建。"
unset RUSTC_WRAPPER
fi
npm run check:rustfmt
pingora_args=()
if [[ "${INCLUDE_PINGORA_GATEWAY:-false}" == "true" ]]; then
pingora_args+=(--include-pingora-gateway)
@@ -129,6 +129,7 @@ pipeline {
echo "[stdb-build] 未找到可用 sccache,改用 rustc 直接构建。"
unset RUSTC_WRAPPER
fi
npm run check:rustfmt
SOURCE_BRANCH="${SOURCE_BRANCH}" SOURCE_COMMIT="${SOURCE_COMMIT}" \
npm run build:production-release -- --component spacetime-module --name "${EFFECTIVE_BUILD_VERSION}"
'
+2 -1
View File
@@ -31,6 +31,7 @@
"preview": "node scripts/vite-cli.mjs preview",
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"check:encoding": "node scripts/check-encoding.mjs",
"check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check",
"check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs",
"check:production-ops": "node scripts/check-production-ops-guardrails.mjs",
"check:maintenance-page": "node scripts/check-maintenance-page.mjs",
@@ -84,7 +85,7 @@
"lint:guardrails": "npm run lint:eslint",
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
"typecheck:guardrails": "npm run typecheck",
"lint": "npm run check:encoding && npm run check:spacetime-schema && npm run check:production-ops && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck",
"lint": "npm run check:encoding && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:production-ops && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .",
"format": "prettier --write .",
"format:check": "prettier --check .",
@@ -1,6 +1,12 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
@@ -30,7 +36,7 @@ test('shows only permanent and daily free points in the shared wallet panel', as
render(
<PlatformMudPointWalletEntry
balance={999}
balance={207}
breakdown={breakdown}
onRequestDetails={onRequestDetails}
onRecharge={onRecharge}
@@ -60,6 +66,25 @@ test('shows only permanent and daily free points in the shared wallet panel', as
expect(onRecharge).toHaveBeenCalledTimes(1);
});
test('keeps the chip on the live balance when cached details are stale', () => {
const props = {
breakdown,
onRequestDetails: vi.fn(),
onRecharge: vi.fn(),
onOpenLedger: vi.fn(),
};
const { rerender } = render(
<PlatformMudPointWalletEntry balance={207} {...props} />,
);
expect(screen.getByRole('button', { name: '泥点 207' })).toBeTruthy();
rerender(<PlatformMudPointWalletEntry balance={195} {...props} />);
expect(screen.getByRole('button', { name: '泥点 195' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '泥点 207' })).toBeNull();
});
test('keeps the desktop panel open while moving across the gap without click pinning', () => {
vi.useFakeTimers();
@@ -67,7 +67,7 @@ export function PlatformMudPointWalletEntry({
const closeTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const isCompact = variant === 'mobile';
const displayedBalance = breakdown?.totalPoints ?? balance;
const displayedBalance = balance ?? breakdown?.totalPoints ?? null;
const balanceLabel =
displayedBalance === null
? '--'
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "1.96.0"
profile = "minimal"
components = ["rustfmt"]
+4
View File
@@ -0,0 +1,4 @@
edition = "2024"
max_width = 100
newline_style = "Unix"
use_small_heuristics = "Default"
@@ -3,6 +3,21 @@
import { readFileSync } from 'node:fs';
const checks = [
{
file: 'package.json',
includes: '"check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check"',
reason: '仓库必须保留统一、只读的 Rust workspace 格式检查入口。',
},
{
file: 'jenkins/Jenkinsfile.production-api-build',
includes: 'npm run check:rustfmt',
reason: 'API 生产构建必须在编译前执行 Rust 格式检查。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
includes: 'npm run check:rustfmt',
reason: 'Stdb module 生产构建必须在编译前执行 Rust 格式检查。',
},
{
file: 'server-rs/crates/spacetime-module/src/migration.rs',
includes:

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