8f19964e3f
Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/124 Reviewed-by: 段舒康 <kdletters@qq.com> Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
204 lines
7.4 KiB
TypeScript
204 lines
7.4 KiB
TypeScript
import { hydratePerfectPixelOperation } from './ImageCanvasEditorModel';
|
|
import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes';
|
|
|
|
/**
|
|
* 中文注释:完美像素操作账本的本机存储。
|
|
*
|
|
* **这是明确设计,不是降级方案**:账本记录的是「本机这次会话发出过哪一次 POST」,
|
|
* 它是对账凭据,不是用户的画布内容,因此不进项目布局。由此得到两条硬性质:
|
|
*
|
|
* 1. **写入同步、不依赖网络、不依赖服务端校验。** 发 POST 前先落本机账本即可获得
|
|
* 「请求可被追溯」的保证,不必再用严格布局保存去换同一个保证。布局校验(例如
|
|
* 资源元数据读写不对称)从此不可能阻断完美像素的发起或重试。
|
|
* 2. **账本缺失只降级、绝不阻断。** 换设备、换浏览器、清缓存、隐私模式、配额写满,
|
|
* 都会读不到账本。那种情况下占位收口为可删除的失败态,用户可以删掉重来;
|
|
* 任何路径都不得因为「读不到账本」而拒绝用户发起、重试或删除。
|
|
*
|
|
* 代价是跨设备不再自动收口:在 A 机发起、到 B 机打开同一项目时,B 机看到的是失败占位
|
|
* 而不是对账中的占位。完美像素是免费同步操作,重做成本极低,用这点换掉「用户数据里
|
|
* 混着系统对账状态」的耦合是划算的。
|
|
*/
|
|
|
|
const PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX =
|
|
'genarrative.imageCanvas.perfectPixelOperations';
|
|
|
|
/**
|
|
* 中文注释:账本保留期。对账窗口只有 75 秒,但 `pending-confirmation` 占位允许用户在很久
|
|
* 之后手动重试同一次 operation,那条路径同样需要账本,所以保留期必须远长于对账窗口。
|
|
*/
|
|
export const PERFECT_PIXEL_OPERATION_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
|
|
/**
|
|
* 中文注释:单个项目最多保留的账本条数,超出时丢弃最旧的。防止长期使用把 localStorage
|
|
* 配额吃满——配额写满会连带影响同域下其它本地缓存,而账本本身是可丢弃的。
|
|
*/
|
|
export const PERFECT_PIXEL_OPERATION_RETENTION_LIMIT = 32;
|
|
|
|
type PerfectPixelOperationLedger = Map<string, PerfectPixelOperationSnapshot>;
|
|
|
|
function getPerfectPixelOperationStorage() {
|
|
if (typeof window === 'undefined') {
|
|
return null;
|
|
}
|
|
try {
|
|
return window.localStorage;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function perfectPixelOperationStorageKey(projectId: string | null | undefined) {
|
|
const normalizedProjectId = projectId?.trim();
|
|
if (!normalizedProjectId) {
|
|
return null;
|
|
}
|
|
return `${PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX}.${normalizedProjectId}`;
|
|
}
|
|
|
|
/**
|
|
* 中文注释:读账本时同时校验归属。同一台机器可能先后登录不同账号,账本里带着上一个
|
|
* 账号的请求(含源图直传地址),换人后必须整条丢弃而不是原样返回。
|
|
*/
|
|
function readLedgerEntries(
|
|
storage: Storage,
|
|
key: string,
|
|
ownerUserId: string,
|
|
): PerfectPixelOperationLedger {
|
|
const ledger: PerfectPixelOperationLedger = new Map();
|
|
const rawValue = storage.getItem(key);
|
|
if (!rawValue) {
|
|
return ledger;
|
|
}
|
|
const parsedValue: unknown = JSON.parse(rawValue);
|
|
if (!parsedValue || typeof parsedValue !== 'object') {
|
|
return ledger;
|
|
}
|
|
const record = parsedValue as Record<string, unknown>;
|
|
const recordOwnerUserId =
|
|
typeof record.ownerUserId === 'string' ? record.ownerUserId.trim() : '';
|
|
if (recordOwnerUserId !== ownerUserId) {
|
|
storage.removeItem(key);
|
|
return ledger;
|
|
}
|
|
const operations = record.operations;
|
|
if (!operations || typeof operations !== 'object') {
|
|
return ledger;
|
|
}
|
|
const now = Date.now();
|
|
for (const [operationId, value] of Object.entries(
|
|
operations as Record<string, unknown>,
|
|
)) {
|
|
// 中文注释:本机账本与布局快照走同一套 v1 白名单校验。存储可被用户或其它脚本改写,
|
|
// 任何字段漂移都必须失败关闭——绝不能据一份可疑账本重放 POST。
|
|
const operation = hydratePerfectPixelOperation(value, operationId);
|
|
if (!operation) {
|
|
continue;
|
|
}
|
|
if (now - operation.submittedAt > PERFECT_PIXEL_OPERATION_RETENTION_MS) {
|
|
continue;
|
|
}
|
|
ledger.set(operationId, operation);
|
|
}
|
|
return ledger;
|
|
}
|
|
|
|
function writeLedgerEntries(
|
|
storage: Storage,
|
|
key: string,
|
|
ownerUserId: string,
|
|
ledger: PerfectPixelOperationLedger,
|
|
) {
|
|
if (ledger.size === 0) {
|
|
storage.removeItem(key);
|
|
return;
|
|
}
|
|
const retained = [...ledger.values()]
|
|
.sort((left, right) => right.submittedAt - left.submittedAt)
|
|
.slice(0, PERFECT_PIXEL_OPERATION_RETENTION_LIMIT);
|
|
storage.setItem(
|
|
key,
|
|
JSON.stringify({
|
|
ownerUserId,
|
|
operations: Object.fromEntries(
|
|
retained.map((operation) => [operation.operationId, operation]),
|
|
),
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 中文注释:读取某项目在本机的全部有效账本。任何异常都返回空账本——读不到账本只意味着
|
|
* 「刷新后不能自动收口」,调用方必须能在空账本下继续工作。
|
|
*/
|
|
export function readPerfectPixelOperations(
|
|
currentUserId: string | null | undefined,
|
|
projectId: string | null | undefined,
|
|
): PerfectPixelOperationLedger {
|
|
const ownerUserId = currentUserId?.trim();
|
|
const key = perfectPixelOperationStorageKey(projectId);
|
|
const storage = getPerfectPixelOperationStorage();
|
|
if (!ownerUserId || !key || !storage) {
|
|
return new Map();
|
|
}
|
|
try {
|
|
return readLedgerEntries(storage, key, ownerUserId);
|
|
} catch {
|
|
return new Map();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 中文注释:写入一条账本。必须在发 POST 之前调用——这是整条链路里唯一「请求已发出」的
|
|
* 本地证据。写入失败(配额、隐私模式)同样不阻断:调用方照常发 POST,只是丢掉刷新后
|
|
* 自动收口的能力。
|
|
*/
|
|
export function savePerfectPixelOperation(
|
|
currentUserId: string | null | undefined,
|
|
projectId: string | null | undefined,
|
|
operation: PerfectPixelOperationSnapshot,
|
|
) {
|
|
const ownerUserId = currentUserId?.trim();
|
|
const key = perfectPixelOperationStorageKey(projectId);
|
|
const storage = getPerfectPixelOperationStorage();
|
|
if (!ownerUserId || !key || !storage) {
|
|
return;
|
|
}
|
|
if (operation.request.projectId !== projectId?.trim()) {
|
|
return;
|
|
}
|
|
try {
|
|
const ledger = readLedgerEntries(storage, key, ownerUserId);
|
|
ledger.set(operation.operationId, operation);
|
|
writeLedgerEntries(storage, key, ownerUserId, ledger);
|
|
} catch {
|
|
// 中文注释:账本是尽力而为的本机便利,写失败不得影响本次提交。
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 中文注释:操作收口(结果已套用 / 只落素材库 / 快照判定失效)后清账本,避免过期条目
|
|
* 在下次加载时再发一次无谓的对账 GET。
|
|
*/
|
|
export function forgetPerfectPixelOperation(
|
|
currentUserId: string | null | undefined,
|
|
projectId: string | null | undefined,
|
|
operationId: string,
|
|
) {
|
|
const ownerUserId = currentUserId?.trim();
|
|
const key = perfectPixelOperationStorageKey(projectId);
|
|
const storage = getPerfectPixelOperationStorage();
|
|
const normalizedOperationId = operationId.trim();
|
|
if (!ownerUserId || !key || !storage || !normalizedOperationId) {
|
|
return;
|
|
}
|
|
try {
|
|
const ledger = readLedgerEntries(storage, key, ownerUserId);
|
|
if (!ledger.delete(normalizedOperationId)) {
|
|
return;
|
|
}
|
|
writeLedgerEntries(storage, key, ownerUserId, ledger);
|
|
} catch {
|
|
// 中文注释:清理失败最多留下一条过期账本,保留期会兜底。
|
|
}
|
|
}
|