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; 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; 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, )) { // 中文注释:本机账本与布局快照走同一套 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 { // 中文注释:清理失败最多留下一条过期账本,保留期会兜底。 } }