统一画布快速编辑白名单与入口行为 (#140)
Project CI / Repository checks (push) Successful in 56s
Project CI / Frontend tests (push) Successful in 4m1s
Project CI / Backend tests (push) Successful in 4m26s
Project CI / Native shell tests (push) Successful in 15m12s

- 使用白名单控制快速编辑功能的显示

- 同步浮动工具栏、右键菜单、打开入口和提交门禁
- 禁用角色动作序列帧(整体)、音频和单个图标(explain: backend reject)的快速编辑

- 把快速编辑api改为只接受resource Id | asset Id,删除 image_src 参数, 以保证校验

其中包含生产数据迁移。迁移用于清理历史普通图片错误持久化的 assetKind="image",覆盖 asset → project-resource → showcase → canvas 四个作用域,并包含 operator 鉴权、分批 dry-run、批次 SHA-256 绑定 apply、active 结构化画
  > 布迁移摘要同步及最终零残留复核。

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/140
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #140.
This commit is contained in:
2026-08-12 14:46:24 +08:00
committed by 段舒康
parent 4c3c0e9cd4
commit 8abeb333bb
61 changed files with 11979 additions and 688 deletions
+1
View File
@@ -558,6 +558,7 @@ copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET
copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本"
copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本"
copy_required_file "${SCRIPT_DIR}/spacetime-maintain-external-generation-jobs.mjs" "${TARGET_DIR}/scripts/spacetime-maintain-external-generation-jobs.mjs" "外部生成任务维护脚本"
copy_required_file "${SCRIPT_DIR}/spacetime-clean-editor-image-asset-kind.mjs" "${TARGET_DIR}/scripts/spacetime-clean-editor-image-asset-kind.mjs" "普通图片素材类型清理脚本"
copy_required_file "${SCRIPT_DIR}/spacetime-normalize-editor-character-actions.mjs" "${TARGET_DIR}/scripts/spacetime-normalize-editor-character-actions.mjs" "角色动作元数据规范化脚本"
copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本"
copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本"
@@ -651,6 +651,21 @@ const checks = [
includes: "await scanScopes(options, { verifyZero: true })",
reason: '角色动作规范化 apply 后必须执行全量零匹配复核。',
},
{
file: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
includes: "const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas']",
reason: '普通图片错误素材类型必须覆盖三张业务表和持久化画布副本。',
},
{
file: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
includes: 'expectedBatchSha256: dryRun.batch_sha256',
reason: '普通图片素材类型 apply 必须绑定同批 dry-run 返回的摘要。',
},
{
file: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
includes: "await scanScopes(options, { verifyZero: true })",
reason: '普通图片素材类型清理 apply 后必须执行全量零匹配复核。',
},
{
file: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
includes: 'buildProcedureInput(canvas, updatedAtMicros, !options.apply)',
@@ -686,6 +701,16 @@ const checks = [
includes: 'scripts/spacetime-normalize-editor-character-actions.mjs',
reason: 'Stdb Publish 必须从同一上游制品复制角色动作元数据规范化脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
includes: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
reason: 'Stdb Build 必须归档普通图片素材类型清理脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
includes: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
reason: 'Stdb Publish 必须从同一上游制品复制普通图片素材类型清理脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
includes: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
@@ -0,0 +1,264 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import {
callSpacetimeProcedure,
callSpacetimeProcedureViaCli,
encodeSpacetimeCliOption,
ensureProcedureOk,
parsePositiveInteger,
} from './spacetime-migration-common.mjs';
const PROCEDURE_NAME = 'clean_editor_image_asset_kind_and_return';
const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas'];
const DEFAULT_CHUNK_SIZE = 25;
const CANVAS_MAX_CHUNK_SIZE = 5;
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
function sha256(value) {
return createHash('sha256').update(value).digest('hex');
}
function usage() {
return `用法:
node scripts/spacetime-clean-editor-image-asset-kind.mjs \\
--database <name> --server <name-or-url> [--chunk-size <1-25>] [--apply]
默认按 asset、project-resource、showcase、canvas 的固定顺序执行全量 dry-run,不修改数据。
脚本只把业务 assetKind 精确等于 "image" 的旧值清为空;不会修改 MIME、媒体类型或 asset_object.asset_kind。
追加 --apply 后,每批仍会先 dry-run;只有 blocker 为零,才携带该批返回的 SHA-256 立即 apply。
apply 完成后脚本会再次从头 dry-run,要求四个 scope 的 matched/blocker 均为零。
必须使用已授权 database migration operator 的 spacetime CLI 登录态,并显式指定 server。`;
}
export function parseOptions(argv, env = process.env) {
const options = {
apply: false,
chunkSize: DEFAULT_CHUNK_SIZE,
database: env.GENARRATIVE_SPACETIME_DATABASE || '',
passthrough: [],
server: env.GENARRATIVE_SPACETIME_SERVER || '',
serverUrl: env.GENARRATIVE_SPACETIME_SERVER_URL || '',
token: env.GENARRATIVE_SPACETIME_TOKEN || '',
useHttp: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = () => {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${arg} 缺少参数值。`);
}
index += 1;
return value.trim();
};
if (arg === '--database') {
options.database = readValue();
} else if (arg === '--server') {
options.server = readValue();
} else if (arg === '--server-url') {
options.serverUrl = readValue();
} else if (arg === '--token') {
options.token = readValue();
} else if (arg === '--chunk-size') {
options.chunkSize = parsePositiveInteger(readValue(), arg);
} else if (arg === '--apply') {
options.apply = true;
} else if (arg === '--use-http') {
options.useHttp = true;
} else if (arg === '--no-config' || arg === '--anonymous') {
options.passthrough.push(arg);
} else if (arg === '--help' || arg === '-h') {
options.help = true;
} else {
throw new Error(`未知参数: ${arg}`);
}
}
if (options.chunkSize > DEFAULT_CHUNK_SIZE) {
throw new Error(`--chunk-size 不能超过 ${DEFAULT_CHUNK_SIZE}`);
}
return options;
}
export function buildCleanupInput({
scope,
cursor = null,
limit,
dryRun,
expectedBatchSha256 = null,
}) {
if (!SCOPES.includes(scope)) {
throw new Error(`未知普通图片 assetKind 清理 scope: ${scope}`);
}
if (!Number.isInteger(limit) || limit < 1) {
throw new Error('普通图片 assetKind 清理 limit 必须是正整数。');
}
if (scope === 'canvas' && limit > CANVAS_MAX_CHUNK_SIZE) {
throw new Error(`canvas scope limit 不能超过 ${CANVAS_MAX_CHUNK_SIZE}`);
}
if (!dryRun && !SHA256_PATTERN.test(expectedBatchSha256 || '')) {
throw new Error('apply 必须绑定 dry-run 返回的 64 位 batch SHA-256。');
}
return {
scope,
cursor: encodeSpacetimeCliOption(cursor),
limit,
dry_run: dryRun,
expected_batch_sha_256: encodeSpacetimeCliOption(
dryRun ? null : expectedBatchSha256,
),
};
}
function scopeLimit(scope, chunkSize) {
return scope === 'canvas'
? Math.min(chunkSize, CANVAS_MAX_CHUNK_SIZE)
: chunkSize;
}
function assertSafeBatch(result, scope) {
ensureProcedureOk(result);
if (result.scope !== scope) {
throw new Error(`procedure 返回 scope ${result.scope},预期为 ${scope}`);
}
if (result.blocker_count !== 0 || result.blocker_samples.length !== 0) {
throw new Error(`${scope} scope 存在 ${result.blocker_count} 个 blocker。`);
}
if (!SHA256_PATTERN.test(result.batch_sha256 || '')) {
throw new Error(`${scope} scope 未返回有效的 batch SHA-256。`);
}
}
async function callBatch(options, input) {
return options.useHttp
? callSpacetimeProcedure(options, PROCEDURE_NAME, input)
: callSpacetimeProcedureViaCli(options, PROCEDURE_NAME, input);
}
export async function scanScopes(
options,
{ apply = false, verifyZero = false, callProcedure = callBatch } = {},
) {
const summaries = [];
for (const scope of SCOPES) {
let cursor = null;
const seenCursors = new Set();
const summary = {
scope,
scanned_count: 0,
matched_count: 0,
updated_count: 0,
cleaned_field_count: 0,
batches: 0,
};
do {
const limit = scopeLimit(scope, options.chunkSize);
const dryRun = await callProcedure(
options,
buildCleanupInput({ scope, cursor, limit, dryRun: true }),
);
assertSafeBatch(dryRun, scope);
summary.scanned_count += dryRun.scanned_count;
summary.matched_count += dryRun.matched_count;
summary.cleaned_field_count += dryRun.cleaned_field_count;
summary.batches += 1;
if (verifyZero && dryRun.matched_count !== 0) {
throw new Error(
`${scope} scope apply 后复核仍有 ${dryRun.matched_count} 行待清理。`,
);
}
if (apply && dryRun.matched_count > 0) {
const applied = await callProcedure(
options,
buildCleanupInput({
scope,
cursor,
limit,
dryRun: false,
expectedBatchSha256: dryRun.batch_sha256,
}),
);
assertSafeBatch(applied, scope);
if (applied.batch_sha256 !== dryRun.batch_sha256) {
throw new Error(`${scope} scope apply 返回的 batch SHA-256 与 dry-run 不一致。`);
}
if (applied.updated_count !== dryRun.matched_count) {
throw new Error(
`${scope} scope apply 更新 ${applied.updated_count} 行,dry-run 匹配 ${dryRun.matched_count} 行。`,
);
}
if (applied.cleaned_field_count !== dryRun.cleaned_field_count) {
throw new Error(`${scope} scope apply 返回的清理字段数与 dry-run 不一致。`);
}
summary.updated_count += applied.updated_count;
}
const nextCursor = dryRun.has_more ? dryRun.next_cursor : null;
if (dryRun.has_more && !nextCursor) {
throw new Error(`${scope} scope 声明 has_more 但未返回 next_cursor。`);
}
if (nextCursor && seenCursors.has(nextCursor)) {
throw new Error(
`${scope} scope 返回了重复的 next_cursorSHA-256: ${sha256(nextCursor)})。`,
);
}
if (nextCursor) {
seenCursors.add(nextCursor);
}
cursor = nextCursor;
} while (cursor);
summaries.push(summary);
}
return summaries;
}
export async function main(argv = process.argv.slice(2)) {
const options = parseOptions(argv);
if (options.help) {
console.log(usage());
return;
}
if (!options.database) {
throw new Error('必须显式传入 --database。');
}
if (!options.server && !options.serverUrl) {
throw new Error('必须显式传入 --server / --server-url,不使用默认 cloud target。');
}
if (options.useHttp && !options.token) {
throw new Error('--use-http 需要通过 --token 或 GENARRATIVE_SPACETIME_TOKEN 提供身份。');
}
const migration = await scanScopes(options, { apply: options.apply });
const verification = options.apply
? await scanScopes(options, { verifyZero: true })
: null;
console.log(
JSON.stringify(
{
procedure: PROCEDURE_NAME,
applied: options.apply,
scope_order: SCOPES,
migration,
verification,
},
null,
2,
),
);
if (!options.apply) {
console.log('全量 dry-run 已通过;确认输出后追加 --apply 重跑。');
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
main().catch((error) => {
console.error(
`[spacetime:editor-image-asset-kind:clean] ${
error instanceof Error ? error.message : String(error)
}`,
);
process.exitCode = 1;
});
}
@@ -0,0 +1,201 @@
import { describe, expect, it } from 'vitest';
import {
buildCleanupInput,
parseOptions,
scanScopes,
} from './spacetime-clean-editor-image-asset-kind.mjs';
describe('普通图片 assetKind 清理脚本', () => {
it('默认 dry-run 并要求调用方显式选择 apply', () => {
expect(
parseOptions(
['--database', 'genarrative-prod', '--server', 'prod'],
{},
),
).toMatchObject({
apply: false,
chunkSize: 25,
database: 'genarrative-prod',
server: 'prod',
});
});
it('编码 cursor、限制 canvas 批量并要求 apply hash', () => {
expect(
buildCleanupInput({
scope: 'asset',
cursor: 'asset-25',
limit: 25,
dryRun: true,
}),
).toEqual({
scope: 'asset',
cursor: [0, 'asset-25'],
limit: 25,
dry_run: true,
expected_batch_sha_256: null,
});
expect(() =>
buildCleanupInput({ scope: 'canvas', limit: 6, dryRun: true }),
).toThrow('canvas scope limit');
expect(() =>
buildCleanupInput({ scope: 'showcase', limit: 25, dryRun: false }),
).toThrow('batch SHA-256');
});
it('按固定 scope 顺序执行 hash 绑定 apply 并保留字段计数', async () => {
const calls: Array<Record<string, unknown>> = [];
const callProcedure = async (
_options: Record<string, unknown>,
input: Record<string, unknown>,
) => {
calls.push(input);
const dryRun = input.dry_run === true;
const scope = String(input.scope);
const hashDigit = {
asset: 'a',
'project-resource': 'b',
showcase: 'c',
canvas: 'd',
}[scope]!;
return {
ok: true,
scope,
dry_run: dryRun,
scanned_count: 1,
matched_count: 1,
updated_count: dryRun ? 0 : 1,
cleaned_field_count: scope === 'canvas' ? 3 : 1,
blocker_count: 0,
blocker_samples: [],
next_cursor: null,
has_more: false,
batch_sha256: hashDigit.repeat(64),
error_message: null,
};
};
const summaries = await scanScopes(
{ chunkSize: 25 },
{ apply: true, callProcedure },
);
expect(summaries.map((summary) => summary.scope)).toEqual([
'asset',
'project-resource',
'showcase',
'canvas',
]);
expect(calls.map((call) => `${call.scope}:${call.dry_run}`)).toEqual([
'asset:true',
'asset:false',
'project-resource:true',
'project-resource:false',
'showcase:true',
'showcase:false',
'canvas:true',
'canvas:false',
]);
expect(summaries.at(-1)?.cleaned_field_count).toBe(3);
expect(calls.at(-2)?.limit).toBe(5);
for (let index = 1; index < calls.length; index += 2) {
expect(calls[index]?.expected_batch_sha_256).toEqual([
0,
String(calls[index - 1]?.scope === 'asset'
? 'a'
: calls[index - 1]?.scope === 'project-resource'
? 'b'
: calls[index - 1]?.scope === 'showcase'
? 'c'
: 'd').repeat(64),
]);
}
});
it('拒绝 apply 后计数漂移与复核残留', async () => {
const driftingCall = async (
_options: Record<string, unknown>,
input: Record<string, unknown>,
) => ({
ok: true,
scope: input.scope,
dry_run: input.dry_run,
scanned_count: 1,
matched_count: 1,
updated_count: input.dry_run ? 0 : 1,
cleaned_field_count: input.dry_run ? 2 : 1,
blocker_count: 0,
blocker_samples: [],
next_cursor: null,
has_more: false,
batch_sha256: 'a'.repeat(64),
error_message: null,
});
await expect(
scanScopes(
{ chunkSize: 25 },
{ apply: true, callProcedure: driftingCall },
),
).rejects.toThrow('清理字段数');
const residualCall = async (
_options: Record<string, unknown>,
input: Record<string, unknown>,
) => ({
ok: true,
scope: input.scope,
dry_run: true,
scanned_count: 1,
matched_count: 1,
updated_count: 0,
cleaned_field_count: 1,
blocker_count: 0,
blocker_samples: [],
next_cursor: null,
has_more: false,
batch_sha256: 'b'.repeat(64),
error_message: null,
});
await expect(
scanScopes(
{ chunkSize: 25 },
{ verifyZero: true, callProcedure: residualCall },
),
).rejects.toThrow('apply 后复核仍有');
});
it('报告游标循环时不泄露原始标识', async () => {
const privateCursor = 'private-asset-id';
const loopingCall = async (
_options: Record<string, unknown>,
input: Record<string, unknown>,
) => ({
ok: true,
scope: input.scope,
dry_run: true,
scanned_count: 1,
matched_count: 0,
updated_count: 0,
cleaned_field_count: 0,
blocker_count: 0,
blocker_samples: [],
next_cursor: privateCursor,
has_more: true,
batch_sha256: 'c'.repeat(64),
error_message: null,
});
let message = '';
try {
await scanScopes(
{ chunkSize: 25 },
{ callProcedure: loopingCall },
);
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toContain('SHA-256');
expect(message).not.toContain(privateCursor);
});
});
+25 -2
View File
@@ -294,8 +294,10 @@ function normalizeProcedureResult(value, procedureName) {
function normalizeSatsObject(value, procedureName) {
const normalized = normalizeSatsValue(value);
if (
procedureName !==
'normalize_editor_character_animation_metadata_and_return' ||
![
'normalize_editor_character_animation_metadata_and_return',
'clean_editor_image_asset_kind_and_return',
].includes(procedureName) ||
!normalized ||
typeof normalized !== 'object' ||
Array.isArray(normalized)
@@ -311,6 +313,27 @@ function normalizeSatsObject(value, procedureName) {
}
function normalizeSatsProduct(value, procedureName) {
if (
procedureName === 'clean_editor_image_asset_kind_and_return' &&
value.length === 13
) {
return {
ok: normalizeSatsValue(value[0]),
scope: normalizeSatsValue(value[1]),
dry_run: normalizeSatsValue(value[2]),
scanned_count: normalizeSatsValue(value[3]),
matched_count: normalizeSatsValue(value[4]),
updated_count: normalizeSatsValue(value[5]),
cleaned_field_count: normalizeSatsValue(value[6]),
blocker_count: normalizeSatsValue(value[7]),
blocker_samples: normalizeSatsValue(value[8]),
next_cursor: normalizeSatsOption(value[9]),
has_more: normalizeSatsValue(value[10]),
batch_sha256: normalizeSatsValue(value[11]),
error_message: normalizeSatsOption(value[12]),
};
}
if (
procedureName === 'normalize_editor_character_animation_metadata_and_return' &&
value.length === 19
@@ -174,4 +174,53 @@ describe('SpacetimeDB CLI SATS option encoding', () => {
});
expect(result).not.toHaveProperty('batch_sha_256');
});
it('normalizes ordinary image assetKind cleanup results', () => {
const tupleResult = parseProcedureResult(
JSON.stringify([
true,
'canvas',
true,
5,
2,
0,
4,
0,
[],
[0, 'canvas-5'],
true,
'c'.repeat(64),
[1],
]),
'clean_editor_image_asset_kind_and_return',
);
expect(tupleResult).toMatchObject({
scope: 'canvas',
cleaned_field_count: 4,
next_cursor: 'canvas-5',
batch_sha256: 'c'.repeat(64),
error_message: null,
});
const objectResult = parseProcedureResult(
JSON.stringify({
ok: true,
scope: 'asset',
dry_run: true,
scanned_count: 1,
matched_count: 1,
updated_count: 0,
cleaned_field_count: 1,
blocker_count: 0,
blocker_samples: [],
next_cursor: null,
has_more: false,
batch_sha_256: 'd'.repeat(64),
error_message: null,
}),
'clean_editor_image_asset_kind_and_return',
);
expect(objectResult.batch_sha256).toBe('d'.repeat(64));
expect(objectResult).not.toHaveProperty('batch_sha_256');
});
});