Files
Genarrative/scripts/spacetime-clean-editor-image-asset-kind.mjs
k88936 8abeb333bb
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
统一画布快速编辑白名单与入口行为 (#140)
- 使用白名单控制快速编辑功能的显示

- 同步浮动工具栏、右键菜单、打开入口和提交门禁
- 禁用角色动作序列帧(整体)、音频和单个图标(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>
2026-08-12 14:46:24 +08:00

265 lines
8.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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;
});
}