From eabcff40f86b31093547b82eccddd78d5cf47866 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Fri, 23 Oct 2026 19:17:42 +0800 Subject: [PATCH 1/9] fix(materials): restore verified cached previews and add health audits --- docs/material-preview-health.md | 72 +++++ docs/material-preview-incident-2026-10-23.md | 96 ++++++ scripts/check-material-preview-health.mjs | 286 ++++++++++++++++++ .../check-material-preview-health.test.mjs | 190 ++++++++++++ ...xport-material-preview-health-manifest.mjs | 141 +++++++++ ...-material-preview-health-manifest.test.mjs | 196 ++++++++++++ scripts/recover-material-previews.mjs | 97 ++++++ scripts/recover-material-previews.test.mjs | 75 +++++ 8 files changed, 1153 insertions(+) create mode 100644 docs/material-preview-health.md create mode 100644 docs/material-preview-incident-2026-10-23.md create mode 100644 scripts/check-material-preview-health.mjs create mode 100644 scripts/check-material-preview-health.test.mjs create mode 100644 scripts/export-material-preview-health-manifest.mjs create mode 100644 scripts/export-material-preview-health-manifest.test.mjs create mode 100644 scripts/recover-material-previews.mjs create mode 100644 scripts/recover-material-previews.test.mjs diff --git a/docs/material-preview-health.md b/docs/material-preview-health.md new file mode 100644 index 00000000..2d81df14 --- /dev/null +++ b/docs/material-preview-health.md @@ -0,0 +1,72 @@ +# 素材预览只读验收 + +当前为独立、显式执行的验收命令,尚未接入发布流程或定时任务。不会修复图片、修改引用、执行 Schema DDL 或调用模型。 + +## 导出并逐批检查 + +在已核对数据库身份、具备项目运行依赖的环境执行,输出目录必须是新目录;不要将连接凭据写入命令或报告。 + +```sh +node scripts/export-material-preview-health-manifest.mjs --output-dir /tmp/material-preview-inventory-20261023 --baseline-host s3.1080p.hk --baseline-count 483 +node scripts/check-material-preview-health.mjs --manifest <新目录>/manifest-000001.json +``` + +导出器在 PostgreSQL `RepeatableRead` 事务中设置 `SET TRANSACTION READ ONLY`,读取全部 `deletedAt: null` 素材,不按 `HISTORY_IMPORT` 标记筛选,不导出人员字段。产物为 `audit.json` 和 `manifest-000001.json` 等清单,每批最多 50 项;须逐批运行检查器,并另存每批 JSON 输出及退出码。 + +输出路径须为运行环境内的绝对路径,父目录须已存在。导出不发图片 GET。先核对 `audit.json` 的 `activeCount`、`exportedCount`、`uncheckableCount`、`inventoryComplete` 和 `legacyHostCounts`。`inventoryComplete` 只说明库存枚举完整,不代表图片健康;缺少预览、源地址不受支持、未检查或检查失败的项目均不能记为健康。2026-10-23 恢复前基线为 483 条活跃旧域名预览引用,只作对照,不能代替本次实时导出。11 项材质精确恢复后,若同期没有其它变动,应为 472;`legacyBaseline.delta = -11` 是预期的本轮变化,并非其余 472 项已恢复。基线不匹配只报告差额,不自动写库或隐藏引用。 + +检查器按清单中的精确登记地址执行有界单次 GET,不重试、不跟随跳转,并核对 HTTP、MIME、图片魔数及 **Sharp 完整像素解码**;仅头部可识别不足以通过。8 MiB、10 秒、2500 万像素上限保持固定,解码器按运行环境惰性 `createRequire` 加载,不执行安装。保留清单、清单哈希和全部批次报告。`audit.batches[].sha256` 对应原文件字节,`manifestSha256` 对应检查器的同名字段(紧凑 JSON 哈希),二者须分别核对。单批 50 项通过、或 12 项材质通过,均不能写成“全库存通过”。 + +完整图片验收需另行安排流量预算,逐一运行所有 `audit.batches` 并对齐哈希、素材 ID、数量及退出码。当前修复验收仅运行材质 12 项的已审核清单;**不因导出了两万余项元数据而自动发出全库存图片 GET**。未运行的批次保留为未检查。 + +## 尚未部署脚本时的 stdin 适配 + +当前生产镜像尚未复制这些新脚本及其 registry 依赖。可从已核对本地源码生成以下 stdin 载荷,在目标容器 `/app` 的依赖环境执行;不向运行目录复制源码,不新增依赖。下例只导出元数据,不读取图片。`/tmp` 输出目录仍必须新建且不与历史验收重名。载荷中不包含数据库 URL、Token 或 Key。 + +在仓库根目录运行以下 JavaScript(本地 Node 的 `--input-type=module -`)。将 stdout 通过已核对的本项目 SSH/`docker exec -i -w /app <当前容器> node --input-type=module -` 管道传入,不要在尚未核对容器身份时执行: + +```js +import { readFile } from 'node:fs/promises' +import { LEGACY_DOMAINS } from './scripts/check-legacy-storage-boundaries.mjs' +const data = s => 'data:text/javascript;base64,' + Buffer.from(s).toString('base64') +const runtimeBase = 'file:///app/package.json' +const adapt = s => s.replaceAll('import.meta.url', JSON.stringify(runtimeBase)) +const registry = data(`export const LEGACY_DOMAINS=${JSON.stringify(LEGACY_DOMAINS)}`) +const health = data(adapt(await readFile('scripts/check-material-preview-health.mjs', 'utf8')) + .replace("'./check-legacy-storage-boundaries.mjs'", JSON.stringify(registry))) +const exporter = data(adapt(await readFile('scripts/export-material-preview-health-manifest.mjs', 'utf8')) + .replace("'./check-legacy-storage-boundaries.mjs'", JSON.stringify(registry)) + .replace("'./check-material-preview-health.mjs'", JSON.stringify(health))) +const args = ['--output-dir', '/tmp/material-preview-inventory-20261023-after11', + '--baseline-host', LEGACY_DOMAINS[0], '--baseline-count', '483'] +process.stdout.write(`const {main}=await import(${JSON.stringify(exporter)}); +try { const r=await main(${JSON.stringify(args)}); + console.log(JSON.stringify({readOnly:true,outputDirectory:r.outputDirectory, + activeCount:r.audit.activeCount,exportedCount:r.audit.exportedCount, + uncheckableCount:r.audit.uncheckableCount,legacyHostCounts:r.audit.legacyHostCounts, + legacyBaseline:r.audit.legacyBaseline,inventoryComplete:true,healthChecksExecuted:false})); + process.exitCode=r.audit.readyForHealthChecks?0:1; +} catch { console.log(JSON.stringify({readOnly:true,inventoryComplete:false,code:'inventory_export_failed'}));process.exitCode=1; }`) +``` + +stdin 中的 `createRequire('file:///app/package.json')` 使用容器已有的 `@prisma/client`/Sharp;不是读取本机另一套数据库配置。若依赖不存在、数据库事务失败或输出中途失败,退出非零,不能把残留文件当完整清单。导出器在所有批次成功写入后才写 `audit.json`。导出完成后再由调用方按需取回只读审计文件,本命令不清理文件。 + +## 发布接入位置(待实施) + +当前发布采用 direct replacement;本轮不修改共享发布脚本。后续接入 `scripts/git-incremental-deploy.ps1` 时按函数语义定位,行号仅供参考: + +| 位置 | 应保留的验收证据 | +| --- | --- | +| `direct_replace_active_container` 中 `docker stop` 前,约 2839–2842 行 | 导出全活跃库存,记录旧容器基线及所有批次结果 | +| `verify_candidate_brand_and_storage_dependencies` 后、切流前,约 2882–2897 行 | 新容器检查同一份清单;核对新增失败,不能重新抽样替换基线 | +| `verify_proxy_target` 成功后、`write_current_container` 前,约 2897–2900 行 | 从公网路径确认同一份清单,保留结果后再记录当前版本 | + +macOS wrapper 从同一 PowerShell 源码抽取发布逻辑,接入时需同时回归,不另复制一套条件。既有故障必须在报告中保留,不将未检查项当通过,也不因独立检查命令存在就宣称发布门禁已启用。 + +11 项材质预览恢复属于独立的精确 CAS 数据补丁:先验证新对象,再以原值为条件更新并保留回退记录;不得混入本只读检查器,也不代表高清原件或其余历史依赖已恢复。 + +相关离线回归: + +```sh +node --test scripts/check-material-preview-health.test.mjs scripts/export-material-preview-health-manifest.test.mjs +``` diff --git a/docs/material-preview-incident-2026-10-23.md b/docs/material-preview-incident-2026-10-23.md new file mode 100644 index 00000000..5fa00ac1 --- /dev/null +++ b/docs/material-preview-incident-2026-10-23.md @@ -0,0 +1,96 @@ +# 旧素材预览失效核查(2026-10-23) + +状态:已确认旧域名入口路由失败、存量迁移覆盖缺口及 11 份真实材质缩略图的转换来源。**11 项临时 160px 预览已恢复,正式 Web 与现有客户端均复核为 12 张缩略图、0 失败;高清源仍未恢复,旧入口的具体触发变更尚未归因,不能记为事故完全解决。** + +## 已确认的事实 + +- 恢复前,正式 Web 材质列表共有 12 项,仅“新花色01”正常,其余 11 项显示图片加载失败;客户端也读取相同素材记录。精确恢复后,Web 刷新及客户端只读重读均显示 12 张缩略图、0 失败。 +- 恢复前,这 11 项 `previewUrl` 指向历史域名及旧桶,创建时间均为 2025-11-07,最晚 `updatedAt` 为 2026-01-14;10 月 19 日留存验收记录也包含相同旧引用。未发现故障前近期正常编辑回写的证据,但不能排除没有审计的历史 SQL。本次只精确变更这 11 项的预览引用及更新时间,原登记来源已保留在恢复日志中。 +- 旧入口真实 TLS 校验通过,HTTP 404 正文是 FRP 默认未命中页面,并非 S3 的 `NoSuchKey` 或 `NoSuchBucket`。这是入口路由层失败,不能据此认定原对象永久丢失。 +- 恢复前,从生产环境运行只读检查器,12 次单次 GET 得到 1 项正常图片、11 项 `routing_failure/frp_default_404`。这一步没有重试、模型调用、生产文件写入或数据库写入;后续恢复的写入范围及验收结果单独记录如下。 + +## 故障时间与变更排除 + +以朴橡柚木色的同一精确文件为准,时区 UTC+8: + +| 时间 | 正式访问记录 | +| --- | --- | +| 2026-10-20 11:36:18 | `/api/proxy-media` 返回 200,15738 字节 | +| 2026-10-21 10:17:30 | 同一文件首次留存 404 | +| 2026-10-22 09:39:01–04 | 其他十项精确文件均有 404 | +| 2026-10-22 12:08 | 后续 Go/frpc 安全升级 | + +因此不能将 10 月 22 日升级认定为本次首次故障原因。故障核查时正式容器实际提交为 `df90e6f4799fcdf69b3b22f9910d8f4b4a596e77`。URL 重写、媒体代理安全策略、媒体代理路由及 S3 模块的生产字节与此前部署的 Git 字节一致;可取得的 10 月 16 日与 20 日配置快照中,S3 端点、公开域名、桶及路径前缀也未变化。 + +四项已升级 frpc 服务配置与升级前 SHA 一致,且均不连接旧域名现指向的入口。故障前后的该旧入口 DNS、FRPS 域名注册、旧客户端启动停止日志目前不在已确认归属的项目运维资料中。**不能据当前 404 推测是谁改了哪个配置,也不能盲目回滚安全升级。** + +## 迁移覆盖与范围 + +`scripts/migrate-history-preview-assets.js` 只选择存在 `HISTORY_IMPORT` 日志标记的素材。这 11 项没有该标记,因此不进入该脚本候选。该脚本不能当作已迁移所有活跃旧预览的证明。 + +恢复前的只读基线中,活跃 `Material.previewUrl` 共有 483 条旧域名引用:3D场景 305、详情模板 87、其他 61、材质 11、模特 7、买家秀图片 6、实拍图片 6。本次仅处理其中 11 项材质预览,没有扩大为批量修改;这 11 项临时预览恢复通过,不代表其余旧依赖已恢复,也不代表高清源或旧入口恢复。 + +恢复前,在当前可信桶中对这 11 个精确旧对象名分别检查桶根路径及配置前缀,共 22 次 Head,均为 404。没有得到能与原源 SHA 对照的迁移副本;后续使用的是单独命名的新临时预览对象,没有直接替换域名、桶名或覆盖旧对象。 + +## 已确认的缓存来源与临时预览恢复 + +已只读复核本项目原会话 2026-10-19 的相关工具代码。会话 JSONL 第 42207 行(UTC+8 17:36:02)记录了下载和转换实现;第 42210 行(17:36:05)返回 `cached=12`、`failed=0`、`productionChanges=false`、退出码 0,其中包括这 11 项旧材质及 1 项当前桶材质。下载按真实素材目录的 `materialId` 与登记 URL 逐项绑定,没有替代图分支。 + +当时的处理流程为:读取 HTTP 响应到内存 `original`,按图片方向归一后等比缩小、不放大,再以 WebP 质量 80 编码,保存到 `output/detail-flow-20261019/material-thumbnails/.webp`: + +```js +sharp(original) + .rotate() + .resize(160, 160, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 80 }) +``` + +代码没有裁切、叠字、拼接或显式改色操作,但等比重采样和有损编码不保持逐像素、逐字节一致。`material-thumbnails.json` 的 `sourceSha256` 计算自转换前的 HTTP 响应,而本地文件 SHA 计算自转换后的 WebP,因此二者不同是预期结果,不是仅凭 SHA 差异就能认定的缓存损坏。本机 11 份文件均可完整解码为 160×160 单帧 WebP,并与 10 月 20 日按相同素材 ID 复制的验收记录逐项一致。 + +原响应只存在当时 Node 进程内存中,没有另存原文件、写入 `store` 或作为图片字节返回会话;原会话保留的是统计值、来源和原响应 SHA,不能据此还原高清原图。对项目已知下载、验收、备份及图片缓存的受限检索也未找到这 11 个原响应 SHA;另找到的同像素 PNG/WebP 副本仍是 160px 派生版本。这不证明旧存储中的原对象永久丢失。 + +本次透明地使用这 11 份有来源记录的 160px 图片,**仅恢复材质卡片和选择器的临时缩略预览**。新对象没有放大、没有冒充高清,旧对象、原件及下载源未改。具体实施和已完成的验证如下。 + +## 已实施的精确恢复及双端验收 + +恢复操作编号为 `material-preview-cache-20261023`。2026-10-23 已在当前可信存储的 `由壹点AI/public-display/material-preview-cache-20261023/` 下新增 11 个对象,文件名包含精确素材 ID 及派生文件 SHA,没有覆盖旧对象。对象上传校验后,按原预览 URL 精确条件执行 11 项 CAS 更新,并在同一数据库事务内写入 11 条 `PREVIEW_CACHE_RECOVERY` 日志。日志逐项保留原 URL、新 URL、原响应 SHA、缓存 SHA、160×160 尺寸和操作编号。 + +前后快照逐项复核通过:除 `Material.previewUrl`、`updatedAt` 外,11 项材料的全部字段及关联 `assets`、`skus` 一致;原文件、ZIP 及其它下载路径不改,未改变权限、素材归属或产品资料。快照内容 SHA 为 `7469b9e0d342c075433a8f133b2910dd82cc543be8feef74031a5b726c8d012f`,事后复核回执内容 SHA 为 `2084dac9220da847bdbbe5d7f39ebd896ee1b1109caa99868dbf49e96231aef6`;两者均按 JSON 紧凑序列化、不含尾换行计算。 + +2026-10-23 19:07(UTC+8)的双端复核结果: + +- 正式 Web 刷新后,可访问性树确认 12 个图片节点、0 个图片加载失败标签,并保留实际页面截图。 +- 复用现有唯一客户端窗口(PID `9260`、HWND `2363054`),没有另开窗口、没有更改项目资料;逐项执行 11 次素材图片只读重读,滚动检查上、下区域并截图,汇总确认 12 张真实缩略图、0 个失败标记。 +- 本轮没有重新验收悬停大图;先前的悬停证据不计作这次恢复后的验证。已通过的范围是 Web 与客户端的临时缩略预览,`originalSourceRestored=false`。 + +因此“11 项临时 160px 缩略预览恢复”已通过;高清源、原件可取回性、旧入口故障的具体触发变更及其余旧依赖仍是开放事项。本次未重发结果未知的模型请求。 + +## 防复发检查及操作边界 + +新增独立只读工具 `scripts/check-material-preview-health.mjs`,输入显式审阅的素材 ID 与登记图片 URL 清单。它限制项目既有域名、公网 DNS 地址、字节数与时间;不跟随跳转、不重试,检查真实 GET、Content-Type、图片魔数和 Sharp 完整像素解码,区分 FRP/HTML 路由失败、S3 缺桶、S3 缺对象及网络异常。只有图片头、损坏正文或像素炸弹不能通过。 + +`scripts/export-material-preview-health-manifest.mjs` 在只读 RepeatableRead 事务中清点所有未删除的 Material,按最多 50 项分批,不再用 `HISTORY_IMPORT` 筛选;缺 URL 或不支持地址仍计入审计,不会被静默遗漏。与安全恢复工具合计 31 项离线测试通过。执行方式、生产 stdin 适配与发布前后接入点见 `docs/material-preview-health.md`。目前为显式执行工具,**尚未接入自动发布或定时监控**,不能声称自动防回归已部署。 + +恢复后生产只读清点共 23467 条活跃记录,其中 23263 条为检查器支持的 URL,204 条缺地址或不受该图片检查器支持(不等于 204 条图片损坏)。旧入口引用由 483 减少到 472,只有本次 11 项材质改变;未对其余库存发起全量图片 GET。11 个新对象的实际公网 GET、完整解码及 SHA 均通过。 + +额外区分了“数据库原登记域名”与“应用实际交付地址”:未修改的新花色01仍登记历史兼容域名,直接访问该域名本次网络检查失败;现有应用按原有规则交付到当前存储。对其当前同桶同键地址做单次 GET,完整解码为 768×1600,正文 SHA 与 10 月 19 日成功的原响应完全一致。此项未改库或替换素材。原始登记地址检查报告保留失败状态,不能将其冒充全库存健康报告。 + +下次涉及存储域名、代理、运行时或 frpc 维护,维护前后需要同时检查当前入口和仍有活跃引用的历史入口,并保留前后报告;仅进程在线、首页健康或配置 SHA 未变,不算素材交付验证。桶退役之前必须清点所有活跃引用,并核对“对象真实存在且内容一致,再改引用”,不能只按导入日志标签判断迁移完成。 + +下一步仍需要旧入口故障窗口内的 DNS/FRPS/客户端变更证据,以及原对象或经过校验的迁移清单。恢复时保留原文件,使用精确条件更新和可回退记录;临时 160px 预览必须保留尺寸与来源记录,不能掩盖高清源未恢复或扩大为其它素材已恢复的结论。 + +## 证据 + +本地 `output/native-brief-panel-20261023/` 下保留: + +- `material-regression-code-config-timeline.json`:生产身份、代码哈希、配置及精确访问时间线。 +- `legacy-preview-migration-readonly-audit.json`:11 项日期、迁移筛选条件、22 次 Head 与依赖计数。 +- `legacy-material-cache-mapping-audit.json`:本机派生图的来源与质量边界。 +- `legacy-material-original-cache-search.json`、`legacy-material-original-cache-search-followup.json`:受限原响应检索及同像素派生副本记录,不是原件恢复证明。 +- `material-preview-health-real.json`:正式环境 12 项单次 GET 检查结果。 +- `web-materials-source-check.png`:正式 Web 实际失败状态。 +- `preview-recovery-before.json`、`preview-recovery-apply-result.json`、`preview-recovery-verify-result.json`:恢复前快照、11 个新增对象及 11 项精确更新、完整字段与审计日志复核。 +- `material-previews-ui-acceptance.json`:单个现有客户端窗口与正式 Web 的 12 张缩略图、0 失败验收回执。 +- `material-previews-native-restored-top.png`、`material-previews-native-restored-bottom.png`、`material-previews-web-restored.png`:恢复后的客户端上、下区域及正式 Web 实际截图。 + +本轮生产恢复范围为 11 个新增临时预览对象、11 项精确预览引用更新及 11 条同事务审计日志;没有修改原文件、下载路径、桶或 FRP 配置,也未重发结果未知的模型请求。本文件此次更新只登记已有实施与验收证据,没有另行执行生产操作。 diff --git a/scripts/check-material-preview-health.mjs b/scripts/check-material-preview-health.mjs new file mode 100644 index 00000000..347536c7 --- /dev/null +++ b/scripts/check-material-preview-health.mjs @@ -0,0 +1,286 @@ +// Standalone, opt-in read-only acceptance for reviewed, registered material URLs. +// LEGACY_READ_ONLY: reuse the project's historical host registry without rewriting URLs. +// node scripts/check-material-preview-health.mjs --manifest +// {"version":1,"reviewed":true,"materials":[{"materialId":"","registeredSource":""}]} +import { createHash } from 'node:crypto' +import { lookup } from 'node:dns/promises' +import { open } from 'node:fs/promises' +import http from 'node:http' +import https from 'node:https' +import { isIP } from 'node:net' +import { createRequire } from 'node:module' +import path from 'node:path' +import { Readable } from 'node:stream' +import { fileURLToPath } from 'node:url' +import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs' + +export const LIMITS = Object.freeze({ timeoutMs: 10_000, maxBytes: 8 * 1024 * 1024, maxPixels: 25_000_000, materials: 50 }) +const CURRENT_HOST = 's3.jiansun.vip' +const HOSTS = new Set([CURRENT_HOST, ...LEGACY_DOMAINS]) +const MIME_FORMATS = new Map([['image/png', 'png'], ['image/jpeg', 'jpeg'], ['image/jpg', 'jpeg'], ['image/webp', 'webp']]) +const sha256 = value => createHash('sha256').update(value).digest('hex') + +class CheckError extends Error { + constructor(classification, code, materialId) { + super(code) + this.classification = classification + this.code = code + this.materialId = materialId + } +} + +export function registeredSource(value) { + if (typeof value !== 'string' || value.length > 4096 || value.trim() !== value || /[\r\n\0]/.test(value)) { + throw new CheckError('source_rejected', 'invalid_url') + } + let url + try { url = new URL(value) } catch { throw new CheckError('source_rejected', 'invalid_url') } + if (!HOSTS.has(url.hostname) || url.username || url.password || url.port || url.hash) { + throw new CheckError('source_rejected', 'unregistered_source') + } + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LEGACY_DOMAINS.includes(url.hostname))) { + throw new CheckError('source_rejected', 'unregistered_protocol') + } + if (!url.pathname || url.pathname === '/') throw new CheckError('source_rejected', 'object_path_required') + return url +} + +export function validateManifest(manifest) { + if (manifest?.version !== 1 || manifest.reviewed !== true || !Array.isArray(manifest.materials) + || manifest.materials.length < 1 || manifest.materials.length > LIMITS.materials) { + throw new CheckError('invalid_manifest', 'reviewed_manifest_required') + } + const identities = new Set() + return manifest.materials.map(item => { + if (typeof item?.materialId !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(item.materialId) || identities.has(item.materialId)) { + throw new CheckError('invalid_manifest', 'unique_material_id_required') + } + identities.add(item.materialId) + try { + const url = registeredSource(item.registeredSource) + return { materialId: item.materialId, url } + } catch (error) { + error.materialId = item.materialId + throw error + } + }) +} + +// A reviewed hostname must not be rebound to local/private/metadata networks. +// The direct Node transport resolves once per connection and uses only these +// checked addresses; environment proxy variables and redirects are not followed. +export function isPublicAddress(address) { + if (isIP(address) === 4) { + const [a, b, c] = address.split('.').map(Number) + return !(a === 0 || a === 10 || a === 127 || a >= 224 + || (a === 100 && b >= 64 && b <= 127) + || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) + || (a === 192 && (b === 168 || (b === 0 && (c === 0 || c === 2)))) + || (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) + || (a === 203 && b === 0 && c === 113)) + } + if (isIP(address) === 6) { + // Only global unicast; this also rejects mapped IPv4, loopback and link-local. + const first = Number.parseInt(address.split(':')[0], 16) + return first >= 0x2000 && first <= 0x3fff && first !== 0x2002 && !/^2001:(?:0*:|0*db8:)/i.test(address) + } + return false +} + +export function directFetch(input, { signal, headers, method, cache, redirect } = {}) { + const url = registeredSource(String(input)) + if (method !== 'GET' || cache !== 'no-store' || redirect !== 'manual') { + throw new CheckError('source_rejected', 'readonly_transport_required') + } + return new Promise((resolve, reject) => { + const transport = url.protocol === 'https:' ? https : http + const request = transport.request(url, { + method: 'GET', signal, headers, + lookup(hostname, options, callback) { + lookup(hostname, { all: true, verbatim: true }).then(addresses => { + if (hostname !== url.hostname || !addresses.length || addresses.some(row => !isPublicAddress(row.address))) { + callback(new CheckError('source_rejected', 'non_public_dns_address')) + } else if (options?.all) { + callback(null, addresses) + } else { + callback(null, addresses[0].address, addresses[0].family) + } + }, () => callback(new CheckError('network_failure', 'dns_lookup_failed'))) + }, + }, response => { + try { + const noBody = [204, 205, 304].includes(response.statusCode) + if (noBody) response.destroy() + resolve(new Response(noBody ? null : Readable.toWeb(response), { + status: response.statusCode, + headers: response.headers, + })) + } catch { + response.destroy() + reject(new CheckError('network_failure', 'invalid_http_response')) + } + }) + request.on('error', reject) + request.end() + }) +} + +function formatOf(bytes) { + if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'png' + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpeg' + if (bytes.length >= 16 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP' + && ['VP8 ', 'VP8L', 'VP8X'].includes(bytes.toString('ascii', 12, 16))) return 'webp' + return null +} + +/** Lazy CJS loading also works when this module's source is run through Node stdin. */ +export function createSharpPreviewDecoder({ requireImpl = createRequire(import.meta.url) } = {}) { + let sharp + return async bytes => { + if (!sharp) { + try { sharp = requireImpl('sharp') } catch { throw new CheckError('decoder_unavailable', 'image_decoder_unavailable') } + } + try { + const image = sharp(bytes, { failOn: 'error', limitInputPixels: LIMITS.maxPixels, animated: true }) + const metadata = await image.metadata() + // metadata() alone only parses the header. Materialise pixels so a + // truncated/corrupt PNG, JPEG or WebP cannot pass from its magic bytes. + const { info } = await image.raw().toBuffer({ resolveWithObject: true }) + return { format: metadata.format, width: info.width, height: info.height } + } catch (error) { + if (error instanceof CheckError) throw error + throw new CheckError('unexpected_content', 'image_decode_failed') + } + } +} +const defaultImageDecoder = createSharpPreviewDecoder() + +function sourceReference(url) { + return { origin: url.origin, pathname: url.pathname, queryRedacted: Boolean(url.search), sha256: sha256(url.href) } +} + +function withAbort(promise, signal) { + if (signal.aborted) return Promise.reject(signal.reason) + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason) + signal.addEventListener('abort', abort, { once: true }) + Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)) + }) +} + +async function checkOne(entry, { fetchImpl, decodeImage, timeoutMs, maxBytes }) { + const base = { materialId: entry.materialId, source: sourceReference(entry.url), attempts: 1 } + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(new CheckError('timeout', 'request_timeout')), timeoutMs) + let reader + let body + let status = null + let contentType = '' + let bytesRead = 0 + const failure = (classification, code) => ({ ...base, ok: false, classification, code, status, contentType, bytes: bytesRead }) + try { + const response = await withAbort(fetchImpl(entry.url.href, { + method: 'GET', cache: 'no-store', redirect: 'manual', signal: controller.signal, + headers: { 'Cache-Control': 'no-store, no-cache, max-age=0', Pragma: 'no-cache', Accept: 'image/png,image/jpeg,image/webp' }, + }), controller.signal) + status = response.status + body = response.body + contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase() + if (response.redirected || (status >= 300 && status < 400)) return failure('redirect_blocked', 'redirect_not_followed') + if (response.url && response.url !== entry.url.href) return failure('redirect_blocked', 'response_url_changed') + const length = response.headers.get('content-length') + if (length && /^\d+$/.test(length) && Number(length) > maxBytes) return failure('too_large', 'declared_body_limit') + const chunks = [] + reader = response.body?.getReader() + while (reader) { + const { done, value } = await withAbort(reader.read(), controller.signal) + if (done) break + bytesRead += value.byteLength + if (bytesRead > maxBytes) return failure('too_large', 'stream_body_limit') + chunks.push(Buffer.from(value)) + } + const bytes = Buffer.concat(chunks) + const prefix = bytes.subarray(0, 4096).toString('utf8').replace(/^\uFEFF/, '').trimStart() + const html = contentType === 'text/html' || /^(?:\s*frp\s*<\/center>)/i.test(prefix) + if (html) return failure('routing_failure', status === 404 && frpMarker ? 'frp_default_404' : 'html_instead_of_image') + if (status === 404 && /\s*NoSuchKey\s*<\/Code>/i.test(prefix)) return failure('missing_object', 's3_no_such_key') + if (status === 404 && /\s*NoSuchBucket\s*<\/Code>/i.test(prefix)) return failure('missing_bucket', 's3_no_such_bucket') + if (!response.ok) return failure('http_failure', 'http_status') + if (!MIME_FORMATS.has(contentType)) return failure('unexpected_content', 'unsupported_image_content_type') + const format = formatOf(bytes) + if (!format || MIME_FORMATS.get(contentType) !== format) return failure('unexpected_content', 'image_signature_mismatch') + const decoded = await withAbort(decodeImage(bytes), controller.signal) + if (decoded?.format !== format || !Number.isSafeInteger(decoded.width) || !Number.isSafeInteger(decoded.height) + || decoded.width < 1 || decoded.height < 1 || decoded.width * decoded.height > LIMITS.maxPixels) { + return failure('unexpected_content', 'image_decode_result_invalid') + } + return { ...base, ok: true, classification: 'healthy', status, contentType, bytes: bytesRead, format, + decoded: { width: decoded.width, height: decoded.height }, bodySha256: sha256(bytes) } + } catch (error) { + if (controller.signal.aborted) return failure('timeout', 'request_timeout') + if (error instanceof CheckError) return failure(error.classification, error.code) + // Transport messages can contain signed query strings. Keep raw exceptions out of reports. + return failure('network_failure', 'request_failed') + } finally { + clearTimeout(timer) + if (reader) reader.cancel().catch(() => {}) + else if (body) body.cancel().catch(() => {}) + controller.abort() + } +} + +export async function checkMaterialPreviewHealth(manifest, { fetchImpl = directFetch, decodeImage = defaultImageDecoder, + timeoutMs = LIMITS.timeoutMs, maxBytes = LIMITS.maxBytes } = {}) { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > LIMITS.timeoutMs + || !Number.isSafeInteger(maxBytes) || maxBytes < 16 || maxBytes > LIMITS.maxBytes || typeof decodeImage !== 'function') { + throw new CheckError('invalid_manifest', 'limits_must_not_exceed_fixed_budget') + } + // Validate the entire manifest before any network request, including mixed lists. + const entries = validateManifest(manifest) + const results = [] + for (const entry of entries) results.push(await checkOne(entry, { fetchImpl, decodeImage, timeoutMs, maxBytes })) + return { version: 1, readOnly: true, checkedAt: new Date().toISOString(), manifestSha256: sha256(JSON.stringify(manifest)), + passed: results.every(result => result.ok), limits: { timeoutMs, maxBytes, maxPixels: LIMITS.maxPixels }, retries: 0, results } +} + +export async function main(args = process.argv.slice(2)) { + const options = {} + for (let i = 0; i < args.length; i += 2) { + if (!['--manifest', '--timeout-ms', '--max-bytes'].includes(args[i]) || args[i + 1] === undefined || options[args[i]] !== undefined) { + throw new CheckError('invalid_manifest', 'usage_manifest_path_required') + } + options[args[i]] = args[i + 1] + } + if (!options['--manifest']) throw new CheckError('invalid_manifest', 'usage_manifest_path_required') + const file = await open(options['--manifest'], 'r') + const buffer = Buffer.alloc(256 * 1024 + 1) + let raw + try { + const stat = await file.stat() + if (!stat.isFile() || stat.size > buffer.byteLength - 1) throw new CheckError('invalid_manifest', 'manifest_too_large_or_not_regular') + const { bytesRead } = await file.read(buffer, 0, buffer.byteLength, 0) + if (bytesRead >= buffer.byteLength) throw new CheckError('invalid_manifest', 'manifest_too_large') + raw = buffer.subarray(0, bytesRead) + } finally { + await file.close() + } + const manifest = JSON.parse(raw.toString('utf8')) + return checkMaterialPreviewHealth(manifest, { + ...(options['--timeout-ms'] ? { timeoutMs: Number(options['--timeout-ms']) } : {}), + ...(options['--max-bytes'] ? { maxBytes: Number(options['--max-bytes']) } : {}), + }) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().then(report => { + console.log(JSON.stringify(report, null, 2)) + process.exitCode = report.passed ? 0 : 1 + }, error => { + console.log(JSON.stringify({ version: 1, readOnly: true, passed: false, + classification: error instanceof CheckError ? error.classification : 'invalid_manifest', + code: error instanceof CheckError ? error.code : 'manifest_unreadable_or_invalid', + ...(error.materialId ? { materialId: error.materialId } : {}), results: [] }, null, 2)) + process.exitCode = 1 + }) +} diff --git a/scripts/check-material-preview-health.test.mjs b/scripts/check-material-preview-health.test.mjs new file mode 100644 index 00000000..365926c9 --- /dev/null +++ b/scripts/check-material-preview-health.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createRequire } from 'node:module' +import { checkMaterialPreviewHealth, createSharpPreviewDecoder, directFetch, isPublicAddress, LIMITS, registeredSource, validateManifest } from './check-material-preview-health.mjs' + +const sharp = createRequire(import.meta.url)('sharp') +const fixture = () => sharp({ create: { width: 3, height: 2, channels: 3, background: '#345678' } }) +const PNG = await fixture().png().toBuffer() +const JPEG = await fixture().jpeg().toBuffer() +const WEBP = await fixture().webp().toBuffer() +const source = 'https://s3.jiansun.vip/gongxiangpan/public-display/example.png' +const manifest = (url = source) => ({ version: 1, reviewed: true, materials: [{ materialId: 'material-a', registeredSource: url }] }) +const response = (body, type, status = 200, extra = {}) => new Response(body, { status, headers: { 'content-type': type, ...extra } }) + +test('normal PNG/JPEG/WebP use one actual uncached GET with no redirects or credentials', async () => { + for (const [body, mime, format] of [[PNG, 'image/png', 'png'], [JPEG, 'image/jpeg', 'jpeg'], [WEBP, 'image/webp', 'webp']]) { + const calls = [] + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async (url, options) => { + calls.push({ url, options }) + return response(body, `${mime}; charset=binary`) + } }) + assert.equal(report.passed, true) + assert.equal(report.readOnly, true) + assert.equal(report.retries, 0) + assert.equal(calls.length, 1) + assert.equal(calls[0].url, source) + assert.equal(calls[0].options.method, 'GET') + assert.equal(calls[0].options.cache, 'no-store') + assert.equal(calls[0].options.redirect, 'manual') + assert.match(calls[0].options.headers['Cache-Control'], /no-store/) + assert.equal(calls[0].options.headers.Authorization, undefined) + assert.equal(report.results[0].classification, 'healthy') + assert.equal(report.results[0].format, format) + assert.deepEqual(report.results[0].decoded, { width: 3, height: 2 }) + assert.equal(report.results[0].bytes, body.length) + assert.match(report.results[0].bodySha256, /^[a-f0-9]{64}$/) + } +}) + +test('correct MIME and magic bytes cannot make truncated image headers healthy', async () => { + for (const [body, mime] of [ + [PNG.subarray(0, 16), 'image/png'], + [JPEG.subarray(0, 16), 'image/jpeg'], + [WEBP.subarray(0, 20), 'image/webp'], + ]) { + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => response(body, mime) }) + assert.equal(report.passed, false) + assert.equal(report.results[0].code, 'image_decode_failed') + } +}) + +test('decoder is lazy, injectable, and a missing runtime decoder fails closed', async () => { + let requires = 0 + const unavailable = createSharpPreviewDecoder({ requireImpl() { requires++; throw new Error('sensitive runtime path') } }) + assert.equal(requires, 0) + const report = await checkMaterialPreviewHealth(manifest(), { decodeImage: unavailable, fetchImpl: async () => response(PNG, 'image/png') }) + assert.equal(requires, 1) + assert.equal(report.results[0].classification, 'decoder_unavailable') + assert.equal(report.results[0].code, 'image_decoder_unavailable') + assert.ok(!JSON.stringify(report).includes('sensitive runtime path')) + const invalid = await checkMaterialPreviewHealth(manifest(), { decodeImage: async () => ({ format: 'png', width: 0, height: 2 }), fetchImpl: async () => response(PNG, 'image/png') }) + assert.equal(invalid.results[0].code, 'image_decode_result_invalid') +}) + +test('pixel bombs cannot pass the native decoder budget', async () => { + const large = await sharp({ create: { width: 5001, height: 5000, channels: 3, background: '#ffffff' } }).png().toBuffer() + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => response(large, 'image/png') }) + assert.equal(report.passed, false) + assert.equal(report.results[0].code, 'image_decode_failed') +}) + +test('FRP default 404 is distinguished from a missing S3 object and never retried', async () => { + let calls = 0 + const report = await checkMaterialPreviewHealth(manifest('https://s3.1080p.hk/jsxiangche/a.png'), { fetchImpl: async () => { + calls++ + return response('

404 Not Found


frp
', 'text/html', 404) + } }) + assert.equal(calls, 1) + assert.equal(report.passed, false) + assert.equal(report.results[0].classification, 'routing_failure') + assert.equal(report.results[0].code, 'frp_default_404') + const missing = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => response('NoSuchKey', 'application/xml', 404) }) + assert.equal(missing.results[0].classification, 'missing_object') +}) + +test('real FRP Not Found page is identified by HTTP status and explicit marker without digits in its body', async () => { + const html = 'Not Found

Not Found

powered by frp

Faithfully yours, frp

' + assert.equal(html.includes('404'), false) + let calls = 0 + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => { calls++; return response(html, 'text/html', 404) } }) + assert.equal(calls, 1) + assert.equal(report.results[0].classification, 'routing_failure') + assert.equal(report.results[0].code, 'frp_default_404') + const fakeSuccess = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => response(html, 'text/html', 200) }) + assert.equal(fakeSuccess.results[0].code, 'html_instead_of_image') +}) + +test('HTML disguised as successful image content is an entrance routing failure', async () => { + for (const type of ['text/html', 'image/png']) { + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => response('wrong gateway', type) }) + assert.equal(report.results[0].classification, 'routing_failure') + assert.equal(report.results[0].code, 'html_instead_of_image') + } +}) + +test('declared and streaming byte limits are enforced without consuming an unbounded body', async () => { + let read = 0 + let cancelled = false + const declared = await checkMaterialPreviewHealth(manifest(), { maxBytes: 16, fetchImpl: async () => response(new ReadableStream({ + pull(controller) { read++; controller.enqueue(new Uint8Array(32)) }, + cancel() { cancelled = true }, + }), 'image/png', 200, { 'content-length': '999999999' }) }) + assert.equal(declared.results[0].code, 'declared_body_limit') + assert.equal(declared.results[0].bytes, 0) + assert.ok(read <= 1, 'the declared limit is checked before reading') + assert.equal(cancelled, true, 'rejected response bodies are cancelled even before acquiring a reader') + const streamed = await checkMaterialPreviewHealth(manifest(), { maxBytes: 16, fetchImpl: async () => response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(12)); controller.enqueue(new Uint8Array(12)); controller.close() }, + }), 'image/png') }) + assert.equal(streamed.results[0].classification, 'too_large') + assert.equal(streamed.results[0].code, 'stream_body_limit') +}) + +test('redirects never follow a second host, including a permitted destination', async () => { + for (const target of ['http://127.0.0.1/private', 'https://s3.jiansun.vip/gongxiangpan/b.png']) { + const calls = [] + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async (url, options) => { + calls.push({ url, options }) + return response('', 'text/html', 302, { location: target }) + } }) + assert.equal(calls.length, 1) + assert.equal(calls[0].options.redirect, 'manual') + assert.equal(report.results[0].classification, 'redirect_blocked') + } + const changed = response(PNG, 'image/png') + Object.defineProperty(changed, 'url', { value: 'https://unregistered.example/p.png' }) + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => changed }) + assert.equal(report.results[0].code, 'response_url_changed') +}) + +test('unreviewed, duplicate and mixed invalid manifests fail before any fetch', async () => { + let calls = 0 + const fetchImpl = async () => { calls++; return response(PNG, 'image/png') } + for (const value of [ + { ...manifest(), reviewed: false }, + { ...manifest(), materials: [...manifest().materials, ...manifest().materials] }, + { ...manifest(), materials: [...manifest().materials, { materialId: 'b', registeredSource: 'http://169.254.169.254/latest/meta-data' }] }, + ]) await assert.rejects(checkMaterialPreviewHealth(value, { fetchImpl })) + assert.equal(calls, 0) +}) + +test('only exact registered hosts and approved schemes/ports are accepted', () => { + for (const url of [ + 'http://127.0.0.1/private', 'http://192.168.58.20:9000/object.png', 'https://s3.jiansun.vip.evil.example/a.png', + 'https://sub.s3.jiansun.vip/a.png', 'https://s3.jiansun.vip:444/a.png', 'https://user:pass@s3.jiansun.vip/a.png', + 'http://s3.jiansun.vip/a.png', 'file:///C:/Windows/a.png', 'https://s3.jiansun.vip/', 'https://s3.jiansun.vip/a.png#secret', + ]) assert.throws(() => registeredSource(url), { classification: 'source_rejected' }) + assert.equal(registeredSource('http://s3.1080p.hk/jsxiangche/a.png').protocol, 'http:') + assert.equal(registeredSource('https://s3.yydd.ai/gongxiangpan/a.png').hostname, 's3.yydd.ai') + assert.equal(validateManifest(manifest()).length, 1) + assert.throws(() => directFetch('https://evil.example/a.png', { method: 'GET', cache: 'no-store', redirect: 'manual' }), { classification: 'source_rejected' }) +}) + +test('DNS guard rejects local, private, mapped and special-purpose addresses', () => { + for (const ip of ['0.0.0.0', '10.0.0.1', '100.64.0.1', '127.0.0.1', '169.254.169.254', '172.16.0.1', '192.168.1.1', '198.18.0.1', '224.0.0.1', '::1', '::ffff:127.0.0.1', 'fe80::1', 'fc00::1', '2001:db8::1', '2002:7f00:1::1']) { + assert.equal(isPublicAddress(ip), false, ip) + } + for (const ip of ['218.93.9.38', '156.233.232.106', '2606:4700::1111']) assert.equal(isPublicAddress(ip), true, ip) +}) + +test('timeouts and network errors remain single-attempt failures and hide signed URL secrets', async () => { + let calls = 0 + const report = await checkMaterialPreviewHealth(manifest(), { timeoutMs: 5, fetchImpl: () => { calls++; return new Promise(() => {}) } }) + assert.equal(report.results[0].classification, 'timeout') + assert.equal(calls, 1) + const secret = 'never-print-this-signature' + const failure = await checkMaterialPreviewHealth(manifest(`${source}?X-Amz-Signature=${secret}`), { fetchImpl: async () => { throw new Error(secret) } }) + assert.equal(failure.results[0].classification, 'network_failure') + assert.equal(failure.results[0].source.queryRedacted, true) + assert.ok(!JSON.stringify(failure).includes(secret)) + await assert.rejects(checkMaterialPreviewHealth(manifest(), { timeoutMs: LIMITS.timeoutMs + 1 })) +}) + +test('image content type and magic must agree; JSON and empty images cannot pass', async () => { + for (const [body, type] of [['{}', 'application/json'], ['', 'image/png'], [JPEG, 'image/png']]) { + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => response(body, type) }) + assert.equal(report.passed, false) + assert.equal(report.results[0].classification, 'unexpected_content') + } +}) diff --git a/scripts/export-material-preview-health-manifest.mjs b/scripts/export-material-preview-health-manifest.mjs new file mode 100644 index 00000000..c37a2cc1 --- /dev/null +++ b/scripts/export-material-preview-health-manifest.mjs @@ -0,0 +1,141 @@ +// Read-only inventory of every active Material.previewUrl, not HISTORY_IMPORT. +// Public-source probes remain separate; an uncheckable row is never omitted or healthy. +import { createHash } from 'node:crypto' +import * as fs from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { LIMITS, registeredSource } from './check-material-preview-health.mjs' +import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs' + +const sha256 = value => createHash('sha256').update(value).digest('hex') +const runtimeRequire = createRequire(import.meta.url) +export class InventoryError extends Error { + constructor(code) { super(code); this.code = code } +} +function optionsChecked({ pageSize = 500, batchSize = LIMITS.materials, baselineHost, baselineCount } = {}) { + if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 1000 + || !Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > LIMITS.materials) throw new InventoryError('invalid_batch_budget') + if ((baselineHost === undefined) !== (baselineCount === undefined) + || (baselineHost !== undefined && (!LEGACY_DOMAINS.includes(baselineHost) || !Number.isSafeInteger(baselineCount) || baselineCount < 0))) { + throw new InventoryError('invalid_legacy_baseline') + } + return { pageSize, batchSize, baselineHost, baselineCount } +} + +export async function collectMaterialPreviewInventory(prisma, options = {}) { + const { pageSize, batchSize, baselineHost, baselineCount } = optionsChecked(options) + if (typeof prisma?.$transaction !== 'function') throw new InventoryError('database_reader_required') + return prisma.$transaction(async tx => { + await tx.$executeRawUnsafe('SET TRANSACTION READ ONLY') + const activeCount = await tx.material.count({ where: { deletedAt: null } }) + if (!Number.isSafeInteger(activeCount) || activeCount < 0) throw new InventoryError('invalid_active_count') + const materials = [], uncheckable = [], seen = new Set(), materialTypeCounts = Object.create(null), legacyHostCounts = Object.fromEntries(LEGACY_DOMAINS.map(h => [h, 0])) + const inventoryDigest = createHash('sha256') + let cursor, pages = 0 + while (true) { + const rows = await tx.material.findMany({ + where: { deletedAt: null }, select: { id: true, materialType: true, previewUrl: true }, + orderBy: { id: 'asc' }, take: pageSize, ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }) + pages++ + if (!Array.isArray(rows) || rows.length > pageSize) throw new InventoryError('invalid_inventory_page') + if (!rows.length) break + for (const row of rows) { + if (typeof row.id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(row.id) || seen.has(row.id)) throw new InventoryError('duplicate_or_invalid_inventory_identity') + seen.add(row.id) + if (seen.size > activeCount) throw new InventoryError('inventory_count_mismatch') + const type = typeof row.materialType === 'string' ? row.materialType : '' + materialTypeCounts[type] = (materialTypeCounts[type] || 0) + 1 + const source = row.previewUrl + inventoryDigest.update(JSON.stringify([row.id, source ?? null]) + '\n') + if (typeof source === 'string') { + for (const host of LEGACY_DOMAINS) { + if ([`https://${host}/`, `http://${host}/`].some(prefix => source.toLowerCase().startsWith(prefix))) legacyHostCounts[host]++ + } + } + try { + const url = registeredSource(source) + materials.push({ materialId: row.id, registeredSource: url.href }) + } catch (error) { + uncheckable.push({ materialId: row.id, materialType: type, sourceSha256: sha256(typeof source === 'string' ? source : ''), + classification: error?.classification || 'source_rejected', code: error?.code || 'invalid_url' }) + } + } + const next = rows.at(-1).id + if (next === cursor) throw new InventoryError('inventory_cursor_stalled') + cursor = next + } + if (seen.size !== activeCount || materials.length + uncheckable.length !== activeCount) throw new InventoryError('inventory_count_mismatch') + const manifests = [] + for (let i = 0; i < materials.length; i += batchSize) { + manifests.push({ version: 1, reviewed: true, provenance: 'read-only-active-material-inventory', materials: materials.slice(i, i + batchSize) }) + } + const audit = { version: 1, readOnly: true, capturedAt: new Date().toISOString(), inventoryComplete: true, + scope: { table: 'Material', where: { deletedAt: null }, historyImportFilter: false }, + transaction: { isolationLevel: 'RepeatableRead', readOnly: true }, + activeCount, exportedCount: materials.length, uncheckableCount: uncheckable.length, materialTypeCounts, legacyHostCounts, + ...(baselineHost === undefined ? {} : { legacyBaseline: { host: baselineHost, expectedCount: baselineCount, actualCount: legacyHostCounts[baselineHost], + delta: legacyHostCounts[baselineHost] - baselineCount, matches: legacyHostCounts[baselineHost] === baselineCount } }), + inventorySha256: inventoryDigest.digest('hex'), pages, batchSize, batchCount: manifests.length, uncheckable, + readyForHealthChecks: uncheckable.length === 0, healthChecksExecuted: false } + return { audit, manifests } + }, { isolationLevel: 'RepeatableRead', timeout: 60_000, maxWait: 5000 }) +} + +export async function writeInventoryArtifacts(outputDirectory, inventory, { fsImpl = fs } = {}) { + if (typeof outputDirectory !== 'string' || !path.isAbsolute(outputDirectory)) throw new InventoryError('absolute_new_output_directory_required') + if (!inventory?.audit?.inventoryComplete || !Array.isArray(inventory.manifests) + || inventory.audit.batchCount !== inventory.manifests.length) throw new InventoryError('complete_inventory_required') + const output = path.resolve(outputDirectory) + // A fresh directory and exclusive files prevent stale acceptance or accidental overwrite. + await fsImpl.mkdir(output, { mode: 0o700, recursive: false }) + const batches = [] + for (let i = 0; i < inventory.manifests.length; i++) { + const file = `manifest-${String(i + 1).padStart(6, '0')}.json` + const raw = JSON.stringify(inventory.manifests[i], null, 2) + '\n' + await fsImpl.writeFile(path.join(output, file), raw, { flag: 'wx', mode: 0o600 }) + batches.push({ file, sha256: sha256(raw), manifestSha256: sha256(JSON.stringify(inventory.manifests[i])), materialCount: inventory.manifests[i].materials.length }) + } + const audit = { ...inventory.audit, batches } + // Written last: incomplete directory contents are never a complete inventory receipt. + await fsImpl.writeFile(path.join(output, 'audit.json'), JSON.stringify(audit, null, 2) + '\n', { flag: 'wx', mode: 0o600 }) + return { outputDirectory: output, audit } +} + +export async function main(args = process.argv.slice(2), { prismaClient, fsImpl = fs } = {}) { + const options = {} + for (let i = 0; i < args.length; i += 2) { + if (!['--output-dir', '--baseline-host', '--baseline-count'].includes(args[i]) || args[i + 1] === undefined || options[args[i]] !== undefined) { + throw new InventoryError('usage_output_directory_required') + } + options[args[i]] = args[i + 1] + } + if (!options['--output-dir']) throw new InventoryError('usage_output_directory_required') + if (!path.isAbsolute(options['--output-dir'])) throw new InventoryError('absolute_new_output_directory_required') + const baseline = options['--baseline-host'] === undefined && options['--baseline-count'] === undefined ? {} : { + baselineHost: options['--baseline-host'], baselineCount: Number(options['--baseline-count']), + } + optionsChecked(baseline) + const owned = !prismaClient + const prisma = prismaClient || new (runtimeRequire('@prisma/client').PrismaClient)({ log: [] }) + try { + const inventory = await collectMaterialPreviewInventory(prisma, baseline) + return await writeInventoryArtifacts(options['--output-dir'], inventory, { fsImpl }) + } finally { + if (owned) await prisma.$disconnect() + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().then(({ outputDirectory, audit }) => { + console.log(JSON.stringify({ version: 1, readOnly: true, outputDirectory, inventoryComplete: audit.inventoryComplete, + activeCount: audit.activeCount, exportedCount: audit.exportedCount, uncheckableCount: audit.uncheckableCount, + batchCount: audit.batchCount, legacyHostCounts: audit.legacyHostCounts, legacyBaseline: audit.legacyBaseline, healthChecksExecuted: false }, null, 2)) + process.exitCode = audit.readyForHealthChecks ? 0 : 1 + }, error => { + console.log(JSON.stringify({ version: 1, readOnly: true, inventoryComplete: false, + code: error instanceof InventoryError ? error.code : 'inventory_export_failed' }, null, 2)) + process.exitCode = 1 + }) +} diff --git a/scripts/export-material-preview-health-manifest.test.mjs b/scripts/export-material-preview-health-manifest.test.mjs new file mode 100644 index 00000000..981a3e2f --- /dev/null +++ b/scripts/export-material-preview-health-manifest.test.mjs @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import test from 'node:test' +import { pathToFileURL } from 'node:url' +import { collectMaterialPreviewInventory, main, writeInventoryArtifacts } from './export-material-preview-health-manifest.mjs' +import { validateManifest } from './check-material-preview-health.mjs' +import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs' + +const current = id => `https://s3.jiansun.vip/gongxiangpan/display/${id}.webp` +const row = (id, previewUrl = current(id), materialType = '材质') => ({ id, previewUrl, materialType }) +const hash = value => createHash('sha256').update(value).digest('hex') + +function database(rows, { reportedCount = rows.length, findMany } = {}) { + const events = [], reads = [] + const prisma = { + async $transaction(fn, options) { + events.push(['transaction', options]) + return fn({ + async $executeRawUnsafe(sql) { events.push(['sql', sql]) }, + material: { + async count(query) { events.push(['count', query]); return reportedCount }, + async findMany(query) { + reads.push(query) + events.push(['page']) + if (findMany) return findMany(query, reads.length) + const start = query.cursor ? rows.findIndex(item => item.id === query.cursor.id) + query.skip : 0 + return rows.slice(start, start + query.take) + }, + }, + }) + }, + async $disconnect() { events.push(['disconnect']) }, + } + return { prisma, events, reads } +} + +function memoryFs({ failFile } = {}) { + const writes = [], directories = new Set() + return { + writes, + async mkdir(directory, options) { + assert.deepEqual(options, { mode: 0o700, recursive: false }) + if (directories.has(directory)) throw new Error('EEXIST') + directories.add(directory) + }, + async writeFile(file, raw, options) { + assert.deepEqual(options, { flag: 'wx', mode: 0o600 }) + if (file.endsWith(failFile || '\0')) throw new Error('simulated write failure') + writes.push({ file, raw }) + }, + } +} + +test('all active rows are read in a consistent read-only snapshot, with no HISTORY_IMPORT filter', async () => { + const db = database(Array.from({ length: 103 }, (_, i) => row(`m-${String(i).padStart(3, '0')}`))) + const { audit, manifests } = await collectMaterialPreviewInventory(db.prisma, { pageSize: 25 }) + assert.deepEqual(db.events[0], ['transaction', { isolationLevel: 'RepeatableRead', timeout: 60_000, maxWait: 5000 }]) + assert.deepEqual(db.events[1], ['sql', 'SET TRANSACTION READ ONLY']) + assert.deepEqual(db.events[2], ['count', { where: { deletedAt: null } }]) + for (const query of db.reads) { + assert.deepEqual(query.where, { deletedAt: null }) + assert.deepEqual(query.select, { id: true, materialType: true, previewUrl: true }) + assert.deepEqual(query.orderBy, { id: 'asc' }) + assert.equal(query.take, 25) + assert.ok(!JSON.stringify(query).includes('HISTORY_IMPORT')) + } + assert.deepEqual(db.reads[1].cursor, { id: 'm-024' }) + assert.equal(db.reads[1].skip, 1) + assert.equal(audit.activeCount, 103) + assert.equal(audit.exportedCount, 103) + assert.equal(audit.uncheckableCount, 0) + assert.equal(audit.pages, 6, 'the final empty page proves pagination is exhausted') + assert.equal(audit.inventoryComplete, true) + assert.equal(audit.healthChecksExecuted, false) + assert.equal(audit.materialTypeCounts['材质'], 103) + assert.deepEqual(manifests.map(item => item.materials.length), [50, 50, 3]) + assert.equal(new Set(manifests.flatMap(item => validateManifest(item).map(entry => entry.materialId))).size, 103) +}) + +test('missing and unregistered sources remain counted and cannot become a full health pass', async () => { + const secret = 'do-not-print-signed-query' + const db = database([row('one'), row('two', null), row('three', '/uploads/old.webp'), + row('four', `https://unregistered.example/image.webp?signature=${secret}`, '__proto__')]) + const { audit, manifests } = await collectMaterialPreviewInventory(db.prisma) + assert.equal(audit.activeCount, 4) + assert.equal(audit.exportedCount + audit.uncheckableCount, audit.activeCount) + assert.equal(audit.uncheckableCount, 3) + assert.equal(audit.readyForHealthChecks, false) + assert.equal(audit.materialTypeCounts.__proto__, 1) + assert.deepEqual(manifests.flatMap(item => item.materials).map(item => item.materialId), ['one']) + assert.deepEqual(audit.uncheckable.map(item => item.materialId), ['two', 'three', 'four']) + assert.ok(!JSON.stringify(audit).includes(secret)) + assert.ok(audit.uncheckable.every(item => /^[a-f0-9]{64}$/.test(item.sourceSha256))) +}) + +test('legacy baseline is exact and reports drift rather than reusing 483 as current truth', async () => { + const rows = Array.from({ length: 483 }, (_, i) => row(`legacy-${i}`, `https://s3.1080p.hk/jsxiangche/${i}.webp`)) + rows.push(row('different-host', 'https://s3.yydd.ai/gongxiangpan/a.webp'), row('new-source')) + const db = database(rows) + const inventory = await collectMaterialPreviewInventory(db.prisma, { baselineHost: 's3.1080p.hk', baselineCount: 483 }) + assert.deepEqual(inventory.audit.legacyBaseline, { host: 's3.1080p.hk', expectedCount: 483, actualCount: 483, delta: 0, matches: true }) + assert.equal(inventory.audit.activeCount, 485) + assert.equal(inventory.audit.batchCount, 10) + const changed = await collectMaterialPreviewInventory(database(rows.slice(1)).prisma, { baselineHost: 's3.1080p.hk', baselineCount: 483 }) + assert.equal(changed.audit.legacyBaseline.actualCount, 482) + assert.equal(changed.audit.legacyBaseline.matches, false) + assert.equal(changed.audit.legacyBaseline.delta, -1) +}) + +test('duplicates, incomplete counts, invalid pages and changed pagination fail closed', async () => { + await assert.rejects(collectMaterialPreviewInventory(database([row('same'), row('same')]).prisma), { code: 'duplicate_or_invalid_inventory_identity' }) + await assert.rejects(collectMaterialPreviewInventory(database([row('one')], { reportedCount: 2 }).prisma), { code: 'inventory_count_mismatch' }) + await assert.rejects(collectMaterialPreviewInventory(database([row('one')], { reportedCount: 0 }).prisma), { code: 'inventory_count_mismatch' }) + await assert.rejects(collectMaterialPreviewInventory(database([], { findMany: () => null }).prisma), { code: 'invalid_inventory_page' }) + await assert.rejects(collectMaterialPreviewInventory(database([row('one'), row('two')], { findMany: () => [row('one'), row('two')] }).prisma, { pageSize: 1 }), { code: 'invalid_inventory_page' }) + await assert.rejects(collectMaterialPreviewInventory(database([row('one'), row('two')], { findMany: () => [row('one')] }).prisma, { pageSize: 1 }), { code: 'duplicate_or_invalid_inventory_identity' }) +}) + +test('empty inventory is explicit and invalid limits cause no database read', async () => { + const db = database([]) + const { audit, manifests } = await collectMaterialPreviewInventory(db.prisma) + assert.equal(audit.activeCount, 0) + assert.equal(audit.inventoryComplete, true) + assert.deepEqual(manifests, []) + const other = database([]) + for (const options of [{ batchSize: 51 }, { pageSize: 1001 }, { baselineCount: 483 }, { baselineHost: 'evil.example', baselineCount: 1 }]) { + await assert.rejects(collectMaterialPreviewInventory(other.prisma, options)) + } + assert.equal(other.events.length, 0) +}) + +test('fresh private files contain all batches and audit is written last with both hash identities', async () => { + const inventory = await collectMaterialPreviewInventory(database([row('one'), row('two')]).prisma, { batchSize: 1 }) + const fsImpl = memoryFs() + const directory = path.resolve('test-output-not-created') + const { audit } = await writeInventoryArtifacts(directory, inventory, { fsImpl }) + assert.deepEqual(fsImpl.writes.map(item => path.basename(item.file)), ['manifest-000001.json', 'manifest-000002.json', 'audit.json']) + for (let i = 0; i < 2; i++) { + assert.equal(audit.batches[i].sha256, hash(fsImpl.writes[i].raw)) + assert.equal(audit.batches[i].manifestSha256, hash(JSON.stringify(inventory.manifests[i]))) + assert.equal(audit.batches[i].materialCount, 1) + } + await assert.rejects(writeInventoryArtifacts(directory, inventory, { fsImpl }), /EEXIST/) + await assert.rejects(writeInventoryArtifacts('relative-output', inventory, { fsImpl }), { code: 'absolute_new_output_directory_required' }) +}) + +test('partial disk failure cannot leave a complete audit receipt or overwrite another run', async () => { + const inventory = await collectMaterialPreviewInventory(database([row('one'), row('two')]).prisma, { batchSize: 1 }) + const fsImpl = memoryFs({ failFile: 'manifest-000002.json' }) + await assert.rejects(writeInventoryArtifacts(path.resolve('not-created'), inventory, { fsImpl }), /simulated write failure/) + assert.equal(fsImpl.writes.length, 1) + assert.ok(!fsImpl.writes.some(item => item.file.endsWith('audit.json'))) +}) + +test('CLI supports injected runtime dependencies and rejects bad arguments before database access', async () => { + const db = database([row('one')]) + const fsImpl = memoryFs() + const result = await main(['--output-dir', path.resolve('not-created'), '--baseline-host', 's3.1080p.hk', '--baseline-count', '483'], { prismaClient: db.prisma, fsImpl }) + assert.equal(result.audit.activeCount, 1) + assert.equal(result.audit.legacyBaseline.matches, false) + assert.ok(!db.events.some(item => item[0] === 'disconnect'), 'an injected Prisma client is not owned by the CLI') + const before = db.events.length + await assert.rejects(main(['--output-dir', 'relative'], { prismaClient: db.prisma, fsImpl }), { code: 'absolute_new_output_directory_required' }) + await assert.rejects(main(['--output-dir', path.resolve('not-created'), '--batch-size', '500'], { prismaClient: db.prisma, fsImpl }), { code: 'usage_output_directory_required' }) + assert.equal(db.events.length, before) +}) + +test('stdin adapter resolves runtime CJS dependencies without deployed script files or a database connection', async () => { + const data = source => 'data:text/javascript;base64,' + Buffer.from(source).toString('base64') + const runtimeBase = pathToFileURL(path.resolve('package.json')).href + const adapt = source => source.replaceAll('import.meta.url', JSON.stringify(runtimeBase)) + const registry = data(`export const LEGACY_DOMAINS=${JSON.stringify(LEGACY_DOMAINS)}`) + const health = data(adapt(await readFile(new URL('./check-material-preview-health.mjs', import.meta.url), 'utf8')) + .replace("'./check-legacy-storage-boundaries.mjs'", JSON.stringify(registry))) + const exporter = data(adapt(await readFile(new URL('./export-material-preview-health-manifest.mjs', import.meta.url), 'utf8')) + .replace("'./check-legacy-storage-boundaries.mjs'", JSON.stringify(registry)) + .replace("'./check-material-preview-health.mjs'", JSON.stringify(health))) + const script = `import {createRequire} from 'node:module'; + const {collectMaterialPreviewInventory}=await import(${JSON.stringify(exporter)}); + const {checkMaterialPreviewHealth}=await import(${JSON.stringify(health)}); + const sharp=createRequire(${JSON.stringify(runtimeBase)})('sharp'); + const preview=await sharp({create:{width:3,height:2,channels:3,background:'#345678'}}).webp().toBuffer(); + let pages=0; + const inventory=await collectMaterialPreviewInventory({$transaction:async fn=>fn({ + $executeRawUnsafe:async sql=>{if(sql!=='SET TRANSACTION READ ONLY')throw Error('unexpected SQL')}, + material:{count:async()=>1,findMany:async()=>++pages===1?[{id:'one',materialType:'材质',previewUrl:${JSON.stringify(current('one'))}}]:[]} + })}); + const result=await checkMaterialPreviewHealth(inventory.manifests[0],{fetchImpl:async()=>new Response(preview,{headers:{'content-type':'image/webp'}})}); + console.log(JSON.stringify({count:inventory.audit.activeCount,passed:result.passed,decoded:result.results[0].decoded}));` + const child = spawnSync(process.execPath, ['--input-type=module', '-'], { input: script, encoding: 'utf8', timeout: 10_000 }) + assert.equal(child.status, 0, child.stderr) + assert.deepEqual(JSON.parse(child.stdout), { count: 1, passed: true, decoded: { width: 3, height: 2 } }) +}) diff --git a/scripts/recover-material-previews.mjs b/scripts/recover-material-previews.mjs new file mode 100644 index 00000000..ba5bf422 --- /dev/null +++ b/scripts/recover-material-previews.mjs @@ -0,0 +1,97 @@ +// Opt-in, reviewed recovery of authentic cached previews. No model calls, deletes, +// download-source changes or URL-prefix substitution. The caller durably saves +// the returned plan before apply, and provides exact storage/DB adapters. +import { createHash } from 'node:crypto' + +export const OPERATION_ID = 'material-preview-cache-20261023' +export const digest = value => createHash('sha256').update(value).digest('hex') +export const json = value => JSON.stringify(value, (_, v) => typeof v === 'bigint' ? v.toString() : v) +const check = (condition, message) => { if (!condition) throw new Error(message) } +const idPattern = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/ + +export function validateRecovery(manifest) { + check(manifest?.version === 1 && manifest.reviewed === true && manifest.operationId === OPERATION_ID, 'reviewed recovery required') + check(manifest.previewQuality === 'temporary-160px-derived' && manifest.materials?.length === 11, 'exact eleven temporary previews required') + check(idPattern.test(manifest.actorId), 'actor required') + const ids = new Set() + for (const row of manifest.materials) { + check(idPattern.test(row.materialId) && !ids.has(row.materialId), 'unique material identity required') + ids.add(row.materialId) + const old = new URL(row.oldPreviewUrl) + check(old.protocol === 'https:' && !old.username && !old.password && !old.search && !old.hash, 'exact original URL required') + check(/^[a-f0-9]{64}$/.test(row.sourceSha256) && /^[a-f0-9]{64}$/.test(row.cacheSha256), 'source and cache hashes required') + const bytes = Buffer.from(row.bodyBase64, 'base64') + check(bytes.length === row.bytes && bytes.length >= 100 && bytes.length <= 16384 && digest(bytes) === row.cacheSha256, 'cache bytes mismatch') + check(bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP', 'WebP required') + check(row.width === 160 && row.height === 160 && row.provenance === '20261019-sharp-rotate-inside160-webp80', 'verified derivation required') + check(row.destinationKey === `由壹点AI/public-display/${OPERATION_ID}/${row.materialId}-${row.cacheSha256}.webp`, 'scoped immutable destination required') + check(row.newPreviewUrl === `https://s3.jiansun.vip/gongxiangpan/${row.destinationKey}`, 'canonical destination required') + } + return manifest +} + +function verifyActor(actor) { + check(actor && actor.isActive === true && actor.isSuperAdmin === true && actor.status === 'active' && actor.archivedAt === null + && actor.employmentStatus === 'active', 'active authorized recovery actor required') +} + +function verifyRecord(record, row) { + check(record?.id === row.materialId && record.productModel === row.name && record.materialType === '材质' + && record.deletedAt === null && record.previewUrl === row.oldPreviewUrl, 'source record changed') +} + +export async function planRecovery(manifest, db) { + validateRecovery(manifest) + verifyActor(await db.actor(manifest.actorId)) + const records = await db.records(manifest.materials.map(r => r.materialId)) + check(records.length === 11, 'eleven source records required') + const ordered = manifest.materials.map(row => { + const record = records.find(r => r.id === row.materialId) + verifyRecord(record, row) + return record + }) + return { version: 1, operationId: OPERATION_ID, manifestSha256: digest(json(manifest)), capturedAt: new Date().toISOString(), records: ordered } +} + +export async function applyRecovery({ manifest, plan, persistedPlanSha256, db, objects }) { + validateRecovery(manifest) + check(plan.operationId === OPERATION_ID && plan.manifestSha256 === digest(json(manifest)) + && persistedPlanSha256 === digest(json(plan)) && plan.records?.length === 11, 'durably persisted matching plan required') + const originalPlan = await planRecovery(manifest, db) + check(json(originalPlan.records) === json(plan.records), 'source snapshot changed before upload') + check(await db.operationCount(OPERATION_ID) === 0, 'operation already recorded; inspect before continuing') + const uploaded = [] + // Every object is publicly readable and hash-verified before the DB transaction. + // A conflict leaves harmless, named unreferenced objects, never deletes anything. + for (const row of manifest.materials) { + const body = Buffer.from(row.bodyBase64, 'base64') + const existing = await objects.get(row.destinationKey) + if (existing === null) { + await objects.putIfAbsent(row.destinationKey, body, { operationId: OPERATION_ID, materialId: row.materialId, sha256: row.cacheSha256, quality: manifest.previewQuality }) + uploaded.push(row.destinationKey) + } else check(digest(existing) === row.cacheSha256, 'existing destination differs; refusing overwrite') + check(digest(await objects.get(row.destinationKey)) === row.cacheSha256, 'internal object readback mismatch') + check(digest(await objects.publicGet(row.newPreviewUrl)) === row.cacheSha256, 'public object readback mismatch') + } + await db.transaction(async tx => { + verifyActor(await tx.actor(manifest.actorId)) + check(await tx.operationCount(OPERATION_ID) === 0, 'operation concurrently recorded') + const current = await tx.records(manifest.materials.map(r => r.materialId)) + const ordered = manifest.materials.map(row => current.find(r => r.id === row.materialId)) + check(json(ordered) === json(plan.records), 'source snapshot changed; transaction aborted') + for (const row of manifest.materials) { + const before = plan.records.find(r => r.id === row.materialId) + check(await tx.compareAndSet(row, before.updatedAt) === 1, 'concurrent record change; transaction aborted') + await tx.log({ materialId: row.materialId, userId: manifest.actorId, action: 'PREVIEW_CACHE_RECOVERY', changes: json({ operationId: OPERATION_ID, previewQuality: manifest.previewQuality, oldPreviewUrl: row.oldPreviewUrl, newPreviewUrl: row.newPreviewUrl, originalSourceSha256: row.sourceSha256, cacheSha256: row.cacheSha256, width: 160, height: 160, downloadsAndAssetsUnchanged: true, snapshotSha256: persistedPlanSha256 }) }) + } + }) + const after = await db.records(manifest.materials.map(r => r.materialId)) + for (const before of plan.records) { + const actual = after.find(r => r.id === before.id) + const row = manifest.materials.find(r => r.materialId === before.id) + check(actual?.previewUrl === row.newPreviewUrl, 'post-commit preview mismatch; inspect operation logs') + const immutable = ({ previewUrl, updatedAt, ...rest }) => rest + check(json(immutable(actual)) === json(immutable(before)), 'post-commit unrelated data mismatch; inspect operation logs') + } + return { operationId: OPERATION_ID, updated: 11, uploaded, after, temporaryPreviewSize: 160, originalSourceRestored: false, downloadsAndAssetsUnchanged: true, modelCalls: 0 } +} diff --git a/scripts/recover-material-previews.test.mjs b/scripts/recover-material-previews.test.mjs new file mode 100644 index 00000000..f8d5361d --- /dev/null +++ b/scripts/recover-material-previews.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { OPERATION_ID, digest, json, planRecovery, applyRecovery, validateRecovery } from './recover-material-previews.mjs' + +function fixture() { + const body = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP'), Buffer.alloc(128)]) + const sha = digest(body) + const manifest = { version: 1, reviewed: true, operationId: OPERATION_ID, previewQuality: 'temporary-160px-derived', actorId: '11111111-1111-4111-8111-111111111111', materials: Array.from({ length: 11 }, (_, i) => { + const materialId = `22222222-2222-4222-8222-${String(i).padStart(12, '0')}` + const destinationKey = `由壹点AI/public-display/${OPERATION_ID}/${materialId}-${sha}.webp` + return { materialId, name: `material-${i}`, oldPreviewUrl: `https://legacy.example.test/${i}.webp`, sourceSha256: '0'.repeat(64), cacheSha256: sha, bodyBase64: body.toString('base64'), bytes: body.length, width: 160, height: 160, provenance: '20261019-sharp-rotate-inside160-webp80', destinationKey, newPreviewUrl: `https://s3.jiansun.vip/gongxiangpan/${destinationKey}` } + }) } + let records = manifest.materials.map(r => ({ id: r.materialId, productModel: r.name, materialType: '材质', deletedAt: null, previewUrl: r.oldPreviewUrl, updatedAt: '2026-01-01T00:00:00.000Z', zipFilePath: 'untouched.zip', zipFileSize: '100', assets: [{ id: 'source', fileUrl: 'original.png' }] })) + let logs = [] + const stored = new Map() + const hooks = { puts: 0, failCas: -1, failLog: false, badPublic: false, actorActive: true } + const db = { + actor: async () => ({ isActive: hooks.actorActive, isSuperAdmin: true, status: hooks.actorActive ? 'active' : 'disabled', employmentStatus: 'active', archivedAt: null }), + records: async () => structuredClone(records), + operationCount: async () => logs.length, + compareAndSet: async (row, updatedAt) => { + const index = records.findIndex(r => r.id === row.materialId && r.previewUrl === row.oldPreviewUrl && r.updatedAt === updatedAt) + if (index < 0 || index === hooks.failCas) return 0 + records[index] = { ...records[index], previewUrl: row.newPreviewUrl, updatedAt: '2026-10-23T00:00:00.000Z' } + return 1 + }, + log: async row => { if (hooks.failLog) throw new Error('log failure'); logs.push(row) }, + transaction: async fn => { + const before = structuredClone(records), previousLogs = structuredClone(logs) + try { await fn(db) } catch (error) { records = before; logs = previousLogs; throw error } + }, + } + const objects = { get: async key => stored.get(key) ?? null, putIfAbsent: async (key, bytes) => { assert.equal(stored.has(key), false); stored.set(key, bytes); hooks.puts++ }, publicGet: async url => hooks.badPublic ? Buffer.from('wrong') : stored.get(decodeURIComponent(new URL(url).pathname).slice('/gongxiangpan/'.length)) } + const prepare = async () => { const plan = await planRecovery(manifest, db); return { manifest, plan, persistedPlanSha256: digest(json(plan)), db, objects } } + return { manifest, db, objects, hooks, stored, prepare, records: () => records, logs: () => logs } +} + +test('requires reviewed exact count, hashes and scoped canonical destinations', () => { + const f = fixture(); assert.equal(validateRecovery(f.manifest), f.manifest) + for (const mutate of [m => { m.reviewed = false }, m => m.materials.pop(), m => { m.materials[0].cacheSha256 = 'f'.repeat(64) }, m => { m.materials[0].destinationKey = 'original.webp' }, m => { m.materials[0].materialId = m.materials[1].materialId }]) { + const m = structuredClone(f.manifest); mutate(m); assert.throws(() => validateRecovery(m)) + } +}) +test('successful recovery changes previews only, preserves source fields and creates eleven audits', async () => { + const f = fixture(), before = structuredClone(f.records()); const result = await applyRecovery(await f.prepare()) + assert.equal(result.updated, 11); assert.equal(f.logs().length, 11); assert.equal(f.hooks.puts, 11) + for (let i = 0; i < 11; i++) { assert.equal(f.records()[i].zipFilePath, before[i].zipFilePath); assert.deepEqual(f.records()[i].assets, before[i].assets); assert.match(f.logs()[i].changes, /temporary-160px-derived/) } +}) +test('rejects missing persisted snapshot proof and changed snapshot before any object write', async () => { + const f = fixture(), args = await f.prepare(); args.persistedPlanSha256 = 'wrong' + await assert.rejects(applyRecovery(args), /persisted/); assert.equal(f.hooks.puts, 0) + const args2 = await f.prepare(); f.records()[0].productModel = 'concurrent edit' + await assert.rejects(applyRecovery(args2), /source record changed/); assert.equal(f.hooks.puts, 0) +}) +test('public readback mismatch cannot change database', async () => { + const f = fixture(), args = await f.prepare(); f.hooks.badPublic = true + await assert.rejects(applyRecovery(args), /public object/); assert.equal(f.logs().length, 0); assert.equal(f.records()[0].previewUrl, f.manifest.materials[0].oldPreviewUrl) +}) +test('existing different object cannot be overwritten', async () => { + const f = fixture(), args = await f.prepare(); f.stored.set(f.manifest.materials[0].destinationKey, Buffer.from('different')) + await assert.rejects(applyRecovery(args), /refusing overwrite/); assert.equal(f.hooks.puts, 0); assert.equal(f.logs().length, 0) +}) +test('CAS conflict rolls back every row and audit, keeps named objects without deleting', async () => { + const f = fixture(), before = structuredClone(f.records()), args = await f.prepare(); f.hooks.failCas = 6 + await assert.rejects(applyRecovery(args), /concurrent record/); assert.deepEqual(f.records(), before); assert.equal(f.logs().length, 0); assert.equal(f.stored.size, 11) +}) +test('audit failure rolls back all database changes', async () => { + const f = fixture(), before = structuredClone(f.records()), args = await f.prepare(); f.hooks.failLog = true + await assert.rejects(applyRecovery(args), /log failure/); assert.deepEqual(f.records(), before); assert.equal(f.logs().length, 0) +}) +test('revoked actor blocks planning and duplicate operation never writes objects again', async () => { + const f = fixture(); f.hooks.actorActive = false; await assert.rejects(f.prepare(), /actor/) + f.hooks.actorActive = true; const args = await f.prepare(); await applyRecovery(args) + await assert.rejects(applyRecovery(args)); assert.equal(f.hooks.puts, 11) +}) From a890660853468e07bb45359d7e79d88d22c48ff1 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Fri, 23 Oct 2026 19:47:58 +0800 Subject: [PATCH 2/9] fix(materials): audit historical previews and gate production releases Preserve known legacy failures by reviewed identity, probe material delivery before and after releases, and retain originals and downloads. --- docs/material-preview-health.md | 81 +- docs/material-preview-incident-2026-10-23.md | 42 +- scripts/audit-legacy-material-previews.mjs | 237 +++ .../audit-legacy-material-previews.test.mjs | 91 + scripts/check-material-preview-health.mjs | 7 +- .../check-material-preview-health.test.mjs | 9 + ...aterial-preview-known-legacy-20261023.json | 1896 +++++++++++++++++ scripts/git-incremental-deploy.ps1 | 127 +- scripts/git-incremental-deploy.test.mjs | 63 + scripts/material-preview-release-gate.mjs | 309 +++ .../material-preview-release-gate.test.mjs | 236 ++ 11 files changed, 3083 insertions(+), 15 deletions(-) create mode 100644 scripts/audit-legacy-material-previews.mjs create mode 100644 scripts/audit-legacy-material-previews.test.mjs create mode 100644 scripts/fixtures/material-preview-known-legacy-20261023.json create mode 100644 scripts/material-preview-release-gate.mjs create mode 100644 scripts/material-preview-release-gate.test.mjs diff --git a/docs/material-preview-health.md b/docs/material-preview-health.md index 2d81df14..f02c5d15 100644 --- a/docs/material-preview-health.md +++ b/docs/material-preview-health.md @@ -1,6 +1,68 @@ # 素材预览只读验收 -当前为独立、显式执行的验收命令,尚未接入发布流程或定时任务。不会修复图片、修改引用、执行 Schema DDL 或调用模型。 +正式发布入口 `scripts/git-incremental-deploy.ps1` 已接入只读预览门禁,Mac 入口抽取同一份模板。它不修复图片、不修改引用、不执行 Schema DDL、不调用模型,也不改原素材或下载文件。新增脚本提交并推送只证明代码已纳入发布;是否已在生产安装首份审核基线、实际执行哪个阶段,必须以对应私有回执为准,不能用离线测试代替生产验收。 + +## 实际发布门禁 + +每次真实发布均执行三阶段:`capture` 在旧容器停止前,`candidate` 在切流前,`post` 在代理目标验证通过后、写入当前版本记录前。direct replacement 的 candidate/post 失败调用现有 `fail_direct_replace_with_restore`;保留的蓝绿代码分别调用已有的切流前失败处理和切流后回滚处理。没有跳过开关。Mac 与 PowerShell 都从构建机**精确 Git checkout**生成门禁模块,并通过现有 SSH stdin 执行,不要求旧镜像内已有新脚本,不向应用目录复制代码。 + +`material-preview-release-gate.mjs` 复用现有清点器、直连网络防护和 Sharp 全像素解码。在只读 RepeatableRead 事务中枚举全部未删除 `Material.previewUrl`,包含缺地址或不支持检查的记录,不用 `HISTORY_IMPORT`。全元数据索引与 SHA 保留在每份回执;图片 GET 仅覆盖: + +- **全部仍指向独立旧桶的活跃引用**,本轮预计 472 项,实时枚举,不硬编码数量掩盖新增或遗漏。 +- 十二项关键材质,按固定素材 ID 核对,不允许减少或改成其它样本。 +- 与上次已接受全元数据索引相比,新增或预览源地址改变的当前素材。 + +其余未变化的当前源与原有不受支持的预览明确登记为 `unverified`,不能记健康。新增或变化后变成不可检查的引用直接阻止发布。并发上传/删除造成候选或 post 库存 SHA 与 capture 不同,也停止并保留旧版本;不会通过重新抽样、忽略新记录或无限重试来抹平差异。 + +固定预算为每阶段最多 768 次单次 GET、4 并发、正文总量 128 MiB、总时长 360 秒;单项仍受 8 MiB、10 秒、2500 万像素限制,不重试、不跟随跳转。范围超过预算时停止,需另行安排独立全量验收,不能临时提高预算或清空基线。 + +候选与 post 使用 capture 的**同一份探测清单及 SHA**,逐项核对素材 ID、源地址 SHA、角色、结果数量、解码与正文 SHA;同时比较规范化索引和原始登记值的库存摘要。既有健康 URL 的内容在未改地址时变化、新失败、原失败类型或完整失败正文改变、漏项、检测器不可用均不通过。472 项既有 FRP 404 可以作为人工审核的事故基线保留,但每个阶段均实际重读并明确报告 `knownFailures`、`allSelectedHealthy=false`;它们不能成为正常图片,也不能自动容纳新失败。某项已恢复健康后若下一阶段再次失败,会阻止发布。 + +首次故障准入还绑定版本化 `scripts/fixtures/material-preview-known-legacy-20261023.json` 的 **472 对精确素材 ID + 规范化源地址 SHA**,并固定 489 字节、正文 SHA `967e369d3be4fab5eb1229ec015b2923abf6bc81ebe3aaa662cb095cbec81023` 的已核实 FRP 页。集合 SHA 固定为 `953e6b1c9e10ea05e30e289ebbd5364569d918b5dd4a1273af52abc531fe475c`,生成依据是独立只读逐项审计原文件 SHA `40eb8d834d5c6bd9edcdc49e69ae227a83be2374bb9b525a0cec0bcd652c4e0d`。proof 只有 ID/摘要,没有名称、URL 或凭据;构建时校验后内联,不能在首次 bootstrap 时根据现场新失败自动重建。第 473 项、删一项换入另一 ID、同 ID 变更源,均不能混入已知故障,即使返回完全相同的 FRP 页。 + +登记源与实际同桶 canonical 交付地址分别探测、分别保留结果。只复用现有“当前桶的旧兼容域名交付到当前公开入口”语义,不改变桶或 key;独立旧桶永远保持原址,不使用域名替换掩盖故障。新花色01的旧登记域名网络失败,只能在其固定素材 ID 的 canonical 图片完整解码为 768×1600、原正文 SHA `b40687cac0cbd537e209f8caa56c642bf501a658d0c3585140e8b9950937da53` 一致时单列;不能把前者改写为健康,也不能将其它当前源失败一概豁免。 + +URL 重写、代理、S3 或网络识别五个源码文件的精确指纹,与**上次接受基线、当前容器、目标 checkout**三方比较。即使现场已被绕过门禁改成 B、此次目标也为 B,旧基线 A 仍会触发扩大范围。非秘密配置 `S3_ENDPOINT`、`S3_BUCKET`、`S3_FILE_PATH`、`S3_PUBLIC_DOMAIN`、`S3_REGION`、`SERVER_IP`、`MINIO_API_PORT`、`APP_PUBLIC_URL` 同样做三方指纹比较;目标来自真实部署 env 文件的白名单,脚本控制的公开 URL 按真正 Docker 参数覆盖,不读取或记录 access key/secret 字段,拒绝 URL userinfo、query、片段和重复目标键。缺少完整目标配置直接停止;candidate/post 实际配置必须与 capture 记录的目标一致,不能只靠公开 URL 仍然可读放行内部回源变化。这些交付代码/配置变化会扩大为全部当前预览;超预算会 fail-closed,要求独立全量验收后再安排该类发布。本入口不提供跳过保护的维护参数。 + +审计位置固定属于本项目: + +```text +/mnt/PoolOne/apps/youyidian-3d/local-proxy/youyidian-3d-material/material-preview-health/ + accepted-pointer.json + bootstrap-<审核编号>/bootstrap.json + release--<随机串>/ + accepted.json + accepted-pointer-before.json + gate-module.url + capture.json + candidate.json + post.json +``` + +根目录和单次目录为 0700,文件为 0600。`accepted-pointer.json` 是唯一原子更新的指针,包含已审核/已接受文件的相对路径和**原文件字节 SHA**。读取时拒绝软链接、越界路径、摘要不符或超过 32 MiB 的基线。首次缺少指针或基线时,在停止旧容器前失败;不自动创建“全部正常”的基线。完整发布验收后才原子推进指针;旧回执、旧指针快照、失败报告仍留存,不混入素材或客户端临时目录。 + +### 首次审核基线(独立、显式步骤) + +首次不把两万余条未探测的当前源伪装为健康,也不因它们全是“新索引项”而自动下载两万张图片。由已核对的项目运维操作者完成一次 bootstrap:读取全库存元数据,探测全部独立旧桶和十二项关键材质,审核其结果后固定原文件 SHA。正常发布永远不能自动重建 bootstrap。 + +1. 在已审核的精确 checkout 中生成模块 URL:`node scripts/material-preview-release-gate.mjs bundle`。模块不含 Key 或数据库 URL。通过已核对的本项目 SSH 将输出作为 0600 文件存入新的私有 `bootstrap-<审核编号>/gate-module.url`,不要写入镜像或源码目录。 +2. 使用本项目当前容器的依赖和环境运行下面的**只读** bootstrap。调用前仍需核对 NAS、当前容器、部署 SHA 和目录身份;`<已核对40位SHA>` 必须替换为真实值。创建新目录与审计文件属于保存证据,不是修改数据库。 + +```sh +python3 - <新的bootstrap目录>/gate-module.url <已核对40位SHA> <<'PY' | docker exec -i -w /app <已核对当前容器> node --input-type=module - > <新的bootstrap目录>/bootstrap.json +import json, pathlib, sys +module = pathlib.Path(sys.argv[1]).read_text() +request = dict(version=1, stage='bootstrap', commit=sys.argv[2]) +print('const {runReleaseStage}=await import('+json.dumps(module)+');') +print('try {const r=await runReleaseStage('+json.dumps(request)+');console.log(JSON.stringify(r));process.exitCode=r.passed?0:1;} catch(e) {console.log(JSON.stringify({passed:false,code:"bootstrap_failed"}));process.exitCode=1;}') +PY +``` + +3. 独立审核该文件的库存数量、完整性、十二项材质、旧桶全部引用数量、每项失败类型、正文 SHA/尺寸、`knownFailures`、`unverified` 和预算。`passed=true` 只表示满足有明确已知事故基线的发布比较要求,不代表旧桶已恢复。原登记 URL 或数据库链接不应粘贴到公开日志。 +4. 用 `sha256sum bootstrap.json` 获得精确原文件 SHA,审核后以相对路径及该**已核对 SHA**写入 0600 的 `accepted-pointer.json`。指针结构示例:`{"version":1,"file":"bootstrap-审核编号/bootstrap.json","sha256":"<已核对64位SHA>"}`。不得把示例 SHA、空结果或重新运行后的不同文件替代已审核基线;正常发布只读取,不自动安装。 +5. 用相同精确门禁模块执行当前容器的 capture→candidate→post 只读演练,并保留三个实际回执。程序化调用后三阶段时除 `accepted`、`capture`、`candidate` 对应回执外,还必须传 `targetDeliveryConfig`;当前容器演练可用审核后的 `accepted.deliveryConfig`,正式发布则由 PS1 白名单解析待部署 env 与实际 Docker 覆盖值。演练没有替换镜像/切换代理,不能写成真实候选切流验收。正式发布时同三个函数自动执行,审计清单由 capture 固定。 + +跨平台演练须核对目标 checkout 与同一 Git 提交的原始 blob 字节。Windows 的 CRLF 工作副本可能与生产 LF 不同,应在新的受限目录按 Git blob 构建匹配字节快照;不能通过忽略或归一化行尾放宽门禁。正式构建的模块指纹与 Docker 上下文均来自同一精确 checkout。 ## 导出并逐批检查 @@ -17,7 +79,7 @@ node scripts/check-material-preview-health.mjs --manifest <新目录>/manifest-0 检查器按清单中的精确登记地址执行有界单次 GET,不重试、不跟随跳转,并核对 HTTP、MIME、图片魔数及 **Sharp 完整像素解码**;仅头部可识别不足以通过。8 MiB、10 秒、2500 万像素上限保持固定,解码器按运行环境惰性 `createRequire` 加载,不执行安装。保留清单、清单哈希和全部批次报告。`audit.batches[].sha256` 对应原文件字节,`manifestSha256` 对应检查器的同名字段(紧凑 JSON 哈希),二者须分别核对。单批 50 项通过、或 12 项材质通过,均不能写成“全库存通过”。 -完整图片验收需另行安排流量预算,逐一运行所有 `audit.batches` 并对齐哈希、素材 ID、数量及退出码。当前修复验收仅运行材质 12 项的已审核清单;**不因导出了两万余项元数据而自动发出全库存图片 GET**。未运行的批次保留为未检查。 +完整图片验收需另行安排流量预算,逐一运行所有 `audit.batches` 并对齐哈希、素材 ID、数量及退出码。2026-10-23 已完成 472 项旧源单次 GET,以及发布检查三阶段每次 485 个固定地址的实际探测;没有执行两万余张当前源全量 GET。**不因导出了两万余项元数据而将它们标记为健康**,未运行的批次保留为未检查。首次基线安装、三阶段原文件 SHA、已知失败与现场范围见 `docs/material-preview-incident-2026-10-23.md`。 ## 尚未部署脚本时的 stdin 适配 @@ -51,22 +113,23 @@ try { const r=await main(${JSON.stringify(args)}); stdin 中的 `createRequire('file:///app/package.json')` 使用容器已有的 `@prisma/client`/Sharp;不是读取本机另一套数据库配置。若依赖不存在、数据库事务失败或输出中途失败,退出非零,不能把残留文件当完整清单。导出器在所有批次成功写入后才写 `audit.json`。导出完成后再由调用方按需取回只读审计文件,本命令不清理文件。 -## 发布接入位置(待实施) +## 发布接入函数 -当前发布采用 direct replacement;本轮不修改共享发布脚本。后续接入 `scripts/git-incremental-deploy.ps1` 时按函数语义定位,行号仅供参考: +当前正式策略仍为 direct replacement,没有恢复蓝绿发布入口。接入点按函数语义定位: | 位置 | 应保留的验收证据 | | --- | --- | -| `direct_replace_active_container` 中 `docker stop` 前,约 2839–2842 行 | 导出全活跃库存,记录旧容器基线及所有批次结果 | -| `verify_candidate_brand_and_storage_dependencies` 后、切流前,约 2882–2897 行 | 新容器检查同一份清单;核对新增失败,不能重新抽样替换基线 | -| `verify_proxy_target` 成功后、`write_current_container` 前,约 2897–2900 行 | 从公网路径确认同一份清单,保留结果后再记录当前版本 | +| `direct_replace_active_container` 中 `docker stop` 前 | `prepare_material_preview_release` 验证已审核指针;`run_material_preview_release_gate ... capture` 清点全库存并固定探测清单 | +| `verify_candidate_brand_and_storage_dependencies` 后、切流前 | `run_material_preview_release_gate ... candidate` 验证同清单与库存;失败走现有 immutable image 恢复 | +| `verify_proxy_target` 成功后、`write_current_container` 前 | `run_material_preview_release_gate ... post` 通过公开存储 URL 实际 GET/解码同清单;失败走现有恢复 | +| 当前版本记录成功后 | `accept_material_preview_release` 核对三个阶段同 commit/清单/库存,原子推进指针 | -macOS wrapper 从同一 PowerShell 源码抽取发布逻辑,接入时需同时回归,不另复制一套条件。既有故障必须在报告中保留,不将未检查项当通过,也不因独立检查命令存在就宣称发布门禁已启用。 +macOS wrapper 从同一 PowerShell 源码抽取发布逻辑,不另复制一套条件。已有 shell 回归验证 capture 失败时没有 stop/switch、candidate 失败时没有 switch、post 失败时调用一次现有回滚,且失败不推进基线。该只读探测验证公开存储交付路径,不能代替登录态页面、私有下载资格或客户端实际 UI 的单独验收。 11 项材质预览恢复属于独立的精确 CAS 数据补丁:先验证新对象,再以原值为条件更新并保留回退记录;不得混入本只读检查器,也不代表高清原件或其余历史依赖已恢复。 相关离线回归: ```sh -node --test scripts/check-material-preview-health.test.mjs scripts/export-material-preview-health-manifest.test.mjs +node --test scripts/check-material-preview-health.test.mjs scripts/export-material-preview-health-manifest.test.mjs scripts/material-preview-release-gate.test.mjs scripts/git-incremental-deploy.test.mjs ``` diff --git a/docs/material-preview-incident-2026-10-23.md b/docs/material-preview-incident-2026-10-23.md index 5fa00ac1..ded3ec0b 100644 --- a/docs/material-preview-incident-2026-10-23.md +++ b/docs/material-preview-incident-2026-10-23.md @@ -1,6 +1,6 @@ # 旧素材预览失效核查(2026-10-23) -状态:已确认旧域名入口路由失败、存量迁移覆盖缺口及 11 份真实材质缩略图的转换来源。**11 项临时 160px 预览已恢复,正式 Web 与现有客户端均复核为 12 张缩略图、0 失败;高清源仍未恢复,旧入口的具体触发变更尚未归因,不能记为事故完全解决。** +状态:已确认旧域名入口路由失败、存量迁移覆盖缺口及 11 份真实材质缩略图的转换来源。**11 项临时 160px 预览已恢复,正式 Web 与现有客户端均复核为 12 张缩略图、0 失败;高清源未恢复。剩余 472 项已逐一核查并确认属于 2025 年旧批次,用户确认旧桶素材无法找回,停止继续追源。具体路由变更仍未归因,不冒充已找回原件或已查明触发者。** ## 已确认的事实 @@ -63,13 +63,25 @@ sharp(original) - 复用现有唯一客户端窗口(PID `9260`、HWND `2363054`),没有另开窗口、没有更改项目资料;逐项执行 11 次素材图片只读重读,滚动检查上、下区域并截图,汇总确认 12 张真实缩略图、0 个失败标记。 - 本轮没有重新验收悬停大图;先前的悬停证据不计作这次恢复后的验证。已通过的范围是 Web 与客户端的临时缩略预览,`originalSourceRestored=false`。 -因此“11 项临时 160px 缩略预览恢复”已通过;高清源、原件可取回性、旧入口故障的具体触发变更及其余旧依赖仍是开放事项。本次未重发结果未知的模型请求。 +因此“11 项临时 160px 缩略预览恢复”已通过;高清源没有恢复,旧入口故障的具体触发变更仍未查明。本次未重发结果未知的模型请求。关于旧批次无法找回的用户确认及停止追源范围,见下文。 + +## 剩余 472 项的完整核查与用户确认 + +2026-10-23 对剩余 472 个独立登记 URL 分十批完成只读 GET,无重试。全部返回 HTTP 404、489 字节 FRP 默认页,正文 SHA 均为 `967e369d3be4fab5eb1229ec015b2923abf6bc81ebe3aaa662cb095cbec81023`,没有一项被记为健康。类型为 3D场景 305、详情模板 87、其他 61、模特 7、实拍图片 6、买家秀图片 6。 + +按数据库 `createdAt`(UTC+8)核对,这 472 条记录建档于 **2025-10-27 16:43:57.805 至 2025-12-20 17:59:08.983**,其中 10 月 8 条、11 月 363 条、12 月 101 条。这是记录建档时间,不是原文件生成时间。先前恢复临时预览的 11 项材质均建档于 2025-11-07,属于同一旧桶。用户据此确认该批旧桶素材无法找回,本次停止继续寻找旧桶服务器或扩大扫描;保留素材记录、缓存、原登记来源和下载文件,不以空图覆盖、不删除素材。 + +106 条记录含 2215 个 `displayImages` 候选,仍全部指向同一旧入口;MaterialAsset 与 SKU 元数据未提供可校验的替代预览来源。该元数据核对没有下载 ZIP 或原素材。后续仅在本项目当前桶的受限范围扫描 250000 条对象元数据,对 27 个尺寸候选读取 452568 字节,没有原 SHA 命中;达到预算时游标尚未耗尽,故**不据此声称整个桶不存在原图**。用户的历史说明与本次未找到可校验副本分别保留,不把 404 等同于物理删除证明。 + +对当前旧入口的 NAS 直连核查证实 FRP 404;本机 TUN 下按 IP/SNI 探测可能被转发,不能单凭本机相似响应归因历史主机。既有访问记录只能确定成功与失败的观测时间,缺少该入口的 DNS/FRPS 审计,仍无法精确指出哪次配置变更触发故障。已排除的本项目变更和迁移筛选缺口见前文,不将后续安全升级认定为首次故障原因。 ## 防复发检查及操作边界 新增独立只读工具 `scripts/check-material-preview-health.mjs`,输入显式审阅的素材 ID 与登记图片 URL 清单。它限制项目既有域名、公网 DNS 地址、字节数与时间;不跟随跳转、不重试,检查真实 GET、Content-Type、图片魔数和 Sharp 完整像素解码,区分 FRP/HTML 路由失败、S3 缺桶、S3 缺对象及网络异常。只有图片头、损坏正文或像素炸弹不能通过。 -`scripts/export-material-preview-health-manifest.mjs` 在只读 RepeatableRead 事务中清点所有未删除的 Material,按最多 50 项分批,不再用 `HISTORY_IMPORT` 筛选;缺 URL 或不支持地址仍计入审计,不会被静默遗漏。与安全恢复工具合计 31 项离线测试通过。执行方式、生产 stdin 适配与发布前后接入点见 `docs/material-preview-health.md`。目前为显式执行工具,**尚未接入自动发布或定时监控**,不能声称自动防回归已部署。 +`scripts/export-material-preview-health-manifest.mjs` 在只读 RepeatableRead 事务中清点所有未删除的 Material,按最多 50 项分批,不再用 `HISTORY_IMPORT` 筛选;缺 URL 或不支持地址仍计入审计,不会被静默遗漏。新增 `scripts/material-preview-release-gate.mjs` 已接入 `git-incremental-deploy.ps1` 实际发布路径,Mac wrapper 共用该模板:停旧容器前 capture,候选切流前 candidate,切流后 post;失败停止或进入原有回滚,不推进已接受基线。相关离线回归 68/68 通过。执行方式、首次审核和实际函数位置见 `docs/material-preview-health.md`,不将其声称为定时监控。 + +已知事故基线严格绑定 472 个素材 ID 与规范化源 SHA 的固定集合(集合 SHA `953e6b1c9e10ea05e30e289ebbd5364569d918b5dd4a1273af52abc531fe475c`);新增第 473 条、等量替换 ID 或同 ID 改源均不能混入。全库存元数据每次重读,新增或变更预览必须进入同一固定验收清单;交付代码或八项非秘密配置在已接受基线、现场、目标之间变化则扩大检查范围,预算不足时拒绝发布,不能跳过检查。已知旧失败仍记录为失败,不计入健康率。 恢复后生产只读清点共 23467 条活跃记录,其中 23263 条为检查器支持的 URL,204 条缺地址或不受该图片检查器支持(不等于 204 条图片损坏)。旧入口引用由 483 减少到 472,只有本次 11 项材质改变;未对其余库存发起全量图片 GET。11 个新对象的实际公网 GET、完整解码及 SHA 均通过。 @@ -77,7 +89,19 @@ sharp(original) 下次涉及存储域名、代理、运行时或 frpc 维护,维护前后需要同时检查当前入口和仍有活跃引用的历史入口,并保留前后报告;仅进程在线、首页健康或配置 SHA 未变,不算素材交付验证。桶退役之前必须清点所有活跃引用,并核对“对象真实存在且内容一致,再改引用”,不能只按导入日志标签判断迁移完成。 -下一步仍需要旧入口故障窗口内的 DNS/FRPS/客户端变更证据,以及原对象或经过校验的迁移清单。恢复时保留原文件,使用精确条件更新和可回退记录;临时 160px 预览必须保留尺寸与来源记录,不能掩盖高清源未恢复或扩大为其它素材已恢复的结论。 +若后续另有旧入口历史审计或可信原件,才能继续精确归因或恢复高清;当前按用户确认停止追源。临时 160px 预览继续保留尺寸与来源记录,不能掩盖高清源未恢复或扩大为其它素材已恢复的结论。所有原素材、现有下载文件和元数据均保留。 + +## 生产发布检查实测(2026-10-23) + +已核对当前生产容器 ID `71b756a0f44a0f7b68733f7245290b629d6e78305f77bac2a0274c4575cfe22e`、不可变镜像及 `/app/DEPLOYMENT_COMMIT=df90e6f4799fcdf69b3b22f9910d8f4b4a596e77`。使用容器已有依赖运行 bootstrap,逐项对照独立 472 项审计及固定集合后,明确审核原文件 SHA `baa10b14036c9a48bd23739d7583c3273fac935d77891fc523373fce289978fc`,安装到本项目私有审计目录的 `bootstrap-20261023-reviewed-472/bootstrap.json` 与 0600 原子指针。 + +随后直接抽取实际 PS1 的 `prepare_material_preview_release` 与 `run_material_preview_release_gate` 函数,针对同一当前容器依次执行 capture、candidate、post:每阶段全量清点 23467 条元数据,按同清单实际 GET 485 次、0 重试。12 项关键材质的交付地址完整解码;472 项旧桶失败及 1 项旧兼容登记域名失败继续如实记录,22983 条未探测库存明确为未验证。`passed=true` 表示未发现较已审核事故基线的新回归,**不代表 472 项恢复、所有库存健康或高清原图找回**。 + +三阶段计划 SHA 均为 `1502a140ddd5f52339f591ad903434617e5274b1b2b6a8d34706b3eabdf5a4f7`;capture、candidate、post 原文件 SHA 分别为 `1cd8fd4cc9a4f51a0445c36d8237c5fb2e6356151c7247befee11094ea54c59a`、`f9ab48db352ec35565269a9da9418dc1bb7efe5bc79188fdfe3ad294e9f6800a`、`469733dda2411817af842d4925bb6621c3aba1dd1f25b5f5fe2c36f21ba6bf5c`。 + +首次从 Windows 工作副本打包时,五个交付文件的 CRLF 与生产 LF 字节不同,capture 在停服务前因扩大范围超预算而拒绝,保留失败回执;逐项核对 Git blob 与生产 SHA 一致后,才从原始 blob 的受限快照生成模块重演,没有忽略行尾或放宽规则。该现场负例没有触发容器停止。 + +本次是对当前容器的真实只读检查演练,**没有替换镜像、切换代理、运行生产回滚或推进到 post 基线**。前后容器、启动时间及 restartCount=0 均一致;原有故障回滚分支由离线部署测试验证。生产只新增本项目的审计文件和已审核 bootstrap 指针,数据库与存储对象零写入,模型调用零次,客户端窗口零新增。成功及失败演练的临时模块 URL/非秘密传输 env,以及本机字节快照均已清理;必要审计回执保留。 ## 证据 @@ -93,4 +117,14 @@ sharp(original) - `material-previews-ui-acceptance.json`:单个现有客户端窗口与正式 Web 的 12 张缩略图、0 失败验收回执。 - `material-previews-native-restored-top.png`、`material-previews-native-restored-bottom.png`、`material-previews-web-restored.png`:恢复后的客户端上、下区域及正式 Web 实际截图。 +本地 `output/material-preview-recovery-20261023/` 下另保留: + +- `remaining-472-audit/`:完整元数据、候选映射、十批实际 GET、独立汇总与文件哈希。 +- `original-preview-after-prefix-audit.json`:受限 250000 条元数据、27 个候选的原 SHA 核对及预算边界。 +- `recorded-entry-probes-from-production.json`:NAS 直连旧入口结果,避免本机 TUN 误归因。 +- `release-gate-live/legacy-472-created-at.json`:472 项建档范围与逐月数量的只读数据库复核。 +- `release-gate-live/bootstrap.json`、`bootstrap-review.json`、`production-bootstrap-install.json`:首次已审核基线及生产安装回执。 +- `release-gate-live/capture.json`、`candidate.json`、`post.json`、`final-validation.json`:实际发布函数的三阶段同清单探测及容器身份未变证据。 +- `release-gate-live/failed-rehearsal-diagnosis.json`、`exact-snapshot-manifest.json`、`cleanup.json`:行尾差异负例、原始 Git 字节快照摘要及临时文件收尾。 + 本轮生产恢复范围为 11 个新增临时预览对象、11 项精确预览引用更新及 11 条同事务审计日志;没有修改原文件、下载路径、桶或 FRP 配置,也未重发结果未知的模型请求。本文件此次更新只登记已有实施与验收证据,没有另行执行生产操作。 diff --git a/scripts/audit-legacy-material-previews.mjs b/scripts/audit-legacy-material-previews.mjs new file mode 100644 index 00000000..14f81179 --- /dev/null +++ b/scripts/audit-legacy-material-previews.mjs @@ -0,0 +1,237 @@ +// Opt-in, read-only audit of one exact registered legacy dependency inventory. +// No database/object writes, source substitution, retries or original-file GETs. +import { createHash } from 'node:crypto' +import * as fs from 'node:fs/promises' +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { checkMaterialPreviewHealth, directFetch, LIMITS, registeredSource } from './check-material-preview-health.mjs' +import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs' + +export const AUDIT_LIMITS = Object.freeze({ expectedCount: 472, batchSize: 50, concurrency: 2 }) +const sha = value => createHash('sha256').update(value).digest('hex') +const assert = (value, code) => { if (!value) throw new Error(code) } +const previewRoles = new Set(['preview', 'main-preview', 'sku-preview', 'detail-preview', 'report-image']) +const imageExtension = /\.(png|jpe?g|webp)$/i + +export function metadataCandidates(row) { + const candidates = [], excludedAssets = [] + const add = (source, origin, identity, size) => { + let url + try { url = new URL(source) } catch { return } + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || !imageExtension.test(url.pathname)) return + let publicCheckable = false + try { registeredSource(source); publicCheckable = true } catch {} + candidates.push({ source, origin, identity, ...(size === undefined ? {} : { declaredBytes: String(size) }), + sameAsRegistered: source === row.previewUrl, legacyHost: LEGACY_DOMAINS.includes(url.hostname), publicCheckable, + metadataOnly: true, bytesRead: 0, originalIdentityVerified: false }) + } + for (const [index, source] of (row.displayImages || []).entries()) add(source, 'displayImages', String(index)) + for (const asset of row.assets || []) { + if (!previewRoles.has(asset.assetType)) { + excludedAssets.push({ assetId: asset.id, assetType: asset.assetType, reason: 'not_a_preview_role', bytesRead: 0 }) + continue + } + const before = candidates.length + add(asset.fileUrl, 'MaterialAsset', asset.id, asset.fileSize) + if (before === candidates.length) excludedAssets.push({ assetId: asset.id, assetType: asset.assetType, reason: 'not_a_supported_image_url', bytesRead: 0 }) + } + for (const sku of row.skus || []) if (sku.previewUrl) add(sku.previewUrl, 'MaterialSku.previewUrl', sku.id) + return { materialId: row.id, materialType: row.materialType, candidates, excludedAssets } +} + +export function validateAuditSnapshot(snapshot, { host, expectedCount = AUDIT_LIMITS.expectedCount } = {}) { + assert(LEGACY_DOMAINS.includes(host), 'registered_legacy_host_required') + assert(snapshot?.readOnly === true && snapshot.count === expectedCount && Array.isArray(snapshot.materials) + && snapshot.materials.length === expectedCount, 'exact_inventory_count_required') + const seen = new Set() + for (const row of snapshot.materials) { + assert(typeof row.id === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(row.id) && !seen.has(row.id), 'unique_material_identity_required') + seen.add(row.id) + assert(typeof row.previewUrl === 'string', 'source_required') + const url = new URL(row.previewUrl) + assert(url.hostname === host && ['https:', 'http:'].includes(url.protocol), 'inventory_host_changed') + } + return snapshot.materials +} + +// Hash every fully consumed response, including the explicit FRP HTML 404 body. +// The checker still owns the byte/time budgets and cancels rejected streams. +export async function auditPreviewBatch(manifest, { fetchImpl = directFetch, ...options } = {}) { + const proofs = new Map() + const capture = async (url, fetchOptions) => { + const response = await fetchImpl(url, fetchOptions) + if (!response.body) return response + const reader = response.body.getReader(), digest = createHash('sha256') + let count = 0 + const stream = new ReadableStream({ + async pull(controller) { + try { + const item = await reader.read() + if (item.done) { + proofs.set(url, { responseBodySha256: digest.digest('hex'), responseBytes: count, responseBodyComplete: true }) + controller.close() + } else { + count += item.value.byteLength + digest.update(item.value) + controller.enqueue(item.value) + } + } catch (error) { controller.error(error) } + }, + cancel(reason) { return reader.cancel(reason) }, + }) + const wrapped = new Response(stream, { status: response.status, statusText: response.statusText, headers: response.headers }) + Object.defineProperties(wrapped, { url: { value: response.url }, redirected: { value: response.redirected } }) + return wrapped + } + const report = await checkMaterialPreviewHealth(manifest, { ...options, fetchImpl: capture }) + return { ...report, results: report.results.map(result => ({ ...result, + ...(proofs.get(manifest.materials.find(entry => entry.materialId === result.materialId).registeredSource) + || { responseBodySha256: null, responseBodyComplete: false }) })) } +} + +export async function auditUniquePreviews(materials, { onBatch = async () => {}, ...options } = {}) { + const byUrl = new Map(), rejected = [] + for (const row of materials) { + try { + const source = registeredSource(row.previewUrl).href + if (!byUrl.has(source)) byUrl.set(source, { materialId: row.id, registeredSource: source, materialIds: [] }) + byUrl.get(source).materialIds.push(row.id) + } catch (error) { + rejected.push({ materialId: row.id, ok: false, classification: 'source_rejected', code: error.code || 'invalid_url', attempts: 0 }) + } + } + const entries = [...byUrl.values()], batches = [] + for (let index = 0; index < entries.length; index += AUDIT_LIMITS.batchSize) { + batches.push({ version: 1, reviewed: true, materials: entries.slice(index, index + AUDIT_LIMITS.batchSize) + .map(({ materialId, registeredSource }) => ({ materialId, registeredSource })) }) + } + const reports = new Array(batches.length) + let next = 0 + await Promise.all(Array.from({ length: Math.min(AUDIT_LIMITS.concurrency, batches.length) }, async () => { + while (next < batches.length) { + const index = next++ + reports[index] = await auditPreviewBatch(batches[index], options) + await onBatch({ index, manifest: batches[index], report: reports[index] }) + } + })) + const byRepresentative = new Map(reports.flatMap(report => report.results).map(result => [result.materialId, result])) + const byId = new Map(rejected.map(result => [result.materialId, result])) + for (const entry of entries) for (const materialId of entry.materialIds) { + byId.set(materialId, { ...byRepresentative.get(entry.materialId), materialId, checkedRepresentativeId: entry.materialId, + sharedRegisteredSource: entry.materialIds.length > 1 }) + } + return { readOnly: true, limits: AUDIT_LIMITS, materialCount: materials.length, uniqueRegisteredSources: entries.length, + actualGetAttempts: entries.length, rejectedBeforeGet: rejected.length, retries: 0, allRowsAccounted: byId.size === materials.length, + deduplication: entries, batches, reports, results: materials.map(row => ({ ...byId.get(row.id), materialType: row.materialType })) } +} + +async function sshStdin(command, input, onLine) { + return new Promise((resolve, reject) => { + const child = spawn('ssh.exe', ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', + 'UserKnownHostsFile=C:/Users/54972/.ssh/gongxiangp2-prod.known_hosts', 'gongxiangp2-prod-legacy-freenas', command], + { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) + let output = '', errors = '', pending = '', lineFailure + child.stdout.on('data', bytes => { + output += bytes + if (Buffer.byteLength(output) > 32 * 1024 * 1024) { child.kill(); lineFailure = new Error('ssh_output_budget_exceeded'); return } + if (onLine) { + pending += bytes + let end + while ((end = pending.indexOf('\n')) !== -1) { + const line = pending.slice(0, end); pending = pending.slice(end + 1) + if (line.trim()) { try { onLine(JSON.parse(line)) } catch (error) { lineFailure = error; child.kill() } } + } + } + }) + child.stderr.on('data', bytes => { if (errors.length < 4000) errors += bytes }) + child.on('error', reject) + child.on('close', code => lineFailure ? reject(lineFailure) : code === 0 ? resolve(output) : reject(new Error('read_only_remote_audit_failed'))) + child.stdin.on('error', () => {}) + child.stdin.end(input) + }) +} + +export async function main(args = process.argv.slice(2)) { + if (!args.includes('--run')) return { planOnly: true, readOnly: true, expectedCount: AUDIT_LIMITS.expectedCount, + metadata: ['Material.id', 'materialType', 'previewUrl', 'displayImages', 'MaterialAsset preview metadata', 'active MaterialSku.previewUrl'], + health: { ...AUDIT_LIMITS, ...LIMITS }, modelCalls: 0, productionWrites: 0 } + assert(args.length === 3 && args[0] === '--run' && args[1] === '--output-dir' && path.isAbsolute(args[2]), 'run_requires_new_absolute_output_directory') + const directory = path.resolve(args[2]) + await fs.mkdir(directory, { recursive: false, mode: 0o700 }) + const write = (file, value) => fs.writeFile(path.join(directory, file), JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 }) + const receipt = JSON.parse(await fs.readFile('output/native-brief-panel-20261023/preview-recovery-reviewed-manifest.json', 'utf8')) + const host = new URL(receipt.materials[0].oldPreviewUrl).hostname + assert(LEGACY_DOMAINS.includes(host), 'reviewed_legacy_host_required') + const identityWire = `import json,subprocess\nc=json.loads(subprocess.check_output(['docker','inspect','youyidian-3d-material-blue']))[0]\nf=subprocess.check_output(['docker','exec',c['Id'],'cat','/app/DEPLOYMENT_COMMIT'],text=True).strip()\nprint(json.dumps({'containerId':c['Id'],'containerName':c['Name'].lstrip('/'),'imageName':c['Config']['Image'],'imageId':c['Image'],'running':c['State']['Running'],'deploymentCommit':f}))\n` + const identity = JSON.parse(await sshStdin('python3 -u -', identityWire)) + assert(identity.running && /^[a-f0-9]{64}$/.test(identity.containerId) && /^[a-f0-9]{40}$/.test(identity.deploymentCommit), 'running_production_identity_required') + await write('production-identity.json', { readOnly: true, checkedAt: new Date().toISOString(), ...identity }) + const command = `docker exec -i -w /app ${identity.containerId} node --input-type=module` + const metadataWire = `import {createRequire} from 'node:module';const require=createRequire(import.meta.url),fs=require('node:fs'),assert=require('node:assert/strict'); +assert.equal(fs.readFileSync('/app/DEPLOYMENT_COMMIT','utf8').trim(),${JSON.stringify(identity.deploymentCommit)}); +const {PrismaClient}=require('@prisma/client');const p=new PrismaClient({log:[]}); +try{const result=await p.$transaction(async tx=>{ + await tx.$executeRawUnsafe('SET TRANSACTION READ ONLY'); + const where={deletedAt:null,OR:[{previewUrl:{startsWith:${JSON.stringify('https://' + host + '/')},mode:'insensitive'}},{previewUrl:{startsWith:${JSON.stringify('http://' + host + '/')},mode:'insensitive'}}]}; + const count=await tx.material.count({where});assert.equal(count,${AUDIT_LIMITS.expectedCount});const materials=[];let cursor; + while(true){const rows=await tx.material.findMany({where,orderBy:{id:'asc'},take:100,...(cursor?{cursor:{id:cursor},skip:1}:{}),select:{id:true,materialType:true,previewUrl:true,displayImages:true, + assets:{orderBy:[{sortOrder:'asc'},{id:'asc'}],select:{id:true,assetType:true,fileUrl:true,fileSize:true,sortOrder:true}}, + skus:{where:{deletedAt:null},orderBy:{id:'asc'},select:{id:true,previewUrl:true}}}});if(!rows.length)break;materials.push(...rows);assert.ok(materials.length<=count);cursor=rows.at(-1).id;} + assert.equal(materials.length,count);return{readOnly:true,capturedAt:new Date().toISOString(),count,host:${JSON.stringify(host)},isolationLevel:'RepeatableRead',historyImportFilter:false,materials}; +},{isolationLevel:'RepeatableRead',timeout:60000,maxWait:5000});console.log(JSON.stringify(result,(_,v)=>typeof v==='bigint'?v.toString():v));}finally{await p.$disconnect();}` + const snapshot = JSON.parse(await sshStdin(command, metadataWire)) + const materials = validateAuditSnapshot(snapshot, { host }) + await write('material-metadata.json', snapshot) + const candidateMapping = materials.map(metadataCandidates) + await write('preview-candidate-mapping.json', { readOnly: true, metadataOnly: true, candidateGets: 0, materials: candidateMapping }) + const checker = await fs.readFile('scripts/check-material-preview-health.mjs', 'utf8') + const own = await fs.readFile(fileURLToPath(import.meta.url), 'utf8') + const checkSource = checker.replace("import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs'", `const LEGACY_DOMAINS=${JSON.stringify(LEGACY_DOMAINS)}`) + const helperSource = own.slice(own.indexOf('export async function auditPreviewBatch'), own.indexOf('async function sshStdin')) + const wire = checkSource + `\nconst AUDIT_LIMITS=${JSON.stringify(AUDIT_LIMITS)};\n` + helperSource + ` +const fsRuntime=createRequire(import.meta.url)('node:fs'); +if(fsRuntime.readFileSync('/app/DEPLOYMENT_COMMIT','utf8').trim()!==${JSON.stringify(identity.deploymentCommit)})throw Error('production_changed'); +console.log(JSON.stringify({event:'started',expectedCount:${materials.length},checkerSha256:${JSON.stringify(sha(checker))}})); +const audit=await auditUniquePreviews(${JSON.stringify(materials.map(({ id, materialType, previewUrl }) => ({ id, materialType, previewUrl })))}, + {onBatch:async value=>console.log(JSON.stringify({event:'batch',...value}))}); +if(fsRuntime.readFileSync('/app/DEPLOYMENT_COMMIT','utf8').trim()!==${JSON.stringify(identity.deploymentCommit)})throw Error('production_changed'); +console.log(JSON.stringify({event:'complete',audit}));` + await write('dispatch-audit.json', { readOnly: true, startedAt: new Date().toISOString(), host, materialCount: materials.length, + checkerSha256: sha(checker), helperSha256: sha(own), wireSha256: sha(wire), maxConcurrentGets: 2, retries: 0, productionWrites: 0, candidateGets: 0 }) + const batchWrites = [] + let completed + try { + await sshStdin(command, wire, event => { + if (event.event === 'batch') batchWrites.push(write(`batch-${String(event.index + 1).padStart(3, '0')}.json`, event)) + else if (event.event === 'complete') completed = event.audit + }) + await Promise.all(batchWrites) + assert(completed?.allRowsAccounted && completed.results.length === materials.length, 'complete_response_mapping_required') + const summary = { readOnly: true, completedAt: new Date().toISOString(), ...identity, materialCount: materials.length, + uniqueRegisteredSources: completed.uniqueRegisteredSources, actualGetAttempts: completed.actualGetAttempts, + results: completed.results, healthyCount: completed.results.filter(item => item.ok).length, + failedCount: completed.results.filter(item => !item.ok).length, statusCounts: {}, classificationCounts: {}, + candidateMetadataCount: candidateMapping.reduce((n, row) => n + row.candidates.length, 0), candidateGets: 0, + allRowsAccounted: true, fullImageAuditExecuted: true, retries: 0, databaseWrites: 0, storageWrites: 0, modelCalls: 0 } + for (const item of completed.results) { + summary.statusCounts[item.status ?? 'not_dispatched'] = (summary.statusCounts[item.status ?? 'not_dispatched'] || 0) + 1 + summary.classificationCounts[item.classification] = (summary.classificationCounts[item.classification] || 0) + 1 + } + await write('unique-url-audit.json', completed) + await write('audit-summary.json', summary) + return { ...summary, results: undefined, outputDirectory: directory } + } catch (error) { + await Promise.allSettled(batchWrites) + await write('audit-incomplete.json', { readOnly: true, complete: false, code: 'read_only_audit_incomplete', completedBatches: batchWrites.length, + noAutomaticRetry: true, metadataCount: materials.length }) + throw error + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().then(result => console.log(JSON.stringify(result, null, 2)), () => { + console.log(JSON.stringify({ readOnly: true, complete: false, code: 'read_only_audit_failed', retries: 0 })) + process.exitCode = 1 + }) +} diff --git a/scripts/audit-legacy-material-previews.test.mjs b/scripts/audit-legacy-material-previews.test.mjs new file mode 100644 index 00000000..60cfb9c9 --- /dev/null +++ b/scripts/audit-legacy-material-previews.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import test from 'node:test' +import { AUDIT_LIMITS, auditPreviewBatch, auditUniquePreviews, main, metadataCandidates, validateAuditSnapshot } from './audit-legacy-material-previews.mjs' +import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs' + +const host = LEGACY_DOMAINS[0] +const source = n => `https://${host}/jsxiangche/preview-${n}.webp` +const row = (id, index = id) => ({ id, materialType: '详情模板', previewUrl: source(index) }) +const html = 'Not Found. The server is powered by frp. Faithfully yours, frp.' +const response = () => new Response(html, { status: 404, headers: { 'content-type': 'text/html' } }) + +test('snapshot must match exact count, identity, and registered legacy host before any GET', () => { + const value = { readOnly: true, count: 2, materials: [row('a'), row('b')] } + assert.equal(validateAuditSnapshot(value, { host, expectedCount: 2 }).length, 2) + assert.throws(() => validateAuditSnapshot(value, { host }), /exact_inventory_count_required/) + assert.throws(() => validateAuditSnapshot({ ...value, materials: [row('a'), row('a')] }, { host, expectedCount: 2 }), /unique_material_identity_required/) + assert.throws(() => validateAuditSnapshot({ ...value, materials: [row('a'), { ...row('b'), previewUrl: 'https://evil.example/a.webp' }] }, { host, expectedCount: 2 }), /inventory_host_changed/) +}) + +test('candidate mapping keeps same-material display/preview evidence and excludes ZIP-like main role', () => { + const material = { ...row('a'), displayImages: [source('display'), 'https://s3.jiansun.vip/gongxiangpan/p.png'], + assets: [{ id: 'main', assetType: 'main', fileUrl: source('looks-like-image'), fileSize: '10' }, + { id: 'good', assetType: 'main-preview', fileUrl: source('real-preview'), fileSize: '0' }, + { id: 'bad', assetType: 'preview', fileUrl: 'https://s3.jiansun.vip/gongxiangpan/download.zip', fileSize: '1000000000' }], + skus: [{ id: 'sku', previewUrl: source('sku'), filePath: 'https://s3.jiansun.vip/gongxiangpan/big.zip' }] } + const result = metadataCandidates(material) + assert.equal(result.materialId, 'a') + assert.equal(result.candidates.length, 4) + assert.equal(result.candidates.find(item => item.identity === 'good').declaredBytes, '0') + assert.ok(result.candidates.every(item => item.metadataOnly && item.bytesRead === 0 && item.originalIdentityVerified === false)) + assert.deepEqual(result.excludedAssets.map(item => item.assetId), ['main', 'bad']) + assert.ok(!JSON.stringify(result.candidates).includes('big.zip')) +}) + +test('FRP failure still retains hash of the complete actual response body', async () => { + let calls = 0 + const report = await auditPreviewBatch({ version: 1, reviewed: true, materials: [{ materialId: 'a', registeredSource: source('a') }] }, + { fetchImpl: async () => { calls++; return response() } }) + assert.equal(calls, 1) + assert.equal(report.results[0].code, 'frp_default_404') + assert.equal(report.results[0].responseBodyComplete, true) + assert.equal(report.results[0].responseBodySha256, createHash('sha256').update(html).digest('hex')) + assert.equal(report.results[0].responseBytes, Buffer.byteLength(html)) +}) + +test('batches stay at fifty, duplicate URLs map every ID, and at most two GETs run concurrently', async () => { + const materials = Array.from({ length: 103 }, (_, n) => row(`m-${n}`, n % 53)) + let active = 0, maxActive = 0 + const gets = new Map(), events = [] + const result = await auditUniquePreviews(materials, { fetchImpl: async url => { + gets.set(url, (gets.get(url) || 0) + 1) + active++; maxActive = Math.max(maxActive, active) + await new Promise(resolve => setTimeout(resolve, 1)) + active-- + return response() + }, onBatch: async event => events.push(event) }) + assert.equal(maxActive, 2) + assert.equal(result.actualGetAttempts, 53) + assert.equal(gets.size, 53) + assert.ok([...gets.values()].every(value => value === 1)) + assert.deepEqual(result.batches.map(item => item.materials.length), [50, 3]) + assert.equal(events.length, 2) + assert.equal(result.results.length, 103) + assert.equal(new Set(result.results.map(item => item.materialId)).size, 103) + assert.equal(result.allRowsAccounted, true) + assert.ok(result.results.every(item => item.code === 'frp_default_404' && item.responseBodyComplete)) + assert.equal(result.results.find(item => item.materialId === 'm-53').checkedRepresentativeId, 'm-0') + assert.equal(AUDIT_LIMITS.concurrency, 2) +}) + +test('a non-preview source is recorded without GET and a timeout is never automatically retried', async () => { + const materials = [row('a'), { ...row('b'), previewUrl: 'http://127.0.0.1/private' }] + let calls = 0 + const result = await auditUniquePreviews(materials, { timeoutMs: 5, fetchImpl: () => { calls++; return new Promise(() => {}) } }) + assert.equal(calls, 1) + assert.equal(result.results[0].classification, 'timeout') + assert.equal(result.results[0].responseBodySha256, null) + assert.equal(result.results[1].attempts, 0) + assert.equal(result.results[1].classification, 'source_rejected') + assert.equal(result.allRowsAccounted, true) + assert.equal(result.retries, 0) +}) + +test('default invocation is plan-only without SSH, database, images or file writes', async () => { + const result = await main([]) + assert.equal(result.planOnly, true) + assert.equal(result.expectedCount, 472) + assert.equal(result.productionWrites, 0) + await assert.rejects(main(['--run']), /run_requires_new_absolute_output_directory/) +}) diff --git a/scripts/check-material-preview-health.mjs b/scripts/check-material-preview-health.mjs index 347536c7..9c9496d7 100644 --- a/scripts/check-material-preview-health.mjs +++ b/scripts/check-material-preview-health.mjs @@ -177,7 +177,9 @@ async function checkOne(entry, { fetchImpl, decodeImage, timeoutMs, maxBytes }) let status = null let contentType = '' let bytesRead = 0 - const failure = (classification, code) => ({ ...base, ok: false, classification, code, status, contentType, bytes: bytesRead }) + let bodySha256 + const failure = (classification, code) => ({ ...base, ok: false, classification, code, status, contentType, bytes: bytesRead, + ...(bodySha256 ? { bodySha256 } : {}) }) try { const response = await withAbort(fetchImpl(entry.url.href, { method: 'GET', cache: 'no-store', redirect: 'manual', signal: controller.signal, @@ -200,6 +202,9 @@ async function checkOne(entry, { fetchImpl, decodeImage, timeoutMs, maxBytes }) chunks.push(Buffer.from(value)) } const bytes = Buffer.concat(chunks) + // A fully read failure body can identify the known incident without storing + // its contents. Partial, oversized and unavailable bodies have no digest. + bodySha256 = sha256(bytes) const prefix = bytes.subarray(0, 4096).toString('utf8').replace(/^\uFEFF/, '').trimStart() const html = contentType === 'text/html' || /^(?:\s*frp\s*<\/center>)/i.test(prefix) diff --git a/scripts/check-material-preview-health.test.mjs b/scripts/check-material-preview-health.test.mjs index 365926c9..74962b64 100644 --- a/scripts/check-material-preview-health.test.mjs +++ b/scripts/check-material-preview-health.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' import test from 'node:test' import { createRequire } from 'node:module' import { checkMaterialPreviewHealth, createSharpPreviewDecoder, directFetch, isPublicAddress, LIMITS, registeredSource, validateManifest } from './check-material-preview-health.mjs' @@ -12,6 +13,14 @@ const source = 'https://s3.jiansun.vip/gongxiangpan/public-display/example.png' const manifest = (url = source) => ({ version: 1, reviewed: true, materials: [{ materialId: 'material-a', registeredSource: url }] }) const response = (body, type, status = 200, extra = {}) => new Response(body, { status, headers: { 'content-type': type, ...extra } }) +test('complete failure bodies retain a digest for exact incident baselines without retaining raw contents', async () => { + const body = 'Faithfully yours, frp.' + const report = await checkMaterialPreviewHealth(manifest(), { fetchImpl: async () => response(body, 'text/html', 404) }) + assert.equal(report.results[0].ok, false) + assert.equal(report.results[0].bodySha256, createHash('sha256').update(body).digest('hex')) + assert.equal(JSON.stringify(report).includes(body), false) +}) + test('normal PNG/JPEG/WebP use one actual uncached GET with no redirects or credentials', async () => { for (const [body, mime, format] of [[PNG, 'image/png', 'png'], [JPEG, 'image/jpeg', 'jpeg'], [WEBP, 'image/webp', 'webp']]) { const calls = [] diff --git a/scripts/fixtures/material-preview-known-legacy-20261023.json b/scripts/fixtures/material-preview-known-legacy-20261023.json new file mode 100644 index 00000000..88aa6795 --- /dev/null +++ b/scripts/fixtures/material-preview-known-legacy-20261023.json @@ -0,0 +1,1896 @@ +{ + "version": 1, + "incident": "legacy-preview-20261023", + "reviewedAuditSha256": "40eb8d834d5c6bd9edcdc49e69ae227a83be2374bb9b525a0cec0bcd652c4e0d", + "membersSha256": "953e6b1c9e10ea05e30e289ebbd5364569d918b5dd4a1273af52abc531fe475c", + "members": [ + { + "materialId": "01331013-f5ca-4f07-8ab4-32b0035d91c3", + "sourceSha256": "56c05f59bb492ca82b79ca6942e947048543b9e2aa006b4fca0c9e51067d0c79" + }, + { + "materialId": "0243d445-dff7-4b12-9e7d-d6e94f13af3b", + "sourceSha256": "42007b6a0df1eea9c472b50950c96485309cc17ee94a46b520c2359f687f4d28" + }, + { + "materialId": "025d5822-e096-490d-8100-0bd18055d738", + "sourceSha256": "505d65b20cb53fdf4bedc91ef139f41d9492e2982df0c93075b417f72d942681" + }, + { + "materialId": "0387680a-a003-4f51-ba6d-f9f3279d33b9", + "sourceSha256": "c6fc5987efba89e7e193101b92d4bdf0697891d4df9da3401b69c10ffb229ed0" + }, + { + "materialId": "03a2dfc4-5a90-4577-8dcc-b6f5ac6d0824", + "sourceSha256": "aee709896dd59513bf110ca04bc9e12ed81018d0be542edcfafae2993229d81e" + }, + { + "materialId": "04685872-18c8-4f70-80b6-eab734811de1", + "sourceSha256": "12b030a83e890c512a5fb89b124c0f233b076214e9309904e407b130f76d3a5d" + }, + { + "materialId": "05826144-065a-40ba-97f7-d830933981bb", + "sourceSha256": "0fbe08e5829f32826837081723abf60e949d92c9ce0fa7b88d7ecc07953f61fe" + }, + { + "materialId": "06e3269a-531a-4761-8ef6-f414a1fad4e0", + "sourceSha256": "e93d9d89dc71704390bd313aa16d7dd0d35a797481d771168cbdde6fb1a152ff" + }, + { + "materialId": "0799cdab-6ffb-4bc0-b435-9d93811dc321", + "sourceSha256": "c518014181ba6a3c83ae4688f818ce61d137b091da6bc6b841ef6a1500edc5f3" + }, + { + "materialId": "08acbe0d-9078-4f42-ab57-00628c39c423", + "sourceSha256": "f10a40eaaca942f2416d5ea13fc05fc206ab8186a823917a2c2ea76e3d75484f" + }, + { + "materialId": "08b31dae-61a1-4e50-9936-4c08238c8ffb", + "sourceSha256": "ffd698ca8370eb93b2ab236e56ae2bb860740b9711d7c2c246b11ed15d2ce221" + }, + { + "materialId": "091312be-55cc-4f64-aa2b-9b6401b6a875", + "sourceSha256": "729e715c3aad6b05823811e8598bf8dfbbe33e98cafc550d9867e727b2d4e7d0" + }, + { + "materialId": "097d2759-81c9-42d0-b367-551f74244c35", + "sourceSha256": "21e065483a12e3d3bd0d8184969cd8795e792acb0ae892b17058d8cae7ee9c1d" + }, + { + "materialId": "09d2043c-2bf4-4f1b-a173-34c874d6552d", + "sourceSha256": "8fcc827b0ea89235534147da76f825d431903ae3b207da3474aafd724707936c" + }, + { + "materialId": "0a027e49-ce6a-47ae-ab52-847f3893e379", + "sourceSha256": "43e581e249f1cb5475e981284f1930adef0d8588a51e30001cf9aa41a43cf019" + }, + { + "materialId": "0a224cee-962c-4b12-af3f-2623ebd0770f", + "sourceSha256": "c7fc8c5d46d02f4f7e559979a989daa61ab9075e3a522799df5e07deb8160442" + }, + { + "materialId": "0a6f4d0f-db03-4498-8dde-b9de1d84a3c4", + "sourceSha256": "96630346cfdf92a69188b1f82b127b97b932b3161a945e5da6a7354f8acd4cc4" + }, + { + "materialId": "0aa9fadf-76f5-4882-a9c8-617b441516db", + "sourceSha256": "337f861faf18167a2b17732be9769b3b218862857d27fd1e58040350a31698c5" + }, + { + "materialId": "0b3d768d-3225-4d63-8e81-87d2ebfb4c97", + "sourceSha256": "8cc7a7aafdf644922ae230a1aa70953457b058bb1f27120fd95d382255e983ea" + }, + { + "materialId": "0bb370e3-7ad4-495a-95c7-92292c455bda", + "sourceSha256": "d7204cf7c41784d62227b4146f5b218c72662cc192b33811e5768610e638de7b" + }, + { + "materialId": "0cb34204-036f-4af3-881d-09ff3eb54013", + "sourceSha256": "59d53184f84bdabc06a2aa898ee058ad39d285a278d7fb3af9b5115b9e0d835f" + }, + { + "materialId": "0cd7ba10-9ff4-4d75-87c0-87b552364e2b", + "sourceSha256": "291fc1385b43c815672f11859b64123700bab826622da6a3b32b20015a6c9aeb" + }, + { + "materialId": "0cdf0b90-b633-4894-a690-52a1c4b6874d", + "sourceSha256": "7ffdd25285fb0aad929183c31ee867402156d546c7b4a6eeb280e6fd6fe6e71e" + }, + { + "materialId": "0d18747f-3cb0-4170-a12d-6049d990d0e6", + "sourceSha256": "e4c1da0550d7fa7a2f07aea6ac41f1f988d52fe7200f1b48c03bf03b9fb8befc" + }, + { + "materialId": "0d1f1c2c-25ba-4fa3-a1c9-33c775fbce65", + "sourceSha256": "033bffc2e0111dc670bafe4f028901d6b2bf222d79540175b998823b28ac8131" + }, + { + "materialId": "0df159cb-e9f8-4d5d-b962-4ed7240402a6", + "sourceSha256": "13edf260e8e0b1131c300e4d8dc79d7896b464b375eb18146a4afadef62ed0d1" + }, + { + "materialId": "0dff1328-ef4a-4816-8063-b87ce5029ad2", + "sourceSha256": "07307bd9a2b37e0e38cade9dfe5bb1d851f3f5e92cac48fceb892fa16798e66e" + }, + { + "materialId": "0e7681c0-5aa9-4659-8582-9f2bc5995bcb", + "sourceSha256": "f2827860475fe6d77c5cf051e69e669108bd3d6a9df22e884332605eb6490eb9" + }, + { + "materialId": "0f018c02-4cfe-4a53-ab8d-c033d4a84a82", + "sourceSha256": "27d9106bf29187fa347a9c968391ea96acf4208d37466acd76b8e048b9c8abe5" + }, + { + "materialId": "0f64eb5f-2740-45b3-990b-69fba35cc66d", + "sourceSha256": "4dd7fea55d3056a9f870b1c2f8b60204ced105dabfff0b36d2eccd49f2187dd3" + }, + { + "materialId": "0f8783c5-1b57-4fd3-bd49-6bfd029e4667", + "sourceSha256": "147b170520e49c4c40c2985c5b6576bc10c1294f9c95567a772f240f2d0707f6" + }, + { + "materialId": "0fc15479-1b12-406a-b4ce-9d4d6dc8a0c8", + "sourceSha256": "0657866896fd48ad554bf3f3ce1ce7c0842fa5dbe586cd6b094aadcd9cf888bf" + }, + { + "materialId": "109ee131-f96e-45b0-bd8e-a2cbc1503d58", + "sourceSha256": "104870d10261949cb3cf2dc532653572bca5a05e20a55077b7f0f1b2ad9ab1a6" + }, + { + "materialId": "10cd39b4-82d8-4644-9c60-28c1043cdc8e", + "sourceSha256": "b28acab0808527363061068557f045caab602aac0f93ffcc35373a341e747f91" + }, + { + "materialId": "10f0dac0-21b5-4b8d-a980-7ead0d0ce1d4", + "sourceSha256": "322410a138087e2a1b9d1b111f8b68630cd3f0a38dad89dcbe03c90e4bca2ef8" + }, + { + "materialId": "11191a17-da31-426d-9107-5ed60d776daf", + "sourceSha256": "8a0a41511832c5c7fd3c39178ae0808bec791fc6a2d639d1c88129e0bce5e986" + }, + { + "materialId": "12cb64a5-2651-429e-bce3-167f8ef6fe2c", + "sourceSha256": "d62ae94f0828aff5d12a3fb35299e18680317afe75884b38dd4b915f77302360" + }, + { + "materialId": "1330e10d-da0d-4f53-b37c-b649d8af9eea", + "sourceSha256": "3fed3451042e517b94c0573e14b56ca8aca48346d854e78ef6f0ea419bc4c2a6" + }, + { + "materialId": "13587237-ac9f-4be2-8a89-3c1116799dbe", + "sourceSha256": "12690d90c5f0925a59cb36d7dcbed1824e90e42ece996fcb63a573a7057141d8" + }, + { + "materialId": "13c2aed5-e197-421c-8fe3-24486506f5aa", + "sourceSha256": "555e73e8ce8831627de804e175d39ae8f348539f5ff9413d6eb270e150f6ad88" + }, + { + "materialId": "13ded73e-29e3-417a-bc6a-4f34bdc9c1b2", + "sourceSha256": "1a423fe391b20428e012de9966a11de0e5aa6b9b1bc11b9a3f932b58673822f1" + }, + { + "materialId": "14482941-1d0d-446b-b339-6d825b9eba94", + "sourceSha256": "a39c31b25c1272746d863c9a93b50340c2a0f86448ebb4f405e26dc447d4b49e" + }, + { + "materialId": "14f4ffe9-73a5-4a0c-a0f3-c08f8b56086a", + "sourceSha256": "05f65b860d7262515001caf04076d4b0ea49f4d64e7b552b95663c2d980764c7" + }, + { + "materialId": "1535871f-4484-41d5-b145-45f76ade6c5e", + "sourceSha256": "f507bf8b04df1cba379a8e83c6776a9681c1bde7807738ac212a1ad29b98d393" + }, + { + "materialId": "158bec33-f316-4981-9dc1-67996cab5d12", + "sourceSha256": "c00ace534e3f8be9ff7912c0f917890586c2839db015f9ef4211894067f35602" + }, + { + "materialId": "16f870d3-8ac4-4538-af9a-0d74b2d15bbf", + "sourceSha256": "cdbab9373ed6bddb8b5aef9bdb5ac1c21644d4a974ef9197aa5a461930f94a0e" + }, + { + "materialId": "1720f168-39d4-4ebd-bc45-9346570ebd1c", + "sourceSha256": "1554acb2891681fddee066dca3f306fe56aa09d445201e719d4dd0c41fa6f42a" + }, + { + "materialId": "19abf7e7-a8e6-4cd5-bdff-8df531c434e9", + "sourceSha256": "1fc90d5af303cf3222d9cce3828da3229d133eab03d28e9ab2d013dc9562e04b" + }, + { + "materialId": "1b3d0235-f3e1-4280-9963-d78983c4e93e", + "sourceSha256": "de1d5276f795718e3ac35bd978dcbf059844da37e66f2416af32f4bb1b37f63f" + }, + { + "materialId": "1c7ff3bb-2cb4-449b-8866-3604b0c8c5d7", + "sourceSha256": "6c8512865ff36c59aab911c30ae8f1e9e71f6d5fa347c1d3194a2c2a96ebe9c3" + }, + { + "materialId": "1c845b4e-40ee-44de-acae-99d9cec50c98", + "sourceSha256": "edc3083b77c8b2bb44fa82a8465d6a983e66715ace1b3843ce05d02e146184fc" + }, + { + "materialId": "1d420d79-fb1a-471e-8048-2e37a9fdcd07", + "sourceSha256": "452cba4f335afbbc980f693ba1537a921717f1bc228b0fdea933dec7623f3553" + }, + { + "materialId": "1dba95a0-62b4-4001-b65d-8087154e0fd7", + "sourceSha256": "ccef1f37b3a18152993a73c940278cb3528b869514b4f189032cdc7aba9feefe" + }, + { + "materialId": "1e710c41-4f92-4c31-8805-ee855702fddf", + "sourceSha256": "a12386d350621cb1a1c4ec433bee2fc780231be3c8df615bbf97a3a105411fd0" + }, + { + "materialId": "1fd77009-8197-45e7-ba22-d43c64da6411", + "sourceSha256": "b45af361b02185431ba5554f24de2ff68e8b095e447a282b898346117ac7a56b" + }, + { + "materialId": "20f120ca-af2d-4b57-b9ba-2ec3d4dae527", + "sourceSha256": "2fc364483f5e63ec67545b17582dbc9cb0089415de8a70229672213926f37ca1" + }, + { + "materialId": "2148651b-7b83-4f4d-bce3-7a6c7f1dd049", + "sourceSha256": "5f95f6675305ad3ec46a96e4bb4a45edd5e7678b6cfc2811870f8c8f711f737f" + }, + { + "materialId": "228bf0e0-aaff-495a-8459-98093c2c6fe2", + "sourceSha256": "21ca677f4e7a3693c800efa2eb82757df737856934642bf2e1ab4844aa8d5bc1" + }, + { + "materialId": "238ec956-dd00-4862-ac35-317fb027c8ac", + "sourceSha256": "6b821031130d2bf60435c8c51d38521fd97be572a826544aa949c3d85d8b05e4" + }, + { + "materialId": "23a5bd50-13ca-4738-83a2-f64a38c33c66", + "sourceSha256": "a6cae9606739da41d2e431da40aca68c5e3f809ca658536b38aa5cd3c413afee" + }, + { + "materialId": "23bc7b41-185c-4a3e-b31a-4f92416d9da0", + "sourceSha256": "e9850e25c6a368426311d948b91c5abc8bdb9770a5aaaa2d2e9087efe7370cf2" + }, + { + "materialId": "242a4637-85e2-4e24-938d-4fb9c6d5742d", + "sourceSha256": "72cbb2baf40ea03cde63a9fb2f72e995231b5b638da1bc231530db322de06286" + }, + { + "materialId": "24c2c972-28a1-476a-8e42-5cd19367e7f8", + "sourceSha256": "72f862102e122cbc44f40e5373f7a194fe3a03e6cd36722a60132131f9dae757" + }, + { + "materialId": "252836b1-db1b-4438-8dc1-38e6142af891", + "sourceSha256": "697c288d79caccc6721c0c55ee94eca9c53c8d3093fd50bf45bf4753e30a807d" + }, + { + "materialId": "25707a7c-61da-43a0-9bbc-b7e2637817d9", + "sourceSha256": "c2ae283afc77231646c0aad033609e7e1336c9524756032baf29af34d4fe2558" + }, + { + "materialId": "25770eef-ea99-49ec-ae40-8a8c1a5d0a8e", + "sourceSha256": "6a6f96bd48850b5c7186c49d82f62d11054972877675dfccb292af149cf184cc" + }, + { + "materialId": "26a38adc-e54b-40af-aacf-4b43c4ab46e6", + "sourceSha256": "1f7a68a1740d886f99e9ccfff51bd51b03711fc9b2672163b80b97bbfe9a5c93" + }, + { + "materialId": "27ef5c54-40e9-4df0-a40f-b643d1076d6e", + "sourceSha256": "59050e8463d4316c7897048bd4832aeb58fb9908f233e3b2283453ff0e58d19d" + }, + { + "materialId": "28c298ca-dbe3-43e1-93b4-ddf45e4f18d3", + "sourceSha256": "cbe274cce87cdc39cd973594872c4268c86baba43987885547c30009d49bc634" + }, + { + "materialId": "28cbffad-7920-455d-ad20-251bdf720eab", + "sourceSha256": "5f911cc78df4d1d14410e37ddb30881db71b31a528328ea04b44e9eed70aed8d" + }, + { + "materialId": "28cc30c9-7894-4d4e-b7ef-f5ecf07473ec", + "sourceSha256": "1e5165d5802c248719346de330070db2c739af090404a9f40ea594da95c6fe82" + }, + { + "materialId": "28df909d-8e62-4bb0-82be-4fa25f6ae766", + "sourceSha256": "d03b29593e9e48147d9669d0e5f3b37b72c727f62f91c2ad2d670051b018782f" + }, + { + "materialId": "290da198-c31f-4c06-a176-401882a6ca95", + "sourceSha256": "e531938cadf5d4755d0ee875dfe0cf1f1ffc104d2114f84b20aac9c48610f3d8" + }, + { + "materialId": "29914b5d-ab1f-4880-af8a-eefa024aa65d", + "sourceSha256": "c54f41b4ad240e972958145c50feb0150a695d3c2d00fbcccc56a070e6f00c6e" + }, + { + "materialId": "29c1673c-ce9b-4732-9cc8-5b984d40e0c4", + "sourceSha256": "5dc6cc0db3bfc82156e9cb79054d1c38d54be15bb3dbebff97649eb78497bb35" + }, + { + "materialId": "29e00b10-59cb-428e-996d-cc4378cf1f26", + "sourceSha256": "f3b552db0dc50dda882b655185a0a7bb3c276247f34c07171569cb78a6c0e960" + }, + { + "materialId": "2b62aa90-8935-4443-883e-a4295fe1a72d", + "sourceSha256": "c4f5913b50337a21251fa29b4502fc4ba6cc1ad4f684aaac1d2369ceaeef919e" + }, + { + "materialId": "2d670623-0855-481b-968e-15d1abc89ec8", + "sourceSha256": "6e7147376a7973663d67311a582b691dad8decc5dd95f1fd0c47451055bac211" + }, + { + "materialId": "2e1d7244-2950-40c0-8bae-db9f78f112a5", + "sourceSha256": "8fb8fe6e045f5d68442d56a55e47da6c2795c46d386c39ced1bedc2be50344cc" + }, + { + "materialId": "2f142cb4-b721-47ac-a40d-962994d62f66", + "sourceSha256": "64b2a0c8ff5fb8b61a22848bd7ebf416362dbfe3e9ec8372ac09dc54e5b3210f" + }, + { + "materialId": "2fb4f1fa-9db4-4d0f-bc57-746cbbaf30ef", + "sourceSha256": "9fc135fbbab6d80fb2066564994f14232105e1f4abb9656280dec40affd1547b" + }, + { + "materialId": "2fd1862d-fad4-4be9-9250-768410e09b33", + "sourceSha256": "6bb41c2172b9fde7a4f04f3a3154b80309ce3fb89dd1ce35dc1d51ff46876174" + }, + { + "materialId": "304c894e-e61c-4681-b2a0-39718a24a588", + "sourceSha256": "43f13b02256b3b3260bee6082a0f923ee1908d2c7c62f4355e20a27b84444f7c" + }, + { + "materialId": "30bf464f-fbbf-4aa6-aa9c-47fad22abb57", + "sourceSha256": "fe75551778a14cb14233a54c4ad16787b0d5beb340b4cb7de13111fe3330ab8e" + }, + { + "materialId": "30c56abd-776e-460c-9fa6-9f694e07dc46", + "sourceSha256": "a6ccc4c0ab474210d36e6d2bd328893f670615877247a20616029af15f7d03ef" + }, + { + "materialId": "32692b50-62fd-4733-bfee-04d76478ac0f", + "sourceSha256": "84de4fb03ff6b83e44b52076a44c91b9a781a1b3a7dbf9e186cfe806d5f350eb" + }, + { + "materialId": "329a0680-17e5-4f09-aa41-c545a3adbcec", + "sourceSha256": "b5dc512af7a93a3882091dbf1e74a0e69c3616178000fb83e8554636744ef221" + }, + { + "materialId": "32cb6b7d-29ad-4535-91fe-d6d64bdb1446", + "sourceSha256": "3bd72d6226918ae6ab176307c03cb4b39bd2a7ee570e92ea819b650d3c8c61b2" + }, + { + "materialId": "330ce179-4c39-42aa-89c3-b54d1e29795a", + "sourceSha256": "8ced59873fbe30d7a3613b01f02d431a66ec7a6aa68993af3d6a0d9ed5651c27" + }, + { + "materialId": "33a0c850-3966-4906-a79a-e0b3bfadba32", + "sourceSha256": "1df3f3586fe654b7259e9ff16bb1b343c12603fda62576b0b65d56cfffeb8ef3" + }, + { + "materialId": "33c7d559-6e91-4477-8baa-785a681ee998", + "sourceSha256": "fb46fe4f6f1c0228659faad9373346b851e7c46128ed2a3fbc334517ff520b9d" + }, + { + "materialId": "364e25f3-4e93-452e-888d-b4e045a8474f", + "sourceSha256": "10479bb3fe13d5efdb4b08d07c99e12cd8f4dd40313bd4b90e761e2ac36c8b74" + }, + { + "materialId": "36669ac2-07bd-4444-8d1e-2597f51a1c25", + "sourceSha256": "fc1ce1b747c53389485475663234185a48f1ffc1740bfe2b70f47f4cb079d689" + }, + { + "materialId": "3677de9f-6398-4fe0-9122-36272cc1a0aa", + "sourceSha256": "f6b9d37b7928217c831c3baddf8756cd015325dccaad7c3dd1e84e2f5d35689e" + }, + { + "materialId": "3679779c-4f3b-4d8e-94b1-ab68b1866452", + "sourceSha256": "e9ed087e70234a559d70eb596abbe029ccae2e16a95ff6dbd054dd7d10db37ab" + }, + { + "materialId": "36d2606c-3531-47d7-9612-202b0d60f736", + "sourceSha256": "10209e5f70fb71a417174391aa2d278c64eac3f93b87a8df53152a0213524c9e" + }, + { + "materialId": "37339999-7875-4bb2-ba7c-f76e0acc4693", + "sourceSha256": "3c9c96306ec6eaa3e1352bf13e2696d0d5d6d8d39aeef92aa204cb75a5ff2b3a" + }, + { + "materialId": "3766c668-ec81-4106-be58-83e8c223e869", + "sourceSha256": "17c7f2e71e4547603481845bbcf58a0ad28058bf5f901b08cd9f6991f2c4fc43" + }, + { + "materialId": "37ed9a71-a5f2-4e2b-94d2-33ad2439e77e", + "sourceSha256": "4362086ed075aba733f0aad9a192daced8eab278c83f5846fa15aa5ee09c06ad" + }, + { + "materialId": "3802993e-013b-4327-99c7-75a706afdd88", + "sourceSha256": "112e84d62a77edf73bc9c33208b7bb7e4fdea8e8fe31a43a8f14d6c98d7a656d" + }, + { + "materialId": "3859bacc-7ac5-4a1e-9ff1-76807bd3744c", + "sourceSha256": "de9cc9ba8a00e5be51733a66560f1cd9af949bba7f4fac50cbeaf6f651f40655" + }, + { + "materialId": "3890824b-9b96-4706-9c5f-8370bdf978ce", + "sourceSha256": "12fa8c33573dab7b1a7345295fa285ff415c5c48cbf42ffeb99450fcb7fc3df8" + }, + { + "materialId": "38d5ad15-3aa6-4900-9a2d-47c0d8fb7b58", + "sourceSha256": "29c72beaa3f33f6d8f7426506575387562b1591cf270fb86667325a0c93a209e" + }, + { + "materialId": "3969d9b9-9a2f-451a-b9af-8d85bea4f6c5", + "sourceSha256": "80da27143e8a0ae157d3a4bbfa6ab1453cd7a2c5e7967d04c9836926f2e26cc5" + }, + { + "materialId": "3b16e032-9293-4fac-8838-52ede1f9eb3d", + "sourceSha256": "d706e9eb8f6ba44dbd007b3485fc9aa897cf485e224e957ebe1d4e460a5d95f9" + }, + { + "materialId": "3b44fba7-c9bc-444b-88b3-ac50944c892e", + "sourceSha256": "c7afb116e727143b77665b96550f69bd56cb85bb124a430a2bb4109ae6c1c38e" + }, + { + "materialId": "3bd58720-dd3f-4df4-959e-54dc0f91f6ab", + "sourceSha256": "d3d7a07c1fc61b46cedeed9057ba9967029f7df7974668fb6ca56378777edd4f" + }, + { + "materialId": "3befd3be-62ea-4a30-986b-f1a476b6a47c", + "sourceSha256": "1043ac19e0c5b745de579bcea65d712a70db0cf3d86b069786b14b118b9b187f" + }, + { + "materialId": "3bfcd48b-4d6f-4bb7-bf6a-15f5c1041eab", + "sourceSha256": "f20c1c1e9523b491061e26f7e199c146bb5b4d176d6db986d07cf8262a9312b8" + }, + { + "materialId": "3c807e92-4c44-400a-b434-830ba7d62bad", + "sourceSha256": "c774761fd0155e2c50c318379566fe2af331680dab8b73fb50384eabe453cfc6" + }, + { + "materialId": "3e7b42e7-ed0d-4aa9-a2c1-d2e9a74b177a", + "sourceSha256": "3fb25e89142103a9b610745972ab5c5730b663c2eafa0b7fb90fd6ac669c90ec" + }, + { + "materialId": "3ee1a02a-067f-49b7-a1f9-5c41bba47cf3", + "sourceSha256": "d7da276f366d5827e797811f18e39425b5f9ea8b35fd79cab3df4041095db4ea" + }, + { + "materialId": "3f926f73-bfd1-4607-bbba-6c5ce4008c1b", + "sourceSha256": "9597e080aeb07cb673d39d1e850e967c2cb9c365fb5976a84a6a8334a964460c" + }, + { + "materialId": "3f973438-c44b-45d3-83fc-0335cd507aad", + "sourceSha256": "ea37f6fa93c419824c8657d1c03118656ea06a5e665d254d219144ff56fb8d49" + }, + { + "materialId": "3fb53b6a-f8aa-4f85-a0eb-08a3bf8172da", + "sourceSha256": "6da848ee6582eeb93dba65c4ed50f5380f356c0b415848c3e4c617a01f537c78" + }, + { + "materialId": "400ab699-2e02-4d92-bf93-754d2797d942", + "sourceSha256": "9270062553b3425edb881cae53d376dd708d46b1bc751ce9915d1460d57194ca" + }, + { + "materialId": "40c17a20-8693-4f90-9fcb-54fb20135eb6", + "sourceSha256": "d9066c67118e5ac4d27157856ccac67ed391faf37c116059c10adef240eae19a" + }, + { + "materialId": "40ccdea3-8f9f-4312-a9d7-9deb8f41269d", + "sourceSha256": "bc04c2125ecc9fe382d446346d7d103cfcd50074e220a97c37669a2b162e9a63" + }, + { + "materialId": "41dde83a-0ef5-449d-9ef9-41f9b8a6be75", + "sourceSha256": "40d12440d3ce485369ae41f5d2646b16f5fa4232ca1d02659b42d0711688d93f" + }, + { + "materialId": "43246e4b-5690-4926-b9e6-3b1486a9913b", + "sourceSha256": "196a96609ea1a6946f8fa58abb7ae4bc99a8e7b1246002fce3c85db0ff475222" + }, + { + "materialId": "43a255cd-a8c8-496c-9cd3-961aa2297b77", + "sourceSha256": "4be2332fd530c5f018ae2817a59b3446d355d4857bcf64eaac20da2fe0281a6e" + }, + { + "materialId": "44902780-9c7e-45c2-87d6-db1d919d9dd5", + "sourceSha256": "29c2c9c7280fb51cdbca71b048a35a0b01de1aca8456b159876753e33026c5f0" + }, + { + "materialId": "45068a93-790c-487d-b971-5840291e129a", + "sourceSha256": "0f41eeb378d28dffbcb654d037d701e72a32f031eba55bca3ca9eff29b93f303" + }, + { + "materialId": "4563c91c-7b29-440d-b2f6-c5d93226fdd4", + "sourceSha256": "268e97d25480d0428621ff4f5b3120d029be4fee013248bd76c1b83bc39695f4" + }, + { + "materialId": "459b6973-baec-457f-b944-903eef422c3f", + "sourceSha256": "787ed9321f01833a991473772f6a4f3c938733cd3f1f4483b64020b784b9f930" + }, + { + "materialId": "459ffde0-b710-4e74-b031-09f4f6511f5b", + "sourceSha256": "ad5c3a0140bf997db7bfd78aa20579582a7811a56dfb1e9b5189171b11ce9de1" + }, + { + "materialId": "45fe0ba7-64a4-40ef-a609-4e2707f5c4fa", + "sourceSha256": "9fedee04d9d593c6075c07ff8cdd2d35c7d85d963579fdb2b3efa9c258ed1021" + }, + { + "materialId": "4769e016-ef23-4d63-9d79-1d0f8a82d41e", + "sourceSha256": "7558967d042ea15a272c7c5627b83d1dfbf547328e01567342de3e4a745ad772" + }, + { + "materialId": "4803cb6d-d7e5-4569-b3b2-beaf80f058c2", + "sourceSha256": "5adbe474ad5f9ca167d28ea543cd33ca1dffb4dd57f7daf771203a2315f33e30" + }, + { + "materialId": "4926cd2d-12c9-4d34-b86d-59ac8b430aa0", + "sourceSha256": "90198de987390657d66c22b642870eccbcab0102be1d442dc84f110d672b683d" + }, + { + "materialId": "4a5e1045-8aa3-4f5d-9584-c5c6eed29b87", + "sourceSha256": "27e55ad33d365234a6b8c51103b72b1f159c19b40b715d81d949e67ea9184bad" + }, + { + "materialId": "4a7a2d40-35b7-4b40-a4a0-440cce5d84e7", + "sourceSha256": "d44eb4138699e299105e5117ca00d1b79d431eac1a972a9ac9cdb2c755556337" + }, + { + "materialId": "4c02d503-ef08-42d9-a0b4-45a0135c4278", + "sourceSha256": "92fbe4ab4de916b4bdf221f8cedee0c58499862433bd729a60a606184bba9d33" + }, + { + "materialId": "4c37d106-b8b1-4cf2-aec6-9b8b3841db2c", + "sourceSha256": "082193cc1520424932a21eac9a39829c385043d6a9991389760e10cca88f042b" + }, + { + "materialId": "4c3bd3dd-cf39-44d6-a91f-c4efb0fe07ea", + "sourceSha256": "24494d263a2568b620e9ffa3c7f41f9aa6c0a897900e151b6f6f9f4f7b220b84" + }, + { + "materialId": "4ccbf909-9505-4b82-9e52-9018dc814203", + "sourceSha256": "e60b6b1411d784c1a9d029b2270d37282b2a452ac93ac7fe6a2894566f1ba985" + }, + { + "materialId": "4e1ba0bf-5e3e-4a44-884e-cbeed91f5f2a", + "sourceSha256": "67aa723600c4f5ab06bdc7d7442f148d6068906f7df8c42ed0f196350f16b13f" + }, + { + "materialId": "4e52f53e-8f63-4c0a-b7c1-447cf93d52ef", + "sourceSha256": "cd0c40dd9464a0fd1cf5872ce9069a4cc244d4eb428ab8ded08c1a5fe7793bbe" + }, + { + "materialId": "4e578562-81ce-4a13-b3c6-71fddad63a9b", + "sourceSha256": "d88a069df57b05e4f2a1369371462288233f29e36ecf362fa327e49a41f5c4cf" + }, + { + "materialId": "4ebab3d0-5dcc-45cb-a9d1-eef33743d24e", + "sourceSha256": "dfc4ac2c24f4c9afb2a416709ba86251dbba394c615940a27bb032c5dbfa75ea" + }, + { + "materialId": "4ebeafab-8cc6-48b3-baf3-0db02faad247", + "sourceSha256": "fdd19afe878ea0943333a9e21f2c3251e4c2809d4bb86d56c678467f06e97356" + }, + { + "materialId": "4ee8b549-f8fd-43ea-93d9-99937bf56557", + "sourceSha256": "7ce8700d4c332c0344583e455277654c7dda0b6b14e23d39749bf98e9150b5eb" + }, + { + "materialId": "4ef94b71-3e7e-4256-a204-85179e35ad37", + "sourceSha256": "457909aa8dd8744cc9a8ce2f98544339a032976558c373c1aafaaf450419a768" + }, + { + "materialId": "4f7623c1-3fe7-4987-9e0d-dcf84b1e87bb", + "sourceSha256": "fdc994d2a729886f27ca48773311bc31f6b23a4e59bb9a73aefe46e18d5c5b97" + }, + { + "materialId": "4fd2cca2-9b0b-4482-8121-5eff61c5ff73", + "sourceSha256": "14231d60db9cb85b1a9e15700ec2cd2175df090985763ee2eaf289b31894ee92" + }, + { + "materialId": "50b3818d-b41d-48b2-9dec-f44003d7a3e5", + "sourceSha256": "69f439ba0db23dd97c624c7858cae48f54fa4f5c03be017b9bcdbb7b1147dd6f" + }, + { + "materialId": "50d4dea8-ee0a-4be7-88dc-96fabda223d0", + "sourceSha256": "d1635b149862a5689613a82716f01cdf54b20ad1f303718ea75223df8b4616e6" + }, + { + "materialId": "50fc09e1-0dfa-4edc-9946-d7506848ed37", + "sourceSha256": "c69ee8025b3a867b3c4b5b653cb4ca2ac09ff3a7ba802da24badcc2184a7fffd" + }, + { + "materialId": "51abd8bb-b0d6-4217-ba87-c4a47dab4c54", + "sourceSha256": "20d5219cd89fb1b86484da991c2ba27f1f171275ef4f97e96b64af9845ead340" + }, + { + "materialId": "521ec179-a5e2-426a-b122-0314d9b989ee", + "sourceSha256": "b93393b700cb919d0c489950fb9221858e1839dc179dd9853d2fab119c0f1eb1" + }, + { + "materialId": "529a2db9-7419-4c89-81bd-2a688497b384", + "sourceSha256": "a4d4c2f16c0c2664b4c5dc2db32102bf6a82bef0e9d6a9d1e8a66b749959a6bd" + }, + { + "materialId": "52db234a-7745-440f-9c32-e1d787b1655b", + "sourceSha256": "8687783e288aad2b43680931a98f457260d46582bec893c685a34c2dfc4ff0c6" + }, + { + "materialId": "5356193d-9697-4a25-b8ad-78ae169ba639", + "sourceSha256": "6da7614dd317480404db3df3416388fd4b13482ff6f2f4294afbe41dc7ad5c78" + }, + { + "materialId": "567bc846-2966-4e57-a1b7-0a2202262ad3", + "sourceSha256": "bf5f1bb97fc5eec97e18ceef7083b15c3cb407887d8eef689fa31576c7f16066" + }, + { + "materialId": "56d12a39-ebbc-44f9-86ff-f3fd044b9c28", + "sourceSha256": "17580d73834dfafc3034735325193936c7f11c3f22a3d57ee1679b7bac070f70" + }, + { + "materialId": "56dae9b9-64ce-4e03-b875-7207db7999ea", + "sourceSha256": "97f2c658456169a4029d68dd6a946bd3fbd795d47d985ea9aaf96ae5d2a67240" + }, + { + "materialId": "56f15dcb-1ec7-479f-9030-c52b6cf670b4", + "sourceSha256": "7629d5b8789d6dd64bf55e14e90f138ff4d3b9d73852e35e87fa667a2f4aecb4" + }, + { + "materialId": "56ff6c0d-6919-4692-8cdf-f99f00eddb91", + "sourceSha256": "32bb75ebd35753364a5e1b7808f1edd00886377a038bce3a3868c33ba0e1c43e" + }, + { + "materialId": "57a98629-7ddb-4fb3-8ac0-3b955db30a64", + "sourceSha256": "4f6e2aed1d22a2ac39795693e0c57be5af114cb29360f7474a1798390beaac59" + }, + { + "materialId": "57fbb369-2132-47bb-ab50-f180c4101a38", + "sourceSha256": "d98a36dad8a0d6a91a501dd79504d2dd2b1cc928fd4327e4fd2ac4aba6adea77" + }, + { + "materialId": "58583bad-7c05-4e39-87d3-08705e9bac04", + "sourceSha256": "5e0740a36dfdb852c0bc35e96b5259e7ce5e0d7c87a786689941b6a36c65891d" + }, + { + "materialId": "58bd7a7a-f3fd-48ae-8a51-3bd7ada06696", + "sourceSha256": "110b2586655a9662d479d8af6b084c247135677bb4a2236cd76fd390674cb7ec" + }, + { + "materialId": "5b59e248-6bcb-4d84-846a-d9738f3e9d1f", + "sourceSha256": "4f275649af720ca648e93d4038572b371418d1cc76cc362709692956d4659a81" + }, + { + "materialId": "5bef8b8e-758b-47af-af4f-f94e32d8ccd5", + "sourceSha256": "1c6f64f410c0350c9dbdc0fe64479bb327fd1ec0c3a144df8f885aa13039dc25" + }, + { + "materialId": "5d218384-b99d-4ae8-8d87-8e3f083e5585", + "sourceSha256": "e74dd291b34d499005b5f289922fc49839c90908539dec439c69c45005a47026" + }, + { + "materialId": "5d55bea1-e0eb-4bab-af51-91f827c6a5c5", + "sourceSha256": "e1751595edc8d4e42e60bed1215df97295ceba8ff01e06ac4656aca5d8cd17cd" + }, + { + "materialId": "5eabd518-8463-4b55-81d4-d60add2b66fb", + "sourceSha256": "c1723545b8693685d216eba539e271a630ab7c8e0c41c29ce78715b5896c7d10" + }, + { + "materialId": "5fcdf746-9af3-4944-a6b7-078bf4d11c39", + "sourceSha256": "4c29b5136e4fceed278350af7a78514cc806343175066306832eab5ea7cc82ae" + }, + { + "materialId": "5fdde197-bf09-4831-8294-211ea7bebdb2", + "sourceSha256": "b9114f15862a7a2c1a8b59707e8f58c930c921ccd3a312fa410a51bf11312c09" + }, + { + "materialId": "602acd7f-9496-445a-93f3-2744fbccb40f", + "sourceSha256": "b642cd08b631f53fe333a9c6ec848f318ac4636767d221cfcbe16cb7b16e7c78" + }, + { + "materialId": "61a001e0-2312-4756-a7ea-b0943849acac", + "sourceSha256": "ef7ebb47c03a23d89bc2facfac5afc7f9e7be9cf3a91d580071fa7705a368784" + }, + { + "materialId": "61df8753-f028-4d5c-a547-ff936b217f6d", + "sourceSha256": "2dfd91991c1e6b70e10aca9ebec232f8e224f59814b902043cf9ffb7ff430ea8" + }, + { + "materialId": "61ef3974-7c19-4502-a806-46427251a64e", + "sourceSha256": "bba9f3b758bc01bf75b7e848ceb90b7a30fac83752cffefae15d7a3138fdd8f8" + }, + { + "materialId": "62770a49-0324-4dc5-abd3-232c13a8dee6", + "sourceSha256": "150a2db739869ada5cf7f829fedece7add721fcf02112cbe8d192d576d7acfcc" + }, + { + "materialId": "628d599b-2681-4019-808a-67f15cfe661d", + "sourceSha256": "09fc5c57e0dadc734a0b1d8d73655a6bc50977073808c2fccee742e5da76e626" + }, + { + "materialId": "6379ad65-aeba-4e4e-a407-197b22324a29", + "sourceSha256": "8b1e6118e669624234d8da58d8cf4fb0a00de6835cd4aef19ce9c9797b0f1e67" + }, + { + "materialId": "63a3e558-7aa0-456f-9d72-11c3c55c2fd3", + "sourceSha256": "4259f4e1a005a844e7b0293928ddf25d04e3a887a2684b56231d728eb7cda0dd" + }, + { + "materialId": "63b48f0f-b4bd-4d53-b8ac-104a919fafd8", + "sourceSha256": "3ecfffc2680f75992b9ddd29836d306d4be1f0fa34080759236ad2fd8146de50" + }, + { + "materialId": "6485133c-a383-469a-858d-f00bd5b0b413", + "sourceSha256": "d656fb26775909e1862f10bb6fb722c596659ec154a04462dc7e7dbddb95c5d9" + }, + { + "materialId": "65e4570b-22c8-4483-981a-a42d8930f98c", + "sourceSha256": "1689fe76c76488582df3a29477e349b0f114427f71b282648ebdc8f85c828f89" + }, + { + "materialId": "666ff7e4-84c7-41c4-8818-3c5bb95658fe", + "sourceSha256": "075954b2c141abdef885045a7d0867b56e90f5f1a017d84e10fe17e8abab4dfe" + }, + { + "materialId": "6687b775-cbfc-49c1-935a-05dcf369a828", + "sourceSha256": "9488466a271a90992663cbe4bd05478d9becd5e23b2ba9c6e5001e6c12369601" + }, + { + "materialId": "67362b0f-ff92-474b-be51-a3600b371c08", + "sourceSha256": "5e7c7619a28dcb031911f3ef024e91841330341121df2d292f72a3edc057d351" + }, + { + "materialId": "673e271c-ee97-4f49-9f4b-5c09fd0f849c", + "sourceSha256": "ff5027f4e4881fb4353da989b26d1c618eeb10fcd93d641237d9861203508fc9" + }, + { + "materialId": "6766eca8-2762-4dbb-bb01-255425ec9a90", + "sourceSha256": "35d46fc4d56e9282484e605cb3a174921fe11a4efac46bf5cb29edee0687e4fe" + }, + { + "materialId": "68c0f425-5a07-4102-888a-07f6504ea1fe", + "sourceSha256": "af3763232364f11e83f134cc4e5b2e4c872d393401bab228c98142ffe8bd6717" + }, + { + "materialId": "68cab0f5-e507-4b24-87e2-137fcfb95f05", + "sourceSha256": "a6c32ee4f261602a73cf95ce4dad46a714227f0f098950950decacadc6fa048c" + }, + { + "materialId": "6918e88f-60cc-4acc-8e7e-5e9ba591f538", + "sourceSha256": "2c56d88086e6c1a9798bfd2a8e6d1405f1555897f0a22ba0830299181e7dfc2d" + }, + { + "materialId": "697bdadf-c1dd-468c-a95c-0917321bb264", + "sourceSha256": "ad21dab666d5c095b589bd6018ab1af7ce1198437e47d06ac921ef49de835cc0" + }, + { + "materialId": "69f104a4-b2ae-4ffe-90c3-065973830814", + "sourceSha256": "05462fe36289c3a5a967aabd1d5334704154d4066c33ce5f29c1b96041b2eed1" + }, + { + "materialId": "6a7ba86c-24a0-48c3-9a4e-6aaf883748ce", + "sourceSha256": "cb442cf6df6a47a06cbd9a3e3eeb9b3bffa77b7200746c74539f41dfa1e5b47a" + }, + { + "materialId": "6ab8db14-297d-407e-83b9-a98910f46b94", + "sourceSha256": "1fd0a245ceecbd50e052463343d1f8faa9613a1ce040d2522d6c6daccb8daea5" + }, + { + "materialId": "6b3f916b-3acc-41b8-9080-228edd235da0", + "sourceSha256": "a9ec70e98262e645d407abdf58fb0ea5ce1422ca1c2f23287749245a0733c921" + }, + { + "materialId": "6b813a93-3e5c-4ce2-89c1-54b203876663", + "sourceSha256": "4017c07c35c890e3d29d0be12f7e661c7671039ca8c8255558f12aa068006b19" + }, + { + "materialId": "6c83e4ef-323c-4171-89b1-55ae8280d1a0", + "sourceSha256": "2532c125659f61d19cb30274b66f8263bc9aeedb386ae40765f96a6f9c31c5d1" + }, + { + "materialId": "6d1bc66d-65d5-435e-a97b-7cb8ea3f12db", + "sourceSha256": "781f8683fa1943e660e237aa1179093d5c477307f608a25a07c1a92a2f43cf83" + }, + { + "materialId": "6d8005ee-dd88-4251-a396-391bf339c484", + "sourceSha256": "eb75cada05f0d600fc6114bd6d6cc9b0c4bd8171a7ac94ab3dc5f0e097d43345" + }, + { + "materialId": "6e53bbf1-f891-4dac-84a2-c42e7b99334a", + "sourceSha256": "6dc1b5b9d2f732305e2e83517b0f095f2f1478581419c0b038700c3b58826e5d" + }, + { + "materialId": "6f70004d-4e20-43ef-8cab-8eae42e08ce0", + "sourceSha256": "cd80b66415bd59b8d2b8ff834d54bdec686f384c56f6c2f0d215e6aa839e5c8f" + }, + { + "materialId": "6fc35857-939d-4fde-bcdd-c99978fbb184", + "sourceSha256": "8b8e9ccc61beb85539c4d2094398e3f35505e28b84fbf250ec8d5238b6ae08e3" + }, + { + "materialId": "7060a618-0e6c-4335-ac39-f63b41de2ddb", + "sourceSha256": "1b2ad8c55814c8bed7cb193b124260b8d2848e7f1fc9e130b741b26412a005cb" + }, + { + "materialId": "70adc9e1-5899-49ad-b04e-44869489692b", + "sourceSha256": "7eae974a40ac9eb7757698f4a2b6fb95e5950660002c26b1d01822b5f6edf633" + }, + { + "materialId": "70e885ca-67c9-479c-9c82-49b4171df869", + "sourceSha256": "e30a6ceb9bd5ea1415a3f991b683cc68763cb0428b2b4d60b5154aa4c1661e19" + }, + { + "materialId": "712d213c-d431-4468-bb98-238961ec3249", + "sourceSha256": "abe3090de5317e9e461d7c560f087a6eaaa54fadb386b6a3eeced75e38d73e4a" + }, + { + "materialId": "718734e3-9913-41d5-92ee-0e80b8b7ab07", + "sourceSha256": "fb6784a5c6f61265fcfa41101025e1e323c1673bc32c9400edccd662f852f49d" + }, + { + "materialId": "71a6c0ab-e893-4d29-a578-33a4e36dacb0", + "sourceSha256": "1ea78fff21e0010f2470c0140c31d96717aa4c98ac23e7d5136ed93aa1ee62ae" + }, + { + "materialId": "739e24e5-b8a9-45b1-922c-eff45b887cd7", + "sourceSha256": "28765ff98e2e092752a23fa87301f1f23d84527159de8951f225fd47b19205d0" + }, + { + "materialId": "7472ad22-ba7b-4b47-8240-c36c6f7e59b4", + "sourceSha256": "3fb4b0f90808aef0dafbca20763796744c1a56f772afe3cbd6955afa1e33cea8" + }, + { + "materialId": "747c27a6-8cb7-4daa-be8b-01b13981c9db", + "sourceSha256": "6de7f1d15ac3d0246ad272ceb89cf74782d33dd0730d82247a3be5906e801711" + }, + { + "materialId": "74d931a1-b618-406a-9d2a-d4edaebaa393", + "sourceSha256": "cde79b43d51ae82040c4854293822793831a242fe9d1dced4ee46534701c9fba" + }, + { + "materialId": "751d879d-e324-499a-8572-97f2d26d1284", + "sourceSha256": "e588ceaab1f21e22f7aed93df162011a77ef7950f6719f70bbf3530ebc40ebc1" + }, + { + "materialId": "758ac6ca-1b1c-4b2c-9b8b-3b8bcf0bddef", + "sourceSha256": "c6895356ef2bb04e775039ec93b16a962c8d9ae16e420d83442f339adcf9e700" + }, + { + "materialId": "75cd0c6d-d281-46c7-afd6-2ef1fb9138cb", + "sourceSha256": "4be3d905d0dc689a41f94c4faa941391c23f179970dbe201b5fb7932e398ca5c" + }, + { + "materialId": "75fae719-41cc-4e21-876d-fad94a618879", + "sourceSha256": "6fd3d0e7bd8e40a122d12b2e040c8eaa2785546e1cb60a9d294cf0347a028f52" + }, + { + "materialId": "760dbd01-ee47-40f8-8da5-c452d5c2bc1d", + "sourceSha256": "2ddd397282987e726baac1fcd15587ad6cf46003bb84eb6941ac89142c394a30" + }, + { + "materialId": "77218f12-fd2b-4581-bc50-ef0e1f1378f4", + "sourceSha256": "023f01fc1f9bf64eca82ca3916109986135b7ab57f6abf30720c39a09057834a" + }, + { + "materialId": "78e00620-6acf-4eb2-a4d9-db8faa06c4f1", + "sourceSha256": "82efa49afced00fa1cdd5919ee671b49a3653c98826a9f8ff8b39e1320b6e2d0" + }, + { + "materialId": "78ff1c03-c0fc-4482-9238-8af648375920", + "sourceSha256": "80b37833610d6e48e7c4b3a6b65ca6b0d29f7c9f9b9aa6dbdc2ab1579c7c17c0" + }, + { + "materialId": "79590769-9cc9-4e16-9aa2-8c0bdd34f431", + "sourceSha256": "a4ef319d20b5170f86ea4ffc93d148fe7c0fff32dbe81fc3f833dd400272c6e1" + }, + { + "materialId": "7b340ec0-9019-4857-a078-1006d9ebb25d", + "sourceSha256": "573417eee2810ff7d3918fc51bf555875002e54c354b9d1c1d20d4d4cf57443a" + }, + { + "materialId": "7b5f26dd-fd96-44c7-902d-524574c07fa1", + "sourceSha256": "77e4c617f4916e4562826af8fda7571c3ccdb896a17aa9c29cd76bb475e3711d" + }, + { + "materialId": "7bf01375-607c-489c-9d3c-dcaa654f1806", + "sourceSha256": "f95660c79df6fe2435c41cc4b1081b16e0b1a074119c35de952aade1182d3298" + }, + { + "materialId": "7cc7ce66-d982-439c-9cc6-b88e450e7416", + "sourceSha256": "6dbc20316f1a1b668043dc7857ab247aef2483707afcbbbe8030aac480f40fed" + }, + { + "materialId": "7da90206-dc0d-4e7e-b4a4-68bb088d3ba7", + "sourceSha256": "90f685960ad48bb68de41eb53d05d66622b8217de511ec95dbd42c6082965b1a" + }, + { + "materialId": "7f10f7ae-b684-4cd7-9160-a70263c15b55", + "sourceSha256": "1c824f801cffaeb6e65be7979ca51bc5f1bd7e2cea0dec27e47985999357a964" + }, + { + "materialId": "7fe4c7ef-0961-428e-a70e-d8ca75f60e7d", + "sourceSha256": "ac2c22df3d691381ff8e3bda61fd097ec26420172879a212d69d35214e4f4f14" + }, + { + "materialId": "7ffdf0d8-8794-4ab5-9963-b46362296734", + "sourceSha256": "0115940b760325fe74be0e009b5d8d314f34ada1ff40c278ced2b1190ea53412" + }, + { + "materialId": "807628c0-8298-4828-bfc8-e6695bbec125", + "sourceSha256": "fc7e36a152af381eade82e11d4bedd03b782c1b011834d12df5c9cf1c91c0ba4" + }, + { + "materialId": "81251f5f-f29a-4b61-a8fe-7fdb8ecfcb06", + "sourceSha256": "9f6cbc7457118de5b95880f2a955809e5943758adfd2df671a99f76e3dac9acd" + }, + { + "materialId": "81df7c04-b1be-4e12-804b-f0a72a6a07c8", + "sourceSha256": "af42710175b07c9f7b12c089b2d20f27722a59221d77878147f65bb77c1302d1" + }, + { + "materialId": "8386499c-b099-49f3-a781-9b0b20d19ec1", + "sourceSha256": "b97dbd4848c5b8125b76cd0be2ecbca655add0df3c907c581a331f10c030a79b" + }, + { + "materialId": "8394125e-eabb-40ae-ab73-9c84bab5ba31", + "sourceSha256": "46930382033817ae80aa1833cd8265479e52d370a94b0119729fa49e5726662b" + }, + { + "materialId": "83d6e11b-5f13-4896-8129-d2dc0afbf64b", + "sourceSha256": "d9daf7ab6af85cfa963bf5df394f13dcc955a106d1102606a622a513beff4754" + }, + { + "materialId": "84cc81dc-40fb-4c9a-b618-115f2135f46b", + "sourceSha256": "e5ac91bf9e830ca5a3e3eb6269953a41fa6c51882c7256917e774c40b40c6773" + }, + { + "materialId": "84e7e2c3-f5ef-4db4-b7d6-1ca32183c27d", + "sourceSha256": "8b31555e7ba965f38662c1be993cd5948669de37f699372ca7a25b4028421466" + }, + { + "materialId": "84edbe3b-61ab-4928-a424-036d9eeab605", + "sourceSha256": "883e82464bbf7353f40c61a55baa9c745a611b301bb91d676c245570a095bc87" + }, + { + "materialId": "85632bb3-f825-4674-ac9c-f599968fb8f2", + "sourceSha256": "35dd514927b225c484f0226ddd1e74bbb57faecaaf36f7870e9b81b2892282fb" + }, + { + "materialId": "859c7395-51e5-4a4e-a359-96635ac3ac3d", + "sourceSha256": "84de9eeeb04b7c8065c7550c5efb50939f726c38cc1e8f0e609a9e50f3fc0261" + }, + { + "materialId": "861b2fbc-fe89-4191-8647-915aee148e63", + "sourceSha256": "10473482d941129f2f4753a1fcc169dd240329a723b8ccbe5a5f27b38cbfeb63" + }, + { + "materialId": "87343317-a050-4d66-bdcb-23a405fe805e", + "sourceSha256": "595b04a52b81cfcdb4c809c16a7bd289bc78692b42bc1a9521aefcdd26a3f1f6" + }, + { + "materialId": "88406fc6-0d26-4900-a659-df46d6285993", + "sourceSha256": "f244ec83a96cb4a0b63705fb42ec301cce4fdad46bad5e1b6f0083ea6b847b25" + }, + { + "materialId": "89ae088e-a51c-467f-9e02-1093310926fc", + "sourceSha256": "c0eb3744d8d14e89e2357b9432d01c67bda5dd9202d037df52426d3225d967e6" + }, + { + "materialId": "89c59b80-45e4-4903-97b6-417396f09a21", + "sourceSha256": "01c20717ce41dc763546dd77fda2836e1dabdf1938700b211f821e3c7afa47b6" + }, + { + "materialId": "89f9cdb5-2316-401d-becd-e8b6ca42b08b", + "sourceSha256": "8ed370c38d52a147f2e0dd72a44b5498a557c82f00ed5806510846ab1c12512c" + }, + { + "materialId": "8aba09cf-0f8d-425c-93d8-4ea70039803a", + "sourceSha256": "237b6d80cdae2f29c4e3484372ead044615ba06a8aa56bc42c2296169b3a26b5" + }, + { + "materialId": "8acc986e-e342-4119-aa73-b068e4394b3e", + "sourceSha256": "b85468dd4258bc4a1d780e8c67d4ac2cab37cf44f3609b42446f52fbfb5dcf1b" + }, + { + "materialId": "8b10b381-3f2a-45ea-93bd-52cb04030de8", + "sourceSha256": "f54ad12e16f2d91c48c55a37909f22ebbbf9e4fd030f235377fb1c7fc71d12d1" + }, + { + "materialId": "8bdfbbc0-6158-416f-9af7-37b8ffe5c56d", + "sourceSha256": "3eaa281dc4cebda0db9c070dacdda2518d14a222e0623c109f5aab93b9ffcc08" + }, + { + "materialId": "8ceb3eaf-b3ee-477e-ab7f-f81ffce9bbe8", + "sourceSha256": "82b65024d2a14189b5cc2097ebb889386742b018affba4cea57c2e6a42602964" + }, + { + "materialId": "8d522931-f969-478f-89d6-e75be103020c", + "sourceSha256": "479439c82a6ead98d08a8ca05e95d3c3d0e57388d642b46952f7bee25734905e" + }, + { + "materialId": "9083eaa7-2b71-4508-9cdd-6892b8ca3993", + "sourceSha256": "0d7ee09437f98f51c40a499bb1a18023fb4cffa6d7340a1285498608b92c4c16" + }, + { + "materialId": "90b92b8d-980d-4132-b26b-491983da2acb", + "sourceSha256": "3b90fe9a4256f9072da7892b78ad5f72ff96435ff782365974f65b5a95043ccf" + }, + { + "materialId": "91ba8833-c608-42ac-bb27-23932547e28f", + "sourceSha256": "ceb342fb6a9676b7e44cbd89ad8f01e4ef6e96c7f39a71c7fd341971c16b36a1" + }, + { + "materialId": "92478538-acec-4b41-8d5e-f74af954b8d0", + "sourceSha256": "307c0d2153e3a746c656c886682aec72def823b413d4e55fbfb782659455b1f7" + }, + { + "materialId": "92a25378-d4b7-4bec-a0d4-b501e9013a53", + "sourceSha256": "a5d47e8c46568b06f0afb3fdfcd74b027c0b6c96f266b500e4c693fdf8bed0ee" + }, + { + "materialId": "92f082e2-8e98-4b4a-b898-bb29a940ac30", + "sourceSha256": "938abbba45b25ea00e2c143ae561f9fed4a6fdd3ba4b68e72a0b032e1acba9d3" + }, + { + "materialId": "93030510-2a11-4569-b62f-7d817943ac56", + "sourceSha256": "0fa982cc5edb9b711a75e6df4589950e7569567e401e2b1f2e1122a06bf93804" + }, + { + "materialId": "93159468-5379-44a9-aeb3-cc82000a9c30", + "sourceSha256": "ff12fa27ebd066327732e1b5fe8e5f61a217456c1631bc44ab8c6fe01c15415a" + }, + { + "materialId": "933e7363-81d7-4315-9ee2-4c15b6b1dc3e", + "sourceSha256": "dbdd8c2b53a5de1575799d1665646398f8914da2597f6b92a84a064227fddb40" + }, + { + "materialId": "9516e94f-d3cf-45ba-9d3d-28ebcae826e8", + "sourceSha256": "71acb0d692fa3493c1565181a2180bcbab3c836d3ee7251125b1475523a51d3c" + }, + { + "materialId": "95b9e57e-e7b2-4b0b-8d10-a825aead7292", + "sourceSha256": "3049fa479f1c5904a40e6f390e30c21ac8188b9428e6552b720b5bd09b7fa3bd" + }, + { + "materialId": "95cefbaa-4e8d-4f0e-81ed-68dfb897c6f4", + "sourceSha256": "bfc58c2c16e45c77adfe50c92aaabc64b5a3494b9eccfcc5272dae39f49fdae8" + }, + { + "materialId": "95d10099-cc8e-4a7f-8099-98d57148f434", + "sourceSha256": "9c996e3a199bb2fe164b00c659d098f887b4300b37f46f5e4f245d5f2825f4dd" + }, + { + "materialId": "9699a0b9-234f-436e-9c60-2b6f793f1b6a", + "sourceSha256": "5aeaff5fb413bc2745da3115e6d0bd1a79523d48c03b5766f7ee54c9159a4907" + }, + { + "materialId": "97135a1b-3d1e-4379-82a9-ca64d756f293", + "sourceSha256": "264bd6afd97b8b407b758caa8ed99cedd333c9349dcee407d417ef1d473c13b1" + }, + { + "materialId": "9740ed75-0a5d-4a73-b07d-250561991e1d", + "sourceSha256": "1c6fa28e2ecfe6e5e1e29cc8e8f2fa674f2b2e92ea0f77ead3d9cbb3ba568659" + }, + { + "materialId": "97b58f34-d869-475e-83a5-e32b634d7c61", + "sourceSha256": "8859a4a9121f38f9a5b8d8bb51fd7a0278cc287a2973293d3aa6693221a9f4fa" + }, + { + "materialId": "99644ca0-6b7a-476a-bf14-4c8affb08f7a", + "sourceSha256": "e4f48e405311a414809b0ed268d9d2578d5ed93f3b20d0db5024ff872bd98873" + }, + { + "materialId": "99b4c2de-f647-432e-a210-be72796bdbf1", + "sourceSha256": "93c9d6573defb2787056f2a2bfbfc3e30d9845c910dce540fa2d361504813fb9" + }, + { + "materialId": "99c9e49f-6530-4af4-ab57-bc5dbfa91c24", + "sourceSha256": "feeedecfc293f5605484e1133d9b26e5220a343fbe880b187fd3089bf81f9851" + }, + { + "materialId": "99f38926-62b5-4f21-a0de-2dad5442f24d", + "sourceSha256": "31580a87ebe39e22e5a708792fab69ce8ea563e5b865b50ed893f113b3c9c589" + }, + { + "materialId": "9ac253dc-04b3-4fbf-ac43-ec04f32690ea", + "sourceSha256": "5733f76537aa7cce4de13c0af3e8d83e0f6cf20847fce03798d38546f5b692f1" + }, + { + "materialId": "9b1ffdad-b7f5-4e72-8537-348548c6e0b6", + "sourceSha256": "7bf4940e7cce5957535893e03c401e9801249ae20ee994cac34fbbcc23ac8d3c" + }, + { + "materialId": "9b6c2932-adcc-4c92-a2bf-52b606b0e12c", + "sourceSha256": "0dce16ec7ac10fc2e5a90d3928143aae35faa1bb2dc5df21fcd5a4db3393d1aa" + }, + { + "materialId": "9ca17c60-3d3d-4b5d-95f1-433d4a786bcb", + "sourceSha256": "11a56aa64d82d8816ec71f62a7a1ec14e2396d571cec1adbe12bc5987b8898f2" + }, + { + "materialId": "9d05ccb1-3eae-43a3-b9bc-79cdefba9c81", + "sourceSha256": "da456019b140a375c9140742e9993a09b6995238df4ffe47a5cbadece9166cff" + }, + { + "materialId": "9d51ff9d-8664-44b2-ac3d-709ec31c4828", + "sourceSha256": "b1ed35a431510296d8cc95c204826049fd1e2fea598c9ef184794d0f1d85f7ba" + }, + { + "materialId": "9dec8235-9861-4b15-bfef-d94fd073b4fa", + "sourceSha256": "b9f0dea5010823ccca97f0ecd809fe580e6f88cf09871c913e63592763b2d2e9" + }, + { + "materialId": "9e0bfbb4-05bc-44db-bf4f-009d734da34c", + "sourceSha256": "48d9b9ee938a6a15d76db497239b8adec8dbd140102e9cca1a9e6815da526519" + }, + { + "materialId": "9e3433a5-7e51-4f64-bb1e-f9c2f8e0a780", + "sourceSha256": "b715fe7fdd995d49d4d99900cafa6d265780349d3292dfcb7d48c6f721799e70" + }, + { + "materialId": "9eef0066-c335-4fa0-8011-6440519f9735", + "sourceSha256": "e840e31c6d036aaf891920fd43231a7d77a26dd0c28beb0f51c800fcb6fc24ef" + }, + { + "materialId": "9f3e55ec-b94e-4373-97fc-40017bc5262e", + "sourceSha256": "fd169e014b63299aed7ad4150adf812f8cf016af115180f76990dbe3b02c6040" + }, + { + "materialId": "9f59dccd-8127-4d91-a807-4802233d54e7", + "sourceSha256": "5e5bc3fac432573821926079df33a28145e443724c3ee3ce569013a272279427" + }, + { + "materialId": "9fdaa233-dc0a-4acc-85b4-7567771e42a5", + "sourceSha256": "e5358eecc14269e1875fd52ac99b5743de1372049e17c78107a4bb38c62786fe" + }, + { + "materialId": "9ff17cd8-eb0b-4b96-b269-982d7bced03c", + "sourceSha256": "dea1299542f1f5a398b4f0b85d7b81b1334043f9a07ac64a51f68c55bc1e3c85" + }, + { + "materialId": "a006e22a-5dd1-4e07-830f-8eefdc8ced12", + "sourceSha256": "3071d34d2017cd5219b268b5e69b7e0ad25b386027c5dff0a87513f67d7631be" + }, + { + "materialId": "a15b7c93-bd62-4497-a9b5-7f277ffdc764", + "sourceSha256": "9f648b21fbd26e5424f72fdaeef9555729deb8680d4f4aa30d96b143d5bd0452" + }, + { + "materialId": "a167a101-bcab-4166-95c3-c68f2479c7bd", + "sourceSha256": "ce0fa8c5b97125a7ff08d797f6321440373d7bf0619f97b820ee12d789329010" + }, + { + "materialId": "a202f607-9413-4895-8c92-ac593a3adac0", + "sourceSha256": "6039b7cbfe90ce1cdf57eeabe65af3fd9cc0e979c30a7df4c9f545baf56b7de8" + }, + { + "materialId": "a252eb77-cff0-4810-b238-7bad4a62acf5", + "sourceSha256": "4f584eb72428565ff63fc9cff4fd4d8e9fcd9fa74904dca9342d7bd09d524d73" + }, + { + "materialId": "a2e80feb-bf5f-4390-bc96-168d8cc16e37", + "sourceSha256": "610a9d601674cdca1ee910c3e4cdca8e3c225aa2b34ca976b0a3cb8d9d57a8e4" + }, + { + "materialId": "a38592d1-606e-47f1-ac21-7218ce9fb1c6", + "sourceSha256": "c0d787031a4608d9b77a7aaa853ecd4eabf1dbd57fa8e72afaea6d782b41566f" + }, + { + "materialId": "a43c0ebd-a00f-4170-a0c3-4e261d22781c", + "sourceSha256": "9c7675ced745c26be4011491132186ff2914a9c91142096a893027d58acb5abe" + }, + { + "materialId": "a452f2f9-50c0-412a-b22f-96687da20b96", + "sourceSha256": "6e693b269191aa8de686150a28be0459f8e586dc698abf18f5e607b08215abad" + }, + { + "materialId": "a478d3bf-526c-48bb-a8d2-730a94586de2", + "sourceSha256": "7cbb73fb666ab200a71692463c9ab771bede137029b3eb80d459cc5c971a912c" + }, + { + "materialId": "a48f3276-98ca-46b1-9083-c21fb8031e69", + "sourceSha256": "a1df6602cc5efdd16ae3529e8384b3126c763c9da8242a05d370e1949cf48e10" + }, + { + "materialId": "a4d6e15f-e878-4943-b936-931dc3be43c1", + "sourceSha256": "0e9d38f588199ab7a4c9eed201fcfe78401c69ca137a4eb2c6d0fe3ec8cfd9d5" + }, + { + "materialId": "a5b8c082-1d40-4038-b349-ad2e58dc16b9", + "sourceSha256": "ee8b447020f3218c4b43827454bb96746e293fd6b91080a174405738d7e15471" + }, + { + "materialId": "a5d04c94-e8a1-439d-950a-352807a5018e", + "sourceSha256": "cbc29c5c08aac1be5317d074e3d52caef4c6bdd36a166b76c0193d293dc695a3" + }, + { + "materialId": "a667990f-a433-40c9-90ac-605a195a3ace", + "sourceSha256": "413845c7ba836dde5b42bb9d688fdfba1a50bb23fe8c7f9a2caf81cde785bf94" + }, + { + "materialId": "a7bbae39-3fcc-4927-b640-3e3078db79b7", + "sourceSha256": "e28f54edaf63b02bc4186911fabbf2944983552a3690755112262f145e7984f7" + }, + { + "materialId": "a7d4be90-551b-4bbb-bafd-186c9e34bca9", + "sourceSha256": "aa5471422e24ef713f4499988f9da6ef1bf2e272aa73b9c84246310d505747f8" + }, + { + "materialId": "a81b9c98-065a-4a65-8aa8-bd46f0baba1a", + "sourceSha256": "0ccf0cd8023fd30abd7e9f997cc1149b71c5117548e738eecafb6742d374274f" + }, + { + "materialId": "a8531cbe-d295-4cc7-bde9-8ba49780bd88", + "sourceSha256": "d248c4c59bef9984169fc8e5be4470cde7f70e9f18c0aa3a4862b4611b1bca86" + }, + { + "materialId": "a8546e2c-4d5f-4f7f-99e8-e3ddbb71794a", + "sourceSha256": "63bf2ae02dc76ef1fedfb1210ab327cc8c2677e9d3786fac83485cf2862d10ee" + }, + { + "materialId": "a8d5067a-1ec9-4d5b-bafd-aec832e1fba8", + "sourceSha256": "1ab3006b49c972d20b09aae55a05fc98938ca15b6c7408d940f9586465159e66" + }, + { + "materialId": "a8f54f9d-6b0f-4453-b38e-9af57eeeafe8", + "sourceSha256": "2466a421f57c7ed6987ca4c7c3937fd811b759ef91e5b521cf3240a41613f579" + }, + { + "materialId": "a9618894-8f53-4f1b-9209-aeda65344b2b", + "sourceSha256": "2bf4dcc9ca3449297a63e5c25d580e59698332ede69eb69a865c89cd507e2a07" + }, + { + "materialId": "a9761a49-91c5-4f32-98ca-b7427b513e1c", + "sourceSha256": "18e48041a5c017e11e1c8281c5ebb8de09b9a305df61a81c53e86c86ed20c252" + }, + { + "materialId": "a9d35a46-2bd6-4d6e-ad53-c09eca618218", + "sourceSha256": "9dd638f94c49e4ccb81633e969f9ba923191f1c1f341024b62e599422eec6032" + }, + { + "materialId": "a9ecce5a-5e3b-40a9-aa10-ce0dd3d9f24a", + "sourceSha256": "ae6853fa99aaf7a7906351e5d13a1e8ef3fc22de0d6cacf5d7aa74bd6019cec2" + }, + { + "materialId": "ab1fa2b8-3337-4443-a0e5-f2a9b40fdd22", + "sourceSha256": "23fddafa5cf6d6b9f93478bd1109ccecaf7d4ed109b351e75901fcfa8180860a" + }, + { + "materialId": "ab5e7684-0399-43fe-bebd-c1be2eaa46a0", + "sourceSha256": "9024b0e3024292de55e2a7fafa24bbfc6d9a842eaedb3bfb1ddfe76fbb63253f" + }, + { + "materialId": "abd55006-6d00-4a58-89a6-883343378b68", + "sourceSha256": "488d02813bca8495cd1558c7fff78def0723fee38a909bba6ac9e44b5039bf00" + }, + { + "materialId": "abeae0bd-e854-468c-a124-2693a38f9525", + "sourceSha256": "be68b8045a54cc1d3ce39c66c8729c4935e13e28a47903601bde883efbedbe7d" + }, + { + "materialId": "ac14a475-3b04-49b8-905e-f5d5d7535757", + "sourceSha256": "c6274af3c6699476d1a6555ed42b97ce6d325dcffda64335525128049fc820db" + }, + { + "materialId": "ac1ea5a2-79e9-47dd-a02c-9386e8f59a1f", + "sourceSha256": "05981b0c975b7a36097ceb97b5bbaecf3c9af519ed3ef101388ad39ee8312715" + }, + { + "materialId": "ac478b88-5445-41cf-abc5-50a82a35880c", + "sourceSha256": "61a29ecbee195988d3b161766a830f580e0c16fe68fe2bbfaebe1f860d8c2a3c" + }, + { + "materialId": "ac633252-3f4e-41ea-87c1-d1164547afd4", + "sourceSha256": "d3bce62e0af435a7d793d7a610fe29dfdd99a23d66a5b2961a71aa90ea532f1a" + }, + { + "materialId": "ad27545e-da14-482e-9a50-834ccc750f36", + "sourceSha256": "93cffc3a3e2a5c426888dff16935d6159fceddd3ecc9ce28613c791656739cfa" + }, + { + "materialId": "ad6dc33e-8a03-45e3-b694-9f52f2ce667c", + "sourceSha256": "413d5d83380428e6b12892b127e4150ec22d999e14a1d5bf2a65b1d1f66415ca" + }, + { + "materialId": "ad92b653-bfba-4a3a-848b-7c6c559430a6", + "sourceSha256": "61f484fd8e56844e0e1e17e5f63b05bf5b1e804705b45e18d4ad0849f4dc2cfa" + }, + { + "materialId": "ae0d732e-db43-4b38-972d-f14197e3bcfb", + "sourceSha256": "9baee788faf23b8943bad62d089cba6ac65924c188fb2b57fee2f81811de90e1" + }, + { + "materialId": "aec53536-2bef-494b-89cd-c747e5c4c665", + "sourceSha256": "1e50474787193b1cb79dd75843a9b73d21557885e95206cd321dab3e0378316d" + }, + { + "materialId": "af150e5c-3474-4f90-ab5d-0a3ce585c20c", + "sourceSha256": "da60e65950be4df9d88c20215673c7aabaeda9e34237706ea7ffd9997807e11e" + }, + { + "materialId": "af8289be-5528-4514-bc47-7b3b2c9625ba", + "sourceSha256": "7ce7c65031f5a1dc4ab32db5e59cb7aeec4b87e1143380f38ad574759f2c6955" + }, + { + "materialId": "afed5985-130c-4569-bbc4-de9ac6aeaced", + "sourceSha256": "07e3b956a09874fd64c6da81e312af85ee6fba941b1b7e56340d08e7f5a506b9" + }, + { + "materialId": "b06d08f9-db56-40cc-98a7-1863eadad05c", + "sourceSha256": "a6bc8c87cd731acd86aebf90efefb1742346390694d88dc36d0b27307b2c073a" + }, + { + "materialId": "b118932e-2b30-4701-9c9a-7980645ac973", + "sourceSha256": "719e51e696e5a1978a9246239dfef4e079312c8a88c7e2c117f460885111a710" + }, + { + "materialId": "b1975f9e-9d1a-470b-9fc2-c08af960ef15", + "sourceSha256": "52f062ab8e140516391ce5fe50334645ac8e92290f24541d49585a5ed7580784" + }, + { + "materialId": "b2332cb8-5690-48bd-a130-c9f0e5447a43", + "sourceSha256": "0d16eac34ad38f7483bd99ba9b5c0e30b2637eca09b958000cccaca1f5ea2065" + }, + { + "materialId": "b2e18993-1b23-4fa7-b4ce-f962976849f1", + "sourceSha256": "c408eb0957c928dbdb1daf1c4be2bbb7813b72d9e43dff0a18702d6f3c916e65" + }, + { + "materialId": "b2f18b3c-fa50-4021-ac6d-e6a83f71a164", + "sourceSha256": "e01eabd5152c2c47e999951955d343da9fd15b60809f0833458dc344d03b8bef" + }, + { + "materialId": "b4abced2-212f-45a4-a2ba-fea8c2db1d57", + "sourceSha256": "0b3a2cd8daa8b0b47868d4fe007908d511c56b10dc431f22cc068f5d14e7cd67" + }, + { + "materialId": "b512f288-251c-4b36-bb3f-ba6808d84669", + "sourceSha256": "13ebb3591f43e3bf0f6db45f5ea14d28edd48adfad831cdf3baee6dc63cd9ac9" + }, + { + "materialId": "b532a3f4-8c6e-494f-95d2-81a106de480d", + "sourceSha256": "94e47cbb00e7e8043d422b1f66224b84a1a00d2df948608b90b0623f66038cf2" + }, + { + "materialId": "b571ffca-7918-42e7-9077-47bb39059f5f", + "sourceSha256": "2c443ca6c30124eec468526a23a4fcb0a0db4f81e62a68c85fc96fbb00faf434" + }, + { + "materialId": "b6093774-0928-4046-b344-4e40c82429fd", + "sourceSha256": "2816a0ae6b71f1f13b9f9a2804b6225c0d6746c4d3fe6cf3eca768de901fad39" + }, + { + "materialId": "b6333048-4c2d-48a7-b0c0-a67a875005b0", + "sourceSha256": "d2a13c03da62624fd784db8e0fdedc420578d07f19d4b4d9fa319330b40b47a1" + }, + { + "materialId": "b690912e-b881-4f08-ba31-52b69f8af504", + "sourceSha256": "0b7c652867c0e4933d544d3095ae0fad7ee57c8b32abe9a6560574f1ae07d3bb" + }, + { + "materialId": "b6cac4b1-4f9d-4e57-ae8b-a2671cc6328c", + "sourceSha256": "6da518232530c1b5337f6ffc4e32fa4adc72024a058aee8a6bdaa8dc489e4fd4" + }, + { + "materialId": "b7cb8a85-9637-4474-acdf-3b45a0a360de", + "sourceSha256": "b4c4af08d9d29b339e059297724c00f80174589cee9093cec682e43d75d5053e" + }, + { + "materialId": "b846469e-909a-4d42-8fb9-030d2abe9245", + "sourceSha256": "9c010eb7a344432c15db0c1de52efd1bde40e6b969561e3f18e78196f21c205d" + }, + { + "materialId": "b8b822d7-fdec-497c-84e7-8fcd4e44b1ce", + "sourceSha256": "c71c4fbcf5182f87ed8291e8fd349415a685042a1a758777f76a23691d113874" + }, + { + "materialId": "b93c47a2-a58d-49e5-ab94-9cad13772725", + "sourceSha256": "77c39044b8fe0943cf4bf9d50998591d8489201685842af933c0868c15ab99a4" + }, + { + "materialId": "b9cacd2e-bea7-42f6-889c-e34fa61af16d", + "sourceSha256": "3906e9989521e1eaa913df1b366e24ecca3668f79d69b5193da79385c722d9c0" + }, + { + "materialId": "b9ffc7a5-00f2-4c5a-96ea-d384788f8de3", + "sourceSha256": "5a34e694ecf0b312d3202c67a91796206c76f33c04c7c244aecdf6d6030fe8a2" + }, + { + "materialId": "ba172796-f6de-4649-99a3-c110a2cd66f2", + "sourceSha256": "4654168fb1ca458945f964f5ecdb19ebff4446235afa70e07177234d0209c3bf" + }, + { + "materialId": "bae84068-df32-45ca-9919-e15180c7c6c7", + "sourceSha256": "aff2a25c326f78907d1a12079e464ad6cc54e502dcafab948332d6b176fffb81" + }, + { + "materialId": "baf4ff0f-7c55-412b-96ca-f6023b7e9593", + "sourceSha256": "bc042f45192f8f7056c6ce7f322c8236d2b4fe687014ddd8a8778fa298707570" + }, + { + "materialId": "bb7435ce-5208-41be-8fc0-4f48bc058ad2", + "sourceSha256": "9a543666dbfd1576680ba3277ae32f75ed8966bfae4608d558ffc8b717a98f9c" + }, + { + "materialId": "bb8e983f-74cc-4b06-9223-daec1d4cf1d1", + "sourceSha256": "0222d8da8cfdf71977bc48387cbe092038f2fa0cedb8433d378c25abe6bf836f" + }, + { + "materialId": "bc12f966-ffe8-468e-b0b9-d1a7fa8e4462", + "sourceSha256": "ec5ca7eade3243da0fdcf7d1f957f6ffe155504b66e1446fd56097a2bf522560" + }, + { + "materialId": "bc730452-5944-49a9-b343-144f204a9991", + "sourceSha256": "1964975d6bf5e999cc669f551b21a5a8d539c7eb36bd1f1a090ec644af0d8e34" + }, + { + "materialId": "bd74c8d0-c580-4b12-9599-ddf7fe20e405", + "sourceSha256": "7ae0f77e86ea35efecf92d0c05aa662bea4c55de6eb7f2f65020b54c748cb896" + }, + { + "materialId": "bdd69f5c-6db7-42ab-aef5-c789a3bf0247", + "sourceSha256": "65c68cb92389f3c4ea00ec197e6546f7086a8b4e522826674121ab22e44f5d39" + }, + { + "materialId": "be316fb8-331f-45a2-a726-c625f6bfede3", + "sourceSha256": "50120db67aba63ff3eb8293455a521a776eae662c1ec5785a0b061a9febb4f90" + }, + { + "materialId": "be68b890-7cce-42d4-bfcf-21d547d35a7e", + "sourceSha256": "22feecfe7e1226e200738e535469226ca5123c7890cdb6b0efd3f7a207791737" + }, + { + "materialId": "bf48f09e-55b9-423e-8d28-111f3378e2bc", + "sourceSha256": "31a3720acca1dc1c8468ccff8162347b1fa8ba262901f457a24abc744bd01559" + }, + { + "materialId": "c1335873-216e-4acb-b113-8a008e674641", + "sourceSha256": "c71de366f232edf876a1c1220fbddab0333d701a0089a0771db1cf9108232a3a" + }, + { + "materialId": "c1ba0475-bf60-402b-abdb-3a14266d8590", + "sourceSha256": "03ddbbf5fb28a579054832483cb2dc76f2a0f99d8c6e2c41dbf99a9c56ca6f24" + }, + { + "materialId": "c37cb3ef-e85b-47b8-bf4f-5549bbc7cb49", + "sourceSha256": "8a0f149a9fe97dde56900b0a01f037a14af4ec764974aec7d421ffbe936c8c06" + }, + { + "materialId": "c3e20bae-d67f-4528-878f-d5ae3bba099d", + "sourceSha256": "3e5b0f55aaf18da6a8e2495bdac51e185721ed6d6bd65ef8d650293492ecfa3e" + }, + { + "materialId": "c4a82b22-b8fc-498c-9b70-5d17163a8fd6", + "sourceSha256": "9b12a203127053d488fb3a560ee2c26cfdcdb25c969e489f6943d242b64eec2d" + }, + { + "materialId": "c4d3e74e-83da-476d-9544-ef565a3b0a83", + "sourceSha256": "1b441292136d09b992f697d5271e94ce683310684d46ceb51bd3b6353e2d5ee0" + }, + { + "materialId": "c50b77ff-e2e6-400c-8bd3-dd1c9076be47", + "sourceSha256": "602d4530b0935f9debb9fb8d21193092ee2247e297652bdc4ebc0c0e64bdfab0" + }, + { + "materialId": "c5dbdff5-d919-4e3d-afed-53442ac46e54", + "sourceSha256": "559bbca50dceafa1b3aa37cba4960e34818a2543a8c24ea7162beed4a4e3b441" + }, + { + "materialId": "c71aed75-d4dd-4397-bc87-bd585e5aa5b3", + "sourceSha256": "b929c1b8011fee694624ac6ec7c773c237d423f6a1b03ed0e2787cd707be7144" + }, + { + "materialId": "c733fca0-10f2-4c83-b31b-a1593a9aab48", + "sourceSha256": "859305334a6616ec87757211838ab5faf8e21b649732f79f9266482b836039f0" + }, + { + "materialId": "c7f3122a-d809-4194-a1c9-d84b92fdc463", + "sourceSha256": "21c5826c8549ec755eb0a27eddbda3bc404525233d62492227a5411d59632f56" + }, + { + "materialId": "c8646a39-549d-4d34-becd-331eadf61ed9", + "sourceSha256": "1ac63e0d6bc3b6d475999bef62bbe7a0e9d0a2264c46b1fd226b7fdf0b145445" + }, + { + "materialId": "c95d009f-8ede-4009-879a-f3dcbf52adff", + "sourceSha256": "93cf3283aff993e7deff7765b45582ad975958deee72cbfbbf8d13339c1e3872" + }, + { + "materialId": "c9b3491c-930f-4d67-906f-618ec2650572", + "sourceSha256": "eb8f677158158d3f42cd7fa5e14528988a4e9eb88d1518460ce270cd90bed14e" + }, + { + "materialId": "c9dc9d00-3b96-42d5-9ab5-b2d3f12e75bd", + "sourceSha256": "079e310f90ef10bcdbf67091b176b3e57bc314e7cba8990966d01c7d2672b03a" + }, + { + "materialId": "ca096cb8-a796-4c68-8896-a440cd1bc2cf", + "sourceSha256": "14e29f18ad00bc75d60ceb3e2aa95ef2e29fef66175fa7ace81f634c83834a16" + }, + { + "materialId": "ca105aa3-d38b-405a-a158-3d98778b490e", + "sourceSha256": "23b9a72c61dc5a0024dd0530b20763504912e9d6aaf5c8fe5319b695c604e41e" + }, + { + "materialId": "cbdb6ac7-68a1-4084-a88a-190ba47f1934", + "sourceSha256": "52047b9d1794982883e4c189427d686302353c2f31639fea201a73e1fd8704d8" + }, + { + "materialId": "ccb11856-4aae-4c27-8536-554efd6f27a5", + "sourceSha256": "0c2a8695b1ee424f2a68736985326235b7701b16179920410a912ff8bfd09df1" + }, + { + "materialId": "cd04c31c-6bb0-4add-94bc-dedc92d02a42", + "sourceSha256": "b113451ac91ff5459859c63ecb004cf9617cdb0fa62da7bca87df0418fa7a5c7" + }, + { + "materialId": "cd233d85-1c94-4b02-b446-2f6c1a316b9e", + "sourceSha256": "645e3ecdc39147b4443982f90dbea8bdb725dfde3ef404b0b35b35e2586ff3e1" + }, + { + "materialId": "cda59121-16c5-4809-b67f-14cbdce6c5e9", + "sourceSha256": "9d14eebaaa517d0055310881a42eb1e878beb58d8e59f9e3ffee0f7af21089f2" + }, + { + "materialId": "cdccd1c5-2f2a-4d61-83ee-cead68b84bd0", + "sourceSha256": "37e5ebd84a554f017b8867b2984506f06f7642b78896e8c8452a22092ce3ebe3" + }, + { + "materialId": "cde1bcd6-4430-45bd-8527-266026bf3c18", + "sourceSha256": "139e72c4794e2a3975daf08200284dccee4810564d41d5ffd0b75f3efb9e7d3c" + }, + { + "materialId": "ce020c8e-0124-4612-b828-d9eaafa25bcd", + "sourceSha256": "8890bc29ff6d6b8fdbd02e76cbc289f6874b2c3b1fe274316600703488791376" + }, + { + "materialId": "cf211576-ba25-464e-8d55-b15d9c4b2560", + "sourceSha256": "563c678e1c906c376b4b52e3becebc98690bad4b8f4398949f2c8bc401dd439f" + }, + { + "materialId": "d024f94a-78b2-4b0c-ab44-a003c9ca4722", + "sourceSha256": "bc2411ef45ef1ff1aea9e2d518cdf4294bd9848af164d0d8ed7ee2513540903b" + }, + { + "materialId": "d04d9dfc-6f31-4073-a5a1-993085222746", + "sourceSha256": "21189a50df4195390c368da7a9b52e742912b699cc52d65f81c54ac6e47baf98" + }, + { + "materialId": "d11fb393-ca08-40cc-ae53-de8a2b42ef4f", + "sourceSha256": "43e9c11ab635c0e85cf3e774890d6a18209444115de4ac1627aaaa8d121960c4" + }, + { + "materialId": "d13f8f00-49e1-4230-92a7-98726d924c33", + "sourceSha256": "2f4cd62ac1d27b1c7d9887199e401f36779ad0474306a022370290d15624fc16" + }, + { + "materialId": "d17c4db6-f6b4-4e09-949f-645f67cf7007", + "sourceSha256": "f4e8d2ef0997cec4a5e9918076af3e2630e8025827c12916c77cf45d559cd55f" + }, + { + "materialId": "d23060f1-1805-4d7b-a539-1cab5cd3b0ad", + "sourceSha256": "2b85cf84adbe646d349d488dfb61a5df7fc87d0085871846dd28bf931262a520" + }, + { + "materialId": "d27f5332-38ff-44e2-880e-cf01c81e5e3d", + "sourceSha256": "2afaf5ebf121787e7b6bf612fe0ebfb1cacc4e221d404b940e4f14b2294e6578" + }, + { + "materialId": "d2f0a9a3-f335-47d8-8f3a-2a892b79202d", + "sourceSha256": "73ef2174b4b51a3069466d729fbef96c692cb9676a9f5ae891a6b4b159fc9645" + }, + { + "materialId": "d2fa3da6-d326-4090-a417-a04798a0e8c2", + "sourceSha256": "21ea45f16b8c9fd482a64ab59237d3a6a79e4572194db891c82e2affdcff2b53" + }, + { + "materialId": "d3a4255a-3a06-402b-8f02-1d03286b2b97", + "sourceSha256": "324f0fc17616ae20bc8a6c49d9bf9b2ac6476ace57ee6e7e4a40febd373f1c12" + }, + { + "materialId": "d3a6414d-e6c5-4732-992a-7de95e130a4f", + "sourceSha256": "56c0788e72d7d9968c20492019eb1d2894cb1412d42ae61fdb262681edc4ef36" + }, + { + "materialId": "d3ebfb29-cd52-469b-8ed1-f41ad1d7131b", + "sourceSha256": "d2a36e8c537251179d8c5ce7749293d6e54668cda096cc74b50b465aaed00141" + }, + { + "materialId": "d4531bba-452f-46b1-b276-830b1ce6ea02", + "sourceSha256": "565f166d1090f764e867e72a08cc530bb137f95afc4dce75ab9841b4eeb6e604" + }, + { + "materialId": "d5a51041-2ff6-4e9e-96d7-8d7ae74e9c12", + "sourceSha256": "d9bd1b93de1b00b50ccda9ddc3fd5ef36156f3db4ccc6d104d10fbef4dc00441" + }, + { + "materialId": "d6321a35-5146-43e5-8d56-efef87583a89", + "sourceSha256": "56e693463e0bf2ee4e86b947c1a07ae4b011ceffd85e19f993239e9ddad429fe" + }, + { + "materialId": "d6dd89a5-7e7c-4382-8c0b-1a4ddc460c90", + "sourceSha256": "e75f8a65c42adedc5a8465ad392deff92f18cb885a8ce81d9a716d3e503e0c7b" + }, + { + "materialId": "d7662b0d-f779-45b0-b7b5-c88703118834", + "sourceSha256": "c2e531de76888deee511fe79ab2d535793edbcfb9ae2249ffcf4713ba536237d" + }, + { + "materialId": "d8387da9-7ba0-4c83-9f98-37f8adf4b2ce", + "sourceSha256": "4886a80903c767e6585aa74121caf729b360f30e4d5cddedc7738245806e231a" + }, + { + "materialId": "d9072c2d-bdf6-49b0-b6f6-b6eb75f4265b", + "sourceSha256": "5c2ee4a290b941b6a0d24848e659850d90357588b47d11b0dd77ac1171982f40" + }, + { + "materialId": "daa05d6f-0ab0-41c3-a7f6-042b2f1fbee0", + "sourceSha256": "f06bec20eb60901a25b714dc06e18da8073c52f09064a4a32f7a418d435579b9" + }, + { + "materialId": "dae03318-2063-4a5b-8d23-7cb9c413e457", + "sourceSha256": "3660a50eb4b63d0173dd18efc0a585266b7ffa7febb98e83724ffd0ac5e278a6" + }, + { + "materialId": "db15629a-b36b-4d4d-a4ae-08a4ab6b7049", + "sourceSha256": "d903f012f42d5776123ecc449b28517e1addb6ccdff163c485ecf2a174ca6c2b" + }, + { + "materialId": "db71d005-e553-4c28-aad3-96d0abb11521", + "sourceSha256": "1579fb0090dea6dacb7893bf7701a69c90ff8fb72021722cb2bdbcc9a48fe74e" + }, + { + "materialId": "dd0e0d98-bd81-4cc7-952e-773d986ae9eb", + "sourceSha256": "b5e48a4b4625b5a42186c8713cb6ad3685961b63199eb30debde05745e5bf017" + }, + { + "materialId": "de9bae42-79fa-437b-a63e-9983368ce59d", + "sourceSha256": "12fc2df5eae9a2863f7bb837b2339b4699696819700de5ef0523346e3f71a270" + }, + { + "materialId": "deab31d6-c813-40f4-b837-8ecff2c3437a", + "sourceSha256": "4a5cefd9abefb3b925e933571343277f7f761ed10f048bceb5c8d2cc67b41714" + }, + { + "materialId": "df261b88-c42d-4f00-80b9-c5cf7188dcc6", + "sourceSha256": "14d832582835d59bef63cc57a58714d96ba252244de916fd4f68574fc38f5780" + }, + { + "materialId": "e0b1e0fa-a4b4-4558-951e-4f712d5b56df", + "sourceSha256": "19b6b20cb4e20a9335a1d42a873142ee7204d66afd6fe23f12a18e31033d2d35" + }, + { + "materialId": "e0cf3e56-fab7-4b1a-9939-41d88d4231dd", + "sourceSha256": "449f9c4e61cd7edaea1ecdcf3d27ed235383351871d4ca037749a95f4905b190" + }, + { + "materialId": "e0fc02f6-fa01-4e52-b233-dc61992633c0", + "sourceSha256": "41857c3dafa5cc74407d6065171f51fc1f8dbb231a4d53d8c0b2af2e4f475141" + }, + { + "materialId": "e14fa8f3-66e5-4ab3-b4f8-7bac16c62d31", + "sourceSha256": "05cb338b49ab8ae6baa0e956b5197f16c6fc27ace3d07c0da911762feea1dc97" + }, + { + "materialId": "e16e33f4-4f0a-44bd-9bef-98359be805fd", + "sourceSha256": "6e2f068a53ce6b36aa9c1c794e26949a27382fb9afa6b22c6013ffea19011082" + }, + { + "materialId": "e217282c-f52c-4cf5-8e97-a7bd676ff3d6", + "sourceSha256": "5e7afc50aa01def5364c1c81ce3335643cf0d0fba23d9b090c797cf04e3e5080" + }, + { + "materialId": "e3f65d4c-aa92-49fd-b1dd-0ab39cb24fa3", + "sourceSha256": "ce96041a4929e829b93a653be10e2a4a0dbf73eec5993d9d89c7ce3853e6671a" + }, + { + "materialId": "e428c9e4-15c4-4ba0-80f5-6125e3fa2e2e", + "sourceSha256": "7967aa27202c417694be585d9596c34eb4b77d19d3d596d1432c7881a92a0dd0" + }, + { + "materialId": "e4ab5ed8-25c6-4c2b-bb1d-f73141fcace8", + "sourceSha256": "ec8f514694f593c9a144904b291878fd5fd626ffd6e7557fff98c26fa463208f" + }, + { + "materialId": "e55e32c6-795b-4fa1-b15c-7321c7c4439f", + "sourceSha256": "b6e738d987c5a3a958ed0b09c1973e8d71c734db35021c7b5d13b1949338ad46" + }, + { + "materialId": "e6655fe5-21ab-419d-adc2-5b81833a50e8", + "sourceSha256": "7f3a65e13e1084aa0b68eb313926603b91d8c557a889f0af369b3e4b5370030b" + }, + { + "materialId": "e6dbff88-652f-4a3b-b619-640a49ef1f08", + "sourceSha256": "10d37361b5b1ec8b4e047c1300ccd7a9eb27c8ec3060d0fcb6fdf7ad2e57a001" + }, + { + "materialId": "e727128f-3d80-467b-9a39-c6550f308877", + "sourceSha256": "91a268973710d36da9340caa05949ac649b4c9f26f92b1188783bf7026b5f486" + }, + { + "materialId": "e77bc201-9bb5-43fd-a701-0df6ed66fe5a", + "sourceSha256": "6d770ec7d263213b2b8ea0511a30baba2d571124c5f286f23b1dc52f39cba756" + }, + { + "materialId": "e7cc094b-0373-48a0-a49e-14403a296b98", + "sourceSha256": "d438c36f14217cbc67a7dc13ce4a5e3a4f66d000cfde12c2f9f3eaf0cc1a156f" + }, + { + "materialId": "e8001d37-877a-41a8-9825-3eeb12f4d409", + "sourceSha256": "1dd8961017945a6797fbe5cfaf95e34d15b6f590adf26babaacae2c6f654d25f" + }, + { + "materialId": "e88f1405-e475-44fd-b3a7-e107550b8a53", + "sourceSha256": "9dd286cb9da930b1ef2519b25d6676b51bdc96f61562088cc838ceb8a6e28117" + }, + { + "materialId": "e91dcb5e-0e2e-4730-b60f-fc6cb9ffe504", + "sourceSha256": "b5dc5258e82bf3050607ab3df0cc601dc15792ef1a95654003ca79e824a7dddc" + }, + { + "materialId": "ea0666b6-5837-4d95-b9b4-9fae3492b2b7", + "sourceSha256": "b6135f273037386e9e248d1a471b9198872c6317a22d35052ef59eb0971a0969" + }, + { + "materialId": "ea3152ad-98ab-47a1-9b60-5f8b4f2eac6e", + "sourceSha256": "6ca7d3262dbdaa11c012ae9e9259a10bcf1515cd6af5d661711bb4028fa12959" + }, + { + "materialId": "eac3cde0-a741-4e4a-bf5d-e00d42ba61bd", + "sourceSha256": "a9a30e622201d4bb3be9a0dc0b94d6fd82fedaa14994e0188cfde640b0b2d94e" + }, + { + "materialId": "ec4bfc54-30a1-4a89-8386-057809a8a206", + "sourceSha256": "b4583195151b5d32449c48240dcd291364a13f4266d88537093f9d0ff7c49dc5" + }, + { + "materialId": "ed056361-da4f-4bd7-b699-644c5d437bba", + "sourceSha256": "fdd97be0cc0a8af0802e7c63878b1ba47ea32a181aa0646db34222605e8726c9" + }, + { + "materialId": "ed32e26c-ef4e-41d5-93f9-b6d92556b6c0", + "sourceSha256": "de124d5f26f35a3f6c5b416e0458b667febc820b9552400c3a3f976d18667d35" + }, + { + "materialId": "ed7b3871-636d-4e51-b2d8-9d304b407bed", + "sourceSha256": "557939c6c0405672e940ff8faba89ba16e160dad06d45c586bb513e72fbab89e" + }, + { + "materialId": "edefc259-df45-43ed-8ed8-c24b76a13ab7", + "sourceSha256": "62472e42b0b1fe04edeae7b46dd17f21ea470077c27843d05fd66c140d6890b7" + }, + { + "materialId": "ee64debb-7a95-44c0-b4de-592318c627c1", + "sourceSha256": "7c1e6aee33ec61b641c69d894549414ae0ccc26fb947cb67313e3c61801b2b1b" + }, + { + "materialId": "eeca10ba-ea32-45a5-bc53-229408631132", + "sourceSha256": "068f8e8a6dbc4bbd210b1ad4a9931cb6d6c08a87a6e01976778d28a9d8a78b88" + }, + { + "materialId": "ef6d9ebc-fa4b-4bb5-b29f-44c5406238a2", + "sourceSha256": "ccb4ed9cb9ff577b51072cd065a9056a39cd70fd9b8a84d0bae6985978e6309f" + }, + { + "materialId": "ef7cef14-5561-468c-b9c9-1a6d6883aee2", + "sourceSha256": "065a7548fc15a1e66ff708fe94a4cde3e5486d92faaddc40f7923f0e00540a3f" + }, + { + "materialId": "ef908b80-4fdf-4bd9-980d-133640585bf9", + "sourceSha256": "9a5455b7fa3b8ed549683104abecdf507a59ad36475fc827964ab87acfc94e15" + }, + { + "materialId": "f0964750-5b15-4fd3-9497-c212764ee0cd", + "sourceSha256": "3f5cea6b294eebb46910820efa72c494373c9d8debc756600f4a103ad5066640" + }, + { + "materialId": "f10f2c6a-be23-492d-9b2f-83e1454a17e6", + "sourceSha256": "07784639005a91352785c80d5f4633051c6bb47c0583fc99455d0e665637623d" + }, + { + "materialId": "f1bbfb67-07e4-45b4-b173-f4e65a61cab7", + "sourceSha256": "0134d9959c40163b88eacf05ffe5df4a61d3c2bfd8d0361ec8651257090a51cf" + }, + { + "materialId": "f1eee192-2f9f-4522-be46-d8ab3e7b4d72", + "sourceSha256": "fd2da3d57aef21098dc1820f8072ffbdcba4c2578326ce8282b7d723670e0a44" + }, + { + "materialId": "f2134231-7f75-483a-944d-44803df2ceef", + "sourceSha256": "173f5dfb3858fe398eba4bb9c1902f6dfa24d0439c49801b02e58796f79c0214" + }, + { + "materialId": "f26d69a4-8c3a-44b5-a642-b967f75bf4bb", + "sourceSha256": "f717ead394d95c5d0740a32f6628e4771190944f6e060a83506b19812624fa13" + }, + { + "materialId": "f2e70bb9-2034-4bb4-97f8-4565b4d667ca", + "sourceSha256": "f8bed2485735e07fa2825e535021cdc74f3eed63e482f70172f7377691266b55" + }, + { + "materialId": "f34e79c3-b83a-428c-a6cb-ee10c1e261c8", + "sourceSha256": "e454c59c1b78ed2e73a6a85739903e0727ea8e21490bf48b98ec453c0dc9c494" + }, + { + "materialId": "f3947ee5-e5aa-4b25-83ea-a5d70e30e0a7", + "sourceSha256": "44c3d13fc9a6001ddd251891ffa848924f3a16a68c5354baf43ee5a6c599789a" + }, + { + "materialId": "f4342715-62b0-4db1-8206-0361f23ab98d", + "sourceSha256": "47dee4ac1ec51e9b5b348b053b9bf13b7970e9bf7dfe2707d310cac41717571f" + }, + { + "materialId": "f4810986-3bed-41ec-b572-588ec19bf723", + "sourceSha256": "9c8265fb2f6eb7463c57f3eb8db2677daee9ab36c3778ec96b71111ccc34ca61" + }, + { + "materialId": "f5a076de-3650-4bfa-b6c8-0897d44368bd", + "sourceSha256": "7277b370d5facd5c9c77632ee9fa7e32eeeec59d76082a10a53681a4bda06d28" + }, + { + "materialId": "f5a32470-43fd-44ac-94ae-d3e51c7cebc6", + "sourceSha256": "2690761623c05a5a87f7be141902567532d87d823ce1087fbbdb238118cda4a2" + }, + { + "materialId": "f5acedee-ffd2-40a0-9ad4-55f9ea5b41a7", + "sourceSha256": "20a69388b379a86d5f489d4a5159c59e74cd67d8c6342a4bc9ca770d0619e8be" + }, + { + "materialId": "f5ec2020-c514-4ca0-a722-0fa441e81a7e", + "sourceSha256": "4b7279f0984f524a897627e48e64f559f360239670792bfc5faae7e5add56e61" + }, + { + "materialId": "f6a70106-9c49-487a-99fb-2087b2326b6e", + "sourceSha256": "75076b00939b1d1af2fc8596065538423a0f83d9a40e0c7f8f16b7cd3e7af52b" + }, + { + "materialId": "f706a89d-3212-47e0-8bca-8065cb18e0d5", + "sourceSha256": "c4de52730c01d03ab8f011101a45e586f59e606c0b2915daa080a581c4816c70" + }, + { + "materialId": "f74c19b4-2624-4d08-bf4c-1b57a5ef0e9b", + "sourceSha256": "ced198788158227a346dd6a90ae3ffd3bca2b16c4512ed9ff3ba33c5da81777f" + }, + { + "materialId": "f859aa7f-7874-409f-8d5f-951113817023", + "sourceSha256": "514c7ede18d13050e133f7cf386062b6c55c25425dd9f91599a634e3687f0cf9" + }, + { + "materialId": "f873e5fd-d7be-4289-b3da-fd28eb183bc7", + "sourceSha256": "f9654cb16d455d4f4cd6b366411914a26ccbfca33c998563b71e37614a028279" + }, + { + "materialId": "f8c5e4b2-9c2e-47a5-86d9-382a3d7d81fc", + "sourceSha256": "7e4b3539f9ce173090cc1f68977ca06daa4c8b30e7d06f3906c8685e5bbc8f66" + }, + { + "materialId": "faace757-6a93-40fa-af3d-cad56b9c3017", + "sourceSha256": "2c461033f4d54641987f2a27bd1496117766cc9dc19b1c29e69a18569abe4c79" + }, + { + "materialId": "faf8b47c-a85f-431b-be4f-6310ea68a909", + "sourceSha256": "7dee046fa31410614d4f15f6934d411abb2ef885b71e572bdd00f37f989289a1" + }, + { + "materialId": "fb417109-9223-46af-ada1-790ee345701b", + "sourceSha256": "decd2cd685f607f0c105c80be4dd46faf9654f18dbba774f9e610fdbd753aa69" + }, + { + "materialId": "fb4e27cc-3f26-45f2-b9c4-986978adabea", + "sourceSha256": "111f25a35819370acbf728a8584ac1079e72a35c72e21e6715a5bb8a8e035f30" + }, + { + "materialId": "fccbd798-4742-4733-8b27-079c9d0a7293", + "sourceSha256": "71169c94ecdd557ad386b1cd5758c2210ed4fc372cd2f9a9f167ca11d168392e" + }, + { + "materialId": "fdd564f5-a989-4398-9d61-db87ad1ee7af", + "sourceSha256": "0381c12ed3c3ee6b7b4fef6cc9397e03f4013b05d26ad770d383c96a18d7ce28" + }, + { + "materialId": "fe21e1d3-c034-4f32-97be-e7d8ea61b1b8", + "sourceSha256": "ead073078e81dab9597ebf9b6134e20f0017bf8c55e95bd89b76d9aac06c3336" + }, + { + "materialId": "ff62362c-889c-4cef-a2d7-c17f26d0b739", + "sourceSha256": "6c18f1af9fde226f78c88003133001ac24267d2e2539a709d6628ba4461dcbb1" + } + ] +} diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index 3e988d71..aa57e32c 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -1052,6 +1052,11 @@ if [ "$SKIP_DEPLOY" != "1" ]; then fi ok "Production image revision label matches $COMMIT_SHA" + # Bundle from the exact fetched checkout, not workstation WIP or old image files. + MATERIAL_PREVIEW_GATE_MODULE="$(node scripts/material-preview-release-gate.mjs bundle)" || fail "Material preview release gate could not be bundled from the exact checkout" + [[ "$MATERIAL_PREVIEW_GATE_MODULE" = data:text/javascript\;base64,* ]] || fail "Material preview release gate bundle is invalid" + [ "${#MATERIAL_PREVIEW_GATE_MODULE}" -le 524288 ] || fail "Material preview gate bundle exceeds its fixed transport budget" + IFS= read -r -d '' PROD_PAYLOAD <<'PROD_SCRIPT' || true set -Eeuo pipefail @@ -1809,6 +1814,104 @@ verify_candidate_brand_and_storage_dependencies() { verify_candidate_configured_brand_assets "$name" "$candidate_ip" || return 1 } +# Every real release uses the same old/candidate/post probe plan. The only +# mutable state is this project's private audit directory; no material, download, +# bucket, or database value is written. A missing first baseline stops deployment. +prepare_material_preview_release() { + MATERIAL_PREVIEW_AUDIT_ROOT="${PROXY_ROOT}/material-preview-health" + [ -n "${MATERIAL_PREVIEW_GATE_MODULE:-}" ] || return 1 + [ -d "$MATERIAL_PREVIEW_AUDIT_ROOT" ] && [ ! -L "$MATERIAL_PREVIEW_AUDIT_ROOT" ] || return 1 + [ -f "$MATERIAL_PREVIEW_AUDIT_ROOT/accepted-pointer.json" ] && [ ! -L "$MATERIAL_PREVIEW_AUDIT_ROOT/accepted-pointer.json" ] || return 1 + MATERIAL_PREVIEW_AUDIT_RUN="$(mktemp -d "${MATERIAL_PREVIEW_AUDIT_ROOT}/release-${COMMIT_SHA:0:12}-XXXXXXXX")" || return 1 + chmod 700 "$MATERIAL_PREVIEW_AUDIT_RUN" || return 1 + printf '%s' "$MATERIAL_PREVIEW_GATE_MODULE" >"$MATERIAL_PREVIEW_AUDIT_RUN/gate-module.url" || return 1 + chmod 600 "$MATERIAL_PREVIEW_AUDIT_RUN/gate-module.url" || return 1 + python3 - "$MATERIAL_PREVIEW_AUDIT_ROOT" "$MATERIAL_PREVIEW_AUDIT_RUN" <<'PY' +import hashlib, json, os, pathlib, re, sys +root, run = map(pathlib.Path, sys.argv[1:]) +try: + pointer_raw = (root / 'accepted-pointer.json').read_bytes() + pointer = json.loads(pointer_raw) + relative = pointer['file'] + if pointer.get('version') != 1 or not re.fullmatch(r'(?:bootstrap-[A-Za-z0-9._-]+/bootstrap|release-[A-Za-z0-9._-]+/post)\.json', relative): raise ValueError() + if not re.fullmatch(r'[0-9a-f]{64}', pointer['sha256']): raise ValueError() + source = root / relative + if source.is_symlink() or source.parent.is_symlink() or not source.is_file() or source.resolve().parent.parent != root.resolve(): raise ValueError() + raw = source.read_bytes() + if len(raw) > 32 * 1024 * 1024 or hashlib.sha256(raw).hexdigest() != pointer['sha256']: raise ValueError() + for filename, content in [('accepted.json', raw), ('accepted-pointer-before.json', pointer_raw)]: + with open(run / filename, 'xb') as f: f.write(content) + os.chmod(run / filename, 0o600) +except Exception: + sys.stderr.write('Reviewed material preview baseline missing, changed, or outside its private audit root\n') + sys.exit(1) +PY +} + +run_material_preview_release_gate() { + local name="$1" phase="$2" output_file="${MATERIAL_PREVIEW_AUDIT_RUN}/${2}.json" status=0 + case "$phase" in capture|candidate|post) ;; *) return 1 ;; esac + [ ! -e "$output_file" ] && [ -f "$MATERIAL_PREVIEW_AUDIT_RUN/gate-module.url" ] || return 1 + # The program and input travel on stdin; no credentials or large JSON appear in + # process arguments, environment variables, the image filesystem, or logs. + if ! python3 - "$MATERIAL_PREVIEW_AUDIT_RUN" "$phase" "$COMMIT_SHA" "$S3_PUBLIC_DOMAIN" "$ENV_FILE_PATH" "$PUBLIC_APP_URL" <<'PY' | docker exec -i -w /app "$name" node --input-type=module - >"$output_file" +import json, pathlib, sys +run = pathlib.Path(sys.argv[1]); phase, commit, public_domain, env_file, app_url = sys.argv[2:] +read = lambda name: json.loads((run / (name + '.json')).read_bytes()) +keys = ['S3_ENDPOINT','S3_BUCKET','S3_FILE_PATH','S3_PUBLIC_DOMAIN','S3_REGION','SERVER_IP','MINIO_API_PORT','APP_PUBLIC_URL'] +target = {key: '' for key in keys}; seen = set() +for line in pathlib.Path(env_file).read_text().splitlines(): + if not line.strip() or line.lstrip().startswith('#') or '=' not in line: continue + key, value = line.split('=', 1); key = key.strip() + if key not in target or key in ('S3_PUBLIC_DOMAIN','APP_PUBLIC_URL'): continue + if key in seen: raise ValueError('duplicate nonsecret delivery key') + seen.add(key); target[key] = value +target['S3_PUBLIC_DOMAIN'] = public_domain; target['APP_PUBLIC_URL'] = app_url +payload = dict(version=1, stage=phase, commit=commit, accepted=read('accepted'), targetDeliveryConfig=target) +if phase in ('candidate', 'post'): payload['capture'] = read('capture') +if phase == 'post': payload['candidate'] = read('candidate') +print('const {runReleaseStage}=await import(' + json.dumps((run / 'gate-module.url').read_text()) + ');') +print('try { const r=await runReleaseStage(' + json.dumps(payload, separators=(',', ':')) + '); console.log(JSON.stringify(r)); process.exitCode=r.passed?0:1; } catch(e) { console.log(JSON.stringify({version:1,readOnly:true,passed:false,stage:' + json.dumps(phase) + ',code:typeof e?.code==="string"&&/^[a-z_]+$/.test(e.code)?e.code:"release_preview_detector_failed"}));process.exitCode=1; }') +PY + then status=1; fi + chmod 600 "$output_file" || return 1 + if ! python3 - "$output_file" "$phase" <<'PY' +import json, sys +try: + r = json.load(open(sys.argv[1])); phase = sys.argv[2] + if r.get('stage') != phase or r.get('passed') is not True or r.get('readOnly') is not True: + sys.stderr.write('Material preview release gate failed; retain the private receipt for diagnosis\n'); sys.exit(1) + print('[OK] Material preview '+phase+': '+str(len(r['inventory']))+' metadata rows, '+str(len(r['checks']))+' fixed probes, '+str(len(r['knownFailures']))+' known failures retained, '+str(len(r['plan']['unverified']))+' explicitly unverified rows') +except Exception: + sys.stderr.write('Material preview release receipt is missing or incomplete\n'); sys.exit(1) +PY + then status=1; fi + [ "$status" = 0 ] || return 1 +} + +accept_material_preview_release() { + # Promote only after the complete existing release acceptance, using one atomic + # pointer. Immutable pre/post receipts and the previous pointer remain intact. + python3 - "$MATERIAL_PREVIEW_AUDIT_ROOT" "$MATERIAL_PREVIEW_AUDIT_RUN" "$COMMIT_SHA" <<'PY' +import hashlib, json, os, pathlib, sys +root, run = map(pathlib.Path, sys.argv[1:3]); commit = sys.argv[3] +try: + if (root / 'accepted-pointer.json').read_bytes() != (run / 'accepted-pointer-before.json').read_bytes(): raise ValueError() + reports = [json.loads((run / (stage + '.json')).read_bytes()) for stage in ('capture', 'candidate', 'post')] + for stage, report in zip(('capture', 'candidate', 'post'), reports): + if report.get('stage') != stage or report.get('commit') != commit or report.get('passed') is not True: raise ValueError() + if len({r['planSha256'] for r in reports}) != 1 or len({r['inventorySha256'] for r in reports}) != 1 or len({r['metadataAudit']['inventorySha256'] for r in reports}) != 1: raise ValueError() + raw = (run / 'post.json').read_bytes() + pointer = dict(version=1, file=str((run / 'post.json').relative_to(root)), sha256=hashlib.sha256(raw).hexdigest()) + temporary = run / 'accepted-pointer.new' + with open(temporary, 'xb') as f: + os.chmod(temporary, 0o600); f.write((json.dumps(pointer, separators=(',', ':'))+'\n').encode()); f.flush(); os.fsync(f.fileno()) + os.replace(temporary, root / 'accepted-pointer.json') +except Exception: + sys.stderr.write('Material preview baseline was not promoted; previous release evidence retained\n'); sys.exit(1) +PY +} + verify_ready_bootstrap_handoff_evidence() { local handoff="$1" local evidence_name="" @@ -2835,6 +2938,8 @@ direct_replace_active_container() { [[ "$previous_image" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Active container immutable image could not be determined: $name" verify_container_uploads_storage "$name" || fail "Active container uploads storage could not be verified before direct replacement" verify_image_revision_label "$REGISTRY_COMMIT" "$COMMIT_SHA" || fail "Direct replacement image label does not match the exact Git commit" + prepare_material_preview_release || fail "Reviewed material preview baseline is unavailable; active container was not stopped" + run_material_preview_release_gate "$name" capture || fail "Material preview pre-release gate failed; active container was not stopped" prepare_worker_runtime_directories || fail "Worker runtime directories could not be prepared" prepare_detail_generator_release || fail "Detail generator configuration or private persistent storage is invalid" @@ -2882,6 +2987,9 @@ direct_replace_active_container() { if ! verify_candidate_brand_and_storage_dependencies "$name" "$replacement_ip"; then fail_direct_replace_with_restore "New direct container cannot authenticate to S3 or decode configured brand images" "$name" "$previous_image" fi + if ! run_material_preview_release_gate "$name" candidate; then + fail_direct_replace_with_restore "Material previews regressed or inventory changed before traffic switch" "$name" "$previous_image" + fi if ! wait_for_direct_worker_capabilities "$name"; then fail_direct_replace_with_restore "New direct container workers did not become ready within the bounded startup window" "$name" "$previous_image" fi @@ -2897,7 +3005,11 @@ direct_replace_active_container() { if ! write_proxy_config "${replacement_ip}:3000" "$name" || ! switch_proxy || ! verify_proxy_target "$name" "$COMMIT_SHA"; then fail_direct_replace_with_restore "New direct container could not become the verified proxy target" "$name" "$previous_image" fi + if ! run_material_preview_release_gate "$name" post; then + fail_direct_replace_with_restore "Public material preview post-release gate failed" "$name" "$previous_image" + fi write_current_container "$name" || fail "Direct replacement traffic is healthy but the current-slot record could not be written" + accept_material_preview_release || fail_direct_replace_with_restore "Material preview accepted baseline could not be atomically recorded" "$name" "$previous_image" retire_inactive_direct_slot "$name" || warn "The inactive historical slot could not be removed; the active direct container remains healthy" rm -f -- "$(transcode_drain_file "$name")" "$(task_detail_drain_file "$name")" || true ok "Direct replacement completed: $name" @@ -3724,6 +3836,10 @@ fi ensure_public_traffic_rollup_token +[ -n "$CURRENT_CONTAINER" ] || fail "Material preview baseline requires an existing verified active container" +prepare_material_preview_release || fail "Reviewed material preview baseline is unavailable; traffic was not changed" +run_material_preview_release_gate "$CURRENT_CONTAINER" capture || fail "Material preview pre-release gate failed; traffic was not changed" + TRANSCODE_DRAIN_SOURCES="" if [ -n "$CURRENT_CONTAINER" ]; then TRANSCODE_DRAIN_SOURCES="$CURRENT_CONTAINER" @@ -3848,6 +3964,9 @@ fi if ! verify_candidate_brand_and_storage_dependencies "$NEXT_CONTAINER" "$NEXT_IP"; then fail_candidate_before_traffic "Candidate cannot authenticate to S3 or fully decode its configured and baked brand images; traffic was not switched" fi +if ! run_material_preview_release_gate "$NEXT_CONTAINER" candidate; then + fail_candidate_before_traffic "Material previews regressed or inventory changed; traffic was not switched" +fi if ! verify_video_transcode_capability "$NEXT_CONTAINER"; then fail_candidate_before_traffic "Candidate did not publish the required video transcode drain capability" fi @@ -3885,6 +4004,10 @@ if [ "$SWITCH_REPORTED_FAILURE" = "1" ]; then warn "Proxy switch command reported failure, but the runtime candidate target was independently verified" fi +if ! run_material_preview_release_gate "$NEXT_CONTAINER" post; then + abort_after_uncertain_switch "Public material preview post-release gate failed" +fi + if ! write_current_container "$NEXT_CONTAINER"; then abort_after_uncertain_switch "Candidate traffic was verified but the committed target record could not be written" fi @@ -4032,6 +4155,8 @@ const fs = require("fs"); ok "Public commit, target, database, Redis, authenticated S3, and all Logo images passed acceptance" fi +accept_material_preview_release || abort_after_uncertain_switch "Material preview accepted baseline could not be atomically recorded" + [ -z "$READY_BOOTSTRAP_HANDOFF" ] || { mv -- "$READY_BOOTSTRAP_HANDOFF/handoff.ready" "$READY_BOOTSTRAP_HANDOFF/consumed" || \ abort_after_uncertain_switch "Bootstrap handoff could not be atomically consumed after acceptance" @@ -4040,7 +4165,7 @@ fi } PROD_SCRIPT - printf '%s' "$PROD_PAYLOAD" | prod_ssh_script \ + printf '%s\n%s' "MATERIAL_PREVIEW_GATE_MODULE=$(printf '%q' "$MATERIAL_PREVIEW_GATE_MODULE")" "$PROD_PAYLOAD" | prod_ssh_script \ "$TARGET" "$CONTAINER_NAME" "$PORT" "$REGISTRY_COMMIT" "$EXPECTED_IMAGE_ID" "$COMMIT_SHA" \ "/mnt/PoolOne/apps/youyidian-3d/$ENV_FILE" "$PUBLIC_APP_URL" "$CORS_ALLOWED_ORIGINS" \ "$DINGTALK_CALLBACK_ORIGIN" "$DINGTALK_INTERNAL_RETURN_ORIGINS" "$S3_PUBLIC_DOMAIN" \ diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs index dc5b57ed..791346ed 100644 --- a/scripts/git-incremental-deploy.test.mjs +++ b/scripts/git-incremental-deploy.test.mjs @@ -127,6 +127,69 @@ function shellFunction(name) { return source.slice(start, end + 3) } +test('preview inventory and fixed probes guard direct stop, switch, and baseline promotion in both wrappers', () => { + const direct = shellFunction('direct_replace_active_container') + const stop = direct.indexOf('docker stop --time 10') + const switchTraffic = direct.indexOf('write_proxy_config "${replacement_ip}:3000"') + const record = direct.indexOf('write_current_container "$name"') + assert.ok(direct.indexOf('prepare_material_preview_release') < stop) + assert.ok(direct.indexOf('run_material_preview_release_gate "$name" capture') < stop) + assert.ok(direct.indexOf('run_material_preview_release_gate "$name" candidate') > stop) + assert.ok(direct.indexOf('run_material_preview_release_gate "$name" candidate') < switchTraffic) + assert.ok(direct.indexOf('run_material_preview_release_gate "$name" post') > switchTraffic) + assert.ok(direct.indexOf('run_material_preview_release_gate "$name" post') < record) + assert.ok(direct.indexOf('accept_material_preview_release') > record) + const payload = extractRemoteTemplate(deploySource()) + assert.match(payload, /node scripts\/material-preview-release-gate\.mjs bundle/) + assert.match(payload, /run_material_preview_release_gate "\$NEXT_CONTAINER" candidate/) + assert.match(payload, /run_material_preview_release_gate "\$NEXT_CONTAINER" post/) + assert.match(payload, /MATERIAL_PREVIEW_GATE_MODULE=\$\(printf '%q'/) + assert.doesNotMatch(payload, /SKIP_MATERIAL_PREVIEW|IGNORE_MATERIAL_PREVIEW|ALLOW_PREVIEW_FAILURE/) +}) + +test('real direct shell control flow stops before replacement or uses existing rollback exactly once on preview regression', () => { + const direct = shellFunction('direct_replace_active_container') + const inert = ['verify_container_uploads_storage', 'verify_image_revision_label', 'prepare_worker_runtime_directories', 'prepare_detail_generator_release', + 'create_container', 'verify_container_uploads_mount_metadata', 'verify_container_upload_dir_config', 'verify_container_baked_revision', + 'verify_transcode_lease_schema', 'verify_task_detail_upload_lease_schema', 'verify_detail_generator_schema', 'verify_detail_generator_dependencies', + 'verify_brand_logo_defaults', 'verify_material_upload_idempotency_schema', 'verify_candidate_database_identity', 'verify_candidate_brand_and_storage_dependencies', + 'wait_for_direct_worker_capabilities', 'verify_video_transcode_capability', 'verify_task_detail_upload_capability', 'verify_proxy_target', 'retire_inactive_direct_slot'] + for (const phase of ['capture', 'candidate', 'post', 'none']) { + const input = `set -u +BLUE_CONTAINER=material-blue +GREEN_CONTAINER=material-green +REGISTRY_COMMIT=registry/exact-image +COMMIT_SHA=${'a'.repeat(40)} +log() { :; } +ok() { :; } +warn() { :; } +fail() { printf 'blocked:%s\n' "$*"; exit 82; } +docker() { case "$1" in inspect) printf 'sha256:${'b'.repeat(64)}\n' ;; *) printf 'docker:%s\n' "$1" ;; esac; } +${inert.map(name => `${name}() { return 0; }`).join('\n')} +prepare_material_preview_release() { printf 'prepare\n'; } +run_material_preview_release_gate() { printf 'probe:%s\n' "$2"; [ "$2" != '${phase}' ]; } +fail_direct_replace_with_restore() { printf 'existing-rollback-once\n'; exit 81; } +wait_for_container_health() { printf '172.18.0.9\n'; } +write_proxy_config() { printf 'traffic-prepared\n'; } +switch_proxy() { printf 'traffic-switched\n'; } +write_current_container() { printf 'current-recorded\n'; } +accept_material_preview_release() { printf 'baseline-promoted\n'; } +transcode_drain_file() { printf '/not-a-real-drain-file\n'; } +task_detail_drain_file() { printf '/not-a-real-upload-file\n'; } +rm() { return 0; } +${direct} +direct_replace_active_container material-blue +` + const result = spawnSync(bash, ['-s'], { input, encoding: 'utf8' }) + assert.equal(result.status, phase === 'none' ? 0 : phase === 'capture' ? 82 : 81, result.stderr || result.stdout) + assert.equal((result.stdout.match(/existing-rollback-once/g) || []).length, ['candidate', 'post'].includes(phase) ? 1 : 0) + if (phase === 'capture') assert.doesNotMatch(result.stdout, /docker:stop|traffic-switched|baseline-promoted/) + if (phase === 'candidate') assert.doesNotMatch(result.stdout, /traffic-switched|baseline-promoted/) + if (phase === 'post') { assert.match(result.stdout, /traffic-switched/); assert.doesNotMatch(result.stdout, /current-recorded|baseline-promoted/) } + if (phase === 'none') assert.ok(result.stdout.indexOf('probe:post') < result.stdout.indexOf('baseline-promoted')) + } +}) + test('storage deployment gate rejects broken public bucket URLs before authenticating', async () => { const fn = shellFunction('verify_candidate_authenticated_s3') const code = fn.slice(fn.indexOf("node -e '\n") + "node -e '\n".length, fn.lastIndexOf("\n'")) diff --git a/scripts/material-preview-release-gate.mjs b/scripts/material-preview-release-gate.mjs new file mode 100644 index 00000000..124195e4 --- /dev/null +++ b/scripts/material-preview-release-gate.mjs @@ -0,0 +1,309 @@ +// Read-only release inventory and bounded probes. No writes to materials or storage. +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { checkMaterialPreviewHealth, directFetch } from './check-material-preview-health.mjs' +import { collectMaterialPreviewInventory } from './export-material-preview-health-manifest.mjs' +import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs' + +export const POLICY = Object.freeze({ version: 1, concurrency: 4, probes: 768, bytes: 128 * 1024 * 1024, wallMs: 360_000 }) +export const CRITICAL_MATERIAL_IDS = Object.freeze([ + '6e763f52-a21f-4b4e-99fb-634478f68eed', '82f3a6b7-6323-4cf5-a741-d990182f382c', + 'd3a0addb-b4f0-4110-958d-3f897490fa4c', '72a39203-7f61-43b5-b243-73fe19ffde99', + 'a6b97e8a-bd68-483f-b8f8-743e8baef850', '0329debe-054d-4ee7-9411-59f7cb2cdcc1', + 'b9a9947c-ef68-4616-9144-a0d1fd912799', 'ac19c78a-c09f-4ed9-a3c3-12bf9c950a56', + 'ea4cb1d2-fd25-46cb-aeba-93508c0793f8', '6c2675ab-c495-4c63-8d63-24ddee9e6535', + 'c9abdd91-a666-4f36-a475-6e082906a94e', 'd0e929f7-1186-4eac-acef-3af74a9567ee', +]) +export const DELIVERY_FILES = Object.freeze(['lib/url-rewrite.ts', 'lib/proxy-media-security.ts', 'app/api/proxy-media/route.ts', 'lib/s3.ts', 'lib/network-detection.ts']) +export const KNOWN_FRP_FAILURE = Object.freeze({ bytes: 489, bodySha256: '967e369d3be4fab5eb1229ec015b2923abf6bc81ebe3aaa662cb095cbec81023' }) +export const KNOWN_COMPATIBILITY_PROOF = Object.freeze({ materialId: '6e763f52-a21f-4b4e-99fb-634478f68eed', + bodySha256: 'b40687cac0cbd537e209f8caa56c642bf501a658d0c3585140e8b9950937da53', width: 768, height: 1600 }) +export const KNOWN_LEGACY_MEMBERS_SHA256 = '953e6b1c9e10ea05e30e289ebbd5364569d918b5dd4a1273af52abc531fe475c' +export const DELIVERY_CONFIG_KEYS = Object.freeze(['S3_ENDPOINT', 'S3_BUCKET', 'S3_FILE_PATH', 'S3_PUBLIC_DOMAIN', 'S3_REGION', 'SERVER_IP', 'MINIO_API_PORT', 'APP_PUBLIC_URL']) +// bundleReleaseGate replaces this literal with hashes from the exact build checkout. +const PINNED_TARGET_DELIVERY_CODE = null +const sha = value => createHash('sha256').update(value).digest('hex') +const digest = value => sha(JSON.stringify(value)) +const runtimeRequire = createRequire(import.meta.url) +export class GateError extends Error { constructor(code) { super(code); this.code = code } } +const fail = code => { throw new GateError(code) } +const sourceKey = row => `${row.materialId}:${row.sourceSha256}` +const probeKey = row => `${row.materialId}:${row.role}:${row.sourceSha256}` + +async function readKnownLegacyProof(root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')) { + const policy = JSON.parse(await readFile(path.join(root, 'scripts/fixtures/material-preview-known-legacy-20261023.json'), 'utf8')) + if (policy?.version !== 1 || policy.reviewedAuditSha256 !== '40eb8d834d5c6bd9edcdc49e69ae227a83be2374bb9b525a0cec0bcd652c4e0d' + || policy.members?.length !== 472 || new Set(policy.members.map(row => row.materialId)).size !== 472 + || digest(policy.members) !== KNOWN_LEGACY_MEMBERS_SHA256 || policy.membersSha256 !== KNOWN_LEGACY_MEMBERS_SHA256) fail('reviewed_legacy_membership_proof_changed') + return policy.members +} +// The exact-checkout bundler inlines this reviewed ID/hash-only proof. +const PINNED_KNOWN_LEGACY_MEMBERS = await readKnownLegacyProof() + +export function normalizeDeliveryConfig(raw) { + if (!raw || Object.keys(raw).sort().join('|') !== [...DELIVERY_CONFIG_KEYS].sort().join('|')) fail('complete_delivery_configuration_required') + const values = Object.fromEntries(DELIVERY_CONFIG_KEYS.map(key => { + if (typeof raw[key] !== 'string' || raw[key].length > 2048 || /[\x00-\x1f\x7f]/.test(raw[key])) fail('invalid_delivery_configuration') + return [key, raw[key].trim()] + })) + for (const key of ['S3_ENDPOINT', 'S3_PUBLIC_DOMAIN', 'APP_PUBLIC_URL']) { + let url + try { url = new URL(values[key]) } catch { fail('invalid_delivery_configuration_url') } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail('credential_or_query_in_delivery_configuration') + values[key] = url.href.replace(/\/$/, '') + } + if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(values.S3_BUCKET) || values.S3_BUCKET.includes('..')) fail('invalid_delivery_bucket') + if (values.SERVER_IP && !/^[A-Za-z0-9.:\[\]-]+$/.test(values.SERVER_IP)) fail('invalid_delivery_server') + values.MINIO_API_PORT ||= '9000' + if (!/^\d{1,5}$/.test(values.MINIO_API_PORT) || Number(values.MINIO_API_PORT) < 1 || Number(values.MINIO_API_PORT) > 65535) fail('invalid_delivery_port') + values.S3_REGION ||= 'us-east-1' + return values +} + +export function canonicalDeliverySource(source, publicDomain) { + const current = new URL(publicDomain) + if (current.href.replace(/\/$/, '') !== 'https://s3.jiansun.vip/gongxiangpan') fail('untrusted_public_bucket_configuration') + const url = new URL(source) + // Independent historical bucket is deliberately never mapped to the current bucket. + if (url.hostname === LEGACY_DOMAINS[0] && url.pathname.startsWith('/jsxiangche/')) return url.href + if (!url.pathname.startsWith('/gongxiangpan/')) return url.href + url.protocol = current.protocol; url.hostname = current.hostname; url.port = '' + return url.href +} + +export function inventoryIndex(inventory) { + const { audit, manifests } = inventory + if (audit?.inventoryComplete !== true || audit.activeCount !== audit.exportedCount + audit.uncheckableCount + || manifests.reduce((n, batch) => n + batch.materials.length, 0) !== audit.exportedCount) fail('inventory_coverage_incomplete') + const index = [ + ...manifests.flatMap(batch => batch.materials.map(row => ({ materialId: row.materialId, sourceSha256: sha(row.registeredSource), registeredSource: row.registeredSource }))), + ...audit.uncheckable.map(row => ({ materialId: row.materialId, sourceSha256: row.sourceSha256, uncheckable: { classification: row.classification, code: row.code } })), + ].sort((a, b) => a.materialId.localeCompare(b.materialId, 'en')) + if (index.length !== audit.activeCount || new Set(index.map(row => row.materialId)).size !== index.length) fail('inventory_coverage_incomplete') + return index +} + +function validateIndex(index) { + if (!Array.isArray(index) || new Set(index.map(row => row.materialId)).size !== index.length + || index.some(row => typeof row.materialId !== 'string' || !/^[a-f0-9]{64}$/.test(row.sourceSha256) + || (row.registeredSource ? sha(row.registeredSource) !== row.sourceSha256 : !row.uncheckable))) fail('invalid_inventory_index') +} + +export function buildPlan(index, prior, { publicDomain, criticalIds = CRITICAL_MATERIAL_IDS, expandedScope = false, bootstrap = false } = {}) { + validateIndex(index) + if (!bootstrap) validateAccepted(prior) + const previous = new Map((prior?.inventory || []).map(row => [row.materialId, row])) + const required = new Set(criticalIds), selected = [], unverified = [], changedUncheckable = [] + for (const row of index) { + const old = previous.get(row.materialId) + const changed = !old || sourceKey(old) !== sourceKey(row) + if (!row.registeredSource) { + if (!bootstrap && changed) changedUncheckable.push(row.materialId) + unverified.push({ materialId: row.materialId, reason: 'unsupported_or_missing_registered_source' }) + continue + } + const source = new URL(row.registeredSource) + const independentLegacy = source.hostname === LEGACY_DOMAINS[0] && source.pathname.startsWith('/jsxiangche/') + const reasons = [...(independentLegacy ? ['independent_legacy_storage'] : []), ...(required.has(row.materialId) ? ['critical_material'] : []), + ...(!bootstrap && changed ? ['new_or_changed_source'] : []), ...(expandedScope ? ['delivery_dependency_changed'] : [])] + if (!reasons.length) { unverified.push({ materialId: row.materialId, reason: 'unchanged_current_source_outside_bounded_scope' }); continue } + const delivery = canonicalDeliverySource(row.registeredSource, publicDomain) + selected.push({ materialId: row.materialId, registeredSource: row.registeredSource, sourceSha256: row.sourceSha256, reasons, + deliverySource: delivery, independentLegacy }) + } + const present = new Set(selected.map(row => row.materialId)) + if ([...required].some(id => !present.has(id))) fail('critical_material_coverage_missing') + if (changedUncheckable.length) fail('new_or_changed_uncheckable_preview') + const probes = selected.flatMap(row => [ + { materialId: row.materialId, role: 'registered', registeredSource: row.registeredSource, sourceSha256: row.sourceSha256 }, + ...(row.deliverySource === row.registeredSource ? [] : [{ materialId: row.materialId, role: 'canonical', registeredSource: row.deliverySource, sourceSha256: sha(row.deliverySource) }]), + ]) + if (!probes.length || probes.length > POLICY.probes) fail('probe_budget_requires_independent_full_acceptance') + return { selected, probes, unverified, expandedScope, publicDomain, criticalIds: [...criticalIds] } +} + +function failureSignature(result) { + return digest([result.classification, result.code, result.status, result.contentType, result.bytes, result.bodySha256 ?? null]) +} +function validateChecks(plan, checks) { + if (!Array.isArray(checks) || checks.length !== plan.probes.length) fail('probe_coverage_incomplete') + const expected = new Set(plan.probes.map(probeKey)), seen = new Set() + for (const check of checks) { + const key = probeKey(check) + if (!expected.has(key) || seen.has(key) || check.result?.source?.sha256 !== check.sourceSha256 || check.result.attempts !== 1 + || check.result.materialId !== check.materialId || typeof check.result.ok !== 'boolean') fail('probe_identity_or_coverage_mismatch') + if (check.result.ok && (!/^[a-f0-9]{64}$/.test(check.result.bodySha256) || check.result.classification !== 'healthy' + || check.result.status < 200 || check.result.status >= 300 || !Number.isSafeInteger(check.result.decoded?.width) || !Number.isSafeInteger(check.result.decoded?.height) + || check.result.decoded.width < 1 || check.result.decoded.height < 1 || check.result.decoded.width * check.result.decoded.height > 25_000_000)) fail('undecoded_healthy_probe') + seen.add(key) + } +} + +export function validateAccepted(artifact) { + if (artifact?.version !== 1 || artifact.policyVersion !== POLICY.version || artifact.passed !== true + || !['bootstrap', 'post'].includes(artifact.stage) || !/^[a-f0-9]{64}$/.test(artifact.inventorySha256)) fail('reviewed_baseline_required') + validateIndex(artifact.inventory) + if (digest(normalizeDeliveryConfig(artifact.deliveryConfig)) !== artifact.deliveryConfigSha256 + || artifact.knownLegacyMembersSha256 !== KNOWN_LEGACY_MEMBERS_SHA256) fail('baseline_delivery_or_legacy_proof_mismatch') + if (digest(artifact.inventory) !== artifact.inventorySha256 || digest(artifact.plan) !== artifact.planSha256) fail('baseline_digest_mismatch') + validateChecks(artifact.plan, artifact.checks) + if (digest(artifact.checks) !== artifact.checksSha256) fail('baseline_checks_digest_mismatch') +} + +export function compareChecks(plan, checks, baseline, { bootstrap = false, legacyFailureProof = KNOWN_FRP_FAILURE, compatibilityProof = KNOWN_COMPATIBILITY_PROOF, + legacyMembers = PINNED_KNOWN_LEGACY_MEMBERS } = {}) { + validateChecks(plan, checks) + const known = new Map((baseline?.checks || []).map(row => [probeKey(row), row.result])) + const checksMap = new Map(checks.map(row => [probeKey(row), row.result])) + const legacyIdentities = new Set(legacyMembers.map(sourceKey)) + const problems = [], knownFailures = [] + for (const check of checks) { + const row = plan.selected.find(item => item.materialId === check.materialId) + const old = known.get(probeKey(check)), result = check.result + if (['decoder_unavailable', 'invalid_manifest', 'source_rejected'].includes(result.classification)) { problems.push({ materialId: check.materialId, role: check.role, code: 'detector_or_source_validation_failure' }); continue } + if (result.ok) { + if (old?.ok && result.bodySha256 !== old.bodySha256) problems.push({ materialId: check.materialId, role: check.role, code: 'unchanged_url_content_changed' }) + continue + } + // Bootstrap is explicitly installed by a human after reviewing its exact artifact SHA. + // It may document the incident's known FRP misses, never arbitrary new failures. + const legacyFrp = row.independentLegacy && result.classification === 'routing_failure' && result.code === 'frp_default_404' && result.status === 404 + && result.bytes === legacyFailureProof.bytes && result.bodySha256 === legacyFailureProof.bodySha256 + && legacyIdentities.has(sourceKey(row)) + const canonical = checksMap.get(probeKey({ materialId: row.materialId, role: 'canonical', sourceSha256: sha(row.deliverySource) })) + const rawCompatUnavailable = check.role === 'registered' && row.deliverySource !== row.registeredSource && canonical?.ok + && row.materialId === compatibilityProof.materialId && canonical.bodySha256 === compatibilityProof.bodySha256 + && canonical.decoded.width === compatibilityProof.width && canonical.decoded.height === compatibilityProof.height + && ['network_failure', 'timeout'].includes(result.classification) + if (bootstrap ? (legacyFrp || rawCompatUnavailable) : old && !old.ok && failureSignature(result) === failureSignature(old)) { + knownFailures.push({ materialId: check.materialId, role: check.role, classification: result.classification, code: result.code, status: result.status }) + } else problems.push({ materialId: check.materialId, role: check.role, code: 'new_or_changed_failure' }) + } + // Canonical delivery must be healthy except for the explicitly documented independent legacy incident. + for (const row of plan.selected) { + const role = row.deliverySource === row.registeredSource ? 'registered' : 'canonical' + const result = checksMap.get(probeKey({ materialId: row.materialId, role, sourceSha256: sha(row.deliverySource) })) + if (!result?.ok && !row.independentLegacy) problems.push({ materialId: row.materialId, role, code: 'delivery_preview_unavailable' }) + } + return { passed: problems.length === 0, problems, knownFailures, allSelectedHealthy: checks.every(row => row.result.ok) } +} + +export async function runProbes(plan, { fetchImpl = directFetch, decodeImage, now = Date.now } = {}) { + if (!Array.isArray(plan?.probes) || !plan.probes.length || plan.probes.length > POLICY.probes) fail('probe_budget_requires_independent_full_acceptance') + const start = now(), controller = new AbortController() + let bytes = 0, cursor = 0, budgetFailure = null + const timer = setTimeout(() => { budgetFailure = 'release_probe_wall_budget'; controller.abort() }, POLICY.wallMs) + const checks = new Array(plan.probes.length) + const guardedFetch = async (url, options) => { + const response = await fetchImpl(url, { ...options, signal: AbortSignal.any([options.signal, controller.signal]) }) + if (!response.body) return response + const guarded = new Response(response.body.pipeThrough(new TransformStream({ transform(chunk, output) { + bytes += chunk.byteLength + if (bytes > POLICY.bytes) { budgetFailure = 'release_probe_byte_budget'; controller.abort(); throw new GateError(budgetFailure) } + output.enqueue(chunk) + } })), { status: response.status, headers: response.headers }) + Object.defineProperties(guarded, { redirected: { value: response.redirected }, url: { value: response.url } }) + return guarded + } + try { + await Promise.all(Array.from({ length: Math.min(POLICY.concurrency, plan.probes.length) }, async () => { + while (cursor < plan.probes.length && !controller.signal.aborted) { + if (now() - start >= POLICY.wallMs) { budgetFailure = 'release_probe_wall_budget'; controller.abort(); break } + const index = cursor++, probe = plan.probes[index] + const manifest = { version: 1, reviewed: true, materials: [{ materialId: probe.materialId, registeredSource: probe.registeredSource }] } + const report = await checkMaterialPreviewHealth(manifest, { fetchImpl: guardedFetch, ...(decodeImage ? { decodeImage } : {}) }) + checks[index] = { ...probe, result: report.results[0] } + } + })) + } finally { clearTimeout(timer); controller.abort() } + if (budgetFailure) fail(budgetFailure) + validateChecks(plan, checks) + return { checks, budget: { ...POLICY, actualBytes: bytes, elapsedMs: now() - start, requests: checks.length, retries: 0 } } +} + +async function deliveryCode(root) { + return Object.fromEntries(await Promise.all(DELIVERY_FILES.map(async file => [file, sha(await readFile(path.join(root, file)))]))) +} + +export async function runReleaseStage(input, { prismaClient, collectInventory = collectMaterialPreviewInventory, probe = runProbes, + root = '/app', readDeliveryCode = deliveryCode, environment = process.env, targetDeliveryCode = PINNED_TARGET_DELIVERY_CODE, + legacyFailureProof = KNOWN_FRP_FAILURE, compatibilityProof = KNOWN_COMPATIBILITY_PROOF, legacyMembers = PINNED_KNOWN_LEGACY_MEMBERS } = {}) { + if (input?.version !== 1 || !['bootstrap', 'capture', 'candidate', 'post'].includes(input.stage) + || !/^[a-f0-9]{40}$/.test(input.commit)) fail('invalid_release_stage') + const bootstrap = input.stage === 'bootstrap' + if (!bootstrap) validateAccepted(input.accepted) + const owned = !prismaClient + const prisma = prismaClient || new (runtimeRequire('@prisma/client').PrismaClient)({ log: [] }) + let collected + try { collected = await collectInventory(prisma) } finally { if (owned) await prisma.$disconnect() } + const inventory = inventoryIndex(collected), inventorySha256 = digest(inventory) + const code = await readDeliveryCode(root) + if (Object.keys(code).sort().join('|') !== [...DELIVERY_FILES].sort().join('|') || Object.values(code).some(v => !/^[a-f0-9]{64}$/.test(v))) fail('delivery_code_identity_unavailable') + const deliveryConfig = normalizeDeliveryConfig(Object.fromEntries(DELIVERY_CONFIG_KEYS.map(key => [key, environment[key] ?? '']))) + const publicDomain = deliveryConfig.S3_PUBLIC_DOMAIN + const targetConfig = bootstrap ? deliveryConfig : normalizeDeliveryConfig(input.targetDeliveryConfig) + let plan, expected + if (bootstrap || input.stage === 'capture') { + const expandedScope = !bootstrap && (!targetDeliveryCode || digest(code) !== digest(targetDeliveryCode) + || digest(input.accepted.deliveryCode) !== digest(code) || digest(input.accepted.deliveryConfig) !== digest(deliveryConfig) + || digest(targetConfig) !== digest(deliveryConfig)) + plan = buildPlan(inventory, input.accepted, { publicDomain, expandedScope, bootstrap }) + expected = input.accepted + } else { + const before = input.capture + if (before?.stage !== 'capture' || before.passed !== true || before.commit !== input.commit || before.inventorySha256 !== inventorySha256 + || before.metadataAudit?.inventorySha256 !== collected.audit.inventorySha256 + || digest(before.inventory) !== inventorySha256 || digest(before.plan) !== before.planSha256 + || before.acceptedArtifactSha256 !== digest(input.accepted)) fail('inventory_or_capture_drift') + if (!targetDeliveryCode || digest(code) !== digest(targetDeliveryCode) || digest(deliveryConfig) !== digest(before.targetDeliveryConfig) + || digest(targetConfig) !== digest(before.targetDeliveryConfig) || publicDomain !== before.plan.publicDomain) fail('candidate_delivery_identity_mismatch') + validateChecks(before.plan, before.checks) + if (digest(before.checks) !== before.checksSha256) fail('capture_checks_digest_mismatch') + plan = before.plan + expected = before + if (input.stage === 'post') { + if (input.candidate?.stage !== 'candidate' || input.candidate.passed !== true || input.candidate.planSha256 !== before.planSha256 + || input.candidate.commit !== input.commit || input.candidate.acceptedArtifactSha256 !== digest(input.accepted) + || digest(input.candidate.plan) !== before.planSha256 || digest(input.candidate.inventory) !== inventorySha256 + || input.candidate.metadataAudit?.inventorySha256 !== collected.audit.inventorySha256 + || input.candidate.inventorySha256 !== inventorySha256 || digest(input.candidate.checks) !== input.candidate.checksSha256) fail('candidate_receipt_required') + validateChecks(plan, input.candidate.checks) + expected = input.candidate + } + } + const { checks, budget } = await probe(plan) + const comparison = compareChecks(plan, checks, expected, { bootstrap, legacyFailureProof, compatibilityProof, legacyMembers }) + const artifact = { version: 1, policyVersion: POLICY.version, readOnly: true, stage: input.stage, commit: input.commit, + capturedAt: new Date().toISOString(), ...comparison, inventory, inventorySha256, metadataAudit: collected.audit, + plan, planSha256: digest(plan), checks, checksSha256: digest(checks), budget, deliveryCode: code, + deliveryConfig, deliveryConfigSha256: digest(deliveryConfig), targetDeliveryConfig: targetConfig, + knownLegacyMembersSha256: KNOWN_LEGACY_MEMBERS_SHA256, + ...(bootstrap ? { bootstrapRequiresExplicitShaReview: true } : { acceptedArtifactSha256: digest(input.accepted) }), + scopeNotice: 'Only selected previews were fetched; all remaining metadata rows are explicitly unverified. Known incident failures are not healthy.' } + return artifact +} + +/** Package only exact-checkout code, with dependency hashes; old images need no new files. */ +export async function bundleReleaseGate(root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), { runtimeBase = 'file:///app/package.json' } = {}) { + const moduleUrl = source => 'data:text/javascript;base64,' + Buffer.from(source).toString('base64') + const adapt = source => source.replaceAll('import.meta.url', JSON.stringify(runtimeBase)) + const registry = moduleUrl(`export const LEGACY_DOMAINS=${JSON.stringify(LEGACY_DOMAINS)}`) + const checker = moduleUrl(adapt(await readFile(path.join(root, 'scripts/check-material-preview-health.mjs'), 'utf8')).replace("'./check-legacy-storage-boundaries.mjs'", JSON.stringify(registry))) + const exporter = moduleUrl(adapt(await readFile(path.join(root, 'scripts/export-material-preview-health-manifest.mjs'), 'utf8')) + .replace("'./check-legacy-storage-boundaries.mjs'", JSON.stringify(registry)).replace("'./check-material-preview-health.mjs'", JSON.stringify(checker))) + const gate = adapt((await readFile(path.join(root, 'scripts/material-preview-release-gate.mjs'), 'utf8')) + .replace('const PINNED_KNOWN_LEGACY_MEMBERS = await readKnownLegacyProof()', `const PINNED_KNOWN_LEGACY_MEMBERS = ${JSON.stringify(await readKnownLegacyProof(root))}`)) + .replace('const PINNED_TARGET_DELIVERY_CODE = null', `const PINNED_TARGET_DELIVERY_CODE = ${JSON.stringify(await deliveryCode(root))}`) + .replace("'./check-legacy-storage-boundaries.mjs'", JSON.stringify(registry)) + .replace("'./check-material-preview-health.mjs'", JSON.stringify(checker)) + .replace("'./export-material-preview-health-manifest.mjs'", JSON.stringify(exporter)) + return moduleUrl(gate) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.length === 3 && process.argv[2] === 'bundle') process.stdout.write(await bundleReleaseGate()) + else { console.error('Usage: node scripts/material-preview-release-gate.mjs bundle'); process.exitCode = 1 } +} diff --git a/scripts/material-preview-release-gate.test.mjs b/scripts/material-preview-release-gate.test.mjs new file mode 100644 index 00000000..195e4478 --- /dev/null +++ b/scripts/material-preview-release-gate.test.mjs @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { createRequire } from 'node:module' +import test from 'node:test' +import { bundleReleaseGate, buildPlan, canonicalDeliverySource, compareChecks, CRITICAL_MATERIAL_IDS, DELIVERY_FILES, KNOWN_LEGACY_MEMBERS_SHA256, normalizeDeliveryConfig, POLICY, runProbes, runReleaseStage, validateAccepted } from './material-preview-release-gate.mjs' +import { LEGACY_DOMAINS } from './check-legacy-storage-boundaries.mjs' + +const sharp = createRequire(import.meta.url)('sharp') +const PNG = await sharp({ create: { width: 3, height: 2, channels: 3, background: '#4d5e6f' } }).png().toBuffer() +const hash = value => createHash('sha256').update(value).digest('hex') +const digest = value => hash(JSON.stringify(value)) +const current = id => `https://s3.jiansun.vip/gongxiangpan/${id}.png` +const independent = id => `https://${LEGACY_DOMAINS[0]}/jsxiangche/${id}.png` +const compatible = id => `https://${LEGACY_DOMAINS[1]}/gongxiangpan/${id}.png` +const FRP = '

The server is powered by frp.

Faithfully yours, frp.

' +const row = (materialId, registeredSource = current(materialId)) => ({ materialId, registeredSource, sourceSha256: hash(registeredSource) }) +const code = Object.fromEntries(DELIVERY_FILES.map(file => [file, 'c'.repeat(64)])) +const config = { S3_ENDPOINT: 'http://192.168.58.20:9000', S3_BUCKET: 'gongxiangpan', S3_FILE_PATH: '由壹点AI/', + S3_PUBLIC_DOMAIN: 'https://s3.jiansun.vip/gongxiangpan', S3_REGION: 'us-east-1', SERVER_IP: '192.168.58.20', MINIO_API_PORT: '9000', APP_PUBLIC_URL: 'https://sc.jiansun.vip' } +const initial = () => [...CRITICAL_MATERIAL_IDS.map((id, i) => row(id, i ? current(id) : compatible(id))), row('legacy', independent('legacy')), row('untested-current'), + { materialId: 'private-source', sourceSha256: hash('uploads/materials/private'), uncheckable: { classification: 'source_rejected', code: 'invalid_url' } }] + +function database(rows) { + const events = [] + const normalized = rows.map(r => ({ id: r.materialId, previewUrl: r.registeredSource || 'uploads/materials/private', materialType: '材质' })).sort((a, b) => a.id.localeCompare(b.id, 'en')) + return { events, prisma: { async $transaction(fn, options) { + events.push(options) + return fn({ async $executeRawUnsafe(sql) { events.push(sql) }, material: { + async count(query) { events.push(query); return normalized.length }, async findMany(query) { + events.push(query) + const start = query.cursor ? normalized.findIndex(r => r.id === query.cursor.id) + 1 : 0 + return normalized.slice(start, start + query.take) + }, + } }) + } } } +} + +const fixtureFetch = async url => { + const parsed = new URL(url) + if (parsed.hostname === LEGACY_DOMAINS[0]) return new Response(FRP, { status: 404, headers: { 'Content-Type': 'text/html' } }) + if (parsed.hostname === LEGACY_DOMAINS[1]) throw new Error('old same-bucket route unavailable') + return new Response(PNG, { status: 200, headers: { 'Content-Type': 'image/png' } }) +} + +async function stage(stageName, rows, { accepted, capture, candidate, probeFetch = fixtureFetch, actualCode = code, targetCode = code, ...input } = {}) { + const db = database(rows) + const report = await runReleaseStage({ version: 1, stage: stageName, commit: 'b'.repeat(40), accepted, capture, candidate, + targetDeliveryConfig: config, ...input }, { + prismaClient: db.prisma, readDeliveryCode: async () => actualCode, targetDeliveryCode: targetCode, + environment: config, probe: plan => runProbes(plan, { fetchImpl: probeFetch }), + // Synthetic offline fixture proofs; the production stdin runner cannot supply + // these dependency injections and uses the fixed incident/original SHA values. + legacyFailureProof: { bytes: Buffer.byteLength(FRP), bodySha256: hash(FRP) }, + compatibilityProof: { materialId: CRITICAL_MATERIAL_IDS[0], bodySha256: hash(PNG), width: 3, height: 2 }, + legacyMembers: [row('legacy', independent('legacy'))], + }) + assert.equal(db.events[1], 'SET TRANSACTION READ ONLY') + assert.deepEqual(db.events[0], { isolationLevel: 'RepeatableRead', timeout: 60_000, maxWait: 5000 }) + return report +} + +test('bootstrap records all metadata, exact 12 materials and all independent legacy rows without inventing health for omitted sources', async () => { + const report = await stage('bootstrap', initial()) + assert.equal(report.passed, true) + assert.equal(report.allSelectedHealthy, false) + assert.equal(report.bootstrapRequiresExplicitShaReview, true) + assert.equal(report.inventory.length, 15) + assert.equal(report.plan.selected.length, 13) + assert.equal(report.checks.length, 14) + assert.equal(report.knownFailures.length, 2) + assert.equal(report.plan.unverified.length, 2) + assert.equal(report.plan.unverified.find(r => r.materialId === 'private-source').reason, 'unsupported_or_missing_registered_source') + assert.equal(report.budget.retries, 0) + validateAccepted(report) + assert.equal(report.checks.find(r => r.materialId === CRITICAL_MATERIAL_IDS[0] && r.role === 'registered').result.ok, false) + assert.equal(report.checks.find(r => r.materialId === CRITICAL_MATERIAL_IDS[0] && r.role === 'canonical').result.bodySha256, hash(PNG)) +}) + +test('fixed same plan is checked before and after candidate with no metadata or content writes', async () => { + const accepted = await stage('bootstrap', initial()) + const rows = [...initial(), row('new-current')] + const capture = await stage('capture', rows, { accepted }) + const candidate = await stage('candidate', rows, { accepted, capture }) + const post = await stage('post', rows, { accepted, capture, candidate }) + assert.equal(capture.plan.selected.some(r => r.materialId === 'new-current' && r.reasons.includes('new_or_changed_source')), true) + assert.equal(post.passed, true) + assert.equal(post.planSha256, capture.planSha256) + assert.equal(candidate.planSha256, capture.planSha256) + assert.equal(post.inventorySha256, capture.inventorySha256) + validateAccepted(post) +}) + +test('missing or forged first baseline stops capture before image GET', async () => { + let calls = 0 + await assert.rejects(stage('capture', initial(), { probeFetch: async () => { calls++; return fixtureFetch(current('x')) } }), /reviewed_baseline_required/) + const accepted = await stage('bootstrap', initial()) + accepted.inventory.pop() + await assert.rejects(stage('capture', initial(), { accepted }), /baseline_digest_mismatch/) + assert.equal(calls, 0) +}) + +test('new failure or changed failure type blocks candidate; existing FRP incident remains explicit', async () => { + const accepted = await stage('bootstrap', initial()), capture = await stage('capture', initial(), { accepted }) + const bad = await stage('candidate', initial(), { accepted, capture, probeFetch: async url => url === current(CRITICAL_MATERIAL_IDS[1]) + ? new Response('failed', { status: 200, headers: { 'Content-Type': 'text/html' } }) : fixtureFetch(url) }) + assert.equal(bad.passed, false) + assert.ok(bad.problems.some(r => r.code === 'new_or_changed_failure')) + assert.ok(bad.knownFailures.some(r => r.materialId === 'legacy' && r.code === 'frp_default_404')) + const legacyChange = await stage('candidate', initial(), { accepted, capture, probeFetch: async url => url === independent('legacy') + ? new Response('NoSuchKey', { status: 404, headers: { 'Content-Type': 'application/xml' } }) : fixtureFetch(url) }) + assert.equal(legacyChange.passed, false) +}) + +test('a newly registered legacy failure cannot join the known baseline automatically', async () => { + const accepted = await stage('bootstrap', initial()) + const capture = await stage('capture', [...initial(), row('new-legacy', independent('new-legacy'))], { accepted }) + assert.equal(capture.passed, false) + assert.ok(capture.problems.some(r => r.materialId === 'new-legacy' && r.code === 'new_or_changed_failure')) +}) + +test('bootstrap only permits the exact documented FRP body and original canonical compatibility proof', async () => { + const wrongLegacy = await stage('bootstrap', initial(), { probeFetch: async url => url === independent('legacy') + ? new Response(FRP + 'changed', { status: 404, headers: { 'Content-Type': 'text/html' } }) : fixtureFetch(url) }) + assert.equal(wrongLegacy.passed, false) + const wrongCanonical = await stage('bootstrap', initial(), { probeFetch: async url => { + if (url !== current(CRITICAL_MATERIAL_IDS[0])) return fixtureFetch(url) + const different = await sharp({ create: { width: 3, height: 2, channels: 3, background: '#112233' } }).png().toBuffer() + return new Response(different, { headers: { 'Content-Type': 'image/png' } }) + } }) + assert.equal(wrongCanonical.passed, false) +}) + +test('bootstrap rejects an extra historical row, a replacement ID at equal count, and a changed historical source', async () => { + for (const rows of [ + [...initial(), row('unreviewed-old', independent('unreviewed-old'))], + initial().map(r => r.materialId === 'legacy' ? row('replacement-old', independent('legacy')) : r), + initial().map(r => r.materialId === 'legacy' ? row('legacy', independent('different-source')) : r), + ]) { + const bootstrap = await stage('bootstrap', rows) + assert.equal(bootstrap.passed, false) + assert.ok(bootstrap.problems.some(r => r.code === 'new_or_changed_failure')) + } +}) + +test('healthy same URL with different bytes requires review rather than silently rebaselining', async () => { + const accepted = await stage('bootstrap', initial()), capture = await stage('capture', initial(), { accepted }) + const variant = await sharp({ create: { width: 3, height: 2, channels: 3, background: '#ffffff' } }).png().toBuffer() + const candidate = await stage('candidate', initial(), { accepted, capture, probeFetch: async url => url === current(CRITICAL_MATERIAL_IDS[1]) + ? new Response(variant, { headers: { 'Content-Type': 'image/png' } }) : fixtureFetch(url) }) + assert.equal(candidate.passed, false) + assert.ok(candidate.problems.some(r => r.code === 'unchanged_url_content_changed')) +}) + +test('inventory drift, missing coverage, missing candidate, and detector failure all fail closed', async () => { + const accepted = await stage('bootstrap', initial()), capture = await stage('capture', initial(), { accepted }) + await assert.rejects(stage('candidate', [...initial(), row('concurrent-upload')], { accepted, capture }), /inventory_or_capture_drift/) + await assert.rejects(stage('post', initial(), { accepted, capture }), /candidate_receipt_required/) + const broken = structuredClone(capture); broken.checks.pop(); broken.checksSha256 = digest(broken.checks) + await assert.rejects(stage('candidate', initial(), { accepted, capture: broken }), /probe_coverage_incomplete/) + const faulty = structuredClone(capture.checks) + faulty[0].result.ok = false; faulty[0].result.classification = 'decoder_unavailable'; faulty[0].result.code = 'image_decoder_unavailable' + assert.equal(compareChecks(capture.plan, faulty, capture).passed, false) +}) + +test('new unsupported source, absent key material and changes beyond budget cannot be dropped', async () => { + const accepted = await stage('bootstrap', initial()) + await assert.rejects(stage('capture', [...initial(), { materialId: 'unsupported-new', sourceSha256: hash('x'), uncheckable: { code: 'invalid_url' } }], { accepted }), /new_or_changed_uncheckable_preview/) + assert.throws(() => buildPlan(initial().slice(1), accepted, { publicDomain: 'https://s3.jiansun.vip/gongxiangpan' }), /critical_material_coverage_missing/) + assert.throws(() => buildPlan([...initial(), ...Array.from({ length: POLICY.probes }, (_, i) => row(`new-${i}`))], accepted, + { publicDomain: 'https://s3.jiansun.vip/gongxiangpan' }), /probe_budget_requires_independent_full_acceptance/) +}) + +test('delivery source or storage code changes expand scope and cannot skip large inventory', async () => { + const accepted = await stage('bootstrap', initial()) + const changed = { ...code, [DELIVERY_FILES[0]]: 'd'.repeat(64) } + const capture = await stage('capture', initial(), { accepted, targetCode: changed }) + assert.equal(capture.plan.expandedScope, true) + assert.ok(capture.plan.selected.some(r => r.materialId === 'untested-current')) + await assert.rejects(stage('candidate', initial(), { accepted, capture, targetCode: changed }), /candidate_delivery_identity_mismatch/) + const many = [...initial(), ...Array.from({ length: POLICY.probes }, (_, i) => row(`unchanged-${i}`))] + const largeBase = await stage('bootstrap', many) + await assert.rejects(stage('capture', many, { accepted: largeBase, targetCode: changed }), /probe_budget_requires_independent_full_acceptance/) + const outOfBand = await stage('capture', initial(), { accepted, actualCode: changed, targetCode: changed }) + assert.equal(outOfBand.plan.expandedScope, true, 'a change since the accepted code baseline cannot disappear when target already equals current') +}) + +test('nonsecret delivery configuration changes expand scope and inconsistent candidate settings stop before GET', async () => { + const accepted = await stage('bootstrap', initial()) + for (const [key, value] of [['S3_ENDPOINT', 'http://192.168.58.21:9000'], ['S3_BUCKET', 'other-bucket'], ['S3_FILE_PATH', 'other/'], + ['SERVER_IP', '192.168.58.21'], ['MINIO_API_PORT', '9001'], ['S3_REGION', 'other-region']]) { + const targetDeliveryConfig = { ...config, [key]: value } + const capture = await stage('capture', initial(), { accepted, targetDeliveryConfig }) + assert.equal(capture.plan.expandedScope, true, key) + await assert.rejects(stage('candidate', initial(), { accepted, capture, targetDeliveryConfig }), /candidate_delivery_identity_mismatch/) + } + await assert.rejects(stage('capture', initial(), { accepted, targetDeliveryConfig: undefined }), /complete_delivery_configuration_required/) + assert.throws(() => normalizeDeliveryConfig({ ...config, S3_ENDPOINT: 'http://user:secret@192.168.58.20:9000' }), /credential_or_query_in_delivery_configuration/) + assert.throws(() => normalizeDeliveryConfig({ ...config, S3_ACCESS_KEY_ID: 'must-not-be-logged' }), /complete_delivery_configuration_required/) +}) + +test('versioned historical membership proof exactly matches the independently reviewed 472 IDs and normalized source digests', async () => { + const { readFile } = await import('node:fs/promises') + const policy = JSON.parse(await readFile(new URL('./fixtures/material-preview-known-legacy-20261023.json', import.meta.url), 'utf8')) + assert.equal(policy.members.length, 472) + assert.equal(digest(policy.members), KNOWN_LEGACY_MEMBERS_SHA256) + assert.deepEqual(Object.keys(policy.members[0]).sort(), ['materialId', 'sourceSha256']) + assert.equal(JSON.stringify(policy).includes('https://'), false) +}) + +test('canonical mapping preserves bucket and key and never rewrites independent old storage', () => { + assert.equal(canonicalDeliverySource(independent('original'), 'https://s3.jiansun.vip/gongxiangpan'), independent('original')) + assert.equal(canonicalDeliverySource(compatible('%E4%B8%AD%E6%96%87'), 'https://s3.jiansun.vip/gongxiangpan'), current('%E4%B8%AD%E6%96%87')) + assert.throws(() => canonicalDeliverySource(compatible('x'), 'https://elsewhere.invalid/gongxiangpan'), /untrusted_public_bucket_configuration/) +}) + +test('read-only probe queue is bounded to four requests and one attempt with a fixed wall budget', async () => { + const accepted = await stage('bootstrap', initial()), plan = buildPlan(initial(), accepted, { publicDomain: 'https://s3.jiansun.vip/gongxiangpan' }) + let active = 0, peak = 0, calls = 0 + await runProbes(plan, { fetchImpl: async (url, options) => { + calls++; peak = Math.max(peak, ++active) + assert.equal(options.method, 'GET'); assert.equal(options.redirect, 'manual'); assert.equal(options.cache, 'no-store') + await new Promise(resolve => setTimeout(resolve, 2)); active-- + return fixtureFetch(url) + } }) + assert.equal(calls, plan.probes.length); assert.equal(peak, POLICY.concurrency) + let time = 0 + await assert.rejects(runProbes(plan, { now: () => (time += POLICY.wallMs), fetchImpl: () => { throw Error('must not fetch') } }), /release_probe_wall_budget/) +}) + +test('exact-checkout bundle loads without an old image containing new scripts', async () => { + const bundle = await bundleReleaseGate(undefined, { runtimeBase: new URL('../package.json', import.meta.url).href }) + assert.ok(bundle.length < 524288) + const gate = await import(bundle) + assert.equal(typeof gate.runReleaseStage, 'function') + assert.deepEqual(gate.POLICY, POLICY) +}) From 20e327590c73f873a781cdee5883e9773d4f764c Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Fri, 23 Oct 2026 20:12:19 +0800 Subject: [PATCH 3/9] docs(materials): correct recent-preview quality and recovery scope --- docs/material-preview-incident-2026-10-23.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/material-preview-incident-2026-10-23.md b/docs/material-preview-incident-2026-10-23.md index ded3ec0b..1a3664b7 100644 --- a/docs/material-preview-incident-2026-10-23.md +++ b/docs/material-preview-incident-2026-10-23.md @@ -1,6 +1,6 @@ # 旧素材预览失效核查(2026-10-23) -状态:已确认旧域名入口路由失败、存量迁移覆盖缺口及 11 份真实材质缩略图的转换来源。**11 项临时 160px 预览已恢复,正式 Web 与现有客户端均复核为 12 张缩略图、0 失败;高清源未恢复。剩余 472 项已逐一核查并确认属于 2025 年旧批次,用户确认旧桶素材无法找回,停止继续追源。具体路由变更仍未归因,不冒充已找回原件或已查明触发者。** +状态:已确认旧域名入口路由失败、存量迁移覆盖缺口及 11 份真实材质缩略图的转换来源。**11 项临时 160px 预览可加载,但高清恢复未完成;当前发糊与本次使用小缓存恢复有关。剩余 472 项建档于 2025 年,但用户提到的“有一批老素材无法找回”不能直接作为这 11 项永久丢失的证明。这 11 项最近仍有原预览读取成功证据,已单独重新核查。具体路由变更仍未归因,不冒充已找回高清或已查明触发者。** ## 已确认的事实 @@ -63,13 +63,21 @@ sharp(original) - 复用现有唯一客户端窗口(PID `9260`、HWND `2363054`),没有另开窗口、没有更改项目资料;逐项执行 11 次素材图片只读重读,滚动检查上、下区域并截图,汇总确认 12 张真实缩略图、0 个失败标记。 - 本轮没有重新验收悬停大图;先前的悬停证据不计作这次恢复后的验证。已通过的范围是 Web 与客户端的临时缩略预览,`originalSourceRestored=false`。 -因此“11 项临时 160px 缩略预览恢复”已通过;高清源没有恢复,旧入口故障的具体触发变更仍未查明。本次未重发结果未知的模型请求。关于旧批次无法找回的用户确认及停止追源范围,见下文。 +因此“11 项临时 160px 缩略预览可加载”已通过;高清源没有恢复,旧入口故障的具体触发变更仍未查明。本次未重发结果未知的模型请求。不能把图片节点无失败等同于清晰度恢复。 ## 剩余 472 项的完整核查与用户确认 2026-10-23 对剩余 472 个独立登记 URL 分十批完成只读 GET,无重试。全部返回 HTTP 404、489 字节 FRP 默认页,正文 SHA 均为 `967e369d3be4fab5eb1229ec015b2923abf6bc81ebe3aaa662cb095cbec81023`,没有一项被记为健康。类型为 3D场景 305、详情模板 87、其他 61、模特 7、实拍图片 6、买家秀图片 6。 -按数据库 `createdAt`(UTC+8)核对,这 472 条记录建档于 **2025-10-27 16:43:57.805 至 2025-12-20 17:59:08.983**,其中 10 月 8 条、11 月 363 条、12 月 101 条。这是记录建档时间,不是原文件生成时间。先前恢复临时预览的 11 项材质均建档于 2025-11-07,属于同一旧桶。用户据此确认该批旧桶素材无法找回,本次停止继续寻找旧桶服务器或扩大扫描;保留素材记录、缓存、原登记来源和下载文件,不以空图覆盖、不删除素材。 +按数据库 `createdAt`(UTC+8)核对,这 472 条记录建档于 **2025-10-27 16:43:57.805 至 2025-12-20 17:59:08.983**,其中 10 月 8 条、11 月 363 条、12 月 101 条。这是记录建档时间,不是原文件生成时间。先前恢复临时预览的 11 项材质均建档于 2025-11-07,也登记同一旧桶。用户说明有一批旧桶素材无法找回,但随后明确指出这 11 项材质几天前仍然清晰;**此前仅凭同桶和建档日期接近就将其并入不可恢复范围,依据不足,撤回该推断**。472 项的故障审计仍保留,不重复扩大扫描;这 11 项的高清线索单独核对,保留记录、缓存、原登记来源和下载文件。 + +## 11 项材质的清晰度与最近可用性复核 + +10 月 19 日回执中,这 11 项的原响应均下载成功、分别保留源 SHA,失败列表为空。朴橡柚木色的同一原预览到 10 月 20 日 11:36:18(UTC+8)仍有 HTTP 200、15738 字节的正式访问记录。原响应像素尺寸没有留存,不能宣称原图就是 160×160,也不能仅凭小文件字节数断定原图低清。 + +160×160 是我们当时为客户端颜色选项主动缩小后留下的缓存尺寸;本次临时恢复把 Web 卡片也改为使用该缓存,因此卡片放大后发糊。作为对照,“新花色01”在同一次本地缓存中也被缩成小图,而它现有真实交付图为 768×1600、原响应 SHA 一致。**小缓存不等于原预览的质量,也不能把本次低清显示解释成用户原图本来模糊。** + +这 11 项各自还登记独立的 `uploads/material--.zip` 下载包,展示图、assets 和 SKU 为空不能代替该下载路径的实际核对。当前标准下载代码按 `UPLOAD_DIR` 解析这些相对本地路径,并非直接按预览的旧 S3 URL 下载。此次针对这 11 个 ZIP 与 11 个原预览名,核对 `/app/uploads` 实际挂载的 `/mnt/PoolOne/youyidian-uploads` 中 22 个精确路径,以及该独立 ZFS dataset 的 10 月 20 日 19:00、20:00、10 月 21 日 00:00(UTC+8)三个故障前快照中的相同路径,均未命中。四处已确认项目备份在严格文件深度不超过 5、每根最多 5000 项的预算内也未匹配;当前 uploads 的目录枚举触及 5000 项预算,故不声称全目录不存在。初始 walker 的文件深度计数偏差已明确记录并按严格规则补查,未读取文件正文或扩大目录。路径未命中只说明该受限范围没有找到,不等同于物理永久丢失。全程只读,不修改下载计数或原包。 106 条记录含 2215 个 `displayImages` 候选,仍全部指向同一旧入口;MaterialAsset 与 SKU 元数据未提供可校验的替代预览来源。该元数据核对没有下载 ZIP 或原素材。后续仅在本项目当前桶的受限范围扫描 250000 条对象元数据,对 27 个尺寸候选读取 452568 字节,没有原 SHA 命中;达到预算时游标尚未耗尽,故**不据此声称整个桶不存在原图**。用户的历史说明与本次未找到可校验副本分别保留,不把 404 等同于物理删除证明。 @@ -89,7 +97,7 @@ sharp(original) 下次涉及存储域名、代理、运行时或 frpc 维护,维护前后需要同时检查当前入口和仍有活跃引用的历史入口,并保留前后报告;仅进程在线、首页健康或配置 SHA 未变,不算素材交付验证。桶退役之前必须清点所有活跃引用,并核对“对象真实存在且内容一致,再改引用”,不能只按导入日志标签判断迁移完成。 -若后续另有旧入口历史审计或可信原件,才能继续精确归因或恢复高清;当前按用户确认停止追源。临时 160px 预览继续保留尺寸与来源记录,不能掩盖高清源未恢复或扩大为其它素材已恢复的结论。所有原素材、现有下载文件和元数据均保留。 +若后续另有旧入口历史审计或可信原件,才能继续精确归因或恢复高清;472 项旧源不再重复扩大扫描,这 11 项最近仍可用过的材质预览单独保持未完成状态。临时 160px 预览继续保留尺寸与来源记录,不能掩盖高清源未恢复或扩大为其它素材已恢复的结论。所有原素材、现有下载文件和元数据均保留。 ## 生产发布检查实测(2026-10-23) @@ -126,5 +134,8 @@ sharp(original) - `release-gate-live/bootstrap.json`、`bootstrap-review.json`、`production-bootstrap-install.json`:首次已审核基线及生产安装回执。 - `release-gate-live/capture.json`、`candidate.json`、`post.json`、`final-validation.json`:实际发布函数的三阶段同清单探测及容器身份未变证据。 - `release-gate-live/failed-rehearsal-diagnosis.json`、`exact-snapshot-manifest.json`、`cleanup.json`:行尾差异负例、原始 Git 字节快照摘要及临时文件收尾。 +- `material-11-recheck/recent-success-and-quality-recheck.json`:11 项最近成功的源响应、当前 160px 临时引用及原尺寸未知的边界。 +- `material-11-recheck/local-exact-original-and-zip-locate.json`:本项目 uploads、backups、output 中 22 个精确原文件/ZIP 名的只读路径索引结果,不读取其他项目或文件正文。 +- `material-11-recheck/exact-file-and-backup-stat.json`、`backup-depth-five-correction.json`:生产 uploads 实际挂载、22 个精确路径、三个故障前快照及四处受限项目备份核对,含深度计数纠正与未命中范围。 本轮生产恢复范围为 11 个新增临时预览对象、11 项精确预览引用更新及 11 条同事务审计日志;没有修改原文件、下载路径、桶或 FRP 配置,也未重发结果未知的模型请求。本文件此次更新只登记已有实施与验收证据,没有另行执行生产操作。 From 93fc28f1bbdbbde60ac9843e99e3e78185f67c4d Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Fri, 23 Oct 2026 21:27:06 +0800 Subject: [PATCH 4/9] feat(client): add native template slots and safe cloud version restore Support explicit current-output A/B fill, protected edited drafts, scoped cloud update offers and atomic native restore with stale-open CAS. Preserve original unknown requests, improve Pi conversation continuity and native DPI/navigation reads, and publish the authenticated template metadata contract. Validated: native backend 172 passed/5 environment tests ignored, UI 110, Pi 53, template/rule API 24 and native cloud contract 6; strict Clippy, layering and wasm. Real S01 selected B, edited/undo-redo, PCraft reopen and layered PSD roundtrip pass with exact pixels. Full nine-output generation and Photoshop acceptance remain open. --- .dockerignore | 2 + app/api/desktop/template-packs/route.ts | 26 + apps/detail-agent-pi/src/agent.mjs | 79 +- apps/detail-agent-pi/src/context-budget.mjs | 8 +- apps/detail-agent-pi/src/desktop-host.mjs | 17 +- apps/detail-agent-pi/src/native-bridge.mjs | 111 +- apps/detail-agent-pi/src/project-context.mjs | 32 + apps/detail-agent-pi/src/stdio-canvas.mjs | 1 + apps/detail-agent-pi/src/store.mjs | 63 +- .../test/conversation.test.mjs | 276 +++ .../test/native-canvas-transport.test.mjs | 118 ++ .../detail-agent-pi/test/native-wire.test.mjs | 7 + apps/detail-agent-pi/test/pi.test.mjs | 2 + apps/detail-agent-pi/test/recovery.test.mjs | 11 +- .../apps/photocraft/src/jiansun_backend.rs | 513 ++++- .../apps/photocraft/src/jiansun_creation.rs | 437 ++++- .../photocraft/src/jiansun_creation_model.rs | 340 +++- .../photocraft/src/jiansun_image_protocol.rs | 41 + .../apps/photocraft/src/jiansun_images.rs | 495 +++-- .../apps/photocraft/src/jiansun_pi.rs | 132 +- .../apps/photocraft/src/jiansun_pi_runtime.rs | 205 +- .../apps/photocraft/src/jiansun_projects.rs | 368 +++- .../apps/photocraft/src/jiansun_recovery.rs | 380 ++-- .../apps/photocraft/src/jiansun_rule_cache.rs | 169 +- .../photocraft/src/jiansun_slot_canvas.rs | 524 +++++ .../apps/photocraft/src/jiansun_stream.rs | 34 +- .../apps/photocraft/src/jiansun_sync.rs | 723 ++++++- .../photocraft/src/jiansun_template_cache.rs | 293 +++ .../src/jiansun_template_local_qa.rs | 168 ++ .../photocraft/src/jiansun_template_slots.rs | 793 ++++++++ .../src/jiansun_template_workflow.rs | 923 +++++++++ .../apps/photocraft/src/main.rs | 23 + .../apps/photocraft/src/ui_scale_policy.rs | 322 ++++ .../apps/photocraft/src/window_frame.rs | 239 ++- .../crates/ui-egui/src/jiansun.rs | 1694 +++++++++++++++- .../crates/ui-egui/src/jiansun/agent_panel.rs | 36 +- .../crates/ui-egui/src/jiansun/creation.rs | 898 ++++++--- .../ui-egui/src/jiansun/creation_reads.rs | 492 +++++ .../crates/ui-egui/src/jiansun/pi_bridge.rs | 289 ++- .../ui-egui/src/jiansun/project_browser.rs | 163 +- .../ui-egui/src/jiansun/template_slots.rs | 1156 +++++++++++ docs/photocraft-client-design.md | 2 + docs/photocraft-client-display-scaling.md | 54 + ...hotocraft-colleague-workflow-2026-10-23.md | 20 +- ...ft-template-slots-acceptance-2026-10-23.md | 126 ++ lib/__tests__/desktop-agent-rules.test.ts | 21 +- lib/__tests__/desktop-native-contract.test.ts | 39 + .../desktop-template-packs-route.test.ts | 59 + lib/__tests__/desktop-template-packs.test.ts | 95 + lib/desktop-client/agent-rule-catalog.json | 55 +- lib/desktop-client/creation.mjs | 4 +- lib/desktop-client/template-packs.ts | 136 ++ .../gj-desk-dnz0385-native-v1.json | 1713 ++++++++++++++++ .../yjd-desk-dnz0385-native-v1.json | 1715 +++++++++++++++++ .../desktop-template-pack-context.test.mjs | 60 + 55 files changed, 15550 insertions(+), 1152 deletions(-) create mode 100644 app/api/desktop/template-packs/route.ts create mode 100644 apps/detail-agent-pi/src/project-context.mjs create mode 100644 apps/detail-agent-pi/test/conversation.test.mjs create mode 100644 apps/detail-agent-pi/test/native-canvas-transport.test.mjs create mode 100644 apps/detail-photocraft/apps/photocraft/src/jiansun_slot_canvas.rs create mode 100644 apps/detail-photocraft/apps/photocraft/src/jiansun_template_cache.rs create mode 100644 apps/detail-photocraft/apps/photocraft/src/jiansun_template_local_qa.rs create mode 100644 apps/detail-photocraft/apps/photocraft/src/jiansun_template_slots.rs create mode 100644 apps/detail-photocraft/apps/photocraft/src/jiansun_template_workflow.rs create mode 100644 apps/detail-photocraft/apps/photocraft/src/ui_scale_policy.rs create mode 100644 apps/detail-photocraft/crates/ui-egui/src/jiansun/creation_reads.rs create mode 100644 apps/detail-photocraft/crates/ui-egui/src/jiansun/template_slots.rs create mode 100644 docs/photocraft-client-display-scaling.md create mode 100644 docs/photocraft-template-slots-acceptance-2026-10-23.md create mode 100644 lib/__tests__/desktop-template-packs-route.test.ts create mode 100644 lib/__tests__/desktop-template-packs.test.ts create mode 100644 lib/desktop-client/template-packs.ts create mode 100644 lib/desktop-client/template-packs/gj-desk-dnz0385-native-v1.json create mode 100644 lib/desktop-client/template-packs/yjd-desk-dnz0385-native-v1.json create mode 100644 scripts/desktop-template-pack-context.test.mjs diff --git a/.dockerignore b/.dockerignore index 67ce0dee..aad32654 100644 --- a/.dockerignore +++ b/.dockerignore @@ -222,6 +222,8 @@ temp_*.sql !prisma/migrations/** !data/team-function-initial-content.json !lib/desktop-client/agent-rule-catalog.json +!lib/desktop-client/template-packs/gj-desk-dnz0385-native-v1.json +!lib/desktop-client/template-packs/yjd-desk-dnz0385-native-v1.json # Private generated data never belongs to a build context. /detail-generator-private diff --git a/app/api/desktop/template-packs/route.ts b/app/api/desktop/template-packs/route.ts new file mode 100644 index 00000000..80883fe3 --- /dev/null +++ b/app/api/desktop/template-packs/route.ts @@ -0,0 +1,26 @@ +import { createHash } from 'node:crypto' +import { NextResponse } from 'next/server' +import { requireDesktop } from '@/lib/desktop-client/service' +import { DesktopClientError, desktopAssert } from '@/lib/desktop-client/contract.mjs' +import { desktopTemplatePacks } from '@/lib/desktop-client/template-packs' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +const headers = { 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff' } + +export async function GET(request: Request) { + try { + // Every request, including a conditional hit, checks current session and + // feature authority. Native account-scoped caching cannot replace this. + await requireDesktop(request) + const query = new URL(request.url).searchParams + desktopAssert(query.getAll('brandId').length === 1 && query.getAll('categoryId').length === 1, '请提供唯一的品牌和品类编号', 400, 'invalid_request') + const value = desktopTemplatePacks(query.get('brandId'), query.get('categoryId')) + const etag = `"${createHash('sha256').update(JSON.stringify(value)).digest('hex')}"` + if (request.headers.get('if-none-match') === etag) return new NextResponse(null, { status: 304, headers: { ...headers, ETag: etag } }) + return NextResponse.json(value, { headers: { ...headers, ETag: etag } }) + } catch (error) { + const known = error instanceof DesktopClientError + return NextResponse.json({ error: known ? error.message : '模板暂时无法读取', code: known ? error.code : 'internal_error' }, { status: known ? error.status : 500, headers }) + } +} diff --git a/apps/detail-agent-pi/src/agent.mjs b/apps/detail-agent-pi/src/agent.mjs index 047d1f17..807ba49b 100644 --- a/apps/detail-agent-pi/src/agent.mjs +++ b/apps/detail-agent-pi/src/agent.mjs @@ -12,19 +12,25 @@ import { parseReceipt } from './receipt.mjs'; import { auditResponse } from './response-audit.mjs'; import { projectContext, promptChars } from './context-budget.mjs'; import { taskFor } from './rule-context.mjs'; +import { productContext } from './project-context.mjs'; import { responseUsage } from './usage.mjs'; import { assertPiNodeVersion } from './runtime-policy.mjs'; -const SYSTEM = `你是由壹点详情页的本地 Agent。使用中文。先看当前上下文;只有用户要求修改画布时才读取和操作画布。 +const SYSTEM = `你是由壹点详情页的本地 Agent。使用中文,能够与用户连贯讨论、回答问题、解释原因、提出并比较方案。可以直接自然语言回答;不需要工具时不要调用工具,不把每次对话都当作执行任务。 +先看当前注入的产品资料和待确认项。只有用户要求修改画布时才读取和操作画布;讨论、解释、建议不自动改图。实际写工具会向本机申请当前版本的写许可;未授权或用户接手时不再修改。 只信当前注入的店铺/品类/品牌规则,不沿用另一店铺或旧规则;品牌规范不是产品参数的证据。 用户新确认的信息覆盖旧信息,缺失事实写待确认,不编造认证、材质、性能。 -重要产品事实和用户决定用 memory_set 保存,必须引用用户原话。需要历史用 memory_search。 +当前产品资料与当前规则注入是权威。历史对话中的旧产品、旧店铺或旧规则只作讨论历史,不能覆盖当前绑定;未配置规则时可以讨论通用方案,明确不冒充已有品牌规范。 +重要产品事实和用户决定用 memory_set 保存,必须引用当前规则域用户明确确认的原话。旧店铺历史只能讨论,需用户在当前域重新确认才能记为当前事实。需要历史用 memory_search。 工具不够时自己写 main(input) JavaScript,用 tool_develop 提交至少3个正常/边界测试,通过后 tool_run 使用。 自建工具在无网络、无文件、无宿主API的QuickJS沙箱运行,仅输入输出JSON;输出编辑建议后仍须canvas_edit执行。 -画布每步必须先canvas_read读取revision。只改用户要求的现有未锁定图层。本轮实时allowed=false时停止写入;历史接手只影响当时那一轮。本轮用户重新要求编辑且实时allowed=true时可以继续,不根据旧对话猜测已接手。 +画布每步必须先canvas_read读取revision。只改用户要求的现有未锁定图层。回合最初allowed=false表示尚未申请写入,canvas_edit会先向宿主申请;writeBlocked=true表示本轮已接手或版本已变化,必须停止写入。历史接手只影响当时那一轮,新用户回合需以实时状态为准。讨论不需要画布写许可。 不要声称尚未执行的修改已完成。工具失败要说明。未知模型请求只读查询,不要要求重试或重新生成。`; const plain = value => ({ content: [{ type: 'text', text: JSON.stringify(value) }], details: value }); +// Only the native owner's explicit inactive-document response detaches display. +// Connection, account/authentication and all other failures must still surface. +const inactiveDocument = error => /^(?:原项目已切换|当前项目已切换)(?:[,,::。]|$)/u.test(error?.message || ''); function toolsFor(host) { const make = (name, description, parameters, execute) => ({ name, label: name, description, parameters, executionMode: 'sequential', execute: async (_id, args, signal) => { if (host.compromised) throw Error('本地会话锁失效,已停止工具执行'); @@ -42,18 +48,31 @@ function toolsFor(host) { make('memory_search', '查找本项目完整历史,包括压缩前的原始记录。', Type.Object({ query: Type.String({ minLength: 1, maxLength: 200 }) }), ({ query }) => host.store.search(query, host.manager)), make('tool_develop', '编写并测试一个新的项目工具。source包含function main(input),仅JSON输入输出;至少3项自写测试全部通过才保存。', Type.Object({ name: Type.String({ pattern: '^[a-z][a-z0-9_]{0,47}$' }), description: string(500), source: string(24000), tests: Type.Array(Type.Object({ input: Type.Unknown(), expected: Type.Unknown() }), { minItems: 3, maxItems: 12 }) }), args => host.lab.develop(args)), make('tool_run', '运行已通过测试的当前版本工具;不会直接修改画布。', Type.Object({ name: string(48), input: Type.Unknown() }), ({ name, input }) => host.lab.run(name, input)), - make('canvas_read', '读取实时画布和revision。可指定layer取得详情、screen读取一屏;默认分页20条摘要,可传offset。allowed=false代表已接手。', Type.Object({ layer: Type.Optional(Type.Integer({ minimum: 1 })), screen: Type.Optional(Type.String({ pattern: '^S[0-9]{1,2}$' })), offset: Type.Optional(Type.Integer({ minimum: 0, maximum: 300 })) }, { additionalProperties: false }), async ({ layer, screen, offset = 0 }) => { + make('canvas_read', '读取实时画布和revision。可指定layer取得详情、screen读取一屏;默认分页20条摘要,可传offset。allowed=false可能只是尚未申请写入;writeBlocked=true才表示本轮不可写。', Type.Object({ layer: Type.Optional(Type.Integer({ minimum: 1 })), screen: Type.Optional(Type.String({ pattern: '^S[0-9]{1,2}$' })), offset: Type.Optional(Type.Integer({ minimum: 0, maximum: 300 })) }, { additionalProperties: false }), async ({ layer, screen, offset = 0 }) => { if (!host.canvas) return { available: false }; const state = await host.canvas.read(), context = state.context; + if(state.conversationActive===false||!context)throw Error('当前项目已切换,未读取其他项目的画布'); const layers = layer ? context.layers.filter(l => l.id === layer) : screen ? context.layers.filter(l => l.name.startsWith(screen + '/') || l.name.startsWith(screen + ' ')) : context.layers; if ((layer || screen) && !layers.length) throw Error('指定图层或分屏不存在,未更改画布'); - return { allowed: state.allowed, revision: state.revision, context: { ...context, totalLayers: context.layers.length, offset, + return { allowed: state.allowed, writeBlocked:state.writeBlocked,revision: state.revision, context: { id:context.id,name:context.name,width:context.width,height:context.height,totalLayers: context.layers.length, offset, hasMore: !layer && offset + 20 < layers.length, layers: layer ? layers : layers.slice(offset, offset + 20).map(l => ({ id: l.id, name: l.name, kind: l.kind, text: l.text ? Array.from(l.text).slice(0, 80).join('') : null, locked: l.locked })) } }; }), make('canvas_edit', '一次修改现有图层,共享本机撤销历史。revision须等于刚读取的版本。支持type.edit/layer.translate/layer.setProps,参数沿用原生画布。', Type.Object({ revision: Type.Integer({ minimum: 0 }), command: Type.Union([Type.Literal('type.edit'), Type.Literal('layer.translate'), Type.Literal('layer.setProps')]), params: Type.Object({ layer: Type.Integer({ minimum: 1 }), text: Type.Optional(string(3000)), size: Type.Optional(Type.Number({ minimum: 8, maximum: 240 })), color: Type.Optional(Type.String({ pattern: '^#[0-9a-fA-F]{6}$' })), name: Type.Optional(string(120)), dx: Type.Optional(Type.Integer({ minimum: -3000, maximum: 3000 })), dy: Type.Optional(Type.Integer({ minimum: -3000, maximum: 3000 })), opacity: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })), visible: Type.Optional(Type.Boolean()) }, { additionalProperties: false }), label: Type.String({ minLength: 1, maxLength: 120 }) }), ({ revision, ...edit }, signal) => { if (!host.canvas) throw Error('本轮没有连接画布'); - return host.canvas.edit(edit, revision, signal).then(state => ({ applied: true, revision: state.revision, allowed: state.allowed, layer: state.context.layers.find(l => l.id === edit.params.layer) })); + host.canvasWriteRequested = true; + return (async () => { + try { + if(host.canvasNotificationsDetached)throw Error('当前项目已切换,本轮画布写入已停止'); + const permission = await host.canvas.requestWrite(revision); + if (!permission.allowed) throw Error('用户已接手,本轮写入未获许可'); + const state = await host.canvas.edit(edit, revision, signal); + return { applied: true, revision: state.revision, allowed: state.allowed, layer: state.context.layers.find(l => l.id === edit.params.layer) }; + } catch (error) { + if (/接手|画布已修改|未获许可|原请求待核查/.test(error.message)||inactiveDocument(error)) host.canvasWritesStopped = true; + throw error; + } + })(); }), ]; return host.canvas ? tools : tools.filter(t => !t.name.startsWith('canvas_')); @@ -61,18 +80,19 @@ function toolsFor(host) { /** Coding Agent SDK owns the transcript/compaction; our host owns all I/O boundaries. */ export class DetailPiAgent { - static async open({ store, broker, canvas, transport, protocolMode = 'native-tools', callBudget = 12, onEvent = () => {} }) { + static async open({ store, broker, canvas, transport, projectEvidence, documentId, protocolMode = 'native-tools', callBudget = 12, onEvent = () => {} }) { assertPiNodeVersion(process.versions.node); if (!['native-tools', 'structured-json'].includes(protocolMode)) throw Error('工具协议模式无效'); if (!Number.isInteger(callBudget) || callBudget < 1 || callBudget > 12) throw Error('单轮模型调用预算无效'); const host = new DetailPiAgent(); - Object.assign(host, { store, broker, canvas, onEvent, protocolMode, callBudget, lab: new ToolLab(store.dir), rounds: 0, inflight: null, text: '', recallCounts: new Map(), recallRepeats: 0 }); + Object.assign(host, { store, broker, canvas, onEvent, protocolMode, callBudget, lab: new ToolLab(store.memoryDir), rounds: 0, inflight: null, text: '', recallCounts: new Map(), recallRepeats: 0 }); try { host.releaseLock = lockfile.lockSync(store.root, { stale: 15000, update: 4000, retries: 0, onCompromised: error => { host.compromised = error; host.session?.agent.abort(); } }); } catch { throw Error('此项目已有本地Agent在运行,请等待当前任务结束;未发起新请求'); } try { const info = transport?.info ?? await broker.info(); if (info.accountScope !== store.identity.scope) throw Error('模型账户与项目隔离域不一致'); host.info = info; + host.productEvidence = productContext(projectEvidence,{...store.identity,documentId}); if (canvas) canvas.identity = { accountId: info.accountId, origin: info.origin }; const runtime = await ModelRuntime.create({ authPath: path.join(store.dir, 'pi-runtime-auth.json'), modelsPath: null, modelsStorePath: path.join(store.dir, 'pi-model-cache'), refreshOnCreate: false, allowModelNetwork: false }); const api = info.model.protocol === 'anthropic-messages' ? 'anthropic-messages' : 'openai-completions'; @@ -87,10 +107,12 @@ export class DetailPiAgent { let round; try { if (host.compromised) throw Error('本地会话锁失效,已停止模型调用'); - const liveCanvas = host.canvas && !host.compacting ? await host.canvas.read() : null; - if (liveCanvas && !liveCanvas.allowed) { - const message = { role: 'assistant', api, provider: model.provider, model: model.id, content: [{ type: 'text', text: '本机:已接手,剩余画布操作已停止。' }], stopReason: 'stop', timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { ...cost, total: 0 } } }; - await store.appendEvent({ type: 'handover_stopped', modelCallSkipped: true }); + const liveCanvas = host.canvas && !host.compacting && !host.canvasNotificationsDetached ? await host.canvas.read() : null; + if (liveCanvas?.conversationActive === false) throw Error('当前项目已切换,本轮对话未应用到其他项目'); + if (host.canvasNotificationsDetached || host.canvasWritesStopped || liveCanvas?.writeBlocked && host.canvasWriteRequested) { + const text=host.canvasNotificationsDetached?'本机:原项目已切换,原回复保留在原项目,后续画布操作已停止。':'本机:已接手,剩余画布操作已停止。'; + const message = { role: 'assistant', api, provider: model.provider, model: model.id, content: [{ type: 'text', text }], stopReason: 'stop', timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { ...cost, total: 0 } } }; + await store.appendEvent({ type: host.canvasNotificationsDetached?'inactive_project_stopped':'handover_stopped', modelCallSkipped: true }); output.push({ type: 'start', partial: message }); output.push({ type: 'done', reason: 'stop', message }); output.end(message); return; @@ -98,10 +120,11 @@ export class DetailPiAgent { if (host.recallRepeats >= 2) throw Error('模型重复检索同一证据,已停止继续调用;历史和结果已保留'); if (++host.rounds > host.callBudget) throw Error(`本轮已达${host.callBudget}次调用预算,停止继续调用`); const injected = await store.modelContext({ task: host.task || 'chat' }); - if (liveCanvas?.context.width) injected.constraints.width = liveCanvas.context.width; - if (liveCanvas) injected.currentCanvasAuthority = { run: liveCanvas.run, allowed: liveCanvas.allowed, revision: liveCanvas.revision, documentId: liveCanvas.context.id, appliesTo: 'current-user-turn' }; + injected.currentProduct = host.productEvidence; + if (liveCanvas?.context?.width) injected.constraints.width = liveCanvas.context.width; + if (liveCanvas) injected.currentCanvasAuthority = { run: liveCanvas.run, conversationActive: liveCanvas.conversationActive, allowed: liveCanvas.allowed, writeBlocked: liveCanvas.writeBlocked, revision: liveCanvas.revision, documentId: liveCanvas.context?.id, appliesTo: 'current-user-turn' }; await store.appendEvent({ type: 'context_injected', bindingHash: store.hash, messageCount: context.messages.length, memoryHash: digest(injected.confirmedMemory) }); - const contextual = projectContext(normalizeContext({ messages: [...context.messages, { role: 'system', content: '当前项目规则快照与已确认事实(每次调用重新注入,以此为准):\n' + JSON.stringify(injected), timestamp: Date.now() }] })); + const contextual = projectContext(normalizeContext({ messages: [...context.messages, { role: 'system', content: '当前项目规则快照与已确认事实(每次调用重新注入,以此为准):\n' + JSON.stringify(injected), timestamp: Date.now() }] }),{bindingHash:store.hash}); const inputChars = JSON.stringify(contextual).length; if (inputChars > 120000) throw Error('本轮上下文超过本地输入预算,请先压缩会话;未发起模型请求'); await store.appendEvent({ type: 'context_budget', inputChars, originalChars: JSON.stringify(context).length }); @@ -122,7 +145,7 @@ export class DetailPiAgent { const response = await round.fetch(...args); wireCapture = response.clone().text().then(text => ({ text }), error => ({ error })); return response; - }, maxRetries: 0, maxRetryDelayMs: 0, maxTokens: 8192, timeoutMs: 180000, reasoning: 'off', cacheRetention: 'none', + }, maxRetries: 0, maxRetryDelayMs: 0, maxTokens: 8192, timeoutMs: 600000, reasoning: 'off', cacheRetention: 'none', onPayload: async payload => { if (!encoded && requiredTool) payload.tool_choice = api === 'anthropic-messages' ? { type: 'tool', name: requiredTool } : { type: 'function', function: { name: requiredTool } }; if (!encoded && api === 'openai-completions' && payload.tools?.length) { @@ -220,14 +243,14 @@ export class DetailPiAgent { // Compaction is bookkeeping, not a user reply or an active canvas edit run. if (host.compacting) return; host.text += event.assistantMessageEvent.delta; - if (canvas && Date.now() - (host.lastDelta ?? 0) > 45) { host.lastDelta = Date.now(); await canvas.delta(host.text.slice(-65000)); } + if (canvas && Date.now() - (host.lastDelta ?? 0) > 45) { host.lastDelta = Date.now(); await host.notifyCanvas('delta',host.text.slice(-65000)); } await onEvent({ type: 'text_delta', delta: event.assistantMessageEvent.delta }); } if (event.type === 'tool_execution_start' || event.type === 'tool_execution_end') { const stopped = event.isError && event.toolName === 'canvas_edit' && event.result?.content?.some(c => c.type === 'text' && /接手|画布已修改/.test(c.text)); const status = event.type.endsWith('start') ? '开始' : stopped ? '已停止' : event.isError ? '失败' : '完成'; const label = { context_read: '读取项目规则', memory_set: '记录事实', memory_search: '检索历史', tool_develop: '编写并测试工具', tool_run: '运行工具', canvas_read: '读取画布', canvas_edit: '更新图层' }[event.toolName] || event.toolName; - if (canvas) await canvas.activity(`${status}:${label}`); + if (canvas) await host.notifyCanvas('activity',`${status}:${label}`); await store.appendEvent({ type: event.type, toolName: event.toolName, toolCallId: event.toolCallId, isError: event.isError ?? false }); await onEvent({ type: event.type, toolName: event.toolName, isError: event.isError ?? false }); } @@ -240,6 +263,19 @@ export class DetailPiAgent { this.operationActive = true; try { return await operation(); } finally { this.operationActive = false; } } + async notifyCanvas(method,...args) { + if(!this.canvas)return; + if(method==='end'){ + if(this.canvasEndAttempted)return; + this.canvasEndAttempted=true; + }else if(this.canvasNotificationsDetached)return; + try{return await this.canvas[method](...args);} + catch(error){ + if(!inactiveDocument(error))throw error; + this.canvasNotificationsDetached=true; + await this.store.appendEvent({type:'inactive_ui_delivery',method,reason:error.message,originalResponseRetained:true}); + } + } async prompt(text, options = {}) { return this.exclusive(() => this.promptTurn(text, options)); } async promptTurn(text, { requiredTool } = {}) { if (this.needsReopen) throw Error('原响应已恢复,请重新打开此会话;未发起新请求'); @@ -254,6 +290,8 @@ export class DetailPiAgent { this.requiredTool = null; this.task = taskFor(text); this.rounds = 0; this.text = ''; this.lastDelta = 0; this.recallCounts.clear(); this.recallRepeats = 0; + this.canvasWriteRequested = false; this.canvasWritesStopped = false; + this.canvasNotificationsDetached=false;this.canvasEndAttempted=false; if (promptChars({ messages: this.session.messages }) > 80000) { await this.store.appendEvent({ type: 'automatic_compaction_started', reason: 'bounded-input-budget' }); await this.compactTurn({ preserveBudget: true }); @@ -266,6 +304,7 @@ export class DetailPiAgent { this.heartbeat.unref(); } try { + await this.store.recordUserEvidence(text); const running = this.session.prompt(text); // SDK flushes the user entry before dispatch. Persist the authoritative file location. if (this.manager.getSessionFile()) await atomic(path.join(this.store.dir, 'session.json'), { file: this.manager.getSessionFile() }); @@ -274,10 +313,10 @@ export class DetailPiAgent { const last = this.session.messages.filter(m => m.role === 'assistant').at(-1); if (last?.stopReason === 'error' || last?.stopReason === 'aborted') throw Error(last.errorMessage || '本轮未完成;保留原请求'); const textResult = this.session.getLastAssistantText() || ''; - if (this.canvas) await this.canvas.end(textResult); + if (this.canvas) await this.notifyCanvas('end',textResult); return { text: textResult, rounds: this.rounds, messages: this.session.messages.length, sessionFile: this.manager.getSessionFile() }; } catch (error) { - if (this.canvas) await this.canvas.end(error.message).catch(() => {}); + if (this.canvas) await this.notifyCanvas('end',error.message).catch(() => {}); throw error; } finally { if (this.heartbeat) clearInterval(this.heartbeat); diff --git a/apps/detail-agent-pi/src/context-budget.mjs b/apps/detail-agent-pi/src/context-budget.mjs index 638b8365..919d9d10 100644 --- a/apps/detail-agent-pi/src/context-budget.mjs +++ b/apps/detail-agent-pi/src/context-budget.mjs @@ -1,5 +1,5 @@ /** A transport projection only: the SDK's original transcript and tool evidence stay intact. */ -export function projectContext(context) { +export function projectContext(context,{bindingHash}={}) { const canvasResults = context.messages.map((m, index) => m.role === 'toolResult' && ['canvas_read', 'canvas_edit'].includes(m.toolName) && !m.isError ? index : -1).filter(i => i >= 0); const newest = canvasResults.at(-1); const ruleResults = context.messages.map((m, index) => m.role === 'toolResult' && m.toolName === 'context_read' && !m.isError ? index : -1).filter(i => i >= 0); @@ -7,7 +7,11 @@ export function projectContext(context) { const messages = context.messages.map((m, index) => { // Display-only details can contain a full duplicate of the tool body. if (m.role === 'toolResult' && Object.hasOwn(m, 'details')) { m = { ...m }; delete m.details; } - if (ruleResults.includes(index) && index !== newestRule) return { ...m, content: [{ type: 'text', text: JSON.stringify({ archivedRuleResult: m.toolCallId, note: '原规则在项目快照;当前核心规则已注入,额外细节按topic读取。' }) }] }; + let staleBinding=false; + if(ruleResults.includes(index)&&bindingHash){ + try{const old=JSON.parse(m.content.filter(c=>c.type==='text').map(c=>c.text).join(''));staleBinding=typeof old.bindingHash==='string'&&old.bindingHash!==bindingHash;}catch{} + } + if (ruleResults.includes(index) && (index !== newestRule||staleBinding)) return { ...m, content: [{ type: 'text', text: JSON.stringify({ archivedRuleResult: m.toolCallId, note: staleBinding?'此为旧规则域历史,不能作为当前店铺/品类规则;本轮权威规则已重新注入。':'原规则在项目快照;当前核心规则已注入,额外细节按topic读取。' }) }] }; if (!canvasResults.includes(index) || index === newest) return m; let value; try { value = JSON.parse(m.content.filter(c => c.type === 'text').map(c => c.text).join('')); } catch { return m; } diff --git a/apps/detail-agent-pi/src/desktop-host.mjs b/apps/detail-agent-pi/src/desktop-host.mjs index d3eccf87..7475e166 100644 --- a/apps/detail-agent-pi/src/desktop-host.mjs +++ b/apps/detail-agent-pi/src/desktop-host.mjs @@ -38,14 +38,19 @@ async function main(config) { broker = new Native98kBroker({ binary: config.binary, profile: config.profile, project: config.project }); const info = await broker.info(); if (info.accountScope !== config.scope) throw Error('账户已切换,未发送请求'); - const combo = config.ruleCatalog?.bindings?.find(b => b.id === config.ruleId); - if (!combo) throw Error('项目规则快照不完整,未发送模型'); - const shop = config.ruleCatalog.shops.find(s => s.id === combo.shopId); - const binding = resolveRules({ brandId: shop.brandId, shopId: shop.id, categoryId: combo.categoryId }, config.ruleCatalog); - store = await ProjectStore.open(config.root, { scope: info.accountScope, project: config.project }, binding); + let binding; + if(config.ruleCatalog==null&&config.ruleId==null){ + binding={brandId:'unconfigured',shopId:'unconfigured',categoryId:'unconfigured',brandName:'未配置',shopName:'未配置',categoryName:'未配置',platform:'native-project',version:'unconfigured-v1',source:'explicit-unconfigured-project-discussion',rules:['当前项目未配置品牌/店铺/品类规则。可以讨论通用思路;不得编造或借用其他品牌规则,产品事实以本项目只读资料为准。']}; + }else{ + const combo = config.ruleCatalog?.bindings?.find(b => b.id === config.ruleId); + if (!combo) throw Error('项目规则快照不完整,未发送模型'); + const shop = config.ruleCatalog.shops.find(s => s.id === combo.shopId); + binding = resolveRules({ brandId: shop.brandId, shopId: shop.id, categoryId: combo.categoryId }, config.ruleCatalog); + } + store = await ProjectStore.open(config.root, { scope: info.accountScope, project: config.project }, binding, { continuous:true }); const canvas = new StdioCanvas(call, config.documentId); const usage = []; - host = await DetailPiAgent.open({ store, broker, canvas, onEvent: event => { + host = await DetailPiAgent.open({ store, broker, canvas, projectEvidence:config.projectEvidence, documentId:config.documentId, onEvent: event => { if (event.type === 'usage') usage.push(event); } }); let result; diff --git a/apps/detail-agent-pi/src/native-bridge.mjs b/apps/detail-agent-pi/src/native-bridge.mjs index 0cef9ff2..03778dc4 100644 --- a/apps/detail-agent-pi/src/native-bridge.mjs +++ b/apps/detail-agent-pi/src/native-bridge.mjs @@ -90,40 +90,115 @@ export class Native98kBroker { /** Trusted host adapter; the agent receives only read/edit tools, not this socket. */ export class NativeCanvas { - constructor(socket) { - this.socket = socket; this.serial = 0; this.pending = new Map(); - createInterface({ input: socket }).on('error', () => this.rejectPending()).on('line', line => { - let value; try { value = JSON.parse(line); } catch { this.rejectPending(); return; } + constructor(socket, options = {}) { + this.socket = null; this.serial = 0; this.pending = new Map(); + this.connection = options.connection; + this.rpcTimeoutMs = options.rpcTimeoutMs ?? 15000; + this.connecting = null; this.closed = false; this.authenticated = Boolean(socket); + if (socket) this.attach(socket); + } + attach(socket) { + this.socket = socket; + const reader = createInterface({ input: socket }); + reader.on('error', () => this.disconnect(socket)).on('line', line => { + let value; try { value = JSON.parse(line); } catch { this.disconnect(socket); return; } const task = this.pending.get(value.id); - if (!task) return; + // A late reply on a previous socket cannot settle a newer generation. + if (!task || task.socket !== socket) return; this.pending.delete(value.id); clearTimeout(task.timer); if (value.error || value.ok === false) task.reject(Error(value.error || '原生操作失败')); else task.resolve(value.result ?? value); }); - socket.on('error', () => this.rejectPending()); - socket.on('close', () => this.rejectPending()); + socket.on('error', () => this.disconnect(socket)); + socket.on('end', () => this.disconnect(socket)); + socket.on('close', () => { reader.close(); this.disconnect(socket); }); } - rejectPending() { for (const v of this.pending.values()) { clearTimeout(v.timer); v.reject(Error('画布连接中断,操作结果需核查')); } this.pending.clear(); } - static async connect({ port, tokenFile }) { + disconnect(socket) { + // Keep the last socket inspectable (destroyed/writable) for existing hosts. + if (this.socket === socket) this.authenticated = false; + this.rejectPending(socket); + if (socket && !socket.destroyed) socket.destroy(); + } + rejectPending(socket) { + for (const [id, task] of this.pending) { + if (socket && task.socket !== socket) continue; + this.pending.delete(id); clearTimeout(task.timer); + task.reject(Error('画布连接中断,已发送操作结果需核查;不会重发')); + } + } + static async connect({ port, tokenFile, connectTimeoutMs = 3000, rpcTimeoutMs = 15000 }) { + if (!Number.isInteger(port) || port < 1024 || port > 65535 || typeof tokenFile !== 'string' || !tokenFile + || !Number.isFinite(connectTimeoutMs) || connectTimeoutMs <= 0 || !Number.isFinite(rpcTimeoutMs) || rpcTimeoutMs <= 0) throw Error('画布连接配置无效,未派发操作'); + const client = new NativeCanvas(null, { connection: { port, tokenFile, connectTimeoutMs }, rpcTimeoutMs }); + try { await client.ensureConnected(); return client; } + catch (error) { client.close(); throw error; } + } + async ensureConnected() { + if (this.closed) throw Error('画布连接已关闭,未派发新操作'); + if (this.connecting) return this.connecting; + if (this.socket && !this.socket.destroyed && this.socket.writable && this.authenticated) return; + if (this.pending.size) { + this.disconnect(this.socket); + throw Error('原画布操作结果需核查,未派发新操作;不会重发'); + } + if (!this.connection) throw Error('画布连接已断开,未派发新操作'); + const attempt = this.openAndAuthenticate(); + this.connecting = attempt; + try { await attempt; } + finally { if (this.connecting === attempt) this.connecting = null; } + } + async openAndAuthenticate() { + const { port, tokenFile, connectTimeoutMs } = this.connection; const socket = net.createConnection({ host: '127.0.0.1', port }); - await new Promise((r, j) => { socket.once('connect', r); socket.once('error', j); }); - const client = new NativeCanvas(socket); - await client.call('auth', { token: (await fs.readFile(tokenFile, 'utf8')).trim() }); - return client; + this.attach(socket); + try { + await new Promise((resolve, reject) => { + const finish = (error) => { + clearTimeout(timer); socket.off('connect', connected); socket.off('error', failed); socket.off('close', failed); + if (error) reject(error); else resolve(); + }; + const connected = () => finish(); + const failed = () => finish(Error('画布连接无法建立,未派发新操作')); + const timer = setTimeout(() => finish(Error('画布连接建立超时,未派发新操作')), connectTimeoutMs); + socket.once('connect', connected); socket.once('error', failed); socket.once('close', failed); + }); + if (this.closed || this.socket !== socket || socket.destroyed || !socket.writable) throw Error('画布连接已关闭,未派发新操作'); + const token = (await fs.readFile(tokenFile, 'utf8')).trim(); + if (!token || token.length > 8192) throw Error('画布授权文件无效,未派发新操作'); + try { await this.send(socket, 'auth', { token }, connectTimeoutMs); } + catch { throw Error('画布重新授权未完成,未派发新操作'); } + if (this.closed || this.socket !== socket || socket.destroyed || !socket.writable) throw Error('画布授权连接已关闭,未派发新操作'); + this.authenticated = true; + } catch (error) { this.disconnect(socket); throw error; } } - call(method, params = {}) { + send(socket, method, params, timeoutMs = this.rpcTimeoutMs) { + if (!socket || this.closed || this.socket !== socket || socket.destroyed || !socket.writable) return Promise.reject(Error('画布连接已断开,未派发新操作')); const id = ++this.serial; + const message = JSON.stringify({ id, method, params }) + '\n'; return new Promise((resolve, reject) => { - const timer = setTimeout(() => { this.pending.delete(id); reject(Error('画布操作结果未知,禁止重复操作')); }, 15000); - this.pending.set(id, { resolve, reject, timer }); - this.socket.write(JSON.stringify({ id, method, params }) + '\n'); + const timer = setTimeout(() => { + this.pending.delete(id); reject(Error('画布操作结果未知,禁止重复操作;不会重发')); + // Do not reuse a timed-out transport or let its late reply affect a + // subsequent command. This never resubmits the timed-out RPC. + this.disconnect(socket); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer, socket }); + try { socket.write(message, error => { if (error) this.disconnect(socket); }); } + catch { this.disconnect(socket); } }); } + async call(method, params = {}) { + // Only a NEW caller command reaches send. No pending RPC is queued/replayed + // during reauthentication, and run/identity/edit counters stay unchanged. + await this.ensureConnected(); + return this.send(this.socket, method, params); + } action(action, input = {}) { return this.call('jiansun.action', { action, input: { run: this.run, ...input } }); } async begin(text) { this.run = randomUUID(); this.edits = 0; return this.action('pi-begin', { text, ...(this.identity || {}) }); } read() { return this.action('pi-state'); } delta(text) { return this.action('pi-delta', { text }); } activity(text) { return this.action('pi-activity', { text }); } + requestWrite(revision) { return this.action('pi-write-request', { revision }); } async edit(edit, revision, signal) { if (++this.edits > 20) throw Error('本轮画布操作已达20步'); signal?.throwIfAborted(); @@ -139,5 +214,5 @@ export class NativeCanvas { throw Error('画布操作尚未确认,禁止重复执行'); } end(text) { return this.action('pi-end', { text }); } - close() { this.socket.end(); } + close() { this.closed = true; this.disconnect(this.socket); } } diff --git a/apps/detail-agent-pi/src/project-context.mjs b/apps/detail-agent-pi/src/project-context.mjs new file mode 100644 index 00000000..5c5883c2 --- /dev/null +++ b/apps/detail-agent-pi/src/project-context.mjs @@ -0,0 +1,32 @@ +import { digest } from './store.mjs'; + +const clip=(value,max)=>Array.from(typeof value==='string'?value:'').slice(0,max).join(''); +const list=(value,max)=>Array.isArray(value)?value.slice(0,max):[]; + +/** Data-only product evidence. Never includes pixels, complete brand prompts, + * request bodies, credentials or model task journals. */ +export function productContext(value,identity){ + if(!value)return {state:'unavailable',missing:['当前项目尚未填写产品资料;可以讨论通用思路,不能假设品牌规则或产品参数。']}; + if(value.accountScope!==identity.scope||value.project!==identity.project||!Number.isSafeInteger(value.documentId)||value.documentId!==identity.documentId)throw Error('产品上下文不属于当前账户和项目'); + const brief=value.brief||{},facts=value.facts||{},selling=value.sellingPoints||{},plan=value.plan||{}; + const selected=[]; + for(const key of list(selling.selected,20)){ + if(typeof key!=='string')continue; + const split=key.lastIndexOf(':'),batch=list(selling.batches,12).find(b=>b.id===key.slice(0,split)); + const index=Number(key.slice(split+1)); + if(split>0&&Number.isInteger(index)&&index>=0&&typeof batch?.items?.[index]==='string')selected.push(clip(batch.items[index],200)); + } + const result={state:'ready',authority:'当前项目只读资料;品牌规范不是产品事实,模型提取仍需保留其来源与待确认项。',documentId:value.documentId,revision:value.revision, + product:Object.fromEntries(['name','brandId','brandName','categoryId','categoryName','identifierType','identifier','variant','colorName'].map(key=>[key,clip(brief[key],160)])), + dimensions:Object.fromEntries(['length','width','height','unit'].map(key=>[key,clip(brief.dimensions?.[key],24)])),note:clip(brief.note,1000), + factsStatus:clip(facts.status,24),facts:list(facts.items,24).map(f=>({text:clip(f.text,260),source:clip(f.source,100)})), + missing:list(facts.missing,20).map(v=>clip(v,220)),selectedSellingPoints:selling.stale?[]:selected, + plan:{status:clip(plan.status,24),screens:list(plan.screens,12).map(s=>({name:clip(s.name||s.title,100),body:clip(s.body,200)}))}}; + // Fixed transport budget; omission is explicit rather than silent evidence invention. + while(JSON.stringify(result).length>13600&&result.facts.length)result.facts.pop(); + while(JSON.stringify(result).length>13600&&result.missing.length)result.missing.pop(); + result.omitted={facts:Math.max(0,(facts.items?.length||0)-result.facts.length),missing:Math.max(0,(facts.missing?.length||0)-result.missing.length),planScreens:Math.max(0,(plan.screens?.length||0)-result.plan.screens.length)}; + if(JSON.stringify(result).length>14000)throw Error('产品上下文超过固定预算,未派发模型'); + result.contentHash=digest(result); + return result; +} diff --git a/apps/detail-agent-pi/src/stdio-canvas.mjs b/apps/detail-agent-pi/src/stdio-canvas.mjs index 47c3aeb4..0cfc3eb8 100644 --- a/apps/detail-agent-pi/src/stdio-canvas.mjs +++ b/apps/detail-agent-pi/src/stdio-canvas.mjs @@ -8,6 +8,7 @@ export class StdioCanvas { read() { return this.action('pi-state'); } delta(text) { return this.action('pi-delta', { text }); } activity(text) { return this.action('pi-activity', { text }); } + requestWrite(revision) { return this.action('pi-write-request', { revision }); } async edit(edit, revision, signal) { if (++this.edits > 20) throw Error('本轮画布操作已达20步'); signal?.throwIfAborted(); diff --git a/apps/detail-agent-pi/src/store.mjs b/apps/detail-agent-pi/src/store.mjs index 6c0d0624..99adc5b8 100644 --- a/apps/detail-agent-pi/src/store.mjs +++ b/apps/detail-agent-pi/src/store.mjs @@ -12,41 +12,84 @@ export async function atomic(file, value) { await fs.rename(temp, file); } const exists = file => fs.access(file).then(() => true, () => false); +// A crashed migration can leave an already-written destination. Resume only +// when it is a regular file with the exact original bytes; never overwrite it. +async function copyIdentical(file, bytes) { + try { + const handle=await fs.open(file,'wx',0o600); + try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } + } + catch (error) { + if (error.code !== 'EEXIST') throw error; + const stat = await fs.lstat(file); + if (!stat.isFile() || stat.isSymbolicLink() || !(await fs.readFile(file)).equals(bytes)) throw Error('旧会话迁移目标与原件不一致,请保留文件核查'); + } +} export class ProjectStore { - static async open(root, identity, binding) { + static async open(root, identity, binding, { continuous = false } = {}) { if (!/^[a-f0-9]{64}$/.test(identity.scope) || !/^[a-f0-9-]{36}$/i.test(identity.project)) throw Error('项目身份无效'); for (const key of ['brandId', 'shopId', 'categoryId', 'platform', 'version', 'source']) if (typeof binding[key] !== 'string' || !binding[key]) throw Error(`规则缺少 ${key}`); if (!Array.isArray(binding.rules) || binding.rules.length > 100 || binding.rules.some(s => typeof s !== 'string' || s.length > 2000)) throw Error('规则内容无效'); const dir = path.join(root, identity.scope, identity.project), hash = digest(binding); // Different shop/category/version gets an independent conversation and memory. - const scopeDir = path.join(dir, hash); - const file = path.join(scopeDir, 'snapshot.json'); + const ruleDir = path.join(dir, hash); + const scopeDir = continuous ? path.join(dir, 'conversation-v1') : ruleDir; + const file = path.join(ruleDir, 'snapshot.json'); if (!await exists(file)) await atomic(file, binding); const snapshot = JSON.parse(await fs.readFile(file, 'utf8')); if (digest(snapshot) !== hash) throw Error('项目规则快照已损坏,请保留文件'); const store = new ProjectStore(); - Object.assign(store, { dir: scopeDir, root: dir, binding: snapshot, hash, identity }); + Object.assign(store, { dir: scopeDir, root: dir, memoryDir: ruleDir, binding: snapshot, hash, identity }); await fs.mkdir(store.dir, { recursive: true }); + if(continuous&&!await exists(path.join(scopeDir,'session.json'))){ + // Import only this project's last legacy transcript. Original session, + // journals and rule-domain memories remain untouched in their old paths. + const previous=await exists(path.join(dir,'current.json'))?JSON.parse(await fs.readFile(path.join(dir,'current.json'),'utf8')):null; + if(previous?.scope===identity.scope&&previous?.project===identity.project&&/^[a-f0-9]{64}$/.test(previous.bindingHash||'')){ + const legacy=path.join(dir,previous.bindingHash),pointer=path.join(legacy,'session.json'); + if(await exists(pointer)){ + const source=JSON.parse(await fs.readFile(pointer,'utf8')).file; + const resolved=typeof source==='string'?await fs.realpath(source):''; + const realLegacy=await fs.realpath(legacy); + if(!resolved.startsWith(realLegacy+path.sep)||!resolved.endsWith('.jsonl'))throw Error('旧会话文件不属于当前项目,未迁移'); + const bytes=await fs.readFile(resolved),target=path.join(scopeDir,'sessions','legacy-'+previous.bindingHash+'.jsonl'); + await fs.mkdir(path.dirname(target),{recursive:true}); + await copyIdentical(target,bytes); + for(const name of ['events.jsonl','recovered-tools.json'])if(await exists(path.join(legacy,name)))await copyIdentical(path.join(scopeDir,name),await fs.readFile(path.join(legacy,name))); + await copyIdentical(path.join(scopeDir,'migration.json'),Buffer.from(JSON.stringify({sourceBindingHash:previous.bindingHash,sourceSha256:createHash('sha256').update(bytes).digest('hex'),originalRetained:true},null,2))); + // Publish the pointer last, after every required file is durably present. + await copyIdentical(path.join(scopeDir,'session.json'),Buffer.from(JSON.stringify({file:target},null,2))); + } + } + } await atomic(path.join(dir, 'current.json'), { ...identity, bindingHash: hash }); return store; } async get(file, fallback) { - const p = path.join(this.dir, file); + const p = path.join(file==='memory.json'?this.memoryDir:this.dir, file); if (!await exists(p)) return fallback; return JSON.parse(await fs.readFile(p, 'utf8')); } async remember({ key, value, evidence }, messages) { if (!/^[a-z][a-z0-9_]{0,63}$/i.test(key) || typeof value !== 'string' || value.length > 1000 || typeof evidence !== 'string' || evidence.length < 4 || evidence.length > 1000) throw Error('事实记录无效'); - const userText = m => typeof m.content === 'string' ? m.content : (m.content || []).filter(c => c.type === 'text').map(c => c.text).join('\n'); - const source = [...messages].reverse().find(m => m.role === 'user' && userText(m).includes(evidence)); - if (!source) throw Error('只能引用本项目用户已明确说出的事实,不能把推断记成事实'); + // Continuous discussion retains old shop history. It is not fresh consent + // to promote that history into a different shop/category's current memory. + let ledger = ''; + try { ledger = await fs.readFile(path.join(this.memoryDir,'user-evidence.jsonl'),'utf8'); } catch(error) { if(error.code!=='ENOENT')throw error; } + const records=ledger.split('\n').filter(Boolean).map(line=>JSON.parse(line)); + const source=[...records].reverse().find(record=>record.bindingHash===this.hash&&record.scope===this.identity.scope&&record.project===this.identity.project&&typeof record.text==='string'&&record.textHash===digest(record.text)&&record.text.includes(evidence)); + if (!source) throw Error('只能引用本规则域用户已明确确认的原话;旧域历史须重新确认,不能把推断记成事实'); const memory = await this.get('memory.json', {}); if (!Object.hasOwn(memory, key) && Object.keys(memory).length >= 100) throw Error('事实记录已达100项'); const previous = memory[key]; - memory[key] = { value, evidence, at: Date.now(), previous: previous?.value ?? null }; - await atomic(path.join(this.dir, 'memory.json'), memory); + memory[key] = { value, evidence, bindingHash:this.hash, userEvidenceHash:source.textHash, at: Date.now(), previous: previous?.value ?? null }; + await atomic(path.join(this.memoryDir, 'memory.json'), memory); return memory[key]; } + async recordUserEvidence(text) { + if(typeof text!=='string'||!text.trim()||text.length>6000)throw Error('当前用户证据无效'); + await fs.appendFile(path.join(this.memoryDir,'user-evidence.jsonl'),JSON.stringify({...this.identity,bindingHash:this.hash,text,textHash:digest(text),at:Date.now()})+'\n',{mode:0o600}); + } async context() { return { bindingHash: this.hash, snapshot: this.binding, confirmedMemory: await this.get('memory.json', {}), constraints: { width: 1440, maxScreens: 12, maxHeight: 30000, font: 'Noto Sans SC' } }; } diff --git a/apps/detail-agent-pi/test/conversation.test.mjs b/apps/detail-agent-pi/test/conversation.test.mjs new file mode 100644 index 00000000..debdaec9 --- /dev/null +++ b/apps/detail-agent-pi/test/conversation.test.mjs @@ -0,0 +1,276 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {randomUUID} from 'node:crypto'; +import {DetailPiAgent} from '../src/agent.mjs'; +import {ProjectStore,atomic} from '../src/store.mjs'; +import {productContext} from '../src/project-context.mjs'; +import {cleanup} from './cleanup.mjs'; + +const scope='6'.repeat(64),binding=shop=>({brandId:shop,shopId:shop,categoryId:'desk',platform:'native',version:'1',source:'explicit-local-test',brandName:shop,rules:[shop==='unconfigured'?'品牌规则未配置,不编造品牌规范':shop+'专属规则']}); +const evidence=(project,color='胡桃色')=>({accountScope:scope,project,documentId:7,revision:2,brief:{name:'测试电脑桌',colorName:color,dimensions:{length:'',width:'',height:'',unit:'mm'}},facts:{status:'ready',items:[{text:'长方形桌面',source:'已核对实拍'}],missing:['尺寸、承重、木种未确认']},sellingPoints:{selected:['batch:0'],batches:[{id:'batch',items:['长方形桌面']}],stale:false},plan:{status:'confirmed',screens:[{name:'首屏',body:'展示当前产品',imagePrompt:'不得重复注入的大段生图提示'.repeat(1000)}]}}); +const sse=(delta,finish='stop')=>`data: ${JSON.stringify({id:'qa',model:'qa',choices:[{index:0,delta,finish_reason:null}]})}\n\ndata: ${JSON.stringify({id:'qa',model:'qa',choices:[{index:0,delta:{},finish_reason:finish}]})}\n\ndata: [DONE]\n\n`; +const tool=(name,args)=>sse({tool_calls:[{index:0,id:randomUUID(),type:'function',function:{name,arguments:JSON.stringify(args)}}]},'tool_calls'); +function canvas(){ + return {revision:1,allowed:false,writeBlocked:false,writes:0,requests:0,text:'原始标题', + async begin(){this.allowed=false;this.writeBlocked=false;}, + async read(){return {conversationActive:true,allowed:this.allowed,writeBlocked:this.writeBlocked,revision:this.revision,context:{id:7,name:'test',width:1440,height:1800,layers:[{id:1,name:'S01 title',kind:'Type',text:this.text,locked:{}}],product:{mustNotDuplicate:'private'}}};}, + async requestWrite(revision){this.requests++;if(this.writeBlocked||revision!==this.revision)throw Error('用户已接手,写入未获许可');this.allowed=true;return this.read();}, + async edit(edit,revision){assert.ok(this.allowed&&!this.writeBlocked);assert.equal(revision,this.revision);this.writes++;this.text=edit.params.text;this.revision++;return this.read();}, + async delta(){},async activity(){},async end(){this.allowed=false;}}; +} +async function fixture(reply,{shop='unconfigured'}={}){ + const root=await fs.mkdtemp(path.join(os.tmpdir(),'jiansun-pi-')),project=randomUUID(),identity={scope,project},store=await ProjectStore.open(root,identity,binding(shop),{continuous:true}); + const requests=[],native=canvas(),events=[]; + const broker={info:async()=>({accountScope:scope,model:{id:'qa',protocol:'openai-chat'},pending:[]}),round(){const id=randomUUID();return {id,fetch:async(_url,init)=>{const payload=JSON.parse(init.body);requests.push(payload);const response=reply(payload,requests.length,native);return response instanceof Response?response:new Response(response,{headers:{'content-type':'text/event-stream'}});},finish:async()=>({phase:'complete'})};}}; + let host=await DetailPiAgent.open({store,broker,canvas:native,projectEvidence:evidence(project),documentId:7,onEvent:e=>events.push(e)}); + return {root,identity,store,broker,native,requests,events,get host(){return host;},async reopen(shop,color='胡桃色'){await host.dispose();const next=await ProjectStore.open(root,identity,binding(shop),{continuous:true});host=await DetailPiAgent.open({store:next,broker,canvas:native,projectEvidence:evidence(project,color),documentId:7,onEvent:e=>events.push(e)});return next;},async close(){await host.dispose();await cleanup(root);}}; +} + +test('three plain native-wire turns discuss product facts without JSON or a canvas write grant',async()=>{ + const x=await fixture(()=>sse({content:'可以先讨论布局。尺寸、承重与木种尚未确认。'})); + try{ + for(const prompt of ['先聊一下这款产品的页面思路','为什么这样排?尺寸和承重能写吗?','给我两个表达方向,先不修改画布'])assert.match((await x.host.prompt(prompt)).text,/先讨论/); + assert.equal(x.requests.length,3);assert.equal(x.native.requests,0);assert.equal(x.native.writes,0); + assert.ok(x.events.some(e=>e.type==='text_delta')); + for(const payload of x.requests){ + assert.equal(payload.tool_choice,'auto');assert.equal(payload.response_format,undefined); + const system=payload.messages.filter(m=>m.role==='system').map(m=>m.content).join('\n'); + assert.equal(system.split('"currentProduct":').length-1,1); + assert.ok(system.includes('尺寸、承重、木种未确认'));assert.ok(system.includes('胡桃色'));assert.ok(system.includes('未配置')); + assert.ok(!system.includes('不得重复注入的大段生图提示'));assert.ok(!system.includes('mustNotDuplicate')); + } + }finally{await x.close();} +}); + +test('project conversation survives restart and first rule binding while old-domain memory is not reinjected',async()=>{ + const x=await fixture(()=>sse({content:'当前资料以本轮注入为准。'})); + try{ + await x.host.prompt('颜色是胡桃色,风格安静,先讨论。'); + await x.store.remember({key:'color',value:'胡桃色',evidence:'颜色是胡桃色'},x.host.session.messages); + await x.host.lab.develop({name:'old_shop_tool',description:'old domain test',source:'function main(i){return i+1}',tests:[{input:0,expected:1},{input:1,expected:2},{input:-1,expected:0}]}); + await x.host.prompt('继续比较刚才的方案。'); + const originalDir=x.store.dir,next=await x.reopen('configured-shop','米白'); + assert.equal(next.dir,originalDir);assert.deepEqual((await next.context()).confirmedMemory,{}); + assert.equal((await x.store.context()).confirmedMemory.color.value,'胡桃色','original rule-domain evidence remains available in its original file'); + await assert.rejects(x.host.lab.run('old_shop_tool',1),/ENOENT/,'a tool from the old rule domain is not silently reused'); + const foreign=await ProjectStore.open(x.root,{scope:'7'.repeat(64),project:x.identity.project},binding('configured-shop'),{continuous:true}); + const otherProject=await ProjectStore.open(x.root,{scope,project:randomUUID()},binding('configured-shop'),{continuous:true}); + assert.notEqual(foreign.dir,next.dir);assert.notEqual(otherProject.dir,next.dir);assert.deepEqual((await foreign.context()).confirmedMemory,{}); + await x.host.prompt('接着刚才的讨论,现在颜色以米白为准,为什么?'); + const payload=x.requests.at(-1),all=JSON.stringify(payload.messages); + assert.ok(all.includes('继续比较刚才的方案'));assert.ok(all.includes('颜色是胡桃色')); + const system=payload.messages.filter(m=>m.role==='system').map(m=>m.content).join('\n'); + assert.ok(system.includes('米白'));assert.ok(system.includes('configured-shop专属规则')); + assert.ok(!system.includes('"evidence":"颜色是胡桃色"'),'old memory cannot become current confirmed memory on a rule change'); + }finally{await x.close();} +}); + +test('discussion followed by explicit editing requests permission only for the actual write',async()=>{ + const x=await fixture((_payload,n,native)=>{ + if(n<3)return sse({content:'先讨论,不修改。'}); + if(n===3)return tool('canvas_read',{}); + if(n===4)return tool('canvas_edit',{revision:native.revision,command:'type.edit',params:{layer:1,text:'新标题'},label:'修改标题'}); + return sse({content:'已按要求修改标题。'}); + }); + try{ + await x.host.prompt('讨论一下配色');await x.host.prompt('解释一下你的建议');assert.equal(x.native.requests,0); + await x.host.prompt('把当前标题改为新标题'); + assert.equal(x.native.requests,1);assert.equal(x.native.writes,1);assert.equal(x.native.text,'新标题'); + assert.ok(x.requests[3].messages.some(m=>m.role==='tool'&&m.content.includes('"writeBlocked":false'))); + }finally{await x.close();} +}); + +test('current-domain memory requires the user to reconfirm old-shop evidence even when it remains in continuous history',async()=>{ + const evidence='当前商品颜色是胡桃色'; + const x=await fixture((_payload,n)=>[2,4].includes(n)?tool('memory_set',{key:'color',value:'胡桃色',evidence}):sse({content:'按当前域明确确认的资料处理。'}),{shop:'old-shop'}); + try{ + await x.host.prompt(evidence+',先讨论旧店铺。'); + const next=await x.reopen('new-shop'); + assert.ok(x.host.session.messages.some(m=>m.role==='user'&&(typeof m.content==='string'?m.content.includes(evidence):m.content.some(c=>c.type==='text'&&c.text.includes(evidence))))); + await x.host.prompt('现在只讨论新店铺的布局,尚未确认旧资料适用于这里。'); + assert.deepEqual((await next.context()).confirmedMemory,{}); + assert.ok(x.host.session.messages.some(m=>m.role==='toolResult'&&m.toolName==='memory_set'&&m.isError)); + await x.host.prompt('我重新确认:'+evidence+',适用于当前店铺,请记住。'); + const memory=(await next.context()).confirmedMemory.color; + assert.equal(memory.value,'胡桃色');assert.equal(memory.bindingHash,next.hash);assert.equal(memory.evidence,evidence); + const oldLedger=await fs.readFile(path.join(x.store.memoryDir,'user-evidence.jsonl'),'utf8'); + const newLedger=await fs.readFile(path.join(next.memoryDir,'user-evidence.jsonl'),'utf8'); + assert.ok(oldLedger.includes('旧店铺'));assert.ok(!newLedger.includes('先讨论旧店铺')); + }finally{await x.close();} +}); + +test('an inactive project cannot read another canvas through a tool or dispatch a new discussion using its context',async()=>{ + const x=await fixture(()=>sse({content:'A项目的原回复留存。'})); + try{ + await x.host.prompt('先讨论A产品,不修改。'); + x.native.read=async()=>({conversationActive:false,allowed:false,writeBlocked:true,revision:99,context:{id:8,name:'B项目私有名称',width:750,layers:[{id:91,text:'B的秘密文字'}]}}); + const read=x.host.session.agent.state.tools.find(t=>t.name==='canvas_read'); + await assert.rejects(read.execute('inactive-read',{},undefined),/项目已切换/); + await assert.rejects(x.host.prompt('仍来自A的迟到要求'),/项目已切换/); + assert.equal(x.requests.length,1);assert.ok(!JSON.stringify(x.requests).includes('B的秘密文字')); + assert.ok(x.host.manager.getEntries().some(e=>e.type==='message'&&e.message.role==='assistant'&&e.message.content.some(c=>c.text==='A项目的原回复留存。'))); + }finally{await x.close();} +}); + +test('switching projects in the middle of the original SSE detaches UI delivery but keeps the complete A reply with one POST',async()=>{ + const fragments=['A开始讨论。','A继续解释,保留这一段。','A结尾完整信息。'],full=fragments.join(''); + let chunksConsumed=0,deltaAttempts=0,endAttempts=0,switched=false;const otherProjectHistory=[]; + const x=await fixture(()=>new Response(new ReadableStream({async start(controller){ + for(const content of fragments){controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({id:'qa',model:'qa',choices:[{index:0,delta:{content},finish_reason:null}]})}\n\n`));chunksConsumed++;await new Promise(r=>setTimeout(r,30));} + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({id:'qa',model:'qa',choices:[{index:0,delta:{},finish_reason:'stop'}]})}\n\ndata: [DONE]\n\n`));controller.close(); + }}),{headers:{'content-type':'text/event-stream'}})); + const originalRead=x.native.read.bind(x.native); + x.native.read=async()=>switched?{conversationActive:false,allowed:false,writeBlocked:true,context:null}:originalRead(); + x.native.delta=async()=>{deltaAttempts++;switched=true;throw Error('原项目已切换,回复保留在原项目会话,未写入当前项目');}; + x.native.activity=async text=>{otherProjectHistory.push(text);throw Error('must not deliver activity into B');}; + x.native.end=async()=>{endAttempts++;throw Error('原项目已切换,回复保留在原项目会话,未写入当前项目');}; + try{ + const result=await x.host.prompt('A项目:完整解释后自然结束,不修改画布。'); + assert.equal(result.text,full);assert.equal(result.rounds,1);assert.equal(x.requests.length,1);assert.equal(chunksConsumed,3); + assert.equal(deltaAttempts,1);assert.equal(endAttempts,1);assert.deepEqual(otherProjectHistory,[]);assert.equal(x.native.writes,0); + const replies=x.host.manager.getEntries().filter(e=>e.type==='message'&&e.message.role==='assistant').map(e=>e.message); + assert.ok(replies.some(m=>m.stopReason==='stop'&&m.content.filter(c=>c.type==='text').map(c=>c.text).join('')===full)); + assert.ok(!replies.some(m=>m.stopReason==='error'),'an inactive display is not an incomplete model result'); + const journal=(await fs.readFile(path.join(x.store.dir,'events.jsonl'),'utf8')).split('\n').filter(Boolean).map(JSON.parse); + assert.equal(journal.filter(e=>e.type==='provider_prepared').length,1);assert.ok(journal.some(e=>e.type==='provider_finished'&&e.phase==='complete')); + assert.ok(journal.some(e=>e.type==='inactive_ui_delivery'&&e.method==='delta'));assert.ok(journal.some(e=>e.type==='inactive_ui_delivery'&&e.method==='end')); + assert.equal(x.events.filter(e=>e.type==='text_delta').map(e=>e.delta).join(''),full); + }finally{await x.close();} +}); + +test('inactive activity delivery preserves the original assistant/tool transcript and stops the next paid model step locally',async()=>{ + let activityAttempts=0,endAttempts=0,switched=false;const otherProjectHistory=[]; + const x=await fixture(()=>sse({content:'A的完整规则解释,随后只读查询原历史。',tool_calls:[{index:0,id:'read-old-a',type:'function',function:{name:'memory_search',arguments:JSON.stringify({query:'A项目'})}}]},'tool_calls')); + const originalRead=x.native.read.bind(x.native); + x.native.read=async()=>switched?{conversationActive:false,allowed:false,writeBlocked:true,context:null}:originalRead(); + x.native.activity=async()=>{activityAttempts++;switched=true;throw Error('当前项目已切换,未向其他项目投递操作说明');}; + x.native.delta=async text=>{if(switched)otherProjectHistory.push(text);}; + x.native.end=async()=>{endAttempts++;throw Error('原项目已切换,回复保留在原项目会话,未写入当前项目');}; + try{ + const result=await x.host.prompt('A项目:解释当前规则,并只读查找A项目的历史。'); + assert.match(result.text,/原项目已切换/);assert.equal(x.requests.length,1);assert.equal(result.rounds,1); + assert.equal(activityAttempts,1);assert.equal(endAttempts,1);assert.deepEqual(otherProjectHistory,[]);assert.equal(x.native.requests,0);assert.equal(x.native.writes,0); + const messages=x.host.manager.getEntries().filter(e=>e.type==='message').map(e=>e.message); + assert.ok(messages.some(m=>m.role==='assistant'&&m.stopReason==='toolUse'&&m.content.some(c=>c.type==='text'&&c.text==='A的完整规则解释,随后只读查询原历史。'))); + assert.ok(messages.some(m=>m.role==='toolResult'&&m.toolCallId==='read-old-a'&&!m.isError)); + assert.ok(!messages.some(m=>m.role==='assistant'&&m.stopReason==='error')); + const log=await fs.readFile(path.join(x.store.dir,'events.jsonl'),'utf8');assert.ok(log.includes('inactive_project_stopped')); + }finally{await x.close();} +}); + +test('UI detachment never swallows transport or account errors and never retries a sent end',async()=>{ + for(const [method,message] of [['delta','画布连接中断,原操作需核查'],['activity','本地Agent会话或账户已改变'],['end','画布连接中断,结束动作结果未知']]){ + let attempts=0; + const x=await fixture(()=>method==='activity'?tool('memory_search',{query:'讨论'}):sse({content:'原回复。'})); + x.native[method]=async()=>{attempts++;throw Error(message);}; + try{ + await assert.rejects(x.host.prompt('当前项目普通讨论'),new RegExp(message)); + assert.equal(attempts,1);assert.equal(x.requests.length,1);assert.notEqual(x.host.canvasNotificationsDetached,true); + }finally{await x.close();} + } +}); + +test('handover rejects the proposed write and skips subsequent editing inference, then a new question works',async()=>{ + const x=await fixture((_payload,n,native)=>{ + if(n===1)return tool('canvas_read',{}); + if(n===2){native.writeBlocked=true;return tool('canvas_edit',{revision:native.revision,command:'type.edit',params:{layer:1,text:'不可应用'},label:'修改标题'});} + return sse({content:'新的问答可以继续,上一轮修改已经停止。'}); + }); + try{ + assert.match((await x.host.prompt('请改标题,过程中用户接手')).text,/已接手/); + assert.equal(x.requests.length,2);assert.equal(x.native.writes,0);assert.equal(x.native.text,'原始标题'); + assert.match((await x.host.prompt('先不修改,继续解释另一个方案')).text,/新的问答/); + assert.equal(x.requests.length,3);assert.equal(x.native.writes,0); + }finally{await x.close();} +}); + +test('a canvas click during discussion blocks writes but does not cut off a read-only discussion or its streamed reply',async()=>{ + const x=await fixture((_payload,n,native)=>{ + if(n===1){native.writeBlocked=true;return tool('context_read',{});} + return sse({content:'可以继续完整讨论:先用真实外观建立首屏,再解释产品细节。尺寸未确认,所以不写具体数值。'}); + }); + try{ + const result=await x.host.prompt('先讨论页面顺序,不修改画布;我会手动点选查看产品。'); + assert.match(result.text,/可以继续完整讨论/);assert.match(result.text,/不写具体数值/); + assert.equal(x.requests.length,2);assert.equal(x.native.requests,0);assert.equal(x.native.writes,0); + assert.ok(x.native.writeBlocked);assert.ok(x.events.some(e=>e.type==='text_delta'&&e.delta.includes('完整讨论'))); + const system=x.requests[1].messages.filter(m=>m.role==='system').map(m=>m.content).join('\n'); + assert.ok(system.includes('"writeBlocked":true')); + assert.ok(!x.host.session.messages.some(m=>m.role==='assistant'&&m.content?.some(c=>c.type==='text'&&c.text==='本机:已接手,剩余画布操作已停止。'))); + }finally{await x.close();} +}); + +test('compact product evidence is bounded, omits visual prompts and rejects foreign identity',()=>{ + const project=randomUUID(),source=evidence(project); + source.facts.items=Array.from({length:100},()=>({text:'事实'.repeat(500),source:'来源'.repeat(500)}));source.facts.missing=Array.from({length:100},()=> '待确认'.repeat(500)); + const context=productContext(source,{scope,project,documentId:7}); + assert.ok(JSON.stringify(context).length<=14000);assert.ok(context.omitted.facts>0);assert.ok(context.omitted.missing>0); + assert.ok(!JSON.stringify(context).includes('imagePrompt')); + for(const identity of [{scope:'7'.repeat(64),project,documentId:7},{scope,project:randomUUID(),documentId:7},{scope,project,documentId:8}])assert.throws(()=>productContext(source,identity),/不属于/); +}); + +test('legacy transcript migration retains originals and never clears broker pending on a binding change',async()=>{ + const root=await fs.mkdtemp(path.join(os.tmpdir(),'jiansun-pi-')),identity={scope,project:randomUUID()};let host; + try{ + const legacy=await ProjectStore.open(root,identity,binding('A')); + const {SessionManager}=await import('@earendil-works/pi-coding-agent'); + const manager=SessionManager.create(legacy.dir,path.join(legacy.dir,'sessions')); + manager.appendMessage({role:'user',content:'原始会话必须保留',timestamp:Date.now()}); + await atomic(path.join(legacy.dir,'session.json'),{file:manager.getSessionFile()}); + await legacy.appendEvent({type:'provider_prepared',id:'preserved-request',bindingHash:legacy.hash}); + await atomic(path.join(legacy.dir,'recovered-tools.json'),[{id:'preserved-request',phase:'review'}]); + const original=await fs.readFile(manager.getSessionFile(),'utf8'); + const stable=await ProjectStore.open(root,identity,binding('B'),{continuous:true}); + assert.deepEqual(await stable.get('recovered-tools.json',[]),[{id:'preserved-request',phase:'review'}]); + assert.ok((await fs.readFile(path.join(stable.dir,'events.jsonl'),'utf8')).includes('preserved-request')); + let posts=0; + host=await DetailPiAgent.open({store:stable,broker:{info:async()=>({accountScope:scope,model:{id:'qa',protocol:'openai-chat'},pending:[{id:'original-unknown'}]}),round(){posts++;throw Error('must not post');}}}); + assert.ok(host.session.messages.some(m=>m.role==='user'&&m.content==='原始会话必须保留')); + await assert.rejects(host.prompt('继续普通聊天也不能重发未知请求'),/未知请求|工具建议/);assert.equal(posts,0); + assert.equal(await fs.readFile(manager.getSessionFile(),'utf8'),original); + }finally{await host?.dispose();await cleanup(root);} +}); + +test('legacy migration resumes exact partially copied files and publishes the pointer last without overwriting originals',async()=>{ + for(const copiedEvents of [false,true]){ + const root=await fs.mkdtemp(path.join(os.tmpdir(),'jiansun-pi-')),identity={scope,project:randomUUID()}; + try{ + const legacy=await ProjectStore.open(root,identity,binding('legacy-shop')); + const {SessionManager}=await import('@earendil-works/pi-coding-agent'); + const manager=SessionManager.create(legacy.dir,path.join(legacy.dir,'sessions')); + manager.appendMessage({role:'user',content:'迁移原件不得改写',timestamp:Date.now()}); + await atomic(path.join(legacy.dir,'session.json'),{file:manager.getSessionFile()}); + await legacy.appendEvent({type:'provider_prepared',id:'old-request',bindingHash:legacy.hash}); + await atomic(path.join(legacy.dir,'recovered-tools.json'),[{id:'old-request',phase:'review'}]); + const original=await fs.readFile(manager.getSessionFile()),events=await fs.readFile(path.join(legacy.dir,'events.jsonl')); + const stableDir=path.join(legacy.root,'conversation-v1'),target=path.join(stableDir,'sessions','legacy-'+legacy.hash+'.jsonl'); + await fs.mkdir(path.dirname(target),{recursive:true});await fs.writeFile(target,original,{flag:'wx'}); + if(copiedEvents)await fs.writeFile(path.join(stableDir,'events.jsonl'),events,{flag:'wx'}); + const resumed=await ProjectStore.open(root,identity,binding('new-shop'),{continuous:true}); + assert.equal((await resumed.get('session.json')).file,target);assert.deepEqual(await fs.readFile(target),original); + assert.deepEqual(await fs.readFile(manager.getSessionFile()),original);assert.deepEqual(await fs.readFile(path.join(stableDir,'events.jsonl')),events); + assert.deepEqual(await resumed.get('recovered-tools.json'),[{id:'old-request',phase:'review'}]); + assert.equal((await resumed.get('migration.json')).originalRetained,true); + await ProjectStore.open(root,identity,binding('new-shop'),{continuous:true}); + assert.deepEqual(await fs.readFile(target),original); + }finally{await cleanup(root);} + } +}); + +test('legacy migration rejects a pre-existing target with different bytes and never publishes a pointer or overwrites it',async()=>{ + const root=await fs.mkdtemp(path.join(os.tmpdir(),'jiansun-pi-')),identity={scope,project:randomUUID()}; + try{ + const legacy=await ProjectStore.open(root,identity,binding('legacy-shop')); + const {SessionManager}=await import('@earendil-works/pi-coding-agent'); + const manager=SessionManager.create(legacy.dir,path.join(legacy.dir,'sessions')); + manager.appendMessage({role:'user',content:'原件',timestamp:Date.now()});await atomic(path.join(legacy.dir,'session.json'),{file:manager.getSessionFile()}); + const original=await fs.readFile(manager.getSessionFile()),stableDir=path.join(legacy.root,'conversation-v1'),target=path.join(stableDir,'sessions','legacy-'+legacy.hash+'.jsonl'); + await fs.mkdir(path.dirname(target),{recursive:true});await fs.writeFile(target,'different retained bytes',{flag:'wx'}); + await assert.rejects(ProjectStore.open(root,identity,binding('new-shop'),{continuous:true}),/迁移目标与原件不一致/); + assert.equal(await fs.readFile(target,'utf8'),'different retained bytes');assert.deepEqual(await fs.readFile(manager.getSessionFile()),original); + await assert.rejects(fs.access(path.join(stableDir,'session.json')),error=>error.code==='ENOENT'); + }finally{await cleanup(root);} +}); diff --git a/apps/detail-agent-pi/test/native-canvas-transport.test.mjs b/apps/detail-agent-pi/test/native-canvas-transport.test.mjs new file mode 100644 index 00000000..b7d84d2d --- /dev/null +++ b/apps/detail-agent-pi/test/native-canvas-transport.test.mjs @@ -0,0 +1,118 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import net from 'node:net'; +import {createInterface} from 'node:readline'; +import {NativeCanvas} from '../src/native-bridge.mjs'; + +const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +async function wait(check){for(let i=0;i<100;i++){if(check())return;await pause(5);}throw Error('Loopback transport state not settled');} +async function fixture(handle,authorize=()=>true){ + const dir=await fs.mkdtemp(path.join(os.tmpdir(),'jiansun-native-transport-')); + const tokenFile=path.join(dir,'control.token');await fs.writeFile(tokenFile,'local-test-control-token'); + const sockets=new Set(),requests=[];let connections=0; + const server=net.createServer(socket=>{ + const connection=++connections;sockets.add(socket);socket.on('close',()=>sockets.delete(socket));socket.on('error',()=>{}); + let authenticated=false; + const reader=createInterface({input:socket});reader.on('error',()=>socket.destroy()); + reader.on('line',line=>{ + const q=JSON.parse(line);requests.push({...q,connection}); + const reply=result=>socket.write(JSON.stringify({id:q.id,ok:true,result})+'\n'); + if(q.method==='auth'){ + authenticated=q.params.token==='local-test-control-token'&&authorize(connection); + if(authenticated)reply({authenticated:true});else socket.end(JSON.stringify({id:q.id,ok:false,error:'denied'})+'\n'); + }else if(!authenticated)socket.destroy();else handle({q,socket,reply,connection}); + }); + }); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + const port=server.address().port; + return{requests,sockets,get connections(){return connections;},options:{port,tokenFile,connectTimeoutMs:300,rpcTimeoutMs:150}, + stop:()=>new Promise(resolve=>server.close(resolve)), + async close(){for(const socket of sockets)socket.destroy();if(server.listening)await new Promise(resolve=>server.close(resolve));await fs.unlink(tokenFile);await fs.rmdir(dir);}, + }; +} + +test('an idle EOF reconnects and authenticates only before a new read/edit, preserving run and CAS',async()=>{ + let revision=7; + const f=await fixture(({q,socket,reply})=>{ + if(q.method==='idle-close'){socket.end(JSON.stringify({id:q.id,ok:true,result:{idle:true}})+'\n');return;} + const action=q.params.action; + if(action==='pi-edit'){assert.equal(q.params.input.revision,7);assert.equal(q.params.input.run,'same-run');revision=8;reply({queued:true});} + else reply({allowed:true,queued:false,revision}); + }); + let canvas; + try{ + canvas=await NativeCanvas.connect(f.options);canvas.run='same-run';canvas.identity={doc:12,revision:7};canvas.edits=2; + await canvas.call('idle-close');await wait(()=>canvas.socket.destroyed); + assert.equal((await canvas.read()).revision,7); + assert.equal((await canvas.edit({type:'text',layer:3,text:'修改中文'},7)).revision,8); + assert.equal(canvas.run,'same-run');assert.deepEqual(canvas.identity,{doc:12,revision:7});assert.equal(canvas.edits,3); + assert.equal(f.connections,2);assert.equal(f.requests.filter(q=>q.method==='auth').length,2); + assert.equal(f.requests.filter(q=>q.params.action==='pi-edit').length,1); + }finally{canvas?.close();await f.close();} +}); +test('a sent edit whose reply was lost is rejected immediately and is never replayed on fresh read',async()=>{ + let edits=0; + const f=await fixture(({q,socket,reply})=>{ + if(q.params.action==='pi-edit'){edits++;socket.destroy();}else reply({allowed:true,queued:false,revision:edits}); + }); + let canvas; + try{ + canvas=await NativeCanvas.connect(f.options);canvas.run='original-run'; + const started=Date.now();await assert.rejects(canvas.action('pi-edit',{revision:0,edit:{type:'text'}}),/已发送操作结果需核查/); + assert.ok(Date.now()-started<1000);await pause(20);assert.equal(f.connections,1);assert.equal(edits,1); + assert.equal((await canvas.read()).revision,1);assert.equal(f.connections,2);assert.equal(edits,1); + assert.equal(f.requests.filter(q=>q.params.action==='pi-edit').length,1); + }finally{canvas?.close();await f.close();} +}); +test('a timed-out sent command closes its generation and cannot be replayed or settled by a late reply',async()=>{ + let edits=0; + const f=await fixture(({q,socket,reply})=>{ + if(q.params.action==='pi-edit'){edits++;return;} + reply({allowed:true,queued:false,revision:edits}); + }); + let canvas; + try{ + canvas=await NativeCanvas.connect({...f.options,rpcTimeoutMs:40});canvas.run='unchanged-run'; + await assert.rejects(canvas.action('pi-edit',{revision:0}),/结果未知.*不会重发/); + assert.equal(canvas.socket.destroyed,true);assert.equal(canvas.pending.size,0); + assert.equal((await canvas.read()).revision,1);assert.equal(edits,1);assert.equal(f.connections,2); + assert.equal(new Set(f.requests.map(q=>q.id)).size,f.requests.length); + }finally{canvas?.close();await f.close();} +}); +test('concurrent new reads after idle close share one reconnect and never replay the closed command',async()=>{ + const f=await fixture(({q,socket,reply})=>q.method==='idle-close'?socket.end(JSON.stringify({id:q.id,ok:true,result:true})+'\n'):reply({fresh:true})); + let canvas; + try{ + canvas=await NativeCanvas.connect(f.options);await canvas.call('idle-close');await wait(()=>canvas.socket.destroyed); + const values=await Promise.all([canvas.call('new-read-a'),canvas.call('new-read-b')]);assert.deepEqual(values,[{fresh:true},{fresh:true}]); + assert.equal(f.connections,2);assert.equal(f.requests.filter(q=>q.method==='auth').length,2);assert.equal(f.requests.filter(q=>q.method==='idle-close').length,1); + }finally{canvas?.close();await f.close();} +}); +test('failed initial authentication closes its socket without dispatching any business RPC',async()=>{ + const f=await fixture(()=>assert.fail('business RPC after auth failure'),()=>false); + try{ + await assert.rejects(NativeCanvas.connect(f.options),/授权未完成.*未派发/);await wait(()=>f.sockets.size===0); + assert.deepEqual(f.requests.map(q=>q.method),['auth']);assert.equal(f.connections,1); + }finally{await f.close();} +}); +test('failed reauthentication rejects the new command before it is sent and does not start a retry loop',async()=>{ + const f=await fixture(({q,socket})=>socket.end(JSON.stringify({id:q.id,ok:true,result:{done:true}})+'\n'),connection=>connection===1); + let canvas; + try{ + canvas=await NativeCanvas.connect(f.options);await canvas.call('idle-close');await wait(()=>canvas.socket.destroyed); + await assert.rejects(canvas.call('new-read'),/授权未完成.*未派发/);await pause(30); + assert.equal(canvas.socket.destroyed,true);assert.equal(f.connections,2);assert.equal(f.requests.filter(q=>q.method==='new-read').length,0); + }finally{canvas?.close();await f.close();} +}); +test('connection refusal fails promptly and explicit close never reconnects',async()=>{ + const f=await fixture(({reply})=>reply({ok:true}));let canvas; + try{ + canvas=await NativeCanvas.connect(f.options);canvas.close();const count=f.connections; + await assert.rejects(canvas.read(),/连接已关闭.*未派发/);assert.equal(f.connections,count); + await wait(()=>f.sockets.size===0);await f.stop();const started=Date.now(); + await assert.rejects(NativeCanvas.connect(f.options),/无法建立.*未派发/);assert.ok(Date.now()-started<1000); + }finally{canvas?.close();await f.close();} +}); diff --git a/apps/detail-agent-pi/test/native-wire.test.mjs b/apps/detail-agent-pi/test/native-wire.test.mjs index d5298b9e..919198bf 100644 --- a/apps/detail-agent-pi/test/native-wire.test.mjs +++ b/apps/detail-agent-pi/test/native-wire.test.mjs @@ -74,6 +74,13 @@ test('projection bounds stale canvas bodies without deleting original facts, err assert.ok(out.messages[1].content[0].text.length < 300); assert.equal(out.messages[1].details, undefined); assert.equal(out.messages[2].content[0].text, m.content[0].text); }); +test('a continuous conversation archives a previous rule domain rather than reinjecting it as the latest rules',()=>{ + const old={role:'toolResult',toolName:'context_read',toolCallId:'old-domain',isError:false,content:[{type:'text',text:JSON.stringify({bindingHash:'old',snapshot:{rules:['旧店铺专属规范']}})}]}; + const original=JSON.stringify(old),projected=projectContext({messages:[old]},{bindingHash:'current'}); + assert.equal(JSON.stringify(old),original); + assert.ok(!JSON.stringify(projected).includes('旧店铺专属规范')); + assert.ok(JSON.stringify(projected).includes('旧规则域历史')); +}); test('wire audit detects complete SSE with a literal early EOS instead of blaming the browser', () => { const a = auditResponse(sse({ content: '{"reply":"未结束<|eos|>' }, 'stop'), 'openai-chat'); assert.equal(a.literalEos, true); assert.deepEqual(a.finishes, ['stop']); diff --git a/apps/detail-agent-pi/test/pi.test.mjs b/apps/detail-agent-pi/test/pi.test.mjs index 28288c71..3dc9d433 100644 --- a/apps/detail-agent-pi/test/pi.test.mjs +++ b/apps/detail-agent-pi/test/pi.test.mjs @@ -40,7 +40,9 @@ test('only tested immutable tool versions execute; failed tests cannot replace', test('snapshots, memories, sessions are isolated by account/shop/category/version', async () => { const dir = await root(), id = identity(); const a = await ProjectStore.open(dir, id, binding('A')); + await a.recordUserEvidence('产品颜色是米白'); await a.remember({ key: 'color', value: '米白', evidence: '颜色是米白' }, [{ role: 'user', content: '产品颜色是米白' }]); + await a.recordUserEvidence('产品改为胡桃色'); await a.remember({ key: 'color', value: '胡桃', evidence: '改为胡桃色' }, [{ role: 'user', content: '产品改为胡桃色' }]); assert.equal((await a.context()).confirmedMemory.color.previous, '米白'); await assert.rejects(a.remember({ key: 'certified', value: '已认证', evidence: '已认证测试' }, []), /用户已明确/); diff --git a/apps/detail-agent-pi/test/recovery.test.mjs b/apps/detail-agent-pi/test/recovery.test.mjs index 97c04a7d..f625e275 100644 --- a/apps/detail-agent-pi/test/recovery.test.mjs +++ b/apps/detail-agent-pi/test/recovery.test.mjs @@ -64,11 +64,16 @@ test('410 remains unknown and all new inference stays blocked', async () => { }); test('handover stops the next model step locally rather than spending another call', async () => { const { root, scope, store } = await setup(); let posts = 0, ended; - const broker = { info: async () => ({ accountScope: scope, model: { id: 'qa', protocol: 'openai-chat' }, pending: [] }), round() { posts++; throw Error('must not post'); } }; - const canvas = { async begin() {}, async read() { return { allowed: false }; }, async end(text) { ended = text; } }; + const broker = { info: async () => ({ accountScope: scope, model: { id: 'qa', protocol: 'openai-chat' }, pending: [] }), round() { + posts++;assert.equal(posts,1,'no additional inference after the write is rejected'); + const call={index:0,id:'proposed',type:'function',function:{name:'canvas_edit',arguments:JSON.stringify({revision:1,command:'type.edit',params:{layer:1,text:'不能应用'},label:'测试写入'})}}; + const raw=`data: ${JSON.stringify({id:'qa',model:'qa',choices:[{index:0,delta:{tool_calls:[call]},finish_reason:'tool_calls'}]})}\n\ndata: [DONE]\n\n`; + return {id:randomUUID(),fetch:async()=>new Response(raw,{headers:{'content-type':'text/event-stream'}}),finish:async()=>({phase:'complete'})}; + } }; + const canvas = { async begin() {}, async read() { return { conversationActive:true,allowed:false,writeBlocked:true,revision:1 }; },async activity(){},async requestWrite(){throw Error('用户已接手,写入未获许可');},async edit(){throw Error('must never edit');}, async end(text) { ended = text; } }; const host = await DetailPiAgent.open({ store, broker, canvas }); const result = await host.prompt('开始后立即接手'); - assert.match(result.text, /本机:已接手/); assert.match(ended, /停止/); assert.equal(posts, 0); + assert.match(result.text, /本机:已接手/); assert.match(ended, /停止/); assert.equal(posts, 1); await host.dispose(); await cleanup(root); }); test('original SSE restores read-only tool through SDK schema/hook pipeline, without inference', async () => { diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_backend.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_backend.rs index f7254b64..d7f634b4 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_backend.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_backend.rs @@ -21,24 +21,35 @@ const CLIENT_ID: &str = "jiansun-detail-desktop"; const UPSTREAM: &str = "https://api.vip98k.com"; const MAX_JSON: u64 = 2 * 1024 * 1024; type R = Result; +#[path = "jiansun_creation.rs"] +mod creation; #[path = "jiansun_images.rs"] mod images; -#[path = "jiansun_projects.rs"] -mod projects; -#[path = "jiansun_stream.rs"] -mod stream; #[path = "jiansun_pi.rs"] mod pi; #[path = "jiansun_pi_runtime.rs"] mod pi_runtime; -#[path = "jiansun_sync.rs"] -mod sync; -#[path = "jiansun_creation.rs"] -mod creation; -#[path = "jiansun_recovery.rs"] -mod recovery; +#[path = "jiansun_projects.rs"] +mod projects; #[path = "jiansun_reads.rs"] mod reads; +#[path = "jiansun_recovery.rs"] +mod recovery; +#[path = "jiansun_slot_canvas.rs"] +mod slot_canvas; +#[path = "jiansun_stream.rs"] +mod stream; +#[path = "jiansun_sync.rs"] +mod sync; +#[path = "jiansun_template_cache.rs"] +mod template_cache; +#[cfg(test)] +#[path = "jiansun_template_local_qa.rs"] +mod template_local_qa; +#[path = "jiansun_template_slots.rs"] +mod template_slots; +#[path = "jiansun_template_workflow.rs"] +mod template_workflow; /// Opt-in, local SDK transport. The personal Key never leaves this native process. pub fn pi_stdio() -> R<()> { @@ -118,10 +129,46 @@ fn read_json(path: &Path) -> R { serde_json::from_slice(&bytes).map_err(|_| "本机记录格式无效".into()) } fn bound_material_read(result: R, reference: &Value) -> Value { - let mut value = match result { Ok(value) => value, Err(error) => json!({"error":error}) }; - for key in ["readId", "accountId", "origin", "doc"] { value[key] = reference[key].clone(); } + let mut value = match result { + Ok(value) => value, + Err(error) => json!({"error":error}), + }; + for key in ["readId", "accountId", "origin", "doc"] { + value[key] = reference[key].clone(); + } value } +fn bound_creation_read(result: R, reference: &Value) -> Value { + let mut value = match result { + Ok(value) => value, + Err(error) => json!({"error":error}), + }; + for key in ["readId", "accountId", "origin", "doc", "kind", "id", "query"] { + value[key] = reference[key].clone(); + } + value +} +#[cfg(test)] +mod creation_read_binding_tests { + use super::*; + #[test] + fn successful_and_failed_creation_gets_keep_the_exact_item_query_and_actor_binding() { + let reference = json!({"readId":17,"accountId":"owner","origin":"https://sc.jiansun.vip","doc":42,"kind":"material","id":"one-preview","query":""}); + for result in [Ok(json!({"catalog":{"items":[]},"id":"incorrect"})), Err("one GET failed".into())] { + let value = bound_creation_read(result, &reference); + for key in ["readId", "accountId", "origin", "doc", "kind", "id", "query"] { + assert_eq!(value[key], reference[key], "{key}"); + } + } + let mut value = bound_creation_read(Err("read queue full".into()), &reference); + value["readError"] = json!("queue_busy"); + assert_eq!(value["id"], "one-preview"); + assert_eq!(value["doc"], 42); + assert_eq!(value["readId"], 17); + assert_eq!(value["readError"], "queue_busy"); + assert!(value["error"].is_string()); + } +} fn write_json(path: &Path, v: &Value) -> R<()> { if let Some(p) = path.parent() { fs::create_dir_all(p).map_err(|_| "无法创建本机项目目录")?; @@ -131,30 +178,49 @@ fn write_json(path: &Path, v: &Value) -> R<()> { } fn project_not_trashed(dir: &Path) -> R<()> { if let (Some(account), Some(local)) = (dir.parent().and_then(Path::parent), dir.file_name()) - && account.join("trash").join(local).exists() { - return Err("原项目已删除,迟到结果未重建项目;原任务在回收区保留".into()); + && account.join("trash").join(local).exists() + { + return Err("原项目已删除,迟到结果未重建项目;原任务在回收区保留".into()); } Ok(()) } fn original_project_present(dir: &Path) -> R<()> { project_not_trashed(dir)?; - if !dir.is_dir() || !dir.join("context.json").is_file() { return Err("原项目已关闭或移除,结果未写入其他项目".into()); } + if !dir.is_dir() || !dir.join("context.json").is_file() { + return Err("原项目已关闭或移除,结果未写入其他项目".into()); + } Ok(()) } /// The slow open owns no receive-loop state. Its completion is queued behind /// ordinary Save requests, and only that loop may adopt the stable binding. fn enqueue_open(requests: mpsc::Sender, open: impl FnOnce() -> T + Send + 'static) { - std::thread::spawn(move || { let _=requests.send(open()); }); + std::thread::spawn(move || { + let _ = requests.send(open()); + }); } -fn open_binding_local(binding:&Value,scope:&str,origin:&str,account:&Value)->R { - if binding["accountScope"]!=scope || binding["origin"]!=origin || binding["accountId"]!=account["id"] {return Err("打开期间账户已变化,原项目未载入当前账户".into());} - binding["localId"].as_str().filter(|id|Uuid::parse_str(id).is_ok() || id.parse::().is_ok()).map(str::to_owned).ok_or("原项目本机身份未能确认".into()) +fn open_binding_local(binding: &Value, scope: &str, origin: &str, account: &Value) -> R { + if binding["accountScope"] != scope || binding["origin"] != origin || binding["accountId"] != account["id"] { + return Err("打开期间账户已变化,原项目未载入当前账户".into()); + } + binding["localId"].as_str().filter(|id| Uuid::parse_str(id).is_ok() || id.parse::().is_ok()).map(str::to_owned).ok_or("原项目本机身份未能确认".into()) } -fn adopt_open_mapping(owners:&mut HashMap,locations:&mut HashMap,doc:u64,binding:&Value,scope:&str,origin:&str,account:&Value)->R<()> { - let local=open_binding_local(binding,scope,origin,account)?; - owners.insert(doc,scope.into());locations.insert(doc,local);Ok(()) +fn adopt_open_mapping( + owners: &mut HashMap, + locations: &mut HashMap, + doc: u64, + binding: &Value, + scope: &str, + origin: &str, + account: &Value, +) -> R<()> { + let local = open_binding_local(binding, scope, origin, account)?; + owners.insert(doc, scope.into()); + locations.insert(doc, local); + Ok(()) +} +fn remember_open(binding: &Value) -> bool { + binding["autoResume"] != true } -fn remember_open(binding:&Value)->bool {binding["autoResume"]!=true} fn body(response: Response, limit: u64) -> R<(u16, Value)> { let status = response.status().as_u16(); if response.content_length().is_some_and(|n| n > limit) { @@ -199,6 +265,7 @@ struct Backend { image_error: String, owners: HashMap, locations: HashMap, + superseded_docs: Arc>>, last_saved: HashMap, recovery_attempts: Arc>>, recovery_serial: Arc>, @@ -228,6 +295,7 @@ impl Backend { image_error: String::new(), owners: HashMap::new(), locations: HashMap::new(), + superseded_docs: Default::default(), last_saved: HashMap::new(), recovery_attempts: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), recovery_serial: Arc::new(std::sync::Mutex::new(())), @@ -240,11 +308,15 @@ impl Backend { self.root.join("accounts").join(self.scope()) } fn bind_new(&mut self, id: u64) { + if self.ensure_current_doc(id).is_err() { + return; + } let scope = self.scope(); self.owners.entry(id).or_insert(scope); self.locations.entry(id).or_insert_with(|| Uuid::new_v4().to_string()); } fn project_dir(&self, id: u64) -> R { + self.ensure_current_doc(id)?; let owner = self.owners.get(&id).ok_or("项目账户尚未绑定")?; let project = self.locations.get(&id).ok_or("项目身份尚未绑定")?; Ok(self.root.join("accounts").join(owner).join("projects").join(project)) @@ -291,7 +363,7 @@ impl Backend { return Err("本机项目编号无效".into()); } out.push( - json!({"id":id,"source":"native","name":v["name"],"revision":v["revision"].as_u64().unwrap_or(0),"updatedAt":v["time"],"hasDraft":v["status"]!="completed","status":v["status"].as_str().unwrap_or("draft"),"cloudLink":v["project"],"cloudPresent":false,"syncedRevision":v["syncedRevision"]}), + json!({"id":id,"source":"native","name":v["name"],"revision":v["revision"].as_u64().unwrap_or(0),"updatedAt":v["time"],"hasDraft":v["status"]!="completed","status":v["status"].as_str().unwrap_or("draft"),"cloudLink":v["project"],"cloudPresent":false,"syncedRevision":v["syncedRevision"],"syncDirty":v["syncDirty"].as_bool().unwrap_or(true),"localPending":projects::idle(&entry.path()).is_err()}), ); } } @@ -312,7 +384,9 @@ impl Backend { }; let v = self.json(&path)?; cloud.extend(v["items"].as_array().ok_or("项目列表格式无效")?.iter().cloned()); - if let Some(items) = v["deleted"].as_array() { deleted.extend(items.iter().cloned()); } + if let Some(items) = v["deleted"].as_array() { + deleted.extend(items.iter().cloned()); + } if out.len() + cloud.len() + deleted.len() > 10000 { return Err("项目过多,请缩小查询范围".into()); } @@ -498,10 +572,15 @@ impl Backend { reference["origin"] = json!(self.origin); if method == "preview" { use photocraft_codecs::{ChannelLayout, SampleType}; - let decoded = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { - limits: photocraft_codecs::Limits { max_width: 16384, max_height: 16384, max_pixels: 24_000_000, max_alloc: 128 * 1024 * 1024 }, - ..Default::default() - }).map_err(|_| "此文件不能作为图片预览")?.convert(ChannelLayout::Rgba, SampleType::U8); + let decoded = photocraft_codecs::decode_with( + &bytes, + &photocraft_codecs::DecodeOptions { + limits: photocraft_codecs::Limits { max_width: 16384, max_height: 16384, max_pixels: 24_000_000, max_alloc: 128 * 1024 * 1024 }, + ..Default::default() + }, + ) + .map_err(|_| "此文件不能作为图片预览")? + .convert(ChannelLayout::Rgba, SampleType::U8); let image = egui::ColorImage::from_rgba_unmultiplied([decoded.width() as usize, decoded.height() as usize], decoded.data()); events.send(Event::Preview(reference.clone(), image)).map_err(|_| "工作台已关闭")?; // Preview is read-only. Placing later uses a fresh authorized GET, and is serialized. @@ -514,8 +593,9 @@ impl Backend { Ok(json!({"doc":id,"path":path.to_string_lossy(),"reference":reference})) } fn save(&mut self, doc: Arc, revision: u64, meta: Value) -> R { + self.ensure_current_doc(doc.id.0)?; let _guard = sync::context_guard(); - if !meta["creationDraft"].is_null() && (!meta["creationDraft"].is_object() || meta["creationDraft"].to_string().len()>100_000) { + if !meta["creationDraft"].is_null() && (!meta["creationDraft"].is_object() || meta["creationDraft"].to_string().len() > 100_000) { return Err("产品资料草稿超过限制,原草稿已保留".into()); } self.bind_new(doc.id.0); @@ -529,22 +609,36 @@ impl Backend { if self.last_saved.get(&doc.id.0) != Some(&revision) { let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).map_err(|_| "原生项目序列化失败,旧草稿已保留")?; photocraft_format::atomic_write(&dir.join("canvas.pcraft"), &bytes).map_err(|_| "本机草稿保存失败")?; + template_workflow::save_edited_output(&dir, &doc, &bytes)?; self.last_saved.insert(doc.id.0, revision); } // UI snapshots may lag a completed sync or creation task. Preserve backend-owned // links/status and creation metadata; an old autosave must never erase them. let previous = if dir.join("context.json").exists() { read_json(&dir.join("context.json"))? } else { json!({}) }; let mut value = previous.clone(); - value["doc"] = json!(doc.id.0); value["localId"] = json!(project); value["name"] = json!(doc.name); - value["revision"] = json!(revision); value["time"] = json!(now().to_string()); - for key in ["composer", "messages", "creation", "creationDraft"] { if !meta[key].is_null() { value[key] = meta[key].clone(); } } - if !value["project"].is_object() && meta["project"].is_object() { value["project"] = meta["project"].clone(); } + value["doc"] = json!(doc.id.0); + value["localId"] = json!(project); + value["name"] = json!(doc.name); + value["revision"] = json!(revision); + value["time"] = json!(now().to_string()); + for key in ["composer", "messages", "creation", "creationDraft"] { + if !meta[key].is_null() { + value[key] = meta[key].clone(); + } + } + if !value["project"].is_object() && meta["project"].is_object() { + value["project"] = meta["project"].clone(); + } let changed_revision = previous["revision"].as_u64().is_some_and(|r| r != revision); value["syncDirty"] = json!(meta["syncDirty"] == true || previous["syncDirty"] == true || changed_revision); - if !value["status"].is_string() || changed_revision || meta["syncDirty"] == true { value["status"] = json!("draft"); } + if !value["status"].is_string() || changed_revision || meta["syncDirty"] == true { + value["status"] = json!("draft"); + } write_json(&dir.join("context.json"), &value)?; self.remember_project(project)?; - Ok(json!({"time":value["time"],"localId":project,"status":value["status"],"project":value["project"],"cloudLink":value["project"],"syncedRevision":value["syncedRevision"],"syncDirty":value["syncDirty"],"projects":self.native_projects()?})) + Ok( + json!({"time":value["time"],"localId":project,"status":value["status"],"project":value["project"],"cloudLink":value["project"],"syncedRevision":value["syncedRevision"],"syncDirty":value["syncDirty"],"projects":self.native_projects()?}), + ) } fn draft(&mut self, id: u64) -> R { self.bind_new(id); @@ -577,10 +671,20 @@ impl Backend { v["unknownKind"] = json!("pi"); v["piPending"] = json!(pending); } - if dir.join("agent-binding.json").exists() { v["agentRule"] = read_json(&dir.join("agent-binding.json"))?; } + if dir.join("agent-binding.json").exists() { + v["agentRule"] = read_json(&dir.join("agent-binding.json"))?; + } Ok(v) } + fn ensure_current_doc(&self, id: u64) -> R<()> { + if self.superseded_docs.lock().map_err(|_| "项目版本状态暂不可用,本机文件已保留")?.contains(&id) { + Err("已读取云端新版,旧画布不能继续保存;请打开新版".into()) + } else { + Ok(()) + } + } fn ensure_owner(&self, id: u64) -> R<()> { + self.ensure_current_doc(id)?; if self.owners.get(&id).is_some_and(|s| s != &self.scope()) { Err("请打开当前账户的项目,旧账户草稿已保留".into()) } else { Ok(()) } } fn agent(&mut self, input: &Value, recover: bool, tx: &mpsc::Sender, ctx: &egui::Context) -> R { @@ -761,10 +865,14 @@ impl Backend { self.state() } "projects" => Ok(json!(self.projects()?)), + "project-list" => self.project_list(input), "project-activate" => { - let doc=input["doc"].as_u64().ok_or("项目身份无效")?;self.ensure_owner(doc)?; - let local=self.locations.get(&doc).ok_or("项目身份尚未绑定")?; - let _guard=sync::context_guard();original_project_present(&self.project_dir(doc)?)?;self.remember_project(local)?; + let doc = input["doc"].as_u64().ok_or("项目身份无效")?; + self.ensure_owner(doc)?; + let local = self.locations.get(&doc).ok_or("项目身份尚未绑定")?; + let _guard = sync::context_guard(); + original_project_present(&self.project_dir(doc)?)?; + self.remember_project(local)?; Ok(json!({"doc":doc,"localId":local})) } "project-delete" => self.delete_project(input), @@ -813,6 +921,45 @@ impl Backend { let (tx, _) = mpsc::channel(); self.open_with_recovery(input, &tx, &egui::Context::default()) } + fn accept_opened(&mut self, binding: &Value, doc: Box, mut meta: Value) -> R<(Box, Value)> { + let scope = self.scope(); + let local = open_binding_local(binding, &scope, &self.origin, &self.auth["account"])?; + let _guard = sync::context_guard(); + let dir = self.dir().join("projects").join(&local); + original_project_present(&dir)?; + if binding["requireCopyIdentity"] == true || !binding["openCopyIdentity"].is_null() { + let expected = binding["openCopyIdentity"].as_str().ok_or("打开版本无法确认,请重新打开;本机文件已保留")?; + if sync::opened_copy_identity(&dir)? != expected { + return Err("打开期间项目版本已变化,请重新打开;旧内容未写入新版".into()); + } + } + if meta["replacedLocalId"] == local { + // A worker's location snapshot may predate another completed open. + // Retire those bindings in the receive loop before exposing the new copy. + let mut retired = meta["supersededDocIds"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect::>()).unwrap_or_default(); + retired.extend(self.locations.iter().filter_map(|(id, location)| (location == &local && *id != doc.id.0).then_some(*id))); + retired.retain(|id| *id != doc.id.0); + retired.sort_unstable(); + retired.dedup(); + let mut superseded = self.superseded_docs.lock().map_err(|_| "项目版本状态暂不可用,本机文件已保留")?; + for id in &retired { + superseded.insert(*id); + self.owners.remove(id); + self.locations.remove(id); + self.last_saved.remove(id); + } + meta["supersededDocIds"] = json!(retired); + } + adopt_open_mapping(&mut self.owners, &mut self.locations, doc.id.0, binding, &scope, &self.origin, &self.auth["account"])?; + meta["localId"] = json!(local); + meta["autoResume"] = binding["autoResume"].clone(); + meta["accountId"] = binding["accountId"].clone(); + meta["origin"] = binding["origin"].clone(); + if remember_open(binding) { + self.remember_project(&local)?; + } + Ok((doc, meta)) + } fn open_with_recovery(&mut self, input: &Value, tx: &mpsc::Sender, ctx: &egui::Context) -> R<(Document, Value)> { let id = input["id"].as_str().ok_or("项目编号无效")?; if input["source"] == "native" { @@ -820,7 +967,11 @@ impl Backend { return Err("项目编号无效".into()); } let dir = self.dir().join("projects").join(id); - let recovery = self.auto_recover_project(id, true, tx, ctx).unwrap_or_else(|message| json!({"state":"pending","applied":false,"message":message,"projectLocalId":id})); + let recovery = self + .auto_recover_project(id, true, tx, ctx) + .unwrap_or_else(|message| json!({"state":"pending","applied":false,"message":message,"projectLocalId":id})); + let _guard = sync::context_guard(); + original_project_present(&dir)?; let mut doc = photocraft_format::load_path(&dir.join("canvas.pcraft")).map_err(|_| "原生草稿未能恢复;文件已保留")?; doc.id = photocraft_doc::DocId::fresh(); self.owners.insert(doc.id.0, self.scope()); @@ -830,7 +981,9 @@ impl Backend { meta["localId"] = json!(id); meta["autoRecovery"] = recovery; meta["autoResume"] = input["autoResume"].clone(); - meta["accountId"] = self.auth["account"]["id"].clone(); meta["origin"] = json!(self.origin); + meta["accountId"] = self.auth["account"]["id"].clone(); + meta["origin"] = json!(self.origin); + meta["openCopyIdentity"] = json!(sync::opened_copy_identity(&dir)?); return Ok((doc, meta)); } self.validate()?; @@ -839,7 +992,9 @@ impl Backend { let value = self.json(&format!("projects/{source}/{id}"))?; let project = &value["project"]; let original = &project["document"]; - if original["version"] == 3 { return self.restore_native_project(project); } + if original["version"] == 3 { + return self.restore_native_project_checked(project, input); + } let screens = original["screens"].as_array().filter(|s| !s.is_empty() && s.len() <= 12).ok_or("项目分屏格式无效")?; let width = screens.first().and_then(|s| s["width"].as_u64()).unwrap_or(750); let height = screens.iter().try_fold(0u64, |a, s| s["height"].as_u64().and_then(|n| a.checked_add(n))).ok_or("画布尺寸无效")?; @@ -881,7 +1036,7 @@ impl Backend { pub fn start(root: PathBuf, ctx: egui::Context) -> Workspace { let (tx, rx) = mpsc::channel(); let (events, results) = mpsc::channel(); - let internal_requests=tx.clone(); + let internal_requests = tx.clone(); std::thread::spawn(move || { let mut backend = match Backend::new(root.join("jiansun")) { Ok(b) => b, @@ -894,26 +1049,31 @@ pub fn start(root: PathBuf, ctx: egui::Context) -> Workspace { let model_busy = Arc::new(std::sync::atomic::AtomicBool::new(false)); let catalog_reads = reads::ReadPool::catalog(); let image_reads = reads::ReadPool::images(); + let project_reads = reads::ReadPool::catalog(); let mut recovery_scope = String::new(); if !backend.auth.is_null() { recovery_scope = backend.scope(); - let mut b = backend.clone(); let tx = events.clone(); let ctx = ctx.clone(); + let mut b = backend.clone(); + let tx = events.clone(); + let ctx = ctx.clone(); std::thread::spawn(move || b.scan_recovery(&tx, &ctx)); } for request in rx { match request { - Request::OpenedResult {binding,result} => { - let label=if binding["autoResume"]==true {"startup-open"} else {"open"}; - let adopted=(||->R<(Box,Value)>{ - let (doc,mut meta)=result?; - let scope=backend.scope();let local=open_binding_local(&binding,&scope,&backend.origin,&backend.auth["account"])?; - {let _guard=sync::context_guard();original_project_present(&backend.dir().join("projects").join(&local))?;} - adopt_open_mapping(&mut backend.owners,&mut backend.locations,doc.id.0,&binding,&scope,&backend.origin,&backend.auth["account"])?; - meta["localId"]=json!(local);meta["autoResume"]=binding["autoResume"].clone();meta["accountId"]=binding["accountId"].clone();meta["origin"]=binding["origin"].clone(); - if remember_open(&binding) {backend.remember_project(&local)?;} - Ok((doc,meta)) - })(); - match adopted {Ok((doc,meta))=>{let _=events.send(Event::Opened(doc,meta));},Err(error)=>{let _=events.send(Event::Json(label.into(),Err(error)));}} + Request::OpenedResult { binding, result } => { + let label = if binding["autoResume"] == true { "startup-open" } else { "open" }; + let adopted = result.and_then(|(doc, meta)| backend.accept_opened(&binding, doc, meta)); + if binding["updateBusy"] == true { + model_busy.store(false, std::sync::atomic::Ordering::Release); + } + match adopted { + Ok((doc, meta)) => { + let _ = events.send(Event::Opened(doc, meta)); + } + Err(error) => { + let _ = events.send(Event::Json(label.into(), Err(error))); + } + } } Request::Generate(doc, input) => { use std::sync::atomic::Ordering; @@ -942,15 +1102,39 @@ pub fn start(root: PathBuf, ctx: egui::Context) -> Workspace { let _ = events.send(event); } Request::Action(method, input) => { + if method == "project-list" { + let mut reader = backend.clone(); + let task_events = events.clone(); + let task_ctx = ctx.clone(); + let reference = json!({"readId":input["readId"],"accountId":backend.auth["account"]["id"],"origin":backend.origin}); + let task_reference = reference.clone(); + if let Err(error) = project_reads.submit(move || { + let result = bound_material_read(reader.project_list(&input), &task_reference); + let _ = task_events.send(Event::Json("project-list".into(), Ok(result))); + task_ctx.request_repaint(); + }) { + let _ = events.send(Event::Json("project-list".into(), Ok(bound_material_read(Err(error.into()), &reference)))); + ctx.request_repaint(); + } + continue; + } + if method == "slot-applied" { + let result = backend.slot_action(&method, &input, &events, &ctx); + let _ = events.send(Event::Json(method, result)); + ctx.request_repaint(); + continue; + } if matches!(method.as_str(), "materials" | "creation-catalog" | "creation-thumbnail" | "preview") { - if method.starts_with("creation-") { - if let Some(doc) = input["doc"].as_u64() { backend.bind_new(doc); } + if method.starts_with("creation-") + && let Some(doc) = input["doc"].as_u64() + { + backend.bind_new(doc); } let mut reader = backend.clone(); let task_events = events.clone(); let task_ctx = ctx.clone(); let task_method = method.clone(); - let reference = json!({"readId":input["readId"],"doc":input["doc"],"accountId":backend.auth["account"]["id"],"origin":backend.origin}); + let reference = json!({"readId":input["readId"],"doc":input["doc"],"accountId":backend.auth["account"]["id"],"origin":backend.origin,"kind":input["kind"],"id":input["id"],"query":input["query"]}); let task_reference = reference.clone(); let pool = if matches!(method.as_str(), "preview" | "creation-thumbnail") { &image_reads } else { &catalog_reads }; if let Err(error) = pool.submit(move || { @@ -962,12 +1146,22 @@ pub fn start(root: PathBuf, ctx: egui::Context) -> Workspace { reader.action(&task_method, &input, &task_events, &task_ctx) }; if task_method != "preview" || result.is_err() { - let result = if matches!(task_method.as_str(), "materials" | "preview") { Ok(bound_material_read(result, &task_reference)) } else { result }; + let result = if matches!(task_method.as_str(), "materials" | "preview") { + Ok(bound_material_read(result, &task_reference)) + } else { + Ok(bound_creation_read(result, &task_reference)) + }; let _ = task_events.send(Event::Json(task_method, result)); } task_ctx.request_repaint(); }) { - let result = if matches!(method.as_str(), "materials" | "preview") { Ok(bound_material_read(Err(error.into()), &reference)) } else { Err(error.into()) }; + let result = if matches!(method.as_str(), "materials" | "preview") { + Ok(bound_material_read(Err(error.into()), &reference)) + } else { + let mut value = bound_creation_read(Err(error.into()), &reference); + value["readError"] = json!("queue_busy"); + Ok(value) + }; let _ = events.send(Event::Json(method, result)); ctx.request_repaint(); } @@ -980,13 +1174,28 @@ pub fn start(root: PathBuf, ctx: egui::Context) -> Workspace { } if method == "project-sync" { use std::sync::atomic::Ordering; - if model_busy.compare_exchange(false,true,Ordering::AcqRel,Ordering::Acquire).is_err() { - let _=events.send(Event::Json(method,Err("原任务仍在处理,未重复同步".into()))); + if model_busy.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).is_err() { + let _ = events.send(Event::Json(method, Err("原任务仍在处理,未重复同步".into()))); } else { - let mut b=backend.clone();let events=events.clone();let ctx=ctx.clone();let busy=model_busy.clone(); - std::thread::spawn(move||{let result=b.sync_project(&input);let _=events.send(Event::Json(method,result));busy.store(false,Ordering::Release);ctx.request_repaint();}); + let mut b = backend.clone(); + let events = events.clone(); + let ctx = ctx.clone(); + let busy = model_busy.clone(); + std::thread::spawn(move || { + let result = b.sync_project(&input); + let _ = events.send(Event::Json(method, result)); + busy.store(false, Ordering::Release); + ctx.request_repaint(); + }); } - } else if method == "agent" || method == "pi-recover" || method == "pi-review" || method == "recover" || method == "image-recover" || matches!(method.as_str(), "creation-facts" | "creation-points" | "creation-plan" | "creation-generate" | "creation-recover") { + } else if method.starts_with("slot-") + || method == "agent" + || method == "pi-recover" + || method == "pi-review" + || method == "recover" + || method == "image-recover" + || matches!(method.as_str(), "creation-facts" | "creation-points" | "creation-plan" | "creation-generate" | "creation-recover") + { // Slow model calls never block local saving. The task owns its account // snapshot; the GUI also checks that identity before applying a result. use std::sync::atomic::Ordering; @@ -1002,14 +1211,18 @@ pub fn start(root: PathBuf, ctx: egui::Context) -> Workspace { let busy = model_busy.clone(); std::thread::spawn(move || { let recover = method == "recover"; - let result = if method.starts_with("creation-") { + let result = if method.starts_with("slot-") { + task_backend.slot_action(&method, &input, &task_events, &task_ctx) + } else if method.starts_with("creation-") { task_backend.creation_action(&method, &input, None, &task_events, &task_ctx) } else if method == "image-recover" { task_backend.image_job(None, &input, true, &task_events, &task_ctx) } else { if matches!(method.as_str(), "agent" | "pi-recover" | "pi-review") { pi_runtime::run(&mut task_backend, &input, &method, &task_events, &task_ctx) - } else { task_backend.agent(&input, recover, &task_events, &task_ctx) } + } else { + task_backend.agent(&input, recover, &task_events, &task_ctx) + } }; let _ = task_events.send(Event::Json(method, result)); busy.store(false, Ordering::Release); @@ -1050,13 +1263,25 @@ pub fn start(root: PathBuf, ctx: egui::Context) -> Workspace { } } } else if method == "open" { - let mut b=backend.clone();let events=events.clone();let ctx=ctx.clone(); - let mut binding=json!({"accountScope":b.scope(),"origin":b.origin,"accountId":b.auth["account"]["id"],"autoResume":input["autoResume"]}); - enqueue_open(internal_requests.clone(),move||{ - let result=b.open_with_recovery(&input,&events,&ctx).map(|(doc,meta)|{ - binding["localId"]=json!(b.locations.get(&doc.id.0));(Box::new(doc),meta) + use std::sync::atomic::Ordering; + let updating = input["updateExisting"] == true; + if updating && model_busy.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).is_err() { + let _ = events.send(Event::Json(method, Err("任务仍在处理,请先保留原请求后读取新版".into()))); + ctx.request_repaint(); + continue; + } + let mut b = backend.clone(); + let events = events.clone(); + let ctx = ctx.clone(); + let mut binding = json!({"accountScope":b.scope(),"origin":b.origin,"accountId":b.auth["account"]["id"],"autoResume":input["autoResume"],"updateBusy":updating,"requireCopyIdentity":updating || input["source"] == "native"}); + enqueue_open(internal_requests.clone(), move || { + let result = b.open_with_recovery(&input, &events, &ctx).map(|(doc, meta)| { + binding["localId"] = json!(b.locations.get(&doc.id.0)); + binding["openCopyIdentity"] = meta["openCopyIdentity"].clone(); + (Box::new(doc), meta) }); - ctx.request_repaint();Request::OpenedResult{binding,result} + ctx.request_repaint(); + Request::OpenedResult { binding, result } }); } else if method == "place" { let result = backend.material_asset(&method, &input, &events); @@ -1069,7 +1294,9 @@ pub fn start(root: PathBuf, ctx: egui::Context) -> Workspace { } if !backend.auth.is_null() && recovery_scope != backend.scope() { recovery_scope = backend.scope(); - let mut b = backend.clone(); let tx = events.clone(); let ctx = ctx.clone(); + let mut b = backend.clone(); + let tx = events.clone(); + let ctx = ctx.clone(); std::thread::spawn(move || b.scan_recovery(&tx, &ctx)); } ctx.request_repaint(); @@ -1095,7 +1322,8 @@ mod tests { assert!(request.starts_with("GET /api/desktop/catalog/products?identifierType=spu&identifier=TEST-001 ")); assert!(request.to_ascii_lowercase().contains("authorization: bearer local-test-only")); let body = if status == 200 { r#"{"items":[]}"# } else { r#"{"error":"denied"}"# }; - write!(stream, "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + write!(stream, "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()) + .unwrap(); } }); let mut backend = Backend::new(std::env::temp_dir().join(format!("catalog-readonly-{}", Uuid::new_v4()))).unwrap(); @@ -1112,30 +1340,52 @@ mod tests { } #[test] fn a_gated_open_leaves_the_receive_queue_free_for_another_project_save() { - enum Message {SaveB,Opened(Value)} - let root=std::env::temp_dir().join(format!("native-open-queue-{}",Uuid::new_v4())); - let first="024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692";let second=Uuid::new_v4().to_string(); - let binding=json!({"accountScope":"own-scope","origin":"https://sc.jiansun.vip","accountId":"owner","localId":first,"autoResume":true}); - let (requests,receiver)=mpsc::channel();let (started,ready)=mpsc::channel();let (release,gate)=mpsc::channel(); - enqueue_open(requests.clone(),move||{started.send(()).unwrap();gate.recv().unwrap();Message::Opened(binding)}); - ready.recv_timeout(Duration::from_secs(2)).unwrap();requests.send(Message::SaveB).unwrap(); - let (saved,saved_receipt)=mpsc::channel(); - match receiver.recv_timeout(Duration::from_secs(2)).unwrap(){Message::SaveB=>{ - write_json(&root.join("projects").join(&second).join("context.json"),&json!({"localId":second,"revision":8,"name":"B的本机草稿"})).unwrap(); - write_json(&root.join("last-project.json"),&json!({"id":second,"source":"native"})).unwrap();saved.send((second.clone(),8)).unwrap(); - },Message::Opened(_)=>panic!("open gate has not been released")} - assert_eq!(saved_receipt.recv_timeout(Duration::from_secs(2)).unwrap(),(second.clone(),8)); - assert_eq!(read_json(&root.join("projects").join(&second).join("context.json")).unwrap()["revision"],8); - let mut owners=HashMap::from([(2,"own-scope".to_owned())]);let mut locations=HashMap::from([(2,second.clone())]); - assert!(!owners.contains_key(&3));release.send(()).unwrap(); - let Message::Opened(binding)=receiver.recv_timeout(Duration::from_secs(2)).unwrap() else {panic!("expected original open completion")}; - adopt_open_mapping(&mut owners,&mut locations,3,&binding,"own-scope","https://sc.jiansun.vip",&json!({"id":"owner"})).unwrap(); - assert_eq!(owners[&3],"own-scope");assert_eq!(locations[&3],first);assert_eq!(locations[&2],second); - if remember_open(&binding){write_json(&root.join("last-project.json"),&json!({"id":first,"source":"native"})).unwrap();} - assert_eq!(read_json(&root.join("last-project.json")).unwrap()["id"],second); - let before=(owners.clone(),locations.clone()); - assert!(adopt_open_mapping(&mut owners,&mut locations,4,&binding,"other-account","https://sc.jiansun.vip",&json!({"id":"other"})).is_err()); - assert_eq!((owners,locations),before);fs::remove_dir_all(root).unwrap(); + enum Message { + SaveB, + Opened(Value), + } + let root = std::env::temp_dir().join(format!("native-open-queue-{}", Uuid::new_v4())); + let first = "024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692"; + let second = Uuid::new_v4().to_string(); + let binding = json!({"accountScope":"own-scope","origin":"https://sc.jiansun.vip","accountId":"owner","localId":first,"autoResume":true}); + let (requests, receiver) = mpsc::channel(); + let (started, ready) = mpsc::channel(); + let (release, gate) = mpsc::channel(); + enqueue_open(requests.clone(), move || { + started.send(()).unwrap(); + gate.recv().unwrap(); + Message::Opened(binding) + }); + ready.recv_timeout(Duration::from_secs(2)).unwrap(); + requests.send(Message::SaveB).unwrap(); + let (saved, saved_receipt) = mpsc::channel(); + match receiver.recv_timeout(Duration::from_secs(2)).unwrap() { + Message::SaveB => { + write_json(&root.join("projects").join(&second).join("context.json"), &json!({"localId":second,"revision":8,"name":"B的本机草稿"})).unwrap(); + write_json(&root.join("last-project.json"), &json!({"id":second,"source":"native"})).unwrap(); + saved.send((second.clone(), 8)).unwrap(); + } + Message::Opened(_) => panic!("open gate has not been released"), + } + assert_eq!(saved_receipt.recv_timeout(Duration::from_secs(2)).unwrap(), (second.clone(), 8)); + assert_eq!(read_json(&root.join("projects").join(&second).join("context.json")).unwrap()["revision"], 8); + let mut owners = HashMap::from([(2, "own-scope".to_owned())]); + let mut locations = HashMap::from([(2, second.clone())]); + assert!(!owners.contains_key(&3)); + release.send(()).unwrap(); + let Message::Opened(binding) = receiver.recv_timeout(Duration::from_secs(2)).unwrap() else { panic!("expected original open completion") }; + adopt_open_mapping(&mut owners, &mut locations, 3, &binding, "own-scope", "https://sc.jiansun.vip", &json!({"id":"owner"})).unwrap(); + assert_eq!(owners[&3], "own-scope"); + assert_eq!(locations[&3], first); + assert_eq!(locations[&2], second); + if remember_open(&binding) { + write_json(&root.join("last-project.json"), &json!({"id":first,"source":"native"})).unwrap(); + } + assert_eq!(read_json(&root.join("last-project.json")).unwrap()["id"], second); + let before = (owners.clone(), locations.clone()); + assert!(adopt_open_mapping(&mut owners, &mut locations, 4, &binding, "other-account", "https://sc.jiansun.vip", &json!({"id":"other"})).is_err()); + assert_eq!((owners, locations), before); + fs::remove_dir_all(root).unwrap(); } #[test] #[ignore = "read-only public TLS acceptance; no account, API Key or inference request"] @@ -1203,6 +1453,7 @@ mod tests { owners: HashMap::new(), locations: HashMap::new(), last_saved: HashMap::new(), + superseded_docs: Default::default(), recovery_attempts: Default::default(), recovery_serial: Default::default(), }; @@ -1224,6 +1475,64 @@ mod tests { assert!(identifier("local:my-image").is_ok()); } #[test] + fn late_open_cannot_adopt_replaced_files_and_a_cloud_open_retires_late_parent_bindings() { + let root = std::env::temp_dir().join(format!("native-open-cas-{}", Uuid::new_v4())); + let mut backend = Backend::new(root.clone()).unwrap(); + backend.auth = json!({"account":{"id":"same-account"}}); + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"name":"原本机版本","width":20,"height":20})).unwrap(); + let original = session.active().unwrap().doc.clone(); + backend.save(original.clone(), 1, json!({"name":"原本机版本"})).unwrap(); + let local = backend.locations[&original.id.0].clone(); + let dir = backend.project_dir(original.id.0).unwrap(); + let mut binding = json!({"accountScope":backend.scope(),"origin":backend.origin,"accountId":"same-account","localId":local,"requireCopyIdentity":true,"openCopyIdentity":sync::opened_copy_identity(&dir).unwrap()}); + let mut opened = (*original).clone(); + opened.id = photocraft_doc::DocId::fresh(); + // A replacement occurred after the worker took its document snapshot. + let mut current = read_json(&dir.join("context.json")).unwrap(); + current["name"] = json!("云端新版"); + write_json(&dir.join("context.json"), ¤t).unwrap(); + assert!(backend.accept_opened(&binding, Box::new(opened.clone()), json!({})).unwrap_err().contains("版本已变化")); + assert!(!backend.locations.contains_key(&opened.id.0)); + assert_eq!(read_json(&dir.join("context.json")).unwrap()["name"], "云端新版"); + // This parent mapping was not visible to the updating worker's clone. + backend.owners.insert(opened.id.0, backend.scope()); + backend.locations.insert(opened.id.0, local.clone()); + let earlier = backend.clone(); + let mut newest = opened.clone(); + newest.id = photocraft_doc::DocId::fresh(); + binding["openCopyIdentity"] = json!(sync::opened_copy_identity(&dir).unwrap()); + let (newest, meta) = backend.accept_opened(&binding, Box::new(newest), json!({"replacedLocalId":local,"supersededDocIds":[]})).unwrap(); + assert_eq!(backend.locations[&newest.id.0], local); + assert!(meta["supersededDocIds"].as_array().unwrap().contains(&json!(opened.id.0))); + assert!(!backend.locations.contains_key(&opened.id.0)); + assert!(earlier.ensure_current_doc(opened.id.0).is_err()); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn a_superseded_canvas_cannot_be_saved_or_rebound_by_an_earlier_backend_clone() { + let root = std::env::temp_dir().join(format!("native-superseded-{}", Uuid::new_v4())); + let mut backend = Backend::new(root.clone()).unwrap(); + backend.auth = json!({"account":{"id":"same-account"}}); + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"name":"旧版本","width":1440,"height":1800})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + backend.bind_new(doc.id.0); + let mut earlier = backend.clone(); + backend.superseded_docs.lock().unwrap().insert(doc.id.0); + backend.owners.remove(&doc.id.0); + backend.locations.remove(&doc.id.0); + for b in [&mut backend, &mut earlier] { + assert!(b.save(doc.clone(), 1, json!({})).unwrap_err().contains("云端新版")); + assert!(b.ensure_owner(doc.id.0).is_err()); + assert!(b.project_dir(doc.id.0).is_err()); + } + backend.bind_new(doc.id.0); + assert!(!backend.owners.contains_key(&doc.id.0)); + assert!(!backend.locations.contains_key(&doc.id.0)); + assert!(!root.exists(), "late autosave must not recreate any project directory"); + } + #[test] fn unknown_journals_survive_restart() { let root = std::env::temp_dir().join(format!("jiansun-test-{}", Uuid::new_v4())); let mut b = Backend { @@ -1238,6 +1547,7 @@ mod tests { owners: HashMap::new(), locations: HashMap::new(), last_saved: HashMap::new(), + superseded_docs: Default::default(), recovery_attempts: Default::default(), recovery_serial: Default::default(), }; @@ -1262,6 +1572,7 @@ mod tests { owners: HashMap::new(), locations: HashMap::new(), last_saved: HashMap::new(), + superseded_docs: Default::default(), recovery_attempts: Default::default(), recovery_serial: Default::default(), }; diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_creation.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_creation.rs index 769de8dd..1f49d46b 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_creation.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_creation.rs @@ -5,6 +5,10 @@ use base64::engine::general_purpose::STANDARD; #[path = "jiansun_creation_model.rs"] mod model; +// One finite total budget for a NEW creation POST, including response reading. +// Expiry retains the original request for read-only recovery, never replay. +const CREATION_REQUEST_TIMEOUT: Duration = Duration::from_secs(600); + pub(super) fn load(dir: &Path) -> R { let file = dir.join("creation.json"); if file.exists() { @@ -32,27 +36,46 @@ pub(super) fn save(dir: &Path, c: &mut Value) -> R<()> { /// Ordinary product saves remain entirely local, including unsupported categories. pub(super) fn attach_agent_rules(dir: &Path, saved: &Value) -> R<()> { let mut c = load(dir)?; - let shop = saved["catalog"]["shops"].as_array().and_then(|a|a.first()).ok_or("项目模板品牌缺失")?; - let category = saved["catalog"]["categories"].as_array().and_then(|a|a.first()).ok_or("项目模板品类缺失")?; - if shop["id"] != c["brief"]["brandId"] || category["id"] != c["brief"]["categoryId"] { return Err("项目资料已切换,未载入旧模板".into()); } - if c["designSnapshot"].is_null() { return Err("请先保存项目品牌资料".into()); } - if c["designSnapshot"]["rules"] == *saved { return Ok(()); } + let shop = saved["catalog"]["shops"].as_array().and_then(|a| a.first()).ok_or("项目模板品牌缺失")?; + let category = saved["catalog"]["categories"].as_array().and_then(|a| a.first()).ok_or("项目模板品类缺失")?; + if shop["id"] != c["brief"]["brandId"] || category["id"] != c["brief"]["categoryId"] { + return Err("项目资料已切换,未载入旧模板".into()); + } + if c["designSnapshot"].is_null() { + return Err("请先保存项目品牌资料".into()); + } + if c["designSnapshot"]["rules"] == *saved { + return Ok(()); + } let before = c.clone(); c["designSnapshot"]["rules"] = saved.clone(); - model::invalidate(&mut c,&before); - save(dir,&mut c) + model::invalidate(&mut c, &before); + save(dir, &mut c) } /// Reserve the screen/task association before a paid dispatch. The immutable /// native baseline makes automatic recovery conditional on *unchanged* editing. pub(super) fn bind_image_dispatch(dir: &Path, task: &mut Value) -> R<()> { let mut c = load(dir)?; let b = task["creationBinding"].clone(); - for (actual, expected) in [(c["generation"]["run"].clone(), b["run"].clone()), (c["generation"]["planHash"].clone(), b["planHash"].clone()), (c["generation"]["pending"]["screenId"].clone(), b["screenId"].clone())] { - if actual.is_null() || actual != expected { return Err("图片派发前方案关联已变化,未发送请求".into()); } + for (actual, expected) in [ + (c["generation"]["run"].clone(), b["run"].clone()), + (c["generation"]["planHash"].clone(), b["planHash"].clone()), + (c["generation"]["pending"]["screenId"].clone(), b["screenId"].clone()), + ] { + if actual.is_null() || actual != expected { + return Err("图片派发前方案关联已变化,未发送请求".into()); + } } - if c["plan"]["status"] != "confirmed" || plan_hash(&c)? != b["planHash"] { return Err("页面方案已有新版本,未发送请求".into()); } - if c["generation"]["pending"]["taskId"].is_string() && c["generation"]["pending"]["taskId"] != task["id"] { return Err("屏幕已有关联任务,未发送请求".into()); } - let baseline = if !dir.join("creation-canvas.pcraft").exists() && c["generation"]["screens"].as_object().is_some_and(|rows| rows.is_empty()) && dir.join("canvas.pcraft").exists() { + if c["plan"]["status"] != "confirmed" || plan_hash(&c)? != b["planHash"] { + return Err("页面方案已有新版本,未发送请求".into()); + } + if c["generation"]["pending"]["taskId"].is_string() && c["generation"]["pending"]["taskId"] != task["id"] { + return Err("屏幕已有关联任务,未发送请求".into()); + } + let baseline = if !dir.join("creation-canvas.pcraft").exists() + && c["generation"]["screens"].as_object().is_some_and(|rows| rows.is_empty()) + && dir.join("canvas.pcraft").exists() + { Some(("initial", fs::read(dir.join("canvas.pcraft")).map_err(|_| "恢复基线无法读取,未发送请求")?)) } else { // Snapshot THIS confirmed plan and its current generated rows, never a @@ -61,10 +84,15 @@ pub(super) fn bind_image_dispatch(dir: &Path, task: &mut Value) -> R<()> { Some(("template", photocraft_format::save_to_bytes(&native, &Default::default()).map_err(|_| "原屏幕恢复基线无法保存,未发送请求")?)) }; if let Some((kind, bytes)) = baseline { - if bytes.len() > 256 * 1024 * 1024 { return Err("恢复基线超过限制,未发送请求".into()); } + if bytes.len() > 256 * 1024 * 1024 { + return Err("恢复基线超过限制,未发送请求".into()); + } let path = dir.join("image-jobs").join(task["id"].as_str().ok_or("任务身份无效")?).join("before-canvas.pcraft"); - { let _guard=sync::context_guard(); original_project_present(dir)?; - photocraft_format::atomic_write(&path, &bytes).map_err(|_| "恢复基线无法保存,未发送请求")?; } + { + let _guard = sync::context_guard(); + original_project_present(dir)?; + photocraft_format::atomic_write(&path, &bytes).map_err(|_| "恢复基线无法保存,未发送请求")?; + } task["creationBinding"]["baselineKind"] = json!(kind); task["creationBinding"]["baselineSha256"] = json!(format!("{:x}", Sha256::digest(&bytes))); } @@ -99,6 +127,72 @@ pub(super) fn unresolved(dir: &Path) -> R { let f = dir.join("creation-task.json"); Ok(f.exists() && matches!(read_json(&f)?["phase"].as_str(), Some("dispatched" | "unknown" | "received"))) } +pub(super) fn pending_text_tasks(dir: &Path) -> R> { + let mut pending = Vec::new(); + let archive = dir.join("creation-tasks"); + if archive.exists() { + for (n, entry) in fs::read_dir(archive).map_err(|_| "创作原任务目录无法读取")?.take(81).enumerate() { + if n >= 80 { + return Err("创作原任务过多,请保留记录核查".into()); + } + let path = entry.map_err(|_| "创作原任务无法读取")?.path(); + if path.extension().is_some_and(|s| s == "json") { + let task = read_json(&path)?; + if !matches!(task["phase"].as_str(), Some("complete" | "applied" | "failed" | "rejected" | "not_dispatched")) { + pending.push(task); + } + } + } + } + let head = dir.join("creation-task.json"); + if head.exists() { + let task = read_json(&head)?; + if !matches!(task["phase"].as_str(), Some("complete" | "applied" | "failed" | "rejected" | "not_dispatched")) && !pending.contains(&task) { + pending.push(task); + } + } + Ok(pending) +} +/// A text candidate never supplies image pixels or auto-applies its text. +/// Only this exact archived, still-unknown slot-copy can remain independent of +/// a first image dispatch under the same immutable confirmed input/template. +pub(super) fn slot_image_text_allowed(dir: &Path, live: &Value, pack: &Value) -> R { + let pending = pending_text_tasks(dir)?; + if pending.is_empty() { + return Ok(true); + } + if pending.len() != 1 { + return Ok(false); + } + let task = &pending[0]; + let Some(id) = task["id"].as_str().filter(|id| Uuid::parse_str(id).is_ok()) else { return Ok(false) }; + if task["phase"] != "unknown" || task["stage"] != "slot-copy" { + return Ok(false); + } + let head = dir.join("creation-task.json"); + let archive = dir.join("creation-tasks").join(format!("{id}.json")); + if !head.is_file() || !archive.is_file() || read_json(&head)? != *task || read_json(&archive)? != *task { + return Ok(false); + } + let original = &task["document"]; + let Ok(original_hash) = model::input_hash("slot-copy", original) else { return Ok(false) }; + let Ok(original_plan) = plan_hash(original) else { return Ok(false) }; + let Ok(live_plan) = plan_hash(live) else { return Ok(false) }; + let fixed = read_json(&dir.join("template-input.json"))?; + Ok(task["inputHash"] == original_hash + && original["plan"]["status"] == "confirmed" + && original["plan"]["confirmedInput"] == original_plan + && live["plan"]["status"] == "confirmed" + && live["plan"]["confirmedInput"] == live_plan + && original_plan == live_plan + && fixed["planHash"] == live_plan + && fixed["templateHash"] == pack["contentHash"] + && original["templateCopyInput"]["templateHash"] == pack["contentHash"] + && original["brief"]["brandId"] == pack["brandId"] + && original["brief"]["categoryId"] == pack["categoryId"] + && live["brief"]["brandId"] == pack["brandId"] + && live["brief"]["categoryId"] == pack["categoryId"]) +} fn assets_file(dir: &Path, source: &str) -> R { let id = source.strip_prefix("local:").filter(|s| s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())).ok_or("产品图片来源无效")?; Ok(dir.join("creation-assets").join(format!("{id}.png"))) @@ -140,12 +234,18 @@ fn clear_product_brand(c: &mut Value, name: &str) { c["designSnapshot"] = Value::Null; } fn copy_product_category(c: &mut Value, product: &Value) -> R<()> { - c["brief"]["categoryId"] = json!(""); c["brief"]["categoryName"] = json!(""); + c["brief"]["categoryId"] = json!(""); + c["brief"]["categoryName"] = json!(""); let id = product["categoryId"].as_str().unwrap_or(""); let name = product["categoryName"].as_str().unwrap_or(""); - if id.is_empty() && name.is_empty() { return Ok(()); } - if Uuid::parse_str(id).is_err() || name.is_empty() || name.chars().count() > 120 { return Err("素材库产品品类资料无效,未沿用旧品类".into()); } - c["brief"]["categoryId"] = json!(id); c["brief"]["categoryName"] = json!(name); + if id.is_empty() && name.is_empty() { + return Ok(()); + } + if Uuid::parse_str(id).is_err() || name.is_empty() || name.chars().count() > 120 { + return Err("素材库产品品类资料无效,未沿用旧品类".into()); + } + c["brief"]["categoryId"] = json!(id); + c["brief"]["categoryName"] = json!(name); Ok(()) } fn task_public(t: &Value) -> Value { @@ -213,13 +313,17 @@ fn apply_text(c: &mut Value, t: &Value, result: Value) -> R<()> { Some("plan") => { c["plan"] = json!({"status":"needs-confirm","screens":result["screens"],"warnings":result["warnings"],"invalidReason":"","confirmedAt":null,"confirmedInput":null,"inputHash":t["inputHash"]}); } + Some("slot-copy") => { + c["templateCopy"] = + json!({"taskId":t["id"],"fields":result["fields"],"warnings":result["warnings"],"templateInput":t["document"]["templateCopyInput"]}); + } _ => return Err("创作阶段无效".into()), } Ok(()) } impl Backend { - fn creation_guard(&self, doc: u64, dir: &Path) -> R<()> { + pub(super) fn creation_guard(&self, doc: u64, dir: &Path) -> R<()> { self.ensure_owner(doc)?; if unresolved(dir)? || images::pending(dir)? { return Err("有原请求待核查,不会重发或新建模型任务".into()); @@ -235,6 +339,10 @@ impl Backend { } Ok(()) } + pub(super) fn slot_image_guard(&self, doc: u64, dir: &Path) -> R<()> { + self.ensure_owner(doc)?; + template_workflow::validate_image_context(dir, None) + } fn creation_result(&self, doc: u64, dir: &Path) -> R { let c = load(dir)?; let mut list = tasks(dir)?; @@ -243,8 +351,9 @@ impl Backend { let t = read_json(&image)?; list.push(json!({"id":t["id"],"stage":"image","phase":t["phase"],"requestId":t["requestId"],"idempotencyKey":t["id"],"model":t["modelId"],"message":t["message"],"canRecover":matches!(t["phase"].as_str(),Some("unknown"|"running"|"dispatched"))||c["generation"]["pending"].is_object()})); } + let workflow = template_workflow::snapshot(dir, &c)?; Ok( - json!({"doc":doc,"accountId":self.auth["account"]["id"],"origin":self.origin,"creation":c,"tasks":list,"pending":unresolved(dir)?||images::pending(dir)?||c["generation"]["pending"].is_object()}), + json!({"doc":doc,"accountId":self.auth["account"]["id"],"origin":self.origin,"creation":c,"templateWorkflow":workflow,"tasks":list,"pending":workflow["pending"]==true||unresolved(dir)?||images::pending(dir)?||c["generation"]["pending"].is_object()}), ) } @@ -312,8 +421,7 @@ impl Backend { ) } "creation-thumbnail" => { - let mut reference = - json!({"purpose":"creation-material","id":input["id"],"doc":doc,"accountId":self.auth["account"]["id"],"origin":self.origin}); + let mut reference = json!({"purpose":"creation-material","readId":input["readId"],"id":input["id"],"kind":input["kind"],"query":input["query"],"doc":doc,"accountId":self.auth["account"]["id"],"origin":self.origin}); let outcome = (|| -> R { // The thumbnail GET performs its own live permission check. let id = identifier(input["id"].as_str().ok_or("材质编号无效")?)?; @@ -327,7 +435,7 @@ impl Backend { ) .map_err(|_| "材质缩略图无法读取")? .convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); - let v = json!({"purpose":"creation-material","id":id,"doc":doc,"accountId":self.auth["account"]["id"],"origin":self.origin}); + let v = reference.clone(); tx.send(Event::Preview( v.clone(), egui::ColorImage::from_rgba_unmultiplied([decoded.width() as usize, decoded.height() as usize], decoded.data()), @@ -406,7 +514,7 @@ impl Backend { self.creation_guard(doc, &dir)?; let mut c = load(&dir)?; revision(&c, input)?; - let value = json!({"screens":input["screens"],"warnings":c["plan"]["warnings"].as_array().cloned().unwrap_or_default()}); + let value = json!({"screens":input["screens"],"warnings":model::plan_edit_warnings(&c)?}); let mut result = model::parse("plan", &value.to_string(), &c)?; let original = input["screens"].as_array().ok_or("页面方案无效")?; for (screen, old) in result["screens"].as_array_mut().ok_or("页面方案无效")?.iter_mut().zip(original) { @@ -500,7 +608,7 @@ impl Backend { Ok((settings, selected)) } - fn creation_text(&mut self, method: &str, input: &Value, tx: &mpsc::Sender, ctx: &egui::Context) -> R { + pub(super) fn creation_text(&mut self, method: &str, input: &Value, tx: &mpsc::Sender, ctx: &egui::Context) -> R { let doc = input["doc"].as_u64().ok_or("项目身份无效")?; let dir = self.project_dir(doc)?; let recover = method == "creation-recover"; @@ -603,7 +711,7 @@ impl Backend { let mut req = self .http .post(format!("{UPSTREAM}{}", task["model"]["path"].as_str().ok_or("模型路径无效")?)) - .timeout(Duration::from_secs(180)) + .timeout(CREATION_REQUEST_TIMEOUT) .bearer_auth(key) .header("Idempotency-Key", task["id"].as_str().ok_or("任务身份无效")?) .json(&payload); @@ -615,7 +723,7 @@ impl Backend { } req.send() }; - let response = response.map_err(|_| "98k连接中断,原请求结果未知;不会重发")?; + let response = response.map_err(|error| stream::transport_error(&error))?; if !recover && let Some(rid) = response.headers().get("x-request-id").and_then(|s| s.to_str().ok()).filter(|s| Uuid::parse_str(s).is_ok()) { task["requestId"] = json!(rid); task_save(&dir, &task)?; @@ -835,7 +943,7 @@ fn loaded_layout(c: &Value, dir: &Path) -> R<(Value, HashMap>)> } Ok((layout, assets)) } -fn reference_canvas(c: &Value, dir: &Path, doc: u64) -> R<(Document, Vec)> { +pub(super) fn reference_canvas(c: &Value, dir: &Path, doc: u64) -> R<(Document, Vec)> { let mut sources: Vec = c["brief"]["images"].as_array().ok_or("参考图无效")?.iter().filter_map(|i| i["source"].as_str().map(str::to_owned)).collect(); if let Some(anchor) = c["generation"]["anchorSource"].as_str() @@ -1154,6 +1262,263 @@ impl Backend { #[cfg(test)] mod tests { use super::*; + struct SlotPendingFixture(PathBuf); + impl Drop for SlotPendingFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + fn slot_copy_pending_fixture() -> (SlotPendingFixture, Value, Value, Value) { + let base = std::env::var_os("JIANSUN_RULE_TEST_ROOT").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); + let dir = SlotPendingFixture(base.join(format!("creation-slot-gate-{}", Uuid::new_v4()))); + write_json(&dir.0.join("context.json"), &json!({"localId":Uuid::new_v4().to_string()})).unwrap(); + let pack: Value = serde_json::from_str(include_str!("../../../../../lib/desktop-client/template-packs/yjd-desk-dnz0385-native-v1.json")).unwrap(); + let mut c = model::empty(); + c["brief"]["name"] = json!("隔离本机图位门禁样例"); + c["brief"]["brandId"] = pack["brandId"].clone(); + c["brief"]["categoryId"] = pack["categoryId"].clone(); + c["facts"] = json!({"status":"ready","items":[{"text":"已核对产品外观"}],"missing":[]}); + c["plan"] = json!({"status":"confirmed","screens":[{"title":"完整商品图","body":"已确认内容","imagePrompt":"保留参考外观,不增绘"}]}); + c["plan"]["confirmedInput"] = json!(plan_hash(&c).unwrap()); + c["templateCopyInput"] = json!({"templateHash":pack["contentHash"],"fields":[],"baseFields":[],"slotRevision":1}); + write_json(&dir.0.join("creation.json"), &c).unwrap(); + template_slots::install(&dir.0, &pack, 0).unwrap(); + write_json(&dir.0.join("template-input.json"), &json!({"planHash":plan_hash(&c).unwrap(),"templateHash":pack["contentHash"]})).unwrap(); + let task = json!({"id":Uuid::new_v4().to_string(),"requestId":Uuid::new_v4().to_string(),"stage":"slot-copy","phase":"unknown","document":c,"inputHash":model::input_hash("slot-copy",&c).unwrap(),"partialText":"{\"fields\":[","message":"隔离未知任务,禁止重发"}); + task_save(&dir.0, &task).unwrap(); + (dir, pack, c, task) + } + #[test] + fn a_single_exact_unknown_slot_copy_allows_first_slot_pixels_but_keeps_global_unknown_and_manual_copy() { + let (dir, pack, c, old) = slot_copy_pending_fixture(); + let head = fs::read(dir.0.join("creation-task.json")).unwrap(); + let archive = fs::read(dir.0.join("creation-tasks").join(format!("{}.json", old["id"].as_str().unwrap()))).unwrap(); + let before = template_workflow::snapshot(&dir.0, &c).unwrap(); + assert_eq!(before["capabilities"], json!({"imageDispatchAllowed":true,"copyDispatchAllowed":false,"localApplyAllowed":true})); + assert_eq!(before["pending"], true); + assert!(unresolved(&dir.0).unwrap()); + let field = template_slots::slot_id(&pack, "S01-T01"); + let state = template_slots::load(&dir.0).unwrap(); + let state = + template_slots::set_texts(&dir.0, &json!([{"id":field,"value":"电脑桌"}]), &json!([{"id":field,"value":""}]), state["revision"].as_u64().unwrap()) + .unwrap(); + template_workflow::validate_image_context(&dir.0, None).unwrap(); + let slot = template_slots::slot_id(&pack, "M01-I01"); + let state = template_slots::reserve_candidate( + &dir.0, + &slot, + "A", + "fixture-no-model", + "首次独立图片", + &[format!("local:{}", "a".repeat(64))], + state["revision"].as_u64().unwrap(), + ) + .unwrap(); + let binding = template_slots::binding(&state, &slot, "A").unwrap(); + template_slots::validate_dispatch(&dir.0, &binding).unwrap(); + template_workflow::validate_image_context(&dir.0, Some(&binding)).unwrap(); + assert!(template_workflow::validate_image_context(&dir.0, None).is_err(), "the pre-reserved original blocks any second A/B dispatch"); + let mut image = + json!({"id":binding["taskId"],"phase":"dispatched","slotBinding":binding,"modelId":"fixture-no-model","requestId":null,"upstreamTaskId":null}); + images::job_save(&dir.0, &image).unwrap(); + template_slots::validate_journal_binding(&dir.0, &image).unwrap(); + template_workflow::validate_image_context(&dir.0, Some(&binding)).unwrap(); + let pixels = b"synthetic-file-hash-not-a-model-result"; + fs::write(dir.0.join("image-jobs").join(binding["taskId"].as_str().unwrap()).join("generated.png"), pixels).unwrap(); + image["phase"] = json!("complete"); + image["sha256"] = json!(format!("{:x}", Sha256::digest(pixels))); + images::job_save(&dir.0, &image).unwrap(); + let state = template_slots::reconcile_candidate(&dir.0, &image, state["revision"].as_u64().unwrap()).unwrap(); + assert_eq!(state["slots"][&field]["text"], "电脑桌"); + let after = template_workflow::snapshot(&dir.0, &c).unwrap(); + assert_eq!(after["pending"], true, "a new image cannot clear the old text unknown"); + assert_eq!(after["capabilities"]["imageDispatchAllowed"], true); + assert_eq!(after["capabilities"]["copyDispatchAllowed"], false); + assert_eq!(fs::read(dir.0.join("creation-task.json")).unwrap(), head); + assert_eq!(fs::read(dir.0.join("creation-tasks").join(format!("{}.json", old["id"].as_str().unwrap()))).unwrap(), archive); + assert!(projects::idle(&dir.0).is_err(), "unknown still prevents completion/deletion after successful pixels"); + assert!( + template_slots::set_texts(&dir.0, &json!([{"id":field,"value":"旧文案"}]), &json!([{"id":field,"value":""}]), state["revision"].as_u64().unwrap()) + .is_err(), + "late copy baseline cannot replace manually edited text" + ); + } + #[test] + fn slot_image_exception_rejects_other_stages_non_unknown_or_unmatched_archive_without_changing_them() { + for stage in ["facts", "points", "plan", "slot-copy"] { + for phase in ["unknown", "dispatched", "received"] { + if stage == "slot-copy" && phase == "unknown" { + continue; + } + let (dir, pack, c, mut task) = slot_copy_pending_fixture(); + task["stage"] = json!(stage); + task["phase"] = json!(phase); + task_save(&dir.0, &task).unwrap(); + let before = fs::read(dir.0.join("creation-task.json")).unwrap(); + assert!(!slot_image_text_allowed(&dir.0, &c, &pack).unwrap(), "{stage}/{phase}"); + assert!(template_workflow::validate_image_context(&dir.0, None).is_err()); + assert_eq!(fs::read(dir.0.join("creation-task.json")).unwrap(), before); + } + } + let (dir, pack, c, task) = slot_copy_pending_fixture(); + let archive = dir.0.join("creation-tasks").join(format!("{}.json", task["id"].as_str().unwrap())); + let mut changed = task.clone(); + changed["requestId"] = json!(Uuid::new_v4().to_string()); + write_json(&archive, &changed).unwrap(); + assert!(!slot_image_text_allowed(&dir.0, &c, &pack).unwrap()); + fs::remove_file(archive).unwrap(); + assert!(!slot_image_text_allowed(&dir.0, &c, &pack).unwrap(), "a head without its exact archive is never exempt"); + } + #[test] + fn slot_image_exception_is_not_a_bypass_for_a_second_unknown_or_changed_plan_template_brand_or_legacy_task() { + let (dir, pack, c, task) = slot_copy_pending_fixture(); + let second = json!({"id":Uuid::new_v4().to_string(),"stage":"facts","phase":"unknown"}); + let second_path = dir.0.join("creation-tasks").join(format!("{}.json", second["id"].as_str().unwrap())); + write_json(&second_path, &second).unwrap(); + assert!(!slot_image_text_allowed(&dir.0, &c, &pack).unwrap()); + fs::remove_file(second_path).unwrap(); + for key in ["template", "plan", "brand", "inputHash"] { + let mut altered = task.clone(); + match key { + "template" => altered["document"]["templateCopyInput"]["templateHash"] = json!("0".repeat(64)), + "plan" => altered["document"]["plan"]["screens"][0]["imagePrompt"] = json!("另一份未确认约束"), + "brand" => altered["document"]["brief"]["brandId"] = json!(Uuid::new_v4().to_string()), + _ => altered["inputHash"] = json!("0".repeat(64)), + } + task_save(&dir.0, &altered).unwrap(); + assert!(!slot_image_text_allowed(&dir.0, &c, &pack).unwrap(), "{key}"); + } + task_save(&dir.0, &task).unwrap(); + write_json(&dir.0.join("task.json"), &json!({"id":Uuid::new_v4().to_string(),"phase":"unknown"})).unwrap(); + let view = template_workflow::snapshot(&dir.0, &c).unwrap(); + assert_eq!(view["capabilities"]["imageDispatchAllowed"], false); + assert_eq!(view["pending"], true); + assert!(template_workflow::validate_image_context(&dir.0, None).is_err()); + } + /// Warm the actual client/runtime and keep-alive connection with a harmless + /// GET before starting the short POST budget. Cold connection scheduling in + /// the parallel native suite must not stand in for a response-read timeout. + fn stalled_creation_peer(event: Option) -> (reqwest::blocking::Client, String, mpsc::Sender<()>, std::thread::JoinHandle) { + use std::io::{Read, Write}; + fn header(socket: &mut std::net::TcpStream) -> Vec { + let mut header = Vec::new(); + let mut bytes = [0_u8; 2048]; + while header.len() < 16_384 && !header.windows(4).any(|s| s == b"\r\n\r\n") { + match socket.read(&mut bytes) { + Ok(0) => break, + Ok(n) => header.extend_from_slice(&bytes[..n]), + Err(error) => panic!("loopback header could not be read: {error}"), + } + } + assert!(header.windows(4).any(|s| s == b"\r\n\r\n"), "the bounded loopback request must finish its headers"); + header + } + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + listener.set_nonblocking(true).unwrap(); + let (release, wait) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(30); + let mut requests = 0; + loop { + match listener.accept() { + Ok((mut socket, _)) => { + // Winsock may inherit the listener's nonblocking mode. + // The accepted peer must block while reading headers. + socket.set_nonblocking(false).unwrap(); + socket.set_read_timeout(Some(Duration::from_secs(30))).unwrap(); + socket.set_write_timeout(Some(Duration::from_secs(10))).unwrap(); + let ready = header(&mut socket); + assert!(ready.starts_with(b"GET /ready HTTP/1.1\r\n")); + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n").unwrap(); + socket.flush().unwrap(); + let request = header(&mut socket); + assert!(request.starts_with(b"POST /new-creation HTTP/1.1\r\n")); + requests += 1; + if let Some(event) = &event { + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n").unwrap(); + socket.write_all(event.as_bytes()).unwrap(); + socket.flush().unwrap(); + } + // Request framing bytes are unrelated to response + // expiry. Keep the peer alive explicitly until the + // caller has observed send/read returning an error. + let _ = wait.recv_timeout(Duration::from_secs(6)); + drop(socket); + // Any automatic replay already queued before the + // client returned must be visible as another socket. + while let Ok((_, _)) = listener.accept() { + requests += 1; + } + return requests; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(2)); + } + Err(error) => panic!("loopback listener stopped before the bounded test request: {error}"), + } + } + }); + let client = reqwest::blocking::Client::builder().no_proxy().http1_only().retry(reqwest::retry::never()).build().unwrap(); + let ready = client.get(format!("http://{address}/ready")).timeout(Duration::from_secs(30)).send().unwrap(); + assert_eq!(ready.status(), reqwest::StatusCode::OK); + assert!(ready.bytes().unwrap().is_empty()); + (client, format!("http://{address}/new-creation"), release, worker) + } + #[test] + fn a_real_reqwest_send_timeout_is_classified_without_replaying_the_creation_post() { + let (client, address, release, peer) = stalled_creation_peer(None); + // Only the already-established POST is timed here; its response never + // begins until the test releases the peer after observing the timeout. + let result = client.post(&address).body(Vec::::new()).timeout(Duration::from_secs(2)).send(); + release.send(()).unwrap(); + let count = peer.join().unwrap(); + let error = result.unwrap_err(); + assert!(error.is_timeout()); + assert_eq!(stream::transport_error(&error), "客户端等待达到时限,原请求结果待核查,不会重发"); + assert_eq!(count, 1, "a timed-out paid-style POST is never retried by this transport"); + assert_eq!(CREATION_REQUEST_TIMEOUT, Duration::from_secs(600), "new creation waits remain finite"); + } + #[test] + fn a_real_reqwest_body_timeout_retains_partial_creation_text_and_the_original_unknown_barrier() { + struct Temp(PathBuf); + impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let base = std::env::var_os("JIANSUN_RULE_TEST_ROOT").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); + let dir = Temp(base.join(format!("native-creation-timeout-{}", Uuid::new_v4()))); + let id = Uuid::new_v4().to_string(); + let request_id = Uuid::new_v4().to_string(); + let partial = "{\"fields\":[{\"id\":\"已到达但不完整"; + let event = format!("data: {}\n\n", json!({"choices":[{"index":0,"delta":{"content":partial}}]})); + let (client, address, release, peer) = stalled_creation_peer(Some(event)); + let mut task = json!({"id":id,"requestId":request_id,"phase":"dispatched","stage":"slot-copy"}); + task_save(&dir.0, &task).unwrap(); + let response = client.post(&address).body(Vec::::new()).header("Idempotency-Key", &id).timeout(Duration::from_secs(2)).send().unwrap(); + let result = stream::read(response, false, |text| { + task["partialText"] = json!(text); + task_save(&dir.0, &task) + }); + release.send(()).unwrap(); + let count = peer.join().unwrap(); + let message = result.unwrap_err(); + task["phase"] = json!("unknown"); + task["message"] = json!(message); + task_save(&dir.0, &task).unwrap(); + assert_eq!(message, "客户端等待达到时限,原请求结果待核查,不会重发"); + let current = read_json(&dir.0.join("creation-task.json")).unwrap(); + let original = read_json(&dir.0.join("creation-tasks").join(format!("{id}.json"))).unwrap(); + assert_eq!(current, original); + assert_eq!(current["id"], id); + assert_eq!(current["requestId"], request_id); + assert_eq!(current["partialText"], partial); + assert!(current["text"].is_null(), "partial JSON is never promoted to a completed response"); + assert!(unresolved(&dir.0).unwrap(), "the original unknown task still blocks all new dispatches"); + assert_eq!(fs::read_dir(dir.0.join("creation-tasks")).unwrap().count(), 1); + assert_eq!(count, 1, "the body timeout never resends the original POST"); + } #[test] fn incomplete_outputs_are_not_accepted_as_valid_facts() { assert!(response_text(&json!({"choices":[{"finish_reason":"length","message":{"content":"{}"}}]}), false).is_err()); @@ -1241,12 +1606,14 @@ mod tests { #[test] fn library_product_only_uses_an_explicit_official_category_and_never_reuses_the_previous_one() { let mut c = model::empty(); - c["brief"]["categoryId"] = json!("old-category"); c["brief"]["categoryName"] = json!("电脑桌"); - copy_product_category(&mut c,&json!({"productModel":"电脑桌样例不能据此猜品类"})).unwrap(); - assert_eq!(c["brief"]["categoryId"],""); assert_eq!(c["brief"]["categoryName"],""); - copy_product_category(&mut c,&json!({"categoryId":"53333bd0-9cc9-440b-80db-dd04280f0295","categoryName":"化妆台"})).unwrap(); - assert_eq!(c["brief"]["categoryId"],"53333bd0-9cc9-440b-80db-dd04280f0295"); - assert!(copy_product_category(&mut c,&json!({"categoryId":"../../../","categoryName":"电脑桌"})).is_err()); + c["brief"]["categoryId"] = json!("old-category"); + c["brief"]["categoryName"] = json!("电脑桌"); + copy_product_category(&mut c, &json!({"productModel":"电脑桌样例不能据此猜品类"})).unwrap(); + assert_eq!(c["brief"]["categoryId"], ""); + assert_eq!(c["brief"]["categoryName"], ""); + copy_product_category(&mut c, &json!({"categoryId":"53333bd0-9cc9-440b-80db-dd04280f0295","categoryName":"化妆台"})).unwrap(); + assert_eq!(c["brief"]["categoryId"], "53333bd0-9cc9-440b-80db-dd04280f0295"); + assert!(copy_product_category(&mut c, &json!({"categoryId":"../../../","categoryName":"电脑桌"})).is_err()); } #[test] fn generated_images_use_real_landscape_portrait_and_square_aspects_and_old_rows_reflow_read_only() { diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_creation_model.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_creation_model.rs index a765c60e..0250b658 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_creation_model.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_creation_model.rs @@ -27,7 +27,10 @@ pub(super) fn brief(value: &Value) -> R { out[field] = json!(string(&value[field], max, field)?); } for (field, max) in [("categoryId", 150), ("categoryName", 120)] { - out[field] = json!(match value.get(field) { Some(value) => string(value, max, field)?, None => String::new() }); + out[field] = json!(match value.get(field) { + Some(value) => string(value, max, field)?, + None => String::new(), + }); } if !matches!(value["source"].as_str(), Some("upload" | "library")) || !matches!(value["identifierType"].as_str(), Some("sku" | "spu" | "barcode")) { return Err("资料来源或编号类型无效".into()); @@ -97,7 +100,40 @@ pub(super) fn selected(c: &Value) -> R> { } pub(super) fn input_hash(stage: &str, c: &Value) -> R { - Ok(hash(&json!({"stage":stage,"brief":c["brief"],"brand":c["designSnapshot"],"width":c["width"],"screenCount":if stage=="plan"{c["screenCount"].clone()}else{Value::Null},"facts":if stage=="facts" {Value::Null}else{c["facts"].clone()},"points":if stage=="points" {c["sellingPoints"].clone()}else{Value::Null},"selected":if stage=="plan" {json!(selected(c)?)}else{Value::Null}}).to_string())) + let mut input = json!({"stage":stage,"brief":c["brief"],"brand":c["designSnapshot"],"width":c["width"],"screenCount":if stage=="plan"{c["screenCount"].clone()}else{Value::Null},"facts":if stage=="facts" {Value::Null}else{c["facts"].clone()},"points":if stage=="points" {c["sellingPoints"].clone()}else{Value::Null},"selected":if stage=="plan" {json!(selected(c)?)}else{Value::Null}}); + if stage == "slot-copy" { + input["templateCopyInput"] = c["templateCopyInput"].clone(); + input["plan"] = c["plan"].clone(); + input["selected"] = json!(selected(c)?); + } + Ok(hash(&input.to_string())) +} + +fn slot_copy_fields(c: &Value) -> R> { + let input = &c["templateCopyInput"]; + if !input["templateHash"].as_str().is_some_and(|s| s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())) || input["slotRevision"].as_u64().is_none() { + return Err("文字图位模板版本缺失".into()); + } + let fields = input["fields"].as_array().filter(|a| !a.is_empty() && a.len() <= 80).ok_or("文字图位应为1至80个")?; + let mut ids = std::collections::HashSet::new(); + let mut out = Vec::new(); + for field in fields { + let id = field["id"].as_str().filter(|s| s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())).ok_or("文字图位稳定编号无效")?; + if !ids.insert(id) { + return Err("文字图位编号重复".into()); + } + let field_id = string(&field["fieldId"], 100, "文字框编号")?; + let document = string(&field["documentId"], 100, "输出编号")?; + if field_id.trim().is_empty() || document.trim().is_empty() { + return Err("文字框或输出编号缺失".into()); + } + let optional = + |key: &str, max: usize| -> R { field.get(key).filter(|v| !v.is_null()).map_or_else(|| Ok(String::new()), |v| string(v, max, key)) }; + let max_chars = field["maxChars"].as_u64().filter(|n| (1..=3000).contains(n)).ok_or("文字图位字数约束无效")?; + let max_lines = field["maxLines"].as_u64().filter(|n| (1..=100).contains(n)).ok_or("文字图位行数约束无效")?; + out.push(json!({"id":id,"fieldId":field_id,"documentId":document,"label":optional("label",200)?,"outputName":optional("outputName",200)?,"role":optional("role",1000)?,"maxChars":max_chars,"maxLines":max_lines})); + } + Ok(out) } pub(super) fn prompt(stage: &str, c: &Value) -> R { @@ -128,6 +164,24 @@ pub(super) fn prompt(stage: &str, c: &Value) -> R { json!(batches.iter().map(|b| b["items"].clone()).collect::>()) )); } + if stage == "slot-copy" { + let fields = slot_copy_fields(c)?; + let points = selected(c)?; + if c["plan"]["status"] != "confirmed" || !c["plan"]["screens"].as_array().is_some_and(|a| !a.is_empty()) { + return Err("先确认当前产品页面方案,再生成图位文案".into()); + } + if points.is_empty() || points.iter().any(|p| p["factsHash"] != facts_hash(c)) { + return Err("请先选择当前已核对事实对应的卖点".into()); + } + return Ok(format!( + "{FACTUAL_RULES}\n{CONSUMER_COPY}\n以上成稿规则同样适用于fields[].text。现在只写稳定文字框的消费者文案,不生成图片,不执行画布操作,也不宣称已回填。只返回完整JSON:{{\"fields\":[{{\"id\":\"输入中的稳定编号\",\"text\":\"消费者文案或空字符串\"}}],\"warnings\":[\"缺失信息及相关文字框编号\"]}}。必须完整覆盖下面每一个id,且每个id恰好一次;不遗漏、不新增、不重复、不更换id。每个文字框分别遵守maxChars(中文、数字、标点均各计一字,换行也计入)和maxLines(换行划分行数);不要为撑字数而扩写。原模板的标题、角色、尺寸位、工艺位只是结构需求,不是当前产品事实;禁止继承模板示例参数、材质、认证、设备或性能承诺。当前已核实事实不足以填写的文字框text必须留空,并在warnings说明相关框缺什么,不用待确认或免责声明填入成稿。空值也必须保留该id。warnings最多20条,可合并同类缺失。\n产品:{product}\n已核对事实:{}\n已选卖点:{}\n品牌风格快照(只控制调性,不是性能证据):{knowledge}\n用户已确认的页面方案:{}\n固定模板校验码:{}\n全部文字框:{}", + c["facts"], + json!(points), + c["plan"]["screens"], + c["templateCopyInput"]["templateHash"], + json!(fields) + )); + } if stage != "plan" { return Err("创作阶段无效".into()); } @@ -148,11 +202,22 @@ pub(super) fn prompt(stage: &str, c: &Value) -> R { fn plan_template(c: &Value) -> R { let saved = &c["designSnapshot"]["rules"]; let catalog = &saved["catalog"]; - let binding = catalog["bindings"].as_array().filter(|a|a.len()==1).and_then(|a|a.first()).ok_or("请先在产品资料中选择已配置模板的品牌和品类")?; - let shop = catalog["shops"].as_array().filter(|a|a.len()==1).and_then(|a|a.first()).ok_or("项目模板品牌缺失")?; - if shop["id"] != c["brief"]["brandId"] || binding["categoryId"] != c["brief"]["categoryId"] || binding["state"] != "ready" { return Err("项目模板与品牌、品类不匹配,请核对产品资料".into()); } - let screens:Vec<_> = binding["template"]["screens"].as_array().ok_or("项目模板模块缺失")?.iter().filter(|s|s["role"]=="content").take(12).map(|s|json!({"id":s["id"],"title":s["title"]})).collect(); - if screens.is_empty() { return Err("此品类模板尚未配置内容模块".into()); } + let binding = catalog["bindings"].as_array().filter(|a| a.len() == 1).and_then(|a| a.first()).ok_or("请先在产品资料中选择已配置模板的品牌和品类")?; + let shop = catalog["shops"].as_array().filter(|a| a.len() == 1).and_then(|a| a.first()).ok_or("项目模板品牌缺失")?; + if shop["id"] != c["brief"]["brandId"] || binding["categoryId"] != c["brief"]["categoryId"] || binding["state"] != "ready" { + return Err("项目模板与品牌、品类不匹配,请核对产品资料".into()); + } + let screens: Vec<_> = binding["template"]["screens"] + .as_array() + .ok_or("项目模板模块缺失")? + .iter() + .filter(|s| s["role"] == "content") + .take(12) + .map(|s| json!({"id":s["id"],"title":s["title"]})) + .collect(); + if screens.is_empty() { + return Err("此品类模板尚未配置内容模块".into()); + } Ok(json!({"id":saved["id"],"revision":saved["contentHash"],"modules":screens})) } @@ -170,6 +235,20 @@ fn entries(v: &Value, limit: usize, max: usize, min: usize) -> R> { } Ok(out) } + +/// A saved plan contains the union of up to 20 inherited facts warnings and up +/// to 20 model warnings. Editing it is not a new model response: remove only +/// exact inherited strings before feeding the original per-response validator. +pub(super) fn plan_edit_warnings(c: &Value) -> R { + let inherited = entries(&c["facts"]["missing"], 20, 500, 0)?; + let saved = json!(c["plan"]["warnings"].as_array().cloned().unwrap_or_default()); + let own: Vec = entries(&saved, 40, 500, 0)?.into_iter().filter(|warning| !inherited.contains(warning)).collect(); + if own.len() > 20 { + return Err("原方案的新增待确认项超过单次模型20条限制,请保留原记录核查".into()); + } + Ok(json!(own)) +} + pub(super) fn parse(stage: &str, text: &str, c: &Value) -> R { if text.len() > 120_000 { return Err("模型结果超出限制".into()); @@ -182,8 +261,11 @@ pub(super) fn parse(stage: &str, text: &str, c: &Value) -> R { warnings.push(item); } } - if warnings.len() > 20 { - return Err("待确认项超过20条,请先核对资料".into()); + // Each input independently passed the 20-item gate above. Their bounded + // union can contain 40 distinct items; never drop inherited facts warnings + // or reject a complete model response solely because the union exceeds 20. + if warnings.len() > 40 { + return Err("合并待确认项超过40条,请先核对资料".into()); } if stage == "facts" { return Ok(json!({"facts":entries(&v["facts"],50,500,1)?,"warnings":warnings})); @@ -215,6 +297,39 @@ pub(super) fn parse(stage: &str, text: &str, c: &Value) -> R { } return Ok(json!({"items":items,"warnings":warnings})); } + if stage == "slot-copy" { + let expected = slot_copy_fields(c)?; + let actual = v["fields"].as_array().filter(|a| a.len() == expected.len()).ok_or("图位文案未完整覆盖全部文字框,原响应已保留")?; + let mut values = std::collections::BTreeMap::new(); + let mut empty = false; + for row in actual { + let id = row["id"].as_str().ok_or("图位文案缺少稳定编号")?; + let definition = expected.iter().find(|f| f["id"] == id).ok_or("图位文案包含非本次文字框编号")?; + let max = definition["maxChars"].as_u64().ok_or("图位字数约束无效")? as usize; + let copy = string(&row["text"], max, "图位文案")?; + if copy.chars().any(|c| c.is_control() && c != '\n') + || copy.split('\n').count() as u64 > definition["maxLines"].as_u64().ok_or("图位行数约束无效")? + { + return Err(format!("{}文案行数或控制字符超出图位约束", definition["fieldId"])); + } + let copy = if copy.trim().is_empty() { String::new() } else { copy }; + empty |= copy.is_empty(); + if values.insert(id.to_owned(), copy).is_some() { + return Err("图位文案稳定编号重复".into()); + } + } + if empty && warnings.is_empty() { + return Err("空白图位需要在warnings说明缺失信息,原响应已保留".into()); + } + let fields = expected + .iter() + .map(|f| { + let id = f["id"].as_str().ok_or("文字框编号无效")?; + Ok(json!({"id":id,"text":values.get(id).ok_or("图位文案缺失")?})) + }) + .collect::>>()?; + return Ok(json!({"fields":fields,"warnings":warnings})); + } let screens = v["screens"].as_array().filter(|a| !a.is_empty() && a.len() <= 12).ok_or("方案必须含1至12屏")?; let mut out = Vec::new(); for screen in screens { @@ -256,6 +371,190 @@ pub(super) fn invalidate(next: &mut Value, before: &Value) { #[cfg(test)] mod tests { use super::*; + + fn warning_list(prefix: &str, count: usize) -> Vec { + (0..count).map(|i| format!("{prefix}待确认事项{}", i + 1)).collect() + } + fn five_screen_plan(warnings: &[String]) -> Value { + json!({"screens":(0..5).map(|i| json!({"title":format!("第{}屏",i+1),"body":"仅描述已核对外观","imagePrompt":"保持当前产品外观,不添加文字"})).collect::>(),"warnings":warnings}) + } + #[test] + fn retained_complete_five_screen_plan_preserves_twelve_plus_twelve_warnings() { + let mut c = empty(); + let inherited = warning_list("事实", 12); + let own = warning_list("方案", 12); + c["facts"]["missing"] = json!(inherited); + let retained_text = five_screen_plan(&own).to_string(); + let result = parse("plan", &retained_text, &c).unwrap(); + assert_eq!(result["screens"].as_array().unwrap().len(), 5); + assert_eq!(result["warnings"].as_array().unwrap().len(), 24); + assert_eq!(result["warnings"], json!([inherited, own].concat())); + assert_eq!( + parse("plan", &retained_text, &c).unwrap()["warnings"], + result["warnings"], + "the original retained text can be reparsed without any new model call" + ); + } + #[test] + fn per_model_warning_limit_and_per_item_size_are_not_relaxed_by_union_capacity() { + let mut c = empty(); + c["facts"]["missing"] = json!(warning_list("事实", 12)); + assert!(parse("plan", &five_screen_plan(&warning_list("模型", 21)).to_string(), &c).is_err()); + assert!( + parse("plan", &five_screen_plan(&vec!["同一原文".into(); 21]).to_string(), &c).is_err(), + "raw input length is checked before exact duplicate removal" + ); + assert!(parse("facts", &json!({"facts":["可见外观"],"warnings":warning_list("模型",21)}).to_string(), &c).is_err()); + assert!(parse("plan", &five_screen_plan(&["字".repeat(501)]).to_string(), &c).is_err()); + assert!(parse("plan", &five_screen_plan(&["字".repeat(500)]).to_string(), &c).is_ok()); + c["facts"]["missing"] = json!(warning_list("事实", 21)); + assert!(parse("plan", &five_screen_plan(&[]).to_string(), &c).is_err()); + } + #[test] + fn plan_edit_roundtrip_removes_only_exact_inherited_input_then_restores_the_full_union() { + let mut c = empty(); + let inherited = warning_list("事实", 12); + let own = warning_list("方案", 12); + c["facts"]["missing"] = json!(inherited); + let saved = parse("plan", &five_screen_plan(&own).to_string(), &c).unwrap(); + c["plan"]["warnings"] = saved["warnings"].clone(); + let edited_input = plan_edit_warnings(&c).unwrap(); + assert_eq!(edited_input, json!(own)); + let mut edited = five_screen_plan(&[]); + edited["warnings"] = edited_input; + edited["screens"][0]["title"] = json!("用户修改的标题"); + let roundtrip = parse("plan", &edited.to_string(), &c).unwrap(); + assert_eq!(roundtrip["warnings"], saved["warnings"]); + assert_eq!(roundtrip["screens"][0]["title"], "用户修改的标题"); + c["plan"]["warnings"] = json!(["材料未确认", "材质未确认"]); + c["facts"]["missing"] = json!(["材料未确认"]); + assert_eq!(plan_edit_warnings(&c).unwrap(), json!(["材质未确认"]), "similar meanings are never used to delete a warning"); + assert_eq!( + parse("plan", &json!({"screens":edited["screens"],"warnings":plan_edit_warnings(&c).unwrap()}).to_string(), &c).unwrap()["warnings"], + c["plan"]["warnings"] + ); + } + #[test] + fn maximum_warning_union_is_forty_and_manual_edits_keep_both_input_bounds() { + let mut c = empty(); + c["facts"]["missing"] = json!(warning_list("事实", 20)); + let own = warning_list("方案", 20); + let saved = parse("plan", &five_screen_plan(&own).to_string(), &c).unwrap(); + assert_eq!(saved["warnings"].as_array().unwrap().len(), 40); + c["plan"]["warnings"] = saved["warnings"].clone(); + assert_eq!(plan_edit_warnings(&c).unwrap(), json!(own)); + let edited = json!({"screens":saved["screens"],"warnings":plan_edit_warnings(&c).unwrap()}); + assert_eq!(parse("plan", &edited.to_string(), &c).unwrap()["warnings"], saved["warnings"]); + c["plan"]["warnings"] = json!(warning_list("独立方案", 21)); + assert!(plan_edit_warnings(&c).is_err(), "saved union capacity must not admit 21 model-owned warnings"); + c["plan"]["warnings"] = json!(warning_list("非法合并", 41)); + assert!(plan_edit_warnings(&c).is_err()); + c["plan"]["warnings"] = json!(["字".repeat(501)]); + assert!(plan_edit_warnings(&c).is_err()); + } + + fn slot_copy_fixture() -> Value { + let mut c = empty(); + c["brief"]["name"] = json!("电脑桌"); + c["brief"]["brandId"] = json!("brand"); + c["brief"]["images"] = json!([{"source":format!("local:{}","a".repeat(64)),"purpose":"product","name":"实拍"}]); + c["designSnapshot"] = json!({"brand":{"id":"brand","profile":{"tone":"简洁清楚"}}}); + c["facts"] = json!({"status":"ready","items":[{"text":"四条支腿","source":"实拍核对"}],"missing":[]}); + c["sellingPoints"]["batches"] = json!([{"id":"chosen","items":["四腿支撑结构"],"factsHash":facts_hash(&c)}]); + c["sellingPoints"]["selected"] = json!(["chosen:0"]); + c["plan"] = json!({"status":"confirmed","screens":[{"title":"四腿结构","body":"简洁轮廓","imagePrompt":"保留产品外观"}],"confirmedInput":"fixed-plan"}); + c["templateCopyInput"] = json!({"templateHash":"c".repeat(64),"slotRevision":7,"fields":[ + {"id":"a".repeat(64),"fieldId":"S01-T01","label":"标题","documentId":"S01","outputName":"首屏","role":"产品标题","maxChars":4,"maxLines":1}, + {"id":"b".repeat(64),"fieldId":"S01-T02","label":"说明","documentId":"S01","outputName":"首屏","role":"结构说明","maxChars":8,"maxLines":2} + ]}); + c + } + + fn slot_copy_response() -> Value { + json!({"fields":[{"id":"a".repeat(64),"text":"四腿桌"},{"id":"b".repeat(64),"text":"简洁\n四腿结构"}],"warnings":[]}) + } + + #[test] + fn slot_copy_requires_complete_unique_known_fields_and_a_complete_json_response() { + let c = slot_copy_fixture(); + assert!(parse("slot-copy", &slot_copy_response().to_string(), &c).is_ok()); + let mut unknown = slot_copy_response(); + unknown["fields"][0]["id"] = json!("f".repeat(64)); + assert!(parse("slot-copy", &unknown.to_string(), &c).unwrap_err().contains("非本次")); + let mut duplicate = slot_copy_response(); + duplicate["fields"][1]["id"] = duplicate["fields"][0]["id"].clone(); + duplicate["fields"][1]["text"] = json!("四腿"); + assert!(parse("slot-copy", &duplicate.to_string(), &c).unwrap_err().contains("重复")); + let mut missing = slot_copy_response(); + missing["fields"].as_array_mut().unwrap().pop(); + assert!(parse("slot-copy", &missing.to_string(), &c).unwrap_err().contains("完整覆盖")); + assert!(parse("slot-copy", r#"{"fields":[{"id":"a","text":"半句"}"#, &c).unwrap_err().contains("完整JSON")); + } + + #[test] + fn slot_copy_checks_character_and_actual_line_limits_before_accepting_any_field() { + let c = slot_copy_fixture(); + for value in [json!("一二三四五"), json!("一\n二"), json!("一\n"), json!("字\t"), Value::Null] { + let mut response = slot_copy_response(); + response["fields"][0]["text"] = value; + assert!(parse("slot-copy", &response.to_string(), &c).is_err()); + } + let mut response = slot_copy_response(); + response["fields"][0]["text"] = json!("一二三四"); + assert_eq!(parse("slot-copy", &response.to_string(), &c).unwrap()["fields"][0]["text"], "一二三四"); + } + + #[test] + fn slot_copy_preserves_empty_fact_dependent_fields_only_with_warnings() { + let c = slot_copy_fixture(); + let mut response = slot_copy_response(); + response["fields"][0]["text"] = json!(""); + assert!(parse("slot-copy", &response.to_string(), &c).unwrap_err().contains("warnings")); + response["warnings"] = json!(["S01-T01 缺少用户确认的产品名称,留空"]); + let result = parse("slot-copy", &response.to_string(), &c).unwrap(); + assert_eq!(result["fields"][0]["text"], ""); + assert_eq!(result["warnings"], response["warnings"]); + } + + #[test] + fn slot_copy_prompt_is_bound_to_confirmed_facts_selected_points_and_field_limits() { + let mut c = slot_copy_fixture(); + let content = prompt("slot-copy", &c).unwrap(); + for value in ["maxChars", "maxLines", "禁止继承模板示例参数", "text必须留空", "用户已确认的页面方案", "四腿支撑结构"] { + assert!(content.contains(value), "{value}"); + } + c["plan"]["status"] = json!("stale"); + assert!(prompt("slot-copy", &c).is_err()); + c["plan"]["status"] = json!("confirmed"); + c["sellingPoints"]["selected"] = json!([]); + assert!(prompt("slot-copy", &c).is_err()); + c["sellingPoints"]["selected"] = json!(["chosen:0"]); + c["facts"]["status"] = json!("stale"); + assert!(prompt("slot-copy", &c).is_err()); + } + + #[test] + fn slot_copy_hash_includes_template_revision_plan_and_evidence_without_changing_other_stages() { + let c = slot_copy_fixture(); + let base = input_hash("slot-copy", &c).unwrap(); + for field in ["template", "revision", "plan", "facts"] { + let mut changed = c.clone(); + match field { + "template" => changed["templateCopyInput"]["templateHash"] = json!("d".repeat(64)), + "revision" => changed["templateCopyInput"]["slotRevision"] = json!(8), + "plan" => changed["plan"]["screens"][0]["title"] = json!("用户已改的新方案"), + _ => changed["facts"]["items"][0]["text"] = json!("三条支腿"), + } + assert_ne!(input_hash("slot-copy", &changed).unwrap(), base, "{field}"); + } + for stage in ["facts", "points", "plan"] { + let legacy=hash(&json!({"stage":stage,"brief":c["brief"],"brand":c["designSnapshot"],"width":c["width"],"screenCount":if stage=="plan"{c["screenCount"].clone()}else{Value::Null},"facts":if stage=="facts" {Value::Null}else{c["facts"].clone()},"points":if stage=="points" {c["sellingPoints"].clone()}else{Value::Null},"selected":if stage=="plan" {json!(selected(&c).unwrap())}else{Value::Null}}).to_string()); + assert_eq!(input_hash(stage, &c).unwrap(), legacy, "{stage} preserves the original hash contract"); + let mut changed = c.clone(); + changed["templateCopyInput"]["slotRevision"] = json!(99); + assert_eq!(input_hash(stage, &changed).unwrap(), legacy, "{stage} ignores the new independent template copy contract"); + } + } #[test] fn dimensions_and_sources_are_checked() { let mut b = empty()["brief"].clone(); @@ -318,13 +617,22 @@ mod tests { #[test] fn category_is_optional_for_old_briefs_and_fixed_template_metadata_is_bounded() { let mut b = empty()["brief"].clone(); - b.as_object_mut().unwrap().remove("categoryId"); b.as_object_mut().unwrap().remove("categoryName"); - let clean = brief(&b).unwrap(); assert_eq!(clean["categoryId"], ""); assert_eq!(clean["categoryName"], ""); - b["categoryId"] = json!(false); assert!(brief(&b).is_err()); - let mut c = empty(); c["brief"]["brandId"] = json!("brand"); c["brief"]["categoryId"] = json!("category"); + b.as_object_mut().unwrap().remove("categoryId"); + b.as_object_mut().unwrap().remove("categoryName"); + let clean = brief(&b).unwrap(); + assert_eq!(clean["categoryId"], ""); + assert_eq!(clean["categoryName"], ""); + b["categoryId"] = json!(false); + assert!(brief(&b).is_err()); + let mut c = empty(); + c["brief"]["brandId"] = json!("brand"); + c["brief"]["categoryId"] = json!("category"); c["designSnapshot"] = json!({"rules":{"id":"template","contentHash":"a".repeat(64),"catalog":{"shops":[{"id":"brand"}],"bindings":[{"state":"ready","categoryId":"category","rules":["do not inject this whole original file"],"template":{"screens":(0..22).map(|i|json!({"id":format!("S{i}"),"title":"模块","role":"content","purpose":"a".repeat(2000)})).collect::>()}}]}}}); - let template = plan_template(&c).unwrap(); assert_eq!(template["modules"].as_array().unwrap().len(),12); - assert!(template.to_string().len()<2000); assert!(!template.to_string().contains("purpose")); - c["brief"]["categoryId"] = json!("other"); assert!(plan_template(&c).is_err()); + let template = plan_template(&c).unwrap(); + assert_eq!(template["modules"].as_array().unwrap().len(), 12); + assert!(template.to_string().len() < 2000); + assert!(!template.to_string().contains("purpose")); + c["brief"]["categoryId"] = json!("other"); + assert!(plan_template(&c).is_err()); } } diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_image_protocol.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_image_protocol.rs index d773110b..4d6d736d 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_image_protocol.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_image_protocol.rs @@ -96,6 +96,34 @@ pub fn accepts(v: &Value, s: &Value, depth: usize) -> bool { !s.get("not").is_some_and(|s| accepts(v, s, depth + 1)) } +/// Choose only dimensions advertised by the selected public invocation schema. +/// Missing dimension controls stay untouched; the native frame still crops explicitly. +pub(super) fn frame(contract: &mut Value, width: u64, height: u64) -> Result<(), String> { + if width == 0 || height == 0 || width > 30000 || height > 30000 { + return Err("模板图位画幅无效".into()); + } + let target = width as f64 / height as f64; + let schema = contract["schema"].clone(); + let nodes = parts(&schema, 0); + for (key, choices) in [ + ("aspect_ratio", vec![("1:1", 1.), ("2:3", 2. / 3.), ("3:4", 0.75), ("9:16", 9. / 16.), ("16:9", 16. / 9.), ("3:2", 1.5), ("4:3", 4. / 3.)]), + ("size", vec![("1024x1024", 1.), ("1024x1536", 2. / 3.), ("1536x1024", 1.5), ("1024x1792", 1024. / 1792.), ("1792x1024", 1792. / 1024.)]), + ] { + let rules: Vec<_> = nodes.iter().filter_map(|n| n["properties"].get(key)).collect(); + if rules.is_empty() { + continue; + } + if let Some((choice, _)) = choices + .into_iter() + .filter(|(s, _)| rules.iter().all(|r| accepts(&json!(s), r, 0))) + .min_by(|(_, a), (_, b)| f64::abs(f64::ln(*a / target)).total_cmp(&f64::abs(f64::ln(*b / target)))) + { + contract["parameters"][key] = json!(choice); + } + } + Ok(()) +} + pub fn select(catalog: &Value, model: &str) -> Result { let m = catalog["data"]["sections"] .as_array() @@ -256,4 +284,17 @@ mod tests { assert!(!accepts(&json!("x"), &json!({"allOf":[{"type":"string"},{"minLength":2}]}), 0)); assert!(!accepts(&json!(1), &json!({"$ref":"private"}), 0)); } + #[test] + fn template_frame_only_chooses_publicly_supported_dimensions() { + let mut p = + json!({"schema":{"properties":{"aspect_ratio":{"enum":["1:1","2:3","16:9"]},"size":{"enum":["1024x1024","1024x1536"]}}},"parameters":{"n":1}}); + frame(&mut p, 1440, 2351).unwrap(); + assert_eq!(p["parameters"]["aspect_ratio"], "2:3"); + assert_eq!(p["parameters"]["size"], "1024x1536"); + assert_eq!(p["parameters"]["n"], 1); + let mut no_controls = json!({"schema":{"properties":{"prompt":{"type":"string"}}},"parameters":{"n":1}}); + frame(&mut no_controls, 351, 215).unwrap(); + assert_eq!(no_controls["parameters"], json!({"n":1})); + assert!(frame(&mut no_controls, 0, 1).is_err()); + } } diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_images.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_images.rs index bf689804..41d60062 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_images.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_images.rs @@ -7,9 +7,14 @@ pub(super) mod protocol; const IMAGE_BYTES: u64 = 20 * 1024 * 1024; const RESPONSE_BYTES: u64 = 40 * 1024 * 1024; pub(super) const IMAGE_REQUEST_TIMEOUT: Duration = Duration::from_secs(180); +// New POSTs may wait for a synchronous image result. GET recovery retains its +// separate bounded 180s window and never inherits this larger request budget. +const IMAGE_POST_TIMEOUT: Duration = Duration::from_secs(600); fn recovery_timeout(deadline: Option, cap: Duration) -> R { - let remaining=deadline.map(|end|end.saturating_duration_since(Instant::now())).unwrap_or(cap); - if remaining.is_zero() {return Err("本次恢复查询窗口已结束,原请求保留待核查;不会重发".into());} + let remaining = deadline.map(|end| end.saturating_duration_since(Instant::now())).unwrap_or(cap); + if remaining.is_zero() { + return Err("本次恢复查询窗口已结束,原请求保留待核查;不会重发".into()); + } Ok(remaining.min(cap)) } @@ -43,19 +48,29 @@ pub(super) fn job_save(dir: &Path, t: &Value) -> R<()> { // the canonical canvas and must settle the journal without locking it recursively. fn job_save_locked(dir: &Path, t: &Value, current_only: bool) -> R<()> { project_not_trashed(dir)?; - if t["creationBinding"].is_object() || t["localProjectId"].is_string() { original_project_present(dir)?; } + if t["creationBinding"].is_object() || t["localProjectId"].is_string() { + original_project_present(dir)?; + } let id = t["id"].as_str().filter(|s| Uuid::parse_str(s).is_ok()).ok_or("任务身份无效")?; let archive = dir.join("image-jobs").join(id).join("task.json"); let mut settled = t.clone(); if archive.exists() { let existing = read_json(&archive)?; - if existing["id"] != t["id"] || existing["keyFingerprint"].is_string() && existing["keyFingerprint"] != t["keyFingerprint"] - || existing["requestId"].is_string() && existing["requestId"] != t["requestId"] { return Err("原任务身份已有新记录,未覆盖原归档".into()); } + if existing["id"] != t["id"] + || existing["keyFingerprint"].is_string() && existing["keyFingerprint"] != t["keyFingerprint"] + || existing["requestId"].is_string() && existing["requestId"] != t["requestId"] + { + return Err("原任务身份已有新记录,未覆盖原归档".into()); + } // An applied result is terminal, including when an older GET finishes with // complete after the canvas switch. Never expose it as ready a second time. - if existing["phase"] == "applied" || existing["phase"] == "complete" && matches!(t["phase"].as_str(), Some("unknown"|"running"|"dispatched")) { settled = existing; } + if existing["phase"] == "applied" || existing["phase"] == "complete" && matches!(t["phase"].as_str(), Some("unknown" | "running" | "dispatched")) { + settled = existing; + } + } + if !archive.exists() || read_json(&archive)? != settled { + write_json(&archive, &settled)?; } - if !archive.exists() || read_json(&archive)? != settled { write_json(&archive, &settled)?; } let pointer = dir.join("image-task.json"); if pointer.exists() { let current = read_json(&pointer)?; @@ -64,7 +79,9 @@ fn job_save_locked(dir: &Path, t: &Value, current_only: bool) -> R<()> { // durable, but it must never move the project's current pointer backwards. return Ok(()); } - if current == settled { return Ok(()); } + if current == settled { + return Ok(()); + } } else if current_only { return Ok(()); } @@ -74,12 +91,16 @@ pub(super) fn mark_applied_locked(dir: &Path, task: &str) -> R<()> { original_project_present(dir)?; let task = Uuid::parse_str(task).map_err(|_| "原图片任务身份无效")?.to_string(); let mut record = read_json(&dir.join("image-jobs").join(&task).join("task.json"))?; - if record["id"] != task || !matches!(record["phase"].as_str(), Some("complete" | "applied")) { return Err("原图片任务尚未完成,未标记应用".into()); } + if record["id"] != task || !matches!(record["phase"].as_str(), Some("complete" | "applied")) { + return Err("原图片任务尚未完成,未标记应用".into()); + } record["phase"] = json!("applied"); job_save_locked(dir, &record, true) } pub(super) fn require_ready_image(task: &Value, input: &Value) -> R<()> { - if task["id"] != input["taskId"] || task["phase"] != "complete" { return Err("没有待应用的图片".into()); } + if task["id"] != input["taskId"] || task["phase"] != "complete" { + return Err("没有待应用的图片".into()); + } Ok(()) } fn job_bytes(dir: &Path, path: &Path, bytes: &[u8]) -> R<()> { @@ -116,11 +137,8 @@ fn located_recovery_request_id(code: u16, response: &Value) -> R { if response["status"] != "succeeded" || response["result_available"] != true { return Err("原请求尚无可取回的成功结果,保留待核查;不会重发".into()); } - let request_id = response["request_id"] - .as_str() - .and_then(|value| Uuid::parse_str(value).ok()) - .ok_or("98k未返回有效原请求号,保留待核查;不会重发")? - .to_string(); + let request_id = + response["request_id"].as_str().and_then(|value| Uuid::parse_str(value).ok()).ok_or("98k未返回有效原请求号,保留待核查;不会重发")?.to_string(); if response["result_path"] != format!("/api/external/requests/{request_id}/result") { return Err("98k原结果路径与请求号不一致,未发起结果查询;不会重发".into()); } @@ -128,35 +146,55 @@ fn located_recovery_request_id(code: u16, response: &Value) -> R { } /// This transport boundary has only GET paths. Tests can supply an isolated HTTP /// server; production supplies the fixed public 98k origin and original Key. -fn original_response( - task: &mut Value, - mut get: impl FnMut(&str, Option<&str>) -> R<(u16, Value)>, - mut persist: impl FnMut(&Value) -> R<()>, -) -> R { +fn original_response(task: &mut Value, mut get: impl FnMut(&str, Option<&str>) -> R<(u16, Value)>, mut persist: impl FnMut(&Value) -> R<()>) -> R { if task["requestId"].is_null() { let task_id = task["id"].as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("原任务身份无效,未发起查询")?; let (code, located) = get("/api/external/requests/by-idempotency-key", Some(task_id))?; let request_id = located_recovery_request_id(code, &located)?; - if bind_recovery_request_id(task, &json!({"requestId":request_id}))? { persist(task)?; } + if bind_recovery_request_id(task, &json!({"requestId":request_id}))? { + persist(task)?; + } } let rid = task["requestId"].as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("原请求缺少可查询编号,请保持待核查;不会重发")?; let (code, body) = get(&format!("/api/external/requests/{rid}/result"), None)?; - if code != 200 { return Err(format!("原结果尚未取回(HTTP {code});不会重发")); } + if code != 200 { + return Err(format!("原结果尚未取回(HTTP {code});不会重发")); + } retained_body(body) } -pub(super) fn retry_original_queries(mut query: impl FnMut(Duration) -> R, mut wait: impl FnMut(Duration), mut elapsed: impl FnMut() -> Duration) -> R { - let backoff=[3,5,8,13,20,30];let mut attempt=0usize; +pub(super) fn retry_original_queries( + mut query: impl FnMut(Duration) -> R, + mut wait: impl FnMut(Duration), + mut elapsed: impl FnMut() -> Duration, +) -> R { + let backoff = [3, 5, 8, 13, 20, 30]; + let mut attempt = 0usize; loop { - let budget=IMAGE_REQUEST_TIMEOUT.saturating_sub(elapsed()); - if budget.is_zero() { return Err("本次恢复查询窗口已结束,原请求保留待核查;不会重发".into()); } + let budget = IMAGE_REQUEST_TIMEOUT.saturating_sub(elapsed()); + if budget.is_zero() { + return Err("本次恢复查询窗口已结束,原请求保留待核查;不会重发".into()); + } let result = query(budget); - let message = match &result { Ok(v) if v["phase"] == "unknown" || v["phase"] == "running" => v["reply"].as_str().unwrap_or(""), Err(e) => e, _ => return result }; - let transient = message.contains("processing") || message.contains("连接中断") || message.contains("HTTP 502") || message.contains("HTTP 503") || message.contains("HTTP 504"); - let remaining=IMAGE_REQUEST_TIMEOUT.saturating_sub(elapsed()); - if !transient || remaining.is_zero() { return result; } - let delay=Duration::from_secs(*backoff.get(attempt).unwrap_or(&30)); - wait(delay.min(remaining));attempt=attempt.saturating_add(1); - if elapsed()>=IMAGE_REQUEST_TIMEOUT {return result;} + let message = match &result { + Ok(v) if v["phase"] == "unknown" || v["phase"] == "running" => v["reply"].as_str().unwrap_or(""), + Err(e) => e, + _ => return result, + }; + let transient = message.contains("processing") + || message.contains("连接中断") + || message.contains("HTTP 502") + || message.contains("HTTP 503") + || message.contains("HTTP 504"); + let remaining = IMAGE_REQUEST_TIMEOUT.saturating_sub(elapsed()); + if !transient || remaining.is_zero() { + return result; + } + let delay = Duration::from_secs(*backoff.get(attempt).unwrap_or(&30)); + wait(delay.min(remaining)); + attempt = attempt.saturating_add(1); + if elapsed() >= IMAGE_REQUEST_TIMEOUT { + return result; + } } } pub(super) fn public(t: &Value, doc: u64, account: &Value, origin: &str) -> Value { @@ -181,12 +219,23 @@ fn asset_url(s: &str) -> R { } impl Backend { fn image_validate(&mut self, deadline: Option) -> R<()> { - if deadline.is_none() {return self.validate();} - let token=self.auth["access_token"].as_str().ok_or("请先在素材库登录")?; - let response=self.http.get(format!("{}/api/desktop/session",self.origin)).bearer_auth(token).timeout(recovery_timeout(deadline,Duration::from_secs(30))?).send().map_err(|_|"素材库授权校验连接中断")?; - let (code,value)=body(response,MAX_JSON)?; - if code!=200 || value["account"]["id"]!=self.auth["account"]["id"] {return Err("授权已失效或账户已变化,原请求保留待核查".into());} - self.auth["account"]=value["account"].clone();Ok(()) + if deadline.is_none() { + return self.validate(); + } + let token = self.auth["access_token"].as_str().ok_or("请先在素材库登录")?; + let response = self + .http + .get(format!("{}/api/desktop/session", self.origin)) + .bearer_auth(token) + .timeout(recovery_timeout(deadline, Duration::from_secs(30))?) + .send() + .map_err(|_| "素材库授权校验连接中断")?; + let (code, value) = body(response, MAX_JSON)?; + if code != 200 || value["account"]["id"] != self.auth["account"]["id"] { + return Err("授权已失效或账户已变化,原请求保留待核查".into()); + } + self.auth["account"] = value["account"].clone(); + Ok(()) } pub(super) fn agent_image_preview(&self, input: &Value) -> R<(Value, egui::ColorImage)> { let doc = input["doc"].as_u64().ok_or("项目身份无效")?; @@ -236,7 +285,7 @@ impl Backend { )) } pub(super) fn image_job(&mut self, doc: Option>, input: &Value, recover: bool, tx: &mpsc::Sender, ctx: &egui::Context) -> R { - let deadline=if recover {input["recoveryBudgetMs"].as_u64().map(|ms|Instant::now()+Duration::from_millis(ms.min(180_000)))} else {None}; + let deadline = if recover { input["recoveryBudgetMs"].as_u64().map(|ms| Instant::now() + Duration::from_millis(ms.min(180_000))) } else { None }; let id = doc.as_ref().map(|d| d.id.0).or_else(|| input["doc"].as_u64()).ok_or("请先打开项目")?; self.ensure_owner(id)?; self.image_validate(deadline)?; @@ -247,25 +296,38 @@ impl Backend { if let Some(task) = input["taskId"].as_str() { Uuid::parse_str(task).map_err(|_| "原图片任务身份无效")?; let original = read_json(&dir.join("image-jobs").join(task).join("task.json"))?; - if original["id"] != task { return Err("原图片任务归档不一致".into()); } + if original["id"] != task { + return Err("原图片任务归档不一致".into()); + } original - } else { read_json(&dir.join("image-task.json"))? } + } else { + read_json(&dir.join("image-task.json"))? + } } else { - if pending(&dir)? - || super::creation::unresolved(&dir)? - || dir.join("task.json").exists() && matches!(read_json(&dir.join("task.json"))?["phase"].as_str(), Some("dispatched" | "unknown")) - { - return Err("原请求待核查,未提交新任务".into()); + if input["slotBinding"].is_object() { + super::template_slots::validate_dispatch(&dir, &input["slotBinding"])?; + super::template_workflow::validate_image_context(&dir, Some(&input["slotBinding"]))?; + } else { + if pending(&dir)? + || super::creation::unresolved(&dir)? + || dir.join("task.json").exists() && matches!(read_json(&dir.join("task.json"))?["phase"].as_str(), Some("dispatched" | "unknown")) + { + return Err("原请求待核查,未提交新任务".into()); + } } let doc = doc.ok_or("画布快照缺失")?; if !dir.join("context.json").exists() { // A brand-new unsaved document can invoke image generation before // the normal autosave tick. Persist it once, never over a newer save. - let bytes=photocraft_format::save_to_bytes(&doc,&Default::default()).map_err(|_|"原画布无法保存,未提交生图")?; - let _guard=sync::context_guard(); project_not_trashed(&dir)?; + let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).map_err(|_| "原画布无法保存,未提交生图")?; + let _guard = sync::context_guard(); + project_not_trashed(&dir)?; if !dir.join("context.json").exists() { - photocraft_format::atomic_write(&dir.join("canvas.pcraft"),&bytes).map_err(|_|"原画布无法保存,未提交生图")?; - write_json(&dir.join("context.json"),&json!({"doc":id,"localId":self.locations.get(&id),"name":doc.name,"revision":input["revision"],"time":now().to_string(),"status":"draft","syncDirty":true,"messages":[]}))?; + photocraft_format::atomic_write(&dir.join("canvas.pcraft"), &bytes).map_err(|_| "原画布无法保存,未提交生图")?; + write_json( + &dir.join("context.json"), + &json!({"doc":id,"localId":self.locations.get(&id),"name":doc.name,"revision":input["revision"],"time":now().to_string(),"status":"draft","syncDirty":true,"messages":[]}), + )?; } } self.refresh_models()?; @@ -283,7 +345,14 @@ impl Backend { if status != 200 { return Err("98k公开模型目录暂不可用".into()); } - let contract = protocol::select(&catalog, model_id)?; + let mut contract = protocol::select(&catalog, model_id)?; + if input["slotBinding"].is_object() { + protocol::frame( + &mut contract, + input["slotFrame"]["width"].as_u64().ok_or("图位宽度无效")?, + input["slotFrame"]["height"].as_u64().ok_or("图位高度无效")?, + )?; + } let refs = input["references"] .as_array() .filter(|a| !a.is_empty() && a.len() <= contract["maxImages"].as_u64().unwrap_or(1) as usize) @@ -300,7 +369,12 @@ impl Backend { } } let text = input["text"].as_str().filter(|s| !s.trim().is_empty() && s.chars().count() <= 6000).ok_or("请输入6000字以内的生图要求")?; - let job = Uuid::new_v4().to_string(); + let job = if input["slotBinding"].is_object() { + super::template_slots::validate_dispatch(&dir, &input["slotBinding"])?; + input["slotBinding"]["taskId"].as_str().ok_or("图位候选身份无效")?.to_owned() + } else { + Uuid::new_v4().to_string() + }; let files = dir.join("image-jobs").join(&job); let mut references = Vec::new(); let mut total = 0usize; @@ -329,7 +403,7 @@ impl Backend { input["screen"].as_str().unwrap_or("当前图片") ); let prior = if dir.join("image-task.json").exists() { read_json(&dir.join("image-task.json"))?["id"].clone() } else { Value::Null }; - json!({"version":1,"id":job,"doc":id,"revision":input["revision"],"mode":input["mode"],"target":target,"modelId":model_id,"modelKey":choice,"protocol":contract,"prompt":prompt,"references":references,"keyFingerprint":hash(key),"phase":"prepared","requestId":null,"upstreamTaskId":null,"center":input["center"],"accountScope":self.scope(),"localProjectId":self.locations.get(&id),"origin":self.origin,"createdAt":now(),"replacesTaskId":prior,"creationBinding":input["creationBinding"]}) + json!({"version":1,"id":job,"doc":id,"revision":input["revision"],"mode":input["mode"],"target":target,"modelId":model_id,"modelKey":choice,"protocol":contract,"prompt":prompt,"references":references,"keyFingerprint":hash(key),"phase":"prepared","requestId":null,"upstreamTaskId":null,"center":input["center"],"accountScope":self.scope(),"localProjectId":self.locations.get(&id),"origin":self.origin,"createdAt":now(),"replacesTaskId":prior,"creationBinding":input["creationBinding"],"slotBinding":input["slotBinding"]}) }; if t["keyFingerprint"] != hash(key) { return Err("请切回原个人Key,只读核查原请求;不会重发".into()); @@ -363,23 +437,34 @@ impl Backend { Value::Null } else { let session = Uuid::new_v4().to_string(); - original_response(&mut t, |path, idempotency| { - progress(if idempotency.is_some() { "正在定位原请求…" } else { "正在核查原请求…" }); - let diagnostic_file = taskdir.join("recovery-http.json"); - let mut diagnostic = if diagnostic_file.exists() { read_json(&diagnostic_file)? } else { json!({"attempts":[]}) }; - let kind = if idempotency.is_some() { "lookup" } else { "result" }; - let attempt = json!({"session":session,"kind":kind,"method":"GET","at":now(),"status":null}); - let rows = diagnostic["attempts"].as_array_mut().ok_or("恢复诊断记录无效")?; - if rows.len() >= 256 { rows.remove(0); } - rows.push(attempt); - job_json(&dir, &diagnostic_file, &diagnostic)?; - let mut request = self.http.get(format!("{UPSTREAM}{path}")).bearer_auth(key).timeout(recovery_timeout(deadline,Duration::from_secs(20))?); - if let Some(id) = idempotency { request = request.header("Idempotency-Key", id); } - let response = request.send().map_err(|_| "原请求只读查询连接中断,保留待核查;不会重发")?; - if let Some(last) = diagnostic["attempts"].as_array_mut().and_then(|a| a.last_mut()) { last["status"] = json!(response.status().as_u16()); } - job_json(&dir, &diagnostic_file, &diagnostic)?; - body(response, if idempotency.is_some() { 64 * 1024 } else { RESPONSE_BYTES }) - }, |task| job_save(&dir, task))? + original_response( + &mut t, + |path, idempotency| { + progress(if idempotency.is_some() { "正在定位原请求…" } else { "正在核查原请求…" }); + let diagnostic_file = taskdir.join("recovery-http.json"); + let mut diagnostic = if diagnostic_file.exists() { read_json(&diagnostic_file)? } else { json!({"attempts":[]}) }; + let kind = if idempotency.is_some() { "lookup" } else { "result" }; + let attempt = json!({"session":session,"kind":kind,"method":"GET","at":now(),"status":null}); + let rows = diagnostic["attempts"].as_array_mut().ok_or("恢复诊断记录无效")?; + if rows.len() >= 256 { + rows.remove(0); + } + rows.push(attempt); + job_json(&dir, &diagnostic_file, &diagnostic)?; + let mut request = + self.http.get(format!("{UPSTREAM}{path}")).bearer_auth(key).timeout(recovery_timeout(deadline, Duration::from_secs(20))?); + if let Some(id) = idempotency { + request = request.header("Idempotency-Key", id); + } + let response = request.send().map_err(|_| "原请求只读查询连接中断,保留待核查;不会重发")?; + if let Some(last) = diagnostic["attempts"].as_array_mut().and_then(|a| a.last_mut()) { + last["status"] = json!(response.status().as_u16()); + } + job_json(&dir, &diagnostic_file, &diagnostic)?; + body(response, if idempotency.is_some() { 64 * 1024 } else { RESPONSE_BYTES }) + }, + |task| job_save(&dir, task), + )? } } else { let p = t["protocol"].clone(); @@ -403,6 +488,11 @@ impl Backend { self.image_validate(deadline)?; // Live permission immediately before a paid dispatch. t["phase"] = json!("dispatched"); job_save(&dir, &t)?; + if t["slotBinding"].is_object() { + super::template_slots::validate_journal_binding(&dir, &t)?; + super::template_workflow::validate_inputs(&dir)?; + super::template_workflow::validate_image_context(&dir, Some(&t["slotBinding"]))?; + } if t["creationBinding"].is_object() { // Both journals know the immutable original task before the paid POST. super::creation::bind_image_dispatch(&dir, &mut t)?; @@ -412,7 +502,7 @@ impl Backend { let mut req = self .http .post(format!("{UPSTREAM}{}", p["path"].as_str().ok_or("模型路径无效")?)) - .timeout(IMAGE_REQUEST_TIMEOUT) + .timeout(IMAGE_POST_TIMEOUT) .bearer_auth(key) .header("Idempotency-Key", t["id"].as_str().ok_or("任务身份无效")?) .header("X-Relay-Route-Option", p["route"].as_str().ok_or("线路身份无效")?); @@ -457,7 +547,12 @@ impl Backend { let uid = t["upstreamTaskId"].as_str().filter(|s| protocol::token(s)).ok_or("任务号无效")?; progress("正在等待原任务结果…"); let (code, v) = body( - self.http.get(format!("{UPSTREAM}/extensions/v1/tasks/{uid}/result")).bearer_auth(key).timeout(recovery_timeout(deadline,Duration::from_secs(30))?).send().map_err(|_| "任务查询连接中断")?, + self.http + .get(format!("{UPSTREAM}/extensions/v1/tasks/{uid}/result")) + .bearer_auth(key) + .timeout(recovery_timeout(deadline, Duration::from_secs(30))?) + .send() + .map_err(|_| "任务查询连接中断")?, RESPONSE_BYTES, )?; if code == 202 && v["ready"] == false { @@ -481,8 +576,7 @@ impl Backend { if raw["model"].is_string() && raw["model"] != t["modelId"] { return Err("原结果与任务模型不一致,未应用".into()); } - job_bytes(&dir, &raw_file, &serde_json::to_vec(&raw).map_err(|_| "原响应保存失败")?) - .map_err(|_| "原结果保存失败,请保留原请求号")?; + job_bytes(&dir, &raw_file, &serde_json::to_vec(&raw).map_err(|_| "原响应保存失败")?).map_err(|_| "原结果保存失败,请保留原请求号")?; let outputs = raw["data"].as_array().filter(|a| a.len() == 1).ok_or("98k图片结果结构无法确认;不会重发")?; let out = &outputs[0]; progress("正在下载并检查图片…"); @@ -494,7 +588,13 @@ impl Backend { } else { let url = asset_url(out["url"].as_str().ok_or("原响应没有可下载的图片")?)?; self.image_validate(deadline)?; - let r = self.http.get(url).bearer_auth(key).timeout(recovery_timeout(deadline,Duration::from_secs(30))?).send().map_err(|_| "图片下载连接中断,保留原结果后可重取")?; + let r = self + .http + .get(url) + .bearer_auth(key) + .timeout(recovery_timeout(deadline, Duration::from_secs(30))?) + .send() + .map_err(|_| "图片下载连接中断,保留原结果后可重取")?; if !r.status().is_success() { return Err(format!("98k图片下载 HTTP {},原结果已保留", r.status().as_u16())); } @@ -565,13 +665,17 @@ mod tests { job_save(&dir, &complete).unwrap(); let input = json!({"taskId":complete["id"]}); assert!(require_ready_image(&complete, &input).is_ok()); - { let _guard = sync::context_guard(); mark_applied_locked(&dir, complete["id"].as_str().unwrap()).unwrap(); } + { + let _guard = sync::context_guard(); + mark_applied_locked(&dir, complete["id"].as_str().unwrap()).unwrap(); + } let applied = read_json(&dir.join("image-task.json")).unwrap(); assert_eq!(applied["phase"], "applied"); assert!(require_ready_image(&applied, &input).is_err(), "image_use must reject an automatically applied result"); assert_ne!(public(&applied, 1, &json!({"id":"own-account"}), "own-origin")["phase"], "complete", "draft must not expose ready_image again"); for phase in ["complete", "unknown", "running", "dispatched", "failed"] { - let mut late = complete.clone(); late["phase"] = json!(phase); + let mut late = complete.clone(); + late["phase"] = json!(phase); job_save(&dir, &late).unwrap(); assert_eq!(read_json(&dir.join("image-task.json")).unwrap(), applied); assert_eq!(read_json(&dir.join("image-jobs").join(complete["id"].as_str().unwrap()).join("task.json")).unwrap(), applied); @@ -586,11 +690,17 @@ mod tests { job_save(&dir, &old).unwrap(); let current = json!({"id":Uuid::new_v4().to_string(),"phase":"dispatched","replacesTaskId":old["id"]}); job_save(&dir, ¤t).unwrap(); - { let _guard = sync::context_guard(); mark_applied_locked(&dir, old["id"].as_str().unwrap()).unwrap(); } + { + let _guard = sync::context_guard(); + mark_applied_locked(&dir, old["id"].as_str().unwrap()).unwrap(); + } assert_eq!(read_json(&dir.join("image-task.json")).unwrap(), current); assert_eq!(read_json(&dir.join("image-jobs").join(old["id"].as_str().unwrap()).join("task.json")).unwrap()["phase"], "applied"); fs::remove_file(dir.join("image-task.json")).unwrap(); - { let _guard = sync::context_guard(); mark_applied_locked(&dir, old["id"].as_str().unwrap()).unwrap(); } + { + let _guard = sync::context_guard(); + mark_applied_locked(&dir, old["id"].as_str().unwrap()).unwrap(); + } assert!(!dir.join("image-task.json").exists(), "repair must not recreate a retired current pointer"); fs::remove_dir_all(dir).unwrap(); } @@ -600,20 +710,37 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let address = listener.local_addr().unwrap(); - let durable = Arc::new(AtomicUsize::new(0)); let check = durable.clone(); + let durable = Arc::new(AtomicUsize::new(0)); + let check = durable.clone(); let server = std::thread::spawn(move || { - let start = Instant::now(); let mut methods = Vec::new(); + let start = Instant::now(); + let mut methods = Vec::new(); while start.elapsed() < Duration::from_secs(2) { - let (mut socket, _) = match listener.accept() { Ok(v) => v, Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { std::thread::sleep(Duration::from_millis(2)); continue; }, Err(e) => panic!("{e}") }; + let (mut socket, _) = match listener.accept() { + Ok(v) => v, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(e) => panic!("{e}"), + }; socket.set_nonblocking(false).unwrap(); socket.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); - let mut request = Vec::new(); let mut byte = [0u8]; - while !request.ends_with(b"\r\n\r\n") && request.len() < 8192 { socket.read_exact(&mut byte).unwrap(); request.extend(byte); } + let mut request = Vec::new(); + let mut byte = [0u8]; + while !request.ends_with(b"\r\n\r\n") && request.len() < 8192 { + socket.read_exact(&mut byte).unwrap(); + request.extend(byte); + } let request = String::from_utf8(request).unwrap(); methods.push(request.lines().next().unwrap().to_owned()); let is_lookup = request.contains("by-idempotency-key"); - if is_lookup && lookup_code == 0 { continue; } - let (code, body) = if is_lookup { (lookup_code, lookup.clone()) } else { + if is_lookup && lookup_code == 0 { + continue; + } + let (code, body) = if is_lookup { + (lookup_code, lookup.clone()) + } else { assert_eq!(check.load(Ordering::SeqCst), 1, "original request ID must be durable before the result GET"); (result_code, json!({"data":[{"b64_json":"original-image"}]})) }; @@ -623,73 +750,151 @@ mod tests { methods }); let mut task = json!({"id":"42e84e04-4738-48c0-b3c0-31ee097b7a35","phase":"unknown","requestId":null}); - let result = original_response(&mut task, |path, key| { - let mut socket = TcpStream::connect(address).map_err(|e| e.to_string())?; - socket.set_read_timeout(Some(Duration::from_secs(1))).map_err(|e| e.to_string())?; - write!(socket,"GET {path} HTTP/1.1\r\nHost: {address}\r\nIdempotency-Key: {}\r\nConnection: close\r\n\r\n",key.unwrap_or("")).map_err(|e| e.to_string())?; - let mut response = String::new(); socket.read_to_string(&mut response).map_err(|e| e.to_string())?; - let (header, body) = response.split_once("\r\n\r\n").ok_or("原请求只读查询连接中断")?; - let code = header.split_whitespace().nth(1).ok_or("missing HTTP status")?.parse::().map_err(|e|e.to_string())?; - Ok((code, serde_json::from_str(body).map_err(|e|e.to_string())?)) - }, |_| { durable.fetch_add(1,Ordering::SeqCst); Ok(()) }); - assert_eq!(task["id"], "42e84e04-4738-48c0-b3c0-31ee097b7a35"); assert_eq!(task["phase"], "unknown"); + let result = original_response( + &mut task, + |path, key| { + let mut socket = TcpStream::connect(address).map_err(|e| e.to_string())?; + socket.set_read_timeout(Some(Duration::from_secs(1))).map_err(|e| e.to_string())?; + write!(socket, "GET {path} HTTP/1.1\r\nHost: {address}\r\nIdempotency-Key: {}\r\nConnection: close\r\n\r\n", key.unwrap_or("")) + .map_err(|e| e.to_string())?; + let mut response = String::new(); + socket.read_to_string(&mut response).map_err(|e| e.to_string())?; + let (header, body) = response.split_once("\r\n\r\n").ok_or("原请求只读查询连接中断")?; + let code = header.split_whitespace().nth(1).ok_or("missing HTTP status")?.parse::().map_err(|e| e.to_string())?; + Ok((code, serde_json::from_str(body).map_err(|e| e.to_string())?)) + }, + |_| { + durable.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ); + assert_eq!(task["id"], "42e84e04-4738-48c0-b3c0-31ee097b7a35"); + assert_eq!(task["phase"], "unknown"); (result, server.join().unwrap(), durable.load(Ordering::SeqCst)) } #[test] fn restart_http_recovery_uses_only_two_original_gets_with_durable_binding() { let lookup = json!({"request_id":"cc6a9ed9-863b-419f-bd1a-b126833227fb","status":"succeeded","result_available":true,"result_path":"/api/external/requests/cc6a9ed9-863b-419f-bd1a-b126833227fb/result"}); let (result, calls, writes) = mock_recovery_http(200, lookup, 200); - assert!(result.is_ok()); assert_eq!(writes,1); assert_eq!(calls.len(),2); - assert!(calls.iter().all(|v|v.starts_with("GET "))); assert!(calls[0].contains("by-idempotency-key")); assert!(calls[1].contains("cc6a9ed9-863b-419f-bd1a-b126833227fb/result")); + assert!(result.is_ok()); + assert_eq!(writes, 1); + assert_eq!(calls.len(), 2); + assert!(calls.iter().all(|v| v.starts_with("GET "))); + assert!(calls[0].contains("by-idempotency-key")); + assert!(calls[1].contains("cc6a9ed9-863b-419f-bd1a-b126833227fb/result")); } #[test] fn restart_http_recovery_never_posts_when_result_is_missing_pending_or_expired() { - let original=json!({"request_id":"cc6a9ed9-863b-419f-bd1a-b126833227fb","status":"succeeded","result_available":true,"result_path":"/api/external/requests/cc6a9ed9-863b-419f-bd1a-b126833227fb/result"}); - for (code,status,available,result_code,expected) in [(404,"succeeded",true,200,1),(200,"processing",false,200,1),(200,"unresolved",false,200,1),(200,"succeeded",false,200,1),(200,"succeeded",true,410,2)] { - let mut lookup=original.clone(); lookup["status"]=json!(status); lookup["result_available"]=json!(available); - let (result,calls,_) = mock_recovery_http(code,lookup,result_code); - assert!(result.is_err()); assert_eq!(calls.len(),expected); assert!(calls.iter().all(|v|v.starts_with("GET "))); + let original = json!({"request_id":"cc6a9ed9-863b-419f-bd1a-b126833227fb","status":"succeeded","result_available":true,"result_path":"/api/external/requests/cc6a9ed9-863b-419f-bd1a-b126833227fb/result"}); + for (code, status, available, result_code, expected) in [ + (404, "succeeded", true, 200, 1), + (200, "processing", false, 200, 1), + (200, "unresolved", false, 200, 1), + (200, "succeeded", false, 200, 1), + (200, "succeeded", true, 410, 2), + ] { + let mut lookup = original.clone(); + lookup["status"] = json!(status); + lookup["result_available"] = json!(available); + let (result, calls, _) = mock_recovery_http(code, lookup, result_code); + assert!(result.is_err()); + assert_eq!(calls.len(), expected); + assert!(calls.iter().all(|v| v.starts_with("GET "))); } - let mut foreign=original; foreign["result_path"]=json!("https://other.invalid/result"); - let (result,calls,_) = mock_recovery_http(200,foreign,200); assert!(result.is_err()); assert_eq!(calls.len(),1); + let mut foreign = original; + foreign["result_path"] = json!("https://other.invalid/result"); + let (result, calls, _) = mock_recovery_http(200, foreign, 200); + assert!(result.is_err()); + assert_eq!(calls.len(), 1); } #[test] fn old_recovery_cannot_replace_the_current_project_task() { - let dir=std::env::temp_dir().join(format!("image-pointer-cas-{}",Uuid::new_v4())); - let old=json!({"id":Uuid::new_v4().to_string(),"phase":"unknown","requestId":null}); - job_save(&dir,&old).unwrap(); - let new=json!({"id":Uuid::new_v4().to_string(),"phase":"dispatched","replacesTaskId":old["id"]}); - job_save(&dir,&new).unwrap(); - let mut completed=old.clone(); completed["phase"]=json!("complete"); completed["requestId"]=json!("cc6a9ed9-863b-419f-bd1a-b126833227fb"); - job_save(&dir,&completed).unwrap(); - assert_eq!(read_json(&dir.join("image-task.json")).unwrap(),new); - assert_eq!(read_json(&dir.join("image-jobs").join(old["id"].as_str().unwrap()).join("task.json")).unwrap(),completed); + let dir = std::env::temp_dir().join(format!("image-pointer-cas-{}", Uuid::new_v4())); + let old = json!({"id":Uuid::new_v4().to_string(),"phase":"unknown","requestId":null}); + job_save(&dir, &old).unwrap(); + let new = json!({"id":Uuid::new_v4().to_string(),"phase":"dispatched","replacesTaskId":old["id"]}); + job_save(&dir, &new).unwrap(); + let mut completed = old.clone(); + completed["phase"] = json!("complete"); + completed["requestId"] = json!("cc6a9ed9-863b-419f-bd1a-b126833227fb"); + job_save(&dir, &completed).unwrap(); + assert_eq!(read_json(&dir.join("image-task.json")).unwrap(), new); + assert_eq!(read_json(&dir.join("image-jobs").join(old["id"].as_str().unwrap()).join("task.json")).unwrap(), completed); fs::remove_dir_all(dir).unwrap(); } #[test] fn bounded_restart_recovery_retries_processing_and_one_transport_break_with_only_gets() { - let original=json!({"request_id":"cc6a9ed9-863b-419f-bd1a-b126833227fb","status":"succeeded","result_available":true,"result_path":"/api/external/requests/cc6a9ed9-863b-419f-bd1a-b126833227fb/result"}); - for first_code in [200,0] { - let mut invocation=0;let mut calls=Vec::new();let mut waits=Vec::new(); - let result=retry_original_queries(|_|{ - invocation+=1;let mut lookup=original.clone();if invocation==1 {lookup["status"]=json!("processing");lookup["result_available"]=json!(false);} - let (result,actual,_) = mock_recovery_http(if invocation==1 {first_code}else{200},lookup,200);calls.extend(actual);result - },|delay|waits.push(delay),||Duration::ZERO); - assert!(result.is_ok());assert_eq!(invocation,2);assert_eq!(waits.len(),1);assert_eq!(calls.len(),3);assert!(calls.iter().all(|v|v.starts_with("GET "))); + let original = json!({"request_id":"cc6a9ed9-863b-419f-bd1a-b126833227fb","status":"succeeded","result_available":true,"result_path":"/api/external/requests/cc6a9ed9-863b-419f-bd1a-b126833227fb/result"}); + for first_code in [200, 0] { + let mut invocation = 0; + let mut calls = Vec::new(); + let mut waits = Vec::new(); + let result = retry_original_queries( + |_| { + invocation += 1; + let mut lookup = original.clone(); + if invocation == 1 { + lookup["status"] = json!("processing"); + lookup["result_available"] = json!(false); + } + let (result, actual, _) = mock_recovery_http(if invocation == 1 { first_code } else { 200 }, lookup, 200); + calls.extend(actual); + result + }, + |delay| waits.push(delay), + || Duration::ZERO, + ); + assert!(result.is_ok()); + assert_eq!(invocation, 2); + assert_eq!(waits.len(), 1); + assert_eq!(calls.len(), 3); + assert!(calls.iter().all(|v| v.starts_with("GET "))); } - let mut count=0; - let result=retry_original_queries(|_|{count+=1;Err("原结果尚未取回(HTTP 410);不会重发".into())},|_|panic!("expired result must not be polled"),||Duration::ZERO); - assert!(result.is_err());assert_eq!(count,1); + let mut count = 0; + let result = retry_original_queries( + |_| { + count += 1; + Err("原结果尚未取回(HTTP 410);不会重发".into()) + }, + |_| panic!("expired result must not be polled"), + || Duration::ZERO, + ); + assert!(result.is_err()); + assert_eq!(count, 1); } #[test] fn processing_can_finish_after_two_seconds_but_this_recovery_window_stops_at_180() { + assert_eq!(IMAGE_POST_TIMEOUT, Duration::from_secs(600)); + assert_eq!(IMAGE_REQUEST_TIMEOUT, Duration::from_secs(180)); use std::cell::Cell; - let elapsed=Cell::new(Duration::ZERO);let mut polls=0; - let result=retry_original_queries(|budget|{polls+=1;assert!(budget<=IMAGE_REQUEST_TIMEOUT);Ok(if elapsed.get()>Duration::from_secs(2) {json!({"phase":"complete"})}else{json!({"phase":"unknown","reply":"processing"})})},|delay|elapsed.set(elapsed.get()+delay),||elapsed.get()); - assert_eq!(result.unwrap()["phase"],"complete");assert_eq!(polls,2);assert!(elapsed.get()>Duration::from_secs(2)); - elapsed.set(Duration::ZERO);let mut polls=0; - let result=retry_original_queries(|budget|{polls+=1;assert!(budget>Duration::ZERO);Ok(json!({"phase":"unknown","reply":"processing"}))},|delay|elapsed.set(elapsed.get()+delay),||elapsed.get()); - assert_eq!(result.unwrap()["phase"],"unknown");assert_eq!(elapsed.get(),IMAGE_REQUEST_TIMEOUT);assert_eq!(polls,10); + let elapsed = Cell::new(Duration::ZERO); + let mut polls = 0; + let result = retry_original_queries( + |budget| { + polls += 1; + assert!(budget <= IMAGE_REQUEST_TIMEOUT); + Ok(if elapsed.get() > Duration::from_secs(2) { json!({"phase":"complete"}) } else { json!({"phase":"unknown","reply":"processing"}) }) + }, + |delay| elapsed.set(elapsed.get() + delay), + || elapsed.get(), + ); + assert_eq!(result.unwrap()["phase"], "complete"); + assert_eq!(polls, 2); + assert!(elapsed.get() > Duration::from_secs(2)); + elapsed.set(Duration::ZERO); + let mut polls = 0; + let result = retry_original_queries( + |budget| { + polls += 1; + assert!(budget > Duration::ZERO); + Ok(json!({"phase":"unknown","reply":"processing"})) + }, + |delay| elapsed.set(elapsed.get() + delay), + || elapsed.get(), + ); + assert_eq!(result.unwrap()["phase"], "unknown"); + assert_eq!(elapsed.get(), IMAGE_REQUEST_TIMEOUT); + assert_eq!(polls, 10); } #[test] fn image_recovery_binds_original_request_without_changing_task_or_phase() { @@ -697,7 +902,9 @@ mod tests { let request_id = "cc6a9ed9-863b-419f-bd1a-b126833227fb"; for phase in ["dispatched", "unknown", "running"] { let id = Uuid::new_v4().to_string(); - if dir.join("image-task.json").exists() { fs::remove_file(dir.join("image-task.json")).unwrap(); } + if dir.join("image-task.json").exists() { + fs::remove_file(dir.join("image-task.json")).unwrap(); + } let original = json!({"id":id,"phase":phase,"requestId":null,"upstreamTaskId":"retained-upstream-task","keyFingerprint":"original-fingerprint","references":[{"path":"original-reference.png"}],"modelId":"original-model"}); job_save(&dir, &original).unwrap(); let mut restored = read_json(&dir.join("image-task.json")).unwrap(); @@ -715,7 +922,9 @@ mod tests { #[test] fn image_recovery_rejects_invalid_request_identifiers_without_mutating_the_task() { let original = json!({"id":"original-task","phase":"unknown","requestId":null}); - for provided in [json!(""), json!("not-a-uuid"), json!("../requests/other"), json!("https://example.invalid/result"), json!(1), json!(true), json!([]), json!({})] { + for provided in + [json!(""), json!("not-a-uuid"), json!("../requests/other"), json!("https://example.invalid/result"), json!(1), json!(true), json!([]), json!({})] + { let mut task = original.clone(); assert!(bind_recovery_request_id(&mut task, &json!({"requestId":provided})).is_err()); assert_eq!(task, original); @@ -737,7 +946,12 @@ mod tests { #[test] fn image_recovery_existing_or_omitted_request_identifiers_are_read_only() { let original = json!({"id":"original-task","phase":"unknown","requestId":"cc6a9ed9-863b-419f-bd1a-b126833227fb"}); - for input in [json!({}), json!({"requestId":null}), json!({"requestId":"cc6a9ed9-863b-419f-bd1a-b126833227fb"}), json!({"requestId":"CC6A9ED9-863B-419F-BD1A-B126833227FB"})] { + for input in [ + json!({}), + json!({"requestId":null}), + json!({"requestId":"cc6a9ed9-863b-419f-bd1a-b126833227fb"}), + json!({"requestId":"CC6A9ED9-863B-419F-BD1A-B126833227FB"}), + ] { let mut task = original.clone(); assert!(!bind_recovery_request_id(&mut task, &input).unwrap()); assert_eq!(task, original); @@ -760,7 +974,16 @@ mod tests { } #[test] fn image_recovery_lookup_does_not_authorize_new_work_from_pending_or_missing_results() { - for (code, status, available) in [(200, "processing", false), (200, "processing", true), (200, "failed", true), (200, "unresolved", true), (200, "succeeded", false), (404, "succeeded", true), (401, "succeeded", true), (502, "succeeded", true)] { + for (code, status, available) in [ + (200, "processing", false), + (200, "processing", true), + (200, "failed", true), + (200, "unresolved", true), + (200, "succeeded", false), + (404, "succeeded", true), + (401, "succeeded", true), + (502, "succeeded", true), + ] { let response = json!({"request_id":"cc6a9ed9-863b-419f-bd1a-b126833227fb","status":status,"result_available":available,"result_path":"/api/external/requests/cc6a9ed9-863b-419f-bd1a-b126833227fb/result"}); assert!(located_recovery_request_id(code, &response).is_err(), "unsafe lookup was accepted: {code} {status} {available}"); } diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_pi.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_pi.rs index 70dc1316..3486e329 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_pi.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_pi.rs @@ -14,23 +14,41 @@ fn uuid(v: &Value, field: &str) -> R { v[field].as_str().and_then(|s| Uuid::parse_str(s).ok()).map(|id| id.to_string()).ok_or_else(|| format!("{field}编号无效")) } fn scope_matches(method: &str, input: &Value, actual: &str) -> bool { - match input["accountScope"].as_str() { Some(scope) => scope == actual, None => method == "info" } + match input["accountScope"].as_str() { + Some(scope) => scope == actual, + None => method == "info", + } } fn record_request_header(task: &mut Value, method: &str, request: &str) { - if method == "request" { task["requestId"] = json!(request); } - else { task["lastQueryRequestId"] = json!(request); } + if method == "request" { + task["requestId"] = json!(request); + } else { + task["lastQueryRequestId"] = json!(request); + } } fn stream_complete(text: &str, anthropic: bool, expected_model: Option<&str>) -> bool { let mut terminal = !anthropic && text.lines().any(|l| l.trim() == "data: [DONE]"); let mut model_seen = false; let mut finish_seen = false; for value in text.lines().filter_map(|l| l.trim().strip_prefix("data:").map(str::trim)).filter_map(|s| serde_json::from_str::(s).ok()) { - if anthropic && value["type"] == "message_stop" { terminal = true; } - if anthropic && value["delta"]["stop_reason"].is_string() { finish_seen = true; } - if !anthropic && value["choices"].as_array().is_some_and(|choices| choices.iter().any(|c| c["index"].as_u64().unwrap_or(0) == 0 && c["finish_reason"].is_string())) { finish_seen = true; } + if anthropic && value["type"] == "message_stop" { + terminal = true; + } + if anthropic && value["delta"]["stop_reason"].is_string() { + finish_seen = true; + } + if !anthropic + && value["choices"] + .as_array() + .is_some_and(|choices| choices.iter().any(|c| c["index"].as_u64().unwrap_or(0) == 0 && c["finish_reason"].is_string())) + { + finish_seen = true; + } if let Some(model) = value["model"].as_str().or(value["message"]["model"].as_str()) { model_seen = true; - if expected_model.is_some_and(|expected| expected != model) { return false; } + if expected_model.is_some_and(|expected| expected != model) { + return false; + } } } terminal && finish_seen && (expected_model.is_none() || model_seen) @@ -61,11 +79,15 @@ fn validate_payload(value: &Value, model: &Value) -> R<()> { Ok(()) } pub(super) fn invocation_headers(input: &Value) -> R> { - let Some(value) = input.get("invocationHeaders") else { return Ok(vec![]); }; + let Some(value) = input.get("invocationHeaders") else { + return Ok(vec![]); + }; let headers = value.as_object().ok_or("公开调用头无效")?; let mut result = Vec::new(); for (name, value) in headers { - if name != "X-Relay-Route-Option" { return Err("调用头未授权".into()); } + if name != "X-Relay-Route-Option" { + return Err("调用头未授权".into()); + } let id = value.as_str().and_then(|s| Uuid::parse_str(s).ok()).ok_or("公开路由选项无效")?; result.push((name.clone(), id.to_string())); } @@ -77,7 +99,10 @@ fn selected(backend: &mut Backend) -> R<(Value, Value)> { let key = settings["key"].as_str().ok_or("请先连接个人98k Key")?; let models = backend.json("agent-models")?; let model = models["models"].as_array().and_then(|a| a.iter().find(|m| m["key"] == settings["model"])).ok_or("当前对话模型未开放")?.clone(); - if !matches!((model["protocol"].as_str(), model["path"].as_str()), (Some("openai-chat"), Some("/v1/chat/completions")) | (Some("anthropic-messages"), Some("/v1/messages"))) { + if !matches!( + (model["protocol"].as_str(), model["path"].as_str()), + (Some("openai-chat"), Some("/v1/chat/completions")) | (Some("anthropic-messages"), Some("/v1/messages")) + ) { return Err("模型协议未适配".into()); } if !backend.key_catalog(key)?.iter().any(|id| model["id"] == id.as_str()) { @@ -86,10 +111,14 @@ fn selected(backend: &mut Backend) -> R<(Value, Value)> { Ok((settings, model)) } pub(super) fn unresolved(dir: &Path) -> R> { - if !dir.exists() { return Ok(vec![]); } + if !dir.exists() { + return Ok(vec![]); + } let mut pending = Vec::new(); for (count, entry) in fs::read_dir(dir).map_err(|_| "请求记录无法读取")?.take(10001).enumerate() { - if count >= 10000 { return Err("请求记录超过本地验证上限,不能跳过未核查请求".into()); } + if count >= 10000 { + return Err("请求记录超过本地验证上限,不能跳过未核查请求".into()); + } let path = entry.map_err(|_| "请求记录无法读取")?.path(); if path.extension().is_some_and(|s| s == "json") { let v = read_json(&path)?; @@ -103,7 +132,9 @@ pub(super) fn unresolved(dir: &Path) -> R> { pub(super) fn run() -> R<()> { let mut bytes = Vec::new(); std::io::stdin().take((LIMIT + 32769) as u64).read_to_end(&mut bytes).map_err(|_| "SDK输入无法读取")?; - if bytes.len() > LIMIT + 32768 { return Err("SDK输入过大".into()); } + if bytes.len() > LIMIT + 32768 { + return Err("SDK输入过大".into()); + } let input: Value = serde_json::from_slice(&bytes).map_err(|_| "SDK输入无效")?; let project = uuid(&input, "project")?; let root = crate::services::config_dir().ok_or("本机配置目录不可用")?.join("jiansun"); @@ -116,12 +147,13 @@ pub(super) fn run() -> R<()> { if method == "info" { let (_, model) = selected(&mut backend)?; let invocation = pi_runtime::invocation(&backend, &model)?; - return emit(json!({"type":"result","value":{"accountScope":backend.scope(),"accountId":backend.auth["account"]["id"],"origin":backend.origin,"model":model,"invocationHeaders":invocation["headers"],"pending":unresolved(&dir)?}})); + return emit( + json!({"type":"result","value":{"accountScope":backend.scope(),"accountId":backend.auth["account"]["id"],"origin":backend.origin,"model":model,"invocationHeaders":invocation["headers"],"pending":unresolved(&dir)?}}), + ); } if method == "knowledge" { backend.validate()?; - let path = if input["brandId"].is_null() { "catalog/brands".into() } - else { format!("catalog/brands/{}", uuid(&input, "brandId")?) }; + let path = if input["brandId"].is_null() { "catalog/brands".into() } else { format!("catalog/brands/{}", uuid(&input, "brandId")?) }; let value = backend.json(&path)?; return emit(json!({"type":"result","value":value})); } @@ -129,7 +161,9 @@ pub(super) fn run() -> R<()> { let file = dir.join(format!("{id}.json")); if method == "finish" { let mut task = read_json(&file)?; - if task["phase"] != "received" || task["bodyHash"] != input["bodyHash"] { return Err("原请求尚未完成或身份不一致,不会重发".into()); } + if task["phase"] != "received" || task["bodyHash"] != input["bodyHash"] { + return Err("原请求尚未完成或身份不一致,不会重发".into()); + } let valid = input["valid"] == true && task["terminal"] == true; task["phase"] = json!(if valid { "complete" } else { "unknown" }); task["finishReason"] = input["reason"].clone(); @@ -139,19 +173,27 @@ pub(super) fn run() -> R<()> { } if method == "read" { let task = read_json(&file)?; - return emit(json!({"type":"result","value":{"phase":task["phase"],"requestId":task["requestId"],"bodyHash":task["bodyHash"],"model":task["model"],"terminal":task["terminal"],"raw":task["raw"],"recovery":task["recovery"]}})); + return emit( + json!({"type":"result","value":{"phase":task["phase"],"requestId":task["requestId"],"bodyHash":task["bodyHash"],"model":task["model"],"terminal":task["terminal"],"raw":task["raw"],"recovery":task["recovery"]}}), + ); + } + if !matches!(method, "request" | "recover") { + return Err("SDK操作未授权".into()); } - if !matches!(method, "request" | "recover") { return Err("SDK操作未授权".into()); } let (settings, model) = if method == "recover" { backend.validate()?; let original = read_json(&file)?; (backend.settings()?, original["model"].clone()) - } else { selected(&mut backend)? }; + } else { + selected(&mut backend)? + }; let key = settings["key"].as_str().ok_or("个人Key缺失")?; let mut task; let response = if method == "recover" { task = read_json(&file)?; - if task["keyFingerprint"] != hash(key) { return Err("必须使用原账户、原Key只读核查".into()); } + if task["keyFingerprint"] != hash(key) { + return Err("必须使用原账户、原Key只读核查".into()); + } let request = uuid(&task, "requestId")?; backend.http.get(format!("{UPSTREAM}/api/external/requests/{request}/result")).bearer_auth(key).send() } else { @@ -159,7 +201,9 @@ pub(super) fn run() -> R<()> { if super::creation::unresolved(&native)? || super::images::pending(&native)? { return Err("原创作或生图请求待核查,禁止从Agent重新派发".into()); } - if file.exists() || !unresolved(&dir)?.is_empty() { return Err("原请求待核查或已提交,禁止重发".into()); } + if file.exists() || !unresolved(&dir)?.is_empty() { + return Err("原请求待核查或已提交,禁止重发".into()); + } let payload = input["body"].as_str().filter(|s| s.len() <= LIMIT).ok_or("模型输入过大")?; let value: Value = serde_json::from_str(payload).map_err(|_| "模型输入不是JSON")?; validate_payload(&value, &model)?; @@ -167,10 +211,20 @@ pub(super) fn run() -> R<()> { task = json!({"id":id,"requestId":null,"phase":"dispatched","model":model,"bodyHash":hash(payload),"keyFingerprint":hash(key),"createdAt":now(),"invocationHeaders":headers}); // Durable dispatch barrier BEFORE the one and only POST. write_json(&file, &task)?; - let mut request = backend.http.post(format!("{UPSTREAM}{}", model["path"].as_str().ok_or("模型路径无效")?)) - .timeout(Duration::from_secs(180)).bearer_auth(key).header("Idempotency-Key", &id).header("Content-Type", "application/json").body(payload.to_owned()); - for (name, value) in headers { request = request.header(name, value); } - if model["protocol"] == "anthropic-messages" { request = request.header("Anthropic-Version", "2023-06-01"); } + let mut request = backend + .http + .post(format!("{UPSTREAM}{}", model["path"].as_str().ok_or("模型路径无效")?)) + .timeout(Duration::from_secs(600)) + .bearer_auth(key) + .header("Idempotency-Key", &id) + .header("Content-Type", "application/json") + .body(payload.to_owned()); + for (name, value) in headers { + request = request.header(name, value); + } + if model["protocol"] == "anthropic-messages" { + request = request.header("Anthropic-Version", "2023-06-01"); + } request.send() }; let mut raw = Vec::new(); @@ -189,9 +243,13 @@ pub(super) fn run() -> R<()> { let mut chunk = [0u8; 8192]; loop { let n = response.read(&mut chunk).map_err(|_| "98k响应中断,结果未知;不会重发")?; - if n == 0 { break; } + if n == 0 { + break; + } // The durable JSON reader is capped at 2MB; leave room for base64/envelope. - if raw.len().saturating_add(n) > 512 * 1024 { return Err("98k响应超限,结果待核查".into()); } + if raw.len().saturating_add(n) > 512 * 1024 { + return Err("98k响应超限,结果待核查".into()); + } let part = chunk.get(..n).ok_or("响应长度无效")?; raw.extend_from_slice(part); emit(json!({"type":"chunk","data":STANDARD.encode(part)}))?; @@ -200,17 +258,24 @@ pub(super) fn run() -> R<()> { if method == "recover" { task["recovery"] = json!({"status":status,"at":now(),"raw":STANDARD.encode(&raw)}); write_json(&file, &task)?; - if status != 200 { return Err(format!("原请求查询HTTP {status},结果未知;不会重发")); } + if status != 200 { + return Err(format!("原请求查询HTTP {status},结果未知;不会重发")); + } } - let sse = content_type.to_ascii_lowercase().starts_with("text/event-stream") || text.trim_start().starts_with("data:") || text.trim_start().starts_with("event:"); + let sse = content_type.to_ascii_lowercase().starts_with("text/event-stream") + || text.trim_start().starts_with("data:") + || text.trim_start().starts_with("event:"); let terminal = if method == "recover" && sse { // The public retained-result endpoint can return the ORIGINAL SSE body. stream_complete(&text, model["protocol"] == "anthropic-messages", model["id"].as_str()) } else if method == "recover" { let v: Value = serde_json::from_slice(&raw).map_err(|_| "原响应无效,继续保留未知状态")?; let v = retained_body(v)?; - v["model"] == model["id"] && (v["stop_reason"].is_string() || v["choices"].as_array().is_some_and(|a| a.iter().any(|c| c["finish_reason"].is_string()))) - } else { stream_complete(&text, model["protocol"] == "anthropic-messages", model["id"].as_str()) }; + v["model"] == model["id"] + && (v["stop_reason"].is_string() || v["choices"].as_array().is_some_and(|a| a.iter().any(|c| c["finish_reason"].is_string()))) + } else { + stream_complete(&text, model["protocol"] == "anthropic-messages", model["id"].as_str()) + }; task["terminal"] = json!(terminal); task["phase"] = json!(if status == 200 && terminal { "received" } else { "unknown" }); task["raw"] = json!(STANDARD.encode(&raw)); @@ -238,7 +303,8 @@ mod tests { #[test] fn scoped_model_and_tool_allowlist() { let model = json!({"id":"test-model"}); - let mut payload = json!({"model":"test-model","stream":true,"max_tokens":100,"messages":[{"role":"user","content":"x"}],"tools":[{"name":"canvas_read"}]}); + let mut payload = + json!({"model":"test-model","stream":true,"max_tokens":100,"messages":[{"role":"user","content":"x"}],"tools":[{"name":"canvas_read"}]}); assert!(validate_payload(&payload, &model).is_ok()); payload["tools"][0]["name"] = json!("bash"); assert!(validate_payload(&payload, &model).is_err()); diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_pi_runtime.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_pi_runtime.rs index 196cadf2..049b71d8 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_pi_runtime.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_pi_runtime.rs @@ -5,7 +5,9 @@ use std::process::{Command, Stdio}; #[path = "jiansun_rule_cache.rs"] mod rule_cache; -fn cache_root(b: &Backend) -> PathBuf { b.dir().join("rule-cache") } +fn cache_root(b: &Backend) -> PathBuf { + b.dir().join("rule-cache") +} fn refresh_cache(b: &Backend, wanted: Option<&str>) -> R { let index = b.json("agent-rules")?; rule_cache::store_index(&cache_root(b), &index, now())?; @@ -16,28 +18,42 @@ fn refresh_cache(b: &Backend, wanted: Option<&str>) -> R { } fn background_refresh(b: &Backend, wanted: Option) { let root = cache_root(b); - if fs::create_dir_all(&root).is_err() { return; } + if fs::create_dir_all(&root).is_err() { + return; + } let lock = root.join("refresh.lock"); // A process exit can leave a marker, but never a permanently locked cache. - if fs::metadata(&lock).and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age > Duration::from_secs(120)) { let _ = fs::remove_file(&lock); } - let Ok(marker) = fs::OpenOptions::new().write(true).create_new(true).open(&lock) else { return; }; + if fs::metadata(&lock).and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age > Duration::from_secs(120)) { + let _ = fs::remove_file(&lock); + } + let Ok(marker) = fs::OpenOptions::new().write(true).create_new(true).open(&lock) else { + return; + }; let backend = b.clone(); std::thread::spawn(move || { let result = refresh_cache(&backend, wanted.as_deref()); - let status = match result { Ok(_) => json!({"checkedAt":now(),"state":"ready"}), Err(error) => json!({"checkedAt":now(),"state":"failed","error":error}) }; + let status = match result { + Ok(_) => json!({"checkedAt":now(),"state":"ready"}), + Err(error) => json!({"checkedAt":now(),"state":"failed","error":error}), + }; let _ = write_json(&root.join("refresh-state.json"), &status); - drop(marker); let _ = fs::remove_file(lock); + drop(marker); + let _ = fs::remove_file(lock); }); } fn cached_index(b: &Backend, wanted: Option) -> R { if let Some((index, at)) = rule_cache::read_index(&cache_root(b))? { - if now().saturating_sub(at) >= rule_cache::CHECK_SECONDS { background_refresh(b, wanted); } + if now().saturating_sub(at) >= rule_cache::CHECK_SECONDS { + background_refresh(b, wanted); + } return Ok(index); } refresh_cache(b, wanted.as_deref()) } fn cached_package(b: &Backend, item: &Value) -> R { - if let Some(saved) = rule_cache::read_package(&cache_root(b), item)? { return Ok(saved); } + if let Some(saved) = rule_cache::read_package(&cache_root(b), item)? { + return Ok(saved); + } let id = item["id"].as_str().ok_or("规则编号无效")?; let value = b.json(&format!("agent-rules?binding={id}"))?; let saved = rule_cache::package(id, &value, Some(item))?; @@ -46,13 +62,19 @@ fn cached_package(b: &Backend, item: &Value) -> R { } fn original_rule(dir: &Path) -> R> { let file = dir.join("agent-binding.json"); - if !file.exists() { return Ok(None); } + if !file.exists() { + return Ok(None); + } let old = read_json(&file)?; Ok(Some(rule_cache::package(old["id"].as_str().ok_or("项目规则编号无效")?, &old, None)?)) } fn can_change_rule(b: &Backend, doc: u64, dir: &Path) -> R<()> { let project = b.locations.get(&doc).ok_or("项目身份尚未绑定")?; - if !super::pi::unresolved(&b.dir().join("pi-validation").join(project).join("requests"))?.is_empty() || super::creation::unresolved(dir)? || super::images::pending(dir)? || !super::projects::creation_barriers(dir)?.is_empty() { + if !super::pi::unresolved(&b.dir().join("pi-validation").join(project).join("requests"))?.is_empty() + || super::creation::unresolved(dir)? + || super::images::pending(dir)? + || !super::projects::creation_barriers(dir)?.is_empty() + { return Err("有原请求待核查,不能切换规则绕过".into()); } Ok(()) @@ -62,14 +84,24 @@ fn automatic_rule(b: &Backend, doc: u64, index: &Value) -> R<(Option, Val let creation = super::creation::load(&dir)?; let brand = creation["brief"]["brandId"].as_str().unwrap_or(""); let category = creation["brief"]["categoryId"].as_str().unwrap_or(""); - if brand.is_empty() || category.is_empty() { return Ok((None, json!({"state":"needs-category","reason":"请在产品资料中选择品牌和品类"}))); } + if brand.is_empty() || category.is_empty() { + return Ok((None, json!({"state":"needs-category","reason":"请在产品资料中选择品牌和品类"}))); + } let item = rule_cache::matching(index, brand, category)?; if let Some(saved) = original_rule(&dir)?.filter(|saved| rule_cache::matches_project(saved, brand, category)) { // A catalogue update never rewrites an existing project's fixed snapshot. - if creation["designSnapshot"]["rules"] != saved { can_change_rule(b,doc,&dir)?; super::creation::attach_agent_rules(&dir, &saved)?; } - return Ok((Some(saved.clone()), json!({"state":"ready","updateAvailable":rule_cache::update_available(&saved,item),"templateId":saved["id"],"contentHash":saved["contentHash"]}))); + if creation["designSnapshot"]["rules"] != saved { + can_change_rule(b, doc, &dir)?; + super::creation::attach_agent_rules(&dir, &saved)?; + } + return Ok(( + Some(saved.clone()), + json!({"state":"ready","updateAvailable":rule_cache::update_available(&saved,item),"templateId":saved["id"],"contentHash":saved["contentHash"]}), + )); } - let Some(item) = item else { return Ok((None, json!({"state":"unavailable","reason":"此品牌与品类的模板待配置"}))); }; + let Some(item) = item else { + return Ok((None, json!({"state":"unavailable","reason":"此品牌与品类的模板待配置"}))); + }; can_change_rule(b, doc, &dir)?; let saved = cached_package(b, item)?; // Download outside the project write lock; re-read the actual project before @@ -84,7 +116,9 @@ fn invocation_from(catalog: &Value, model: &Value) -> R { let sections = catalog["data"]["sections"].as_array().or_else(|| catalog["sections"].as_array()).ok_or("98k公开模型目录格式已改变")?; let found = sections.iter().filter_map(|s| s["models"].as_array()).flatten().find(|m| m["id"] == model["id"]).ok_or("当前模型尚未发布调用契约")?; let invocation = &found["invocation"]; - if invocation["method"] != "POST" || invocation["path"] != model["path"] || invocation["content_type"] != "application/json" { return Err("当前模型公开契约与已选协议不一致,未调用".into()); } + if invocation["method"] != "POST" || invocation["path"] != model["path"] || invocation["content_type"] != "application/json" { + return Err("当前模型公开契约与已选协议不一致,未调用".into()); + } let headers = invocation.get("headers").cloned().unwrap_or(json!({})); super::pi::invocation_headers(&json!({"invocationHeaders":headers}))?; Ok(json!({"headers":headers,"model":model["id"],"path":model["path"],"at":now()})) @@ -93,39 +127,58 @@ pub(super) fn invocation(b: &Backend, model: &Value) -> R { let file = b.root.join("public-invocations").join(format!("{}.json", hash(model["id"].as_str().ok_or("模型编号无效")?))); if file.exists() { let value = read_json(&file)?; - if value["model"] == model["id"] && value["path"] == model["path"] && value["at"].as_u64().is_some_and(|at| now().saturating_sub(at) < 300) { return Ok(value); } + if value["model"] == model["id"] && value["path"] == model["path"] && value["at"].as_u64().is_some_and(|at| now().saturating_sub(at) < 300) { + return Ok(value); + } } let response = b.http.get(format!("{UPSTREAM}/api/v2/catalog/models")).send().map_err(|_| "98k公开调用目录暂时不可用,未派发模型")?; let (status, catalog) = body(response, 16 * 1024 * 1024)?; - if status != 200 { return Err(format!("98k公开调用目录HTTP {status},未派发模型")); } + if status != 200 { + return Err(format!("98k公开调用目录HTTP {status},未派发模型")); + } let value = invocation_from(&catalog, model)?; write_json(&file, &value)?; Ok(value) } pub(super) fn rules(b: &mut Backend, input: &Value) -> R { let doc = input["doc"].as_u64().ok_or("请先打开项目")?; - b.bind_new(doc); b.ensure_owner(doc)?; b.validate()?; + b.bind_new(doc); + b.ensure_owner(doc)?; + b.validate()?; let dir = b.project_dir(doc)?; - let wanted = original_rule(&dir)?.and_then(|s|s["id"].as_str().map(str::to_owned)); + let wanted = original_rule(&dir)?.and_then(|s| s["id"].as_str().map(str::to_owned)); let mut result = cached_index(b, wanted)?; let (rule, state) = automatic_rule(b, doc, &result)?; - result["rule"] = rule.unwrap_or(Value::Null); result["ruleState"] = state; + result["rule"] = rule.unwrap_or(Value::Null); + result["ruleState"] = state; result["creation"] = super::creation::load(&dir)?; - result["doc"] = json!(doc); result["accountId"] = b.auth["account"]["id"].clone(); result["origin"] = json!(b.origin); + result["doc"] = json!(doc); + result["accountId"] = b.auth["account"]["id"].clone(); + result["origin"] = json!(b.origin); Ok(result) } pub(super) fn select(b: &mut Backend, input: &Value) -> R { let doc = input["doc"].as_u64().ok_or("请先打开项目")?; - b.bind_new(doc); b.ensure_owner(doc)?; b.validate()?; - let id = input["id"].as_str().filter(|s| !s.is_empty() && s.len() <= 100 && s.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_'))).ok_or("规则编号无效")?; + b.bind_new(doc); + b.ensure_owner(doc)?; + b.validate()?; + let id = input["id"] + .as_str() + .filter(|s| !s.is_empty() && s.len() <= 100 && s.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_'))) + .ok_or("规则编号无效")?; let dir = b.project_dir(doc)?; can_change_rule(b, doc, &dir)?; let file = dir.join("agent-binding.json"); let index = cached_index(b, Some(id.into()))?; let c = super::creation::load(&dir)?; - let item = rule_cache::matching(&index, c["brief"]["brandId"].as_str().unwrap_or(""), c["brief"]["categoryId"].as_str().unwrap_or(""))?.filter(|item| item["id"] == id).ok_or("此模板不属于当前项目的品牌和品类")?; + let item = rule_cache::matching(&index, c["brief"]["brandId"].as_str().unwrap_or(""), c["brief"]["categoryId"].as_str().unwrap_or(""))? + .filter(|item| item["id"] == id) + .ok_or("此模板不属于当前项目的品牌和品类")?; if let Some(old) = original_rule(&dir)?.filter(|old| old["id"] == id) - && input["adoptUpdate"] != true { return Ok(json!({"doc":doc,"rule":old,"updateAvailable":rule_cache::update_available(&old,Some(item))})); } + && input["adoptUpdate"] != true + { + return Ok(json!({"doc":doc,"rule":old,"updateAvailable":rule_cache::update_available(&old,Some(item))})); + } let saved = cached_package(b, item)?; can_change_rule(b, doc, &dir)?; super::creation::attach_agent_rules(&dir, &saved)?; @@ -137,49 +190,80 @@ fn runtime() -> R<(PathBuf, PathBuf)> { let packaged = exe.parent().ok_or("客户端位置不可用")?.join("agent"); let script = packaged.join("src").join("desktop-host.mjs"); let node = packaged.join(if cfg!(windows) { "node.exe" } else { "node" }); - if script.is_file() && node.is_file() { return Ok((node, script)); } + if script.is_file() && node.is_file() { + return Ok((node, script)); + } #[cfg(debug_assertions)] { let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../detail-agent-pi/src/desktop-host.mjs"); let node = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../detail-agent-pi/runtime").join(if cfg!(windows) { "node.exe" } else { "node" }); - if script.is_file() && node.is_file() { return Ok((node, script)); } + if script.is_file() && node.is_file() { + return Ok((node, script)); + } } Err("客户端缺少Agent运行组件,请保留项目并安装完整版本".into()) } +fn project_evidence(c: &Value, scope: &str, project: &str, doc: u64) -> Value { + let brief: serde_json::Map = + ["name", "brandId", "brandName", "categoryId", "categoryName", "identifierType", "identifier", "variant", "colorName", "dimensions", "note"] + .into_iter() + .filter_map(|key| c["brief"].get(key).map(|value| (key.to_owned(), value.clone()))) + .collect(); + let screens: Vec = c["plan"]["screens"] + .as_array() + .into_iter() + .flatten() + .take(12) + .map(|screen| json!({"name":screen["name"],"title":screen["title"],"body":screen["body"]})) + .collect(); + json!({"accountScope":scope,"project":project,"documentId":doc,"revision":c["revision"],"brief":brief,"facts":c["facts"],"sellingPoints":c["sellingPoints"],"plan":{"status":c["plan"]["status"],"screens":screens}}) +} pub(super) fn run(b: &mut Backend, input: &Value, method: &str, events: &mpsc::Sender, ctx: &egui::Context) -> R { match run_inner(b, input, method, events, ctx) { Ok(value) => Ok(value), Err(error) => { - let doc = input["context"]["id"].as_u64().or_else(||input["doc"].as_u64()).ok_or(error.clone())?; + let doc = input["context"]["id"].as_u64().or_else(|| input["doc"].as_u64()).ok_or(error.clone())?; let pending = b.locations.get(&doc).map(|project| super::pi::unresolved(&b.dir().join("pi-validation").join(project).join("requests"))).transpose(); - let unknown = pending.as_ref().map_or(true, |p| p.as_ref().is_some_and(|p|!p.is_empty())); + let unknown = pending.as_ref().map_or(true, |p| p.as_ref().is_some_and(|p| !p.is_empty())); Ok(json!({"engine":"pi","mode":"prompt","doc":doc,"accountId":b.auth["account"]["id"],"origin":b.origin,"error":error,"unknown":unknown})) } } } fn run_inner(b: &mut Backend, input: &Value, method: &str, events: &mpsc::Sender, ctx: &egui::Context) -> R { let doc = input["context"]["id"].as_u64().or_else(|| input["doc"].as_u64()).ok_or("请先打开项目")?; - b.ensure_owner(doc)?; b.validate()?; + b.ensure_owner(doc)?; + b.validate()?; let project = b.locations.get(&doc).ok_or("项目身份尚未绑定")?.clone(); let dir = b.project_dir(doc)?; - let saved = if matches!(method,"pi-recover"|"pi-review") { - original_rule(&dir)?.ok_or("原项目规则快照缺失,请保留原请求")? + let saved = if matches!(method, "pi-recover" | "pi-review") { + original_rule(&dir)? } else { - let wanted = original_rule(&dir)?.and_then(|s|s["id"].as_str().map(str::to_owned)); - let index = cached_index(b,wanted)?; - let (saved,state) = automatic_rule(b,doc,&index)?; - saved.ok_or_else(||state["reason"].as_str().unwrap_or("项目模板尚未配置").to_string())? + let wanted = original_rule(&dir)?.and_then(|s| s["id"].as_str().map(str::to_owned)); + let index = cached_index(b, wanted)?; + let (saved, state) = automatic_rule(b, doc, &index)?; + let _ = state; // Missing rules do not block a project's ordinary discussion. + saved }; let (node, script) = runtime()?; let binary = std::env::current_exe().map_err(|_| "客户端位置不可用")?; let profile = b.root.parent().ok_or("配置位置无效")?; - let mode = if method == "pi-recover" { "recover" } else if method == "pi-review" { "review" } else { "prompt" }; + let mode = if method == "pi-recover" { + "recover" + } else if method == "pi-review" { + "review" + } else { + "prompt" + }; let config = json!({"binary":binary,"profile":profile,"root":dir.join("agent"),"project":project,"scope":b.scope(),"documentId":doc, - "ruleCatalog":saved["catalog"],"ruleId":saved["id"],"mode":mode,"text":input["text"]}); + "ruleCatalog":saved.as_ref().map(|rule|rule["catalog"].clone()),"ruleId":saved.as_ref().map(|rule|rule["id"].clone()), + "projectEvidence":project_evidence(&super::creation::load(&dir)?,&b.scope(),&project,doc),"mode":mode,"text":input["text"]}); let mut command = Command::new(node); command.arg(script).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).env_remove("NODE_OPTIONS").env_remove("NODE_PATH"); #[cfg(windows)] - { use std::os::windows::process::CommandExt; command.creation_flags(0x08000000); } + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } let mut child = command.spawn().map_err(|_| "本机Agent无法启动,未派发模型")?; let mut stdin = child.stdin.take().ok_or("本机Agent输入不可用")?; let stdout = child.stdout.take().ok_or("本机Agent输出不可用")?; @@ -189,22 +273,39 @@ fn run_inner(b: &mut Backend, input: &Value, method: &str, events: &mpsc::Sender loop { let mut line = String::new(); let n = reader.by_ref().take(2_000_001).read_line(&mut line).map_err(|_| "Agent连接中断,原请求需核查")?; - if n == 0 { break; } - if n > 2_000_000 { let _ = child.kill(); return Err("Agent消息超限,原请求不会重发".into()); } + if n == 0 { + break; + } + if n > 2_000_000 { + let _ = child.kill(); + return Err("Agent消息超限,原请求不会重发".into()); + } let value: Value = serde_json::from_str(&line).map_err(|_| "Agent消息无效,原请求不会重发")?; if value["type"] == "canvas" { - let action = value["action"].as_str().filter(|s| matches!(*s, "pi-begin" | "pi-state" | "pi-delta" | "pi-activity" | "pi-edit" | "pi-end")).ok_or("Agent本机动作未授权")?; + let action = value["action"] + .as_str() + .filter(|s| matches!(*s, "pi-begin" | "pi-state" | "pi-delta" | "pi-activity" | "pi-write-request" | "pi-edit" | "pi-end")) + .ok_or("Agent本机动作未授权")?; let (tx, rx) = mpsc::channel(); events.send(Event::PiAction(action.into(), value["input"].clone(), tx)).map_err(|_| "工作台已关闭,原请求需核查")?; ctx.request_repaint(); - let reply = match rx.recv_timeout(Duration::from_secs(12)) { Ok(Ok(v)) => json!({"id":value["id"],"value":v}), Ok(Err(e)) => json!({"id":value["id"],"error":e}), Err(_) => json!({"id":value["id"],"error":"画布未确认操作,请保留当前状态"}) }; + let reply = match rx.recv_timeout(Duration::from_secs(12)) { + Ok(Ok(v)) => json!({"id":value["id"],"value":v}), + Ok(Err(e)) => json!({"id":value["id"],"error":e}), + Err(_) => json!({"id":value["id"],"error":"画布未确认操作,请保留当前状态"}), + }; writeln!(stdin, "{reply}").and_then(|_| stdin.flush()).map_err(|_| "Agent连接中断,原操作不会重发")?; - } else if value["type"] == "complete" { result = Some(value["value"].clone()); } + } else if value["type"] == "complete" { + result = Some(value["value"].clone()); + } } let _ = child.wait(); let mut result = result.ok_or("Agent异常退出,草稿和原请求已保留,请核查原结果")?; - result["engine"] = json!("pi"); result["doc"] = json!(doc); - result["accountId"] = b.auth["account"]["id"].clone(); result["origin"] = json!(b.origin); result["mode"] = json!(mode); + result["engine"] = json!("pi"); + result["doc"] = json!(doc); + result["accountId"] = b.auth["account"]["id"].clone(); + result["origin"] = json!(b.origin); + result["mode"] = json!(mode); result["unknown"] = json!(!super::pi::unresolved(&b.dir().join("pi-validation").join(project).join("requests"))?.is_empty()); Ok(result) } @@ -212,6 +313,18 @@ fn run_inner(b: &mut Backend, input: &Value, method: &str, events: &mpsc::Sender mod tests { use super::*; #[test] + fn discussion_product_snapshot_keeps_project_identity_and_omits_image_prompts() { + let c = json!({"revision":2,"brief":{"name":"电脑桌","colorName":"胡桃色","images":[{"source":"private-original"}]},"facts":{"status":"ready","items":[],"missing":["尺寸待确认"]},"sellingPoints":{"selected":[],"batches":[]},"plan":{"status":"confirmed","screens":[{"name":"首屏","body":"产品介绍","imagePrompt":"不应注入普通问答的长图提示"}]}}); + let evidence = project_evidence(&c, "account-scope", "project-id", 7); + assert_eq!(evidence["accountScope"], "account-scope"); + assert_eq!(evidence["project"], "project-id"); + assert_eq!(evidence["documentId"], 7); + assert_eq!(evidence["facts"]["missing"][0], "尺寸待确认"); + assert_eq!(evidence["plan"]["screens"][0]["body"], "产品介绍"); + assert!(evidence["brief"]["images"].is_null()); + assert!(evidence["plan"]["screens"][0]["imagePrompt"].is_null()); + } + #[test] fn public_default_is_pinned_without_changing_credentials_or_path() { let model = json!({"id":"qa","path":"/v1/chat/completions"}); let mut catalog = json!({"data":{"sections":[{"models":[{"id":"qa","invocation":{"method":"POST","path":"/v1/chat/completions","content_type":"application/json","headers":{"X-Relay-Route-Option":"a3830b3b-6fa5-4a2b-80d0-9e7a1c5cf61a"}}}]}]}}); diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_projects.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_projects.rs index bbe874e0..8e4fc91a 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_projects.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_projects.rs @@ -8,7 +8,7 @@ fn project_id(input: &Value) -> R<&str> { input["id"].as_str().filter(|s| Uuid::parse_str(s).is_ok() || s.parse::().is_ok()).ok_or("项目编号无效".into()) } -fn local_path(base: &Path, id: &str) -> R { +pub(super) fn local_path(base: &Path, id: &str) -> R { let parent = base.canonicalize().map_err(|_| "本机项目目录不存在")?; let path = base.join(id).canonicalize().map_err(|_| "本机项目不存在")?; // A junction or symlink must never turn a project action into an operation on another folder. @@ -21,15 +21,35 @@ fn local_path(base: &Path, id: &str) -> R { /// Native generation has a second barrier after a successful GET: its result may /// still be staged rather than applied to the user's canonical canvas. pub(super) fn creation_barriers(dir: &Path) -> R> { - if !dir.join("creation.json").exists() { return Ok(vec![]); } + let mut barriers = Vec::new(); + if dir.join("template-slots.json").exists() { + let slots = super::template_slots::load(dir)?; + for slot in slots["slots"].as_object().ok_or("图位任务记录无效,请保留项目核查")?.values() { + for task in slot["candidates"].as_object().into_iter().flat_map(|m| m.values()).filter(|t| t.is_object()) { + let phase = task["phase"].as_str().ok_or("图位任务状态无效")?; + if !["complete", "failed"].contains(&phase) { + let id = task["taskId"].as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("图位原请求身份无效")?; + barriers.push(json!({"id":id,"role":"assistant","text":"图位原请求待核查,未重发","pending":true,"unknown":true})); + } + } + } + } + if !dir.join("creation.json").exists() { + return Ok(barriers); + } let creation = super::creation::load(dir)?; let generation = &creation["generation"]; - if generation.is_null() { return Ok(vec![]); } - if !generation.is_object() { return Err("生成恢复记录无效,请保留原项目核查".into()); } - let mut barriers = Vec::new(); + if generation.is_null() { + return Ok(barriers); + } + if !generation.is_object() { + return Err("生成恢复记录无效,请保留原项目核查".into()); + } let pending = &generation["pending"]; if !pending.is_null() { - if !pending.is_object() { return Err("原图片待处理记录无效,请保留原项目核查".into()); } + if !pending.is_object() { + return Err("原图片待处理记录无效,请保留原项目核查".into()); + } let id = match pending.get("taskId").filter(|id| !id.is_null()) { Some(id) => id.as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("原图片任务身份无效,请保留原项目核查")?, // Legacy/pre-dispatch journals have no task ID yet. This marker does @@ -40,21 +60,28 @@ pub(super) fn creation_barriers(dir: &Path) -> R> { } let recovery = &generation["recovery"]; if !recovery.is_null() { - if !recovery.is_object() { return Err("原图片恢复记录无效,请保留原项目核查".into()); } + if !recovery.is_object() { + return Err("原图片恢复记录无效,请保留原项目核查".into()); + } let id = recovery["taskId"].as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("原图片恢复身份无效,请保留原项目核查")?; match recovery["applied"].as_bool() { - Some(true) => {}, // Older successful native recoveries may retain phase=complete. + Some(true) => {} // Older successful native recoveries may retain phase=complete. Some(false) => { let task = read_json(&dir.join("image-jobs").join(id).join("task.json"))?; - if task["id"] != id { return Err("原图片恢复任务不一致,请保留原项目核查".into()); } - let phase = task["phase"].as_str().filter(|phase| matches!(*phase, "prepared" | "dispatched" | "running" | "unknown" | "complete" | "applied" | "failed" | "rejected")).ok_or("原图片恢复状态无效,请保留原项目核查")?; + if task["id"] != id { + return Err("原图片恢复任务不一致,请保留原项目核查".into()); + } + let phase = task["phase"] + .as_str() + .filter(|phase| matches!(*phase, "prepared" | "dispatched" | "running" | "unknown" | "complete" | "applied" | "failed" | "rejected")) + .ok_or("原图片恢复状态无效,请保留原项目核查")?; // The existing explicit image-applied acknowledgement also closes // a conflict staged in creation.json; never mistake another task's ACK. if phase != "applied" { let unknown = matches!(phase, "dispatched" | "running" | "unknown"); barriers.push(json!({"id":id,"role":"assistant","text":if unknown {"原请求待核查,未重发"} else {"原图片已保留,尚待应用到画布"},"pending":true,"unknown":unknown})); } - }, + } None => return Err("原图片应用状态无效,请保留原项目核查".into()), } } @@ -66,8 +93,9 @@ pub(super) fn idle(dir: &Path) -> R<()> { return Err("项目有待处理的生成任务或待应用结果,请先处理原任务;不能完结或删除".into()); } if let (Some(account), Some(id)) = (dir.parent().and_then(Path::parent), dir.file_name()) - && !super::pi::unresolved(&account.join("pi-validation").join(id).join("requests"))?.is_empty() { - return Err("项目有Pi请求待核查,不能删除或绕过原请求".into()); + && !super::pi::unresolved(&account.join("pi-validation").join(id).join("requests"))?.is_empty() + { + return Err("项目有Pi请求待核查,不能删除或绕过原请求".into()); } for name in ["task.json", "image-task.json", "creation-task.json"] { let path = dir.join(name); @@ -83,8 +111,58 @@ pub(super) fn idle(dir: &Path) -> R<()> { fn cloud_matches(local: &Value, remote: &Value) -> bool { let link = &local["cloudLink"]; - link["id"].is_string() && ((link["id"] == remote["id"] && link["source"] == remote["source"]) - || (link["source"] == "web" && link["id"] == remote["sourceWebId"])) + link["id"].is_string() + && ((link["id"] == remote["id"] && link["source"] == remote["source"]) || (link["source"] == "web" && link["id"] == remote["sourceWebId"])) +} + +/// The card may offer an explicit update, but this read never replaces files. +/// Web-import revisions are a different counter and must not be compared here. +fn cloud_update_offer(local: &mut Value, remote: &Value, account: &Value, origin: &str) { + let link = &local["cloudLink"]; + let Some(previous) = link["revision"].as_u64().filter(|v| *v > 0) else { return }; + let Some(latest) = remote["revision"].as_u64().filter(|v| *v > previous) else { return }; + if link["source"] != "desktop" || remote["source"] != "desktop" || link["id"] != remote["id"] { + return; + } + let revision = local["revision"].as_u64(); + let blocked = if local["syncDirty"] != false || revision.is_none() || local["syncedRevision"].as_u64() != revision { + Some("unsynced_draft") + } else if local["localPending"] == true || remote["deleteBlocked"] == true { + Some("pending_request") + } else if remote["backupState"] != "ready" { + Some("backup_pending") + } else { + None + }; + local["cloudUpdateAvailable"] = json!(blocked.is_none()); + local["cloudUpdateBlocked"] = json!(blocked); + local["cloudUpdateRef"] = json!({"source":"desktop","id":remote["id"],"revision":latest,"updateExisting":true, + "localId":local["id"],"localRevision":local["revision"],"localCloudRevision":previous,"accountId":account["id"],"origin":origin}); +} + +/// Read all pages before publishing anything. Failure cannot be mistaken for +/// an empty cloud, and a missing row is never synthesized into a tombstone. +fn cloud_project_pages(mut get: impl FnMut(&str) -> R) -> R<(Vec, Vec)> { + let mut items = Vec::new(); + let mut deleted = Vec::new(); + let mut cursor = String::new(); + let mut seen = std::collections::HashSet::new(); + loop { + let page = get(&cursor)?; + items.extend(page["items"].as_array().ok_or("项目列表格式无效")?.iter().cloned()); + if let Some(rows) = page["deleted"].as_array() { + deleted.extend(rows.iter().cloned()); + } + if items.len() + deleted.len() > 10000 { + return Err("项目过多,请缩小查询范围".into()); + } + let Some(next) = page["nextCursor"].as_str() else { break }; + if next.is_empty() || !seen.insert(next.to_owned()) { + return Err("项目分页状态无效,原列表保留".into()); + } + cursor = next.to_owned(); + } + Ok((items, deleted)) } /// A version conflict needs a fresh confirmation; an unresolved request needs @@ -110,7 +188,9 @@ fn trash_local(account: &Path, id: &str) -> R<()> { let trash = account.join("trash"); fs::create_dir_all(&trash).map_err(|_| "无法创建本机回收区,项目未删除")?; let destination = trash.join(id); - if destination.exists() || trash.canonicalize().map_err(|_| "本机回收区不可用")?.parent() != Some(account.canonicalize().map_err(|_| "账户目录不可用")?.as_path()) { + if destination.exists() + || trash.canonicalize().map_err(|_| "本机回收区不可用")?.parent() != Some(account.canonicalize().map_err(|_| "账户目录不可用")?.as_path()) + { return Err("本机回收区状态无效,项目未删除".into()); } fs::rename(&dir, &destination).map_err(|_| "项目移动失败,请关闭占用文件后重试".into()) @@ -167,6 +247,30 @@ fn screen_sources(screen: &Value, web: bool) -> Vec { } impl Backend { + /// Independent authenticated GET worker. This does not reconcile files, + /// trash a project, upload a draft, or touch model/request journals. + pub(super) fn project_list(&mut self, input: &Value) -> R { + self.validate()?; + let read = input["readId"].as_u64().ok_or("项目读取身份无效")?; + if input["accountId"] != self.auth["account"]["id"] || input["origin"] != self.origin { + return Err("项目读取账户已变化,未读取其他账户项目".into()); + } + let (items, deleted) = cloud_project_pages(|cursor| { + let path = if cursor.is_empty() { + "projects".into() + } else { + format!("projects?{}", url::form_urlencoded::Serializer::new(String::new()).append_pair("cursor", cursor).finish()) + }; + self.json(&path) + })?; + let mut locals = self.native_projects()?; + for local in &mut locals { + if let Some(remote) = items.iter().find(|remote| cloud_matches(local, remote)) { + cloud_update_offer(local, remote, &self.auth["account"], &self.origin); + } + } + Ok(json!({"readId":read,"accountId":self.auth["account"]["id"],"origin":self.origin,"items":items,"deleted":deleted,"locals":locals,"complete":true})) + } pub(super) fn reconcile_projects(&self, locals: Vec, cloud: Vec, deleted: &[Value]) -> R> { let mut out = Vec::new(); let mut represented = std::collections::HashSet::new(); @@ -174,23 +278,26 @@ impl Backend { if deleted.iter().any(|d| cloud_matches(&local, d)) { let id = project_id(&local)?; let dir = local_path(&self.dir().join("projects"), id)?; - if idle(&dir).is_ok() { + let context = read_json(&dir.join("context.json"))?; + if context["syncDirty"] == false && idle(&dir).is_ok() { trash_local(&self.dir(), id)?; continue; } // Keep unknown request identities intact until they can be read-only checked. local["cloudDeleted"] = json!(true); local["cloudPresent"] = json!(false); + local["retainedReason"] = json!(if idle(&dir).is_err() { "pending_request" } else { "unsynced_draft" }); } else if let Some(remote) = cloud.iter().find(|r| cloud_matches(&local, r)) { local["cloudPresent"] = json!(true); local["cloudRef"] = json!({"id":remote["id"],"source":remote["source"],"revision":remote["revision"]}); local["deleteBlocked"] = json!(remote["deleteBlocked"] == true); local["deleteBlockedReason"] = remote["deleteBlockedReason"].clone(); + cloud_update_offer(&mut local, remote, &self.auth["account"], &self.origin); represented.insert(format!("{}:{}", remote["source"], remote["id"])); } out.push(local); } - out.extend(cloud.into_iter().filter(|r| !represented.contains(&format!("{}:{}",r["source"],r["id"])))); + out.extend(cloud.into_iter().filter(|r| !represented.contains(&format!("{}:{}", r["source"], r["id"])))); Ok(out) } pub(super) fn project_cover(&mut self, input: &Value) -> R { @@ -213,10 +320,16 @@ impl Backend { let source = first["previewSource"].as_str().ok_or("云同步尚未完成,暂无首屏预览")?; identifier(source)?; let bytes = self.bytes(&format!("projects/desktop/{id}/assets/{source}"))?; - let image = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { - limits: photocraft_codecs::Limits { max_width: 1440, max_height: 4608, max_pixels: 7_000_000, max_alloc: 64 * 1024 * 1024 }, ..Default::default() - }).map_err(|_| "首屏预览格式无效")?.convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); - return Ok(egui::ColorImage::from_rgba_unmultiplied([image.width() as usize,image.height() as usize],image.data())); + let image = photocraft_codecs::decode_with( + &bytes, + &photocraft_codecs::DecodeOptions { + limits: photocraft_codecs::Limits { max_width: 1440, max_height: 4608, max_pixels: 7_000_000, max_alloc: 64 * 1024 * 1024 }, + ..Default::default() + }, + ) + .map_err(|_| "首屏预览格式无效")? + .convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + return Ok(egui::ColorImage::from_rgba_unmultiplied([image.width() as usize, image.height() as usize], image.data())); } let mut document = original.clone(); document["screens"] = json!([first]); @@ -254,9 +367,13 @@ impl Backend { } idle(&dir)?; if !local_only && context["project"]["id"].is_string() { - if !input["cloudRef"].is_object() { return Err("云端关联尚未核对,项目未删除;请联网刷新,或选择仅移除本机副本".into()); } + if !input["cloudRef"].is_object() { + return Err("云端关联尚未核对,项目未删除;请联网刷新,或选择仅移除本机副本".into()); + } let local = json!({"cloudLink":context["project"]}); - if !cloud_matches(&local,&remote) { return Err("云端项目关联已变更,未删除;请刷新后重新确认".into()); } + if !cloud_matches(&local, &remote) { + return Err("云端项目关联已变更,未删除;请刷新后重新确认".into()); + } } local_items.push(input.clone()); } @@ -289,12 +406,18 @@ impl Backend { return Err(delete_error(status, &value)); } deleted_projects = value["deletedProjects"].as_array().cloned().unwrap_or_else(|| vec![remote.clone()]); - if deleted_projects.is_empty() { deleted_projects.push(remote.clone()); } + if deleted_projects.is_empty() { + deleted_projects.push(remote.clone()); + } } for item in &local_items { - trash_local(&self.dir(), project_id(item)?).map_err(|e| if deleted_projects.is_empty() {e} else {format!("云端已删除;本机副本待清理:{e}。请刷新核对,不会重发删除")})?; + trash_local(&self.dir(), project_id(item)?).map_err(|e| { + if deleted_projects.is_empty() { e } else { format!("云端已删除;本机副本待清理:{e}。请刷新核对,不会重发删除") } + })?; } - Ok(json!({"id":id,"source":source,"accountId":self.auth["account"]["id"],"origin":self.origin,"deleted":true,"deletedProjects":deleted_projects,"localIds":local_items.iter().map(|p|p["id"].clone()).collect::>()})) + Ok( + json!({"id":id,"source":source,"accountId":self.auth["account"]["id"],"origin":self.origin,"deleted":true,"deletedProjects":deleted_projects,"localIds":local_items.iter().map(|p|p["id"].clone()).collect::>()}), + ) } } @@ -302,6 +425,92 @@ impl Backend { mod tests { use super::*; #[test] + fn cloud_update_offer_is_read_only_scoped_and_never_compares_web_revision_counters() { + let account = json!({"id":"account"}); + let base = json!({"source":"native","id":"local","revision":7,"syncedRevision":7,"syncDirty":false,"localPending":false, + "cloudLink":{"source":"desktop","id":"cloud","revision":2}}); + let remote = json!({"source":"desktop","id":"cloud","revision":3,"backupState":"ready","deleteBlocked":false}); + let mut local = base.clone(); + cloud_update_offer(&mut local, &remote, &account, "https://sc.jiansun.vip"); + assert_eq!(local["cloudUpdateAvailable"], true); + assert_eq!(local["cloudUpdateRef"]["accountId"], "account"); + assert_eq!(local["cloudUpdateRef"]["localRevision"], 7); + assert_eq!(local["cloudUpdateRef"]["localCloudRevision"], 2); + assert_eq!(local["cloudUpdateRef"]["revision"], 3); + for (field, value, reason) in + [("syncDirty", json!(true), "unsynced_draft"), ("localPending", json!(true), "pending_request"), ("syncedRevision", json!(6), "unsynced_draft")] + { + let mut local = base.clone(); + local[field] = value; + cloud_update_offer(&mut local, &remote, &account, "https://sc.jiansun.vip"); + assert_eq!(local["cloudUpdateAvailable"], false); + assert_eq!(local["cloudUpdateBlocked"], reason); + } + let mut web = base.clone(); + web["cloudLink"]["source"] = json!("web"); + cloud_update_offer(&mut web, &remote, &account, "https://sc.jiansun.vip"); + assert!(web["cloudUpdateRef"].is_null()); + let mut same = base; + let mut remote = remote; + remote["revision"] = json!(2); + cloud_update_offer(&mut same, &remote, &account, "https://sc.jiansun.vip"); + assert!(same["cloudUpdateRef"].is_null()); + } + #[test] + fn cloud_pages_publish_all_pages_and_explicit_tombstones_without_inventing_missing_deletions() { + let mut calls = Vec::new(); + let (items, deleted) = cloud_project_pages(|cursor| { + calls.push(cursor.to_owned()); + Ok(if cursor.is_empty() { + json!({"items":[{"source":"web","id":"first"}],"deleted":[],"nextCursor":"second"}) + } else { + assert_eq!(cursor, "second"); + json!({"items":[{"source":"desktop","id":"last"}],"deleted":[{"source":"web","id":"removed"}],"nextCursor":null}) + }) + }) + .unwrap(); + assert_eq!(calls, ["", "second"]); + assert_eq!(items.iter().map(|p| p["id"].as_str().unwrap()).collect::>(), ["first", "last"]); + assert_eq!(deleted, vec![json!({"source":"web","id":"removed"})]); + } + #[test] + fn failed_or_cyclic_cloud_pages_cannot_publish_a_partial_empty_snapshot() { + let old = [json!({"source":"web","id":"already-visible"})]; + let result = cloud_project_pages(|cursor| { + if cursor.is_empty() { + Ok(json!({"items":[],"deleted":[{"source":"web","id":"already-visible"}],"nextCursor":"second"})) + } else { + Err("fake GET failed".into()) + } + }); + assert_eq!(result.unwrap_err(), "fake GET failed"); + assert_eq!(old.len(), 1); + let mut calls = 0; + let result = cloud_project_pages(|_| { + calls += 1; + Ok(json!({"items":[],"deleted":[],"nextCursor":if calls % 2 == 1 {"a"} else {"b"}})) + }); + assert!(result.unwrap_err().contains("分页")); + assert_eq!(calls, 3); + } + #[test] + fn project_read_pool_remains_available_while_the_model_worker_is_busy() { + let model_busy = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let (release, gate) = std::sync::mpsc::channel(); + let busy = model_busy.clone(); + let model = std::thread::spawn(move || { + gate.recv_timeout(Duration::from_secs(3)).unwrap(); + busy.store(false, std::sync::atomic::Ordering::Release); + }); + let pool = super::super::reads::ReadPool::catalog(); + let (send, receive) = std::sync::mpsc::channel(); + pool.submit(move || send.send(cloud_project_pages(|_| Ok(json!({"items":[{"id":"fresh"}],"deleted":[],"nextCursor":null})))).unwrap()).unwrap(); + assert_eq!(receive.recv_timeout(Duration::from_secs(2)).unwrap().unwrap().0[0]["id"], "fresh"); + assert!(model_busy.load(std::sync::atomic::Ordering::Acquire)); + release.send(()).unwrap(); + model.join().unwrap(); + } + #[test] fn deletion_conflicts_keep_the_authoritative_reason_and_do_not_echo_untrusted_details() { assert!(delete_error(409, &json!({"code":"revision_conflict"})).contains("新版本")); assert!(delete_error(409, &json!({"code":"project_busy"})).contains("正在执行")); @@ -312,7 +521,10 @@ mod tests { for status in [200, 401, 403, 404, 409, 503] { assert!(!delete_error(status, &json!({"code":"unrecognized", "error":detail})).contains(detail)); } - assert!(delete_error(409, &json!({"error":"revision_conflict"})).contains("不会自动重试"), "an untyped conflict must not be guessed to be a stale revision"); + assert!( + delete_error(409, &json!({"error":"revision_conflict"})).contains("不会自动重试"), + "an untyped conflict must not be guessed to be a stale revision" + ); } fn creation_barrier_fixture() -> (PathBuf, PathBuf, Value, Value) { let account = std::env::temp_dir().join(format!("creation-lifecycle-{}", Uuid::new_v4())); @@ -326,12 +538,44 @@ mod tests { (account, dir, c, task) } #[test] + fn reserved_slot_blocks_delete_completion_and_backup_even_before_image_archive_exists() { + let (account, dir, _, _) = creation_barrier_fixture(); + let mut pack = json!({"schemaVersion":1,"id":"LIFECYCLE-TEST","version":"1","name":"任务屏障测试","brandId":"brand","categoryId":"category","outputs":[{"id":"S01","component":"detail","name":"首屏","width":1440,"height":2160,"slots":[{"fieldId":"S01-I01","kind":"image","crop":"cover","frame":{"x":0,"y":0,"width":1440,"height":2160}}]}]}); + pack["contentHash"] = json!(super::super::template_slots::pack_hash(&pack).unwrap()); + let state = super::super::template_slots::install(&dir, &pack, 0).unwrap(); + let slot = super::super::template_slots::slot_id(&pack, "S01-I01"); + let state = super::super::template_slots::reserve_candidate( + &dir, + &slot, + "A", + "nano-banana-2", + "仅测试原请求保护,不调用模型", + &[format!("local:{}", "a".repeat(64))], + state["revision"].as_u64().unwrap(), + ) + .unwrap(); + let task = state["slots"][&slot]["candidates"]["A"]["taskId"].as_str().unwrap(); + assert!(!dir.join("image-jobs").join(task).exists()); + let barriers = creation_barriers(&dir).unwrap(); + assert_eq!(barriers.len(), 1); + assert_eq!(barriers[0]["id"], task); + assert_eq!(barriers[0]["unknown"], true); + assert!(idle(&dir).is_err()); + assert!(trash_local(&account, dir.file_name().unwrap().to_str().unwrap()).is_err()); + assert!(!account.join("trash").exists()); + assert_eq!(fs::read(dir.join("canvas.pcraft")).unwrap(), b"unchanged-canonical-canvas"); + fs::remove_dir_all(account).unwrap(); + } + #[test] fn generation_pending_and_unapplied_conflicts_block_the_shared_lifecycle_guard() { let (account, dir, original, task) = creation_barrier_fixture(); for conflict in [false, true] { let mut c = original.clone(); - if conflict { c["generation"]["recovery"] = json!({"taskId":task["id"],"applied":false,"state":"conflict"}); } - else { c["generation"]["pending"] = json!({"taskId":task["id"],"sequence":1}); } + if conflict { + c["generation"]["recovery"] = json!({"taskId":task["id"],"applied":false,"state":"conflict"}); + } else { + c["generation"]["pending"] = json!({"taskId":task["id"],"sequence":1}); + } write_json(&dir.join("creation.json"), &c).unwrap(); assert!(idle(&dir).unwrap_err().contains("待应用")); assert!(trash_local(&account, dir.file_name().unwrap().to_str().unwrap()).unwrap_err().contains("待应用")); @@ -361,7 +605,15 @@ mod tests { #[test] fn corrupt_creation_barriers_default_deny_without_touching_the_project() { let (account, dir, original, task) = creation_barrier_fixture(); - for malformed in [json!({"version":2}), json!({"version":1,"generation":[]}), json!({"version":1,"generation":{"pending":[]}}), json!({"version":1,"generation":{"pending":{"taskId":"../other"}}}), json!({"version":1,"generation":{"recovery":[]}}), json!({"version":1,"generation":{"recovery":{"taskId":task["id"]}}}), json!({"version":1,"generation":{"recovery":{"taskId":"../other","applied":false}}})] { + for malformed in [ + json!({"version":2}), + json!({"version":1,"generation":[]}), + json!({"version":1,"generation":{"pending":[]}}), + json!({"version":1,"generation":{"pending":{"taskId":"../other"}}}), + json!({"version":1,"generation":{"recovery":[]}}), + json!({"version":1,"generation":{"recovery":{"taskId":task["id"]}}}), + json!({"version":1,"generation":{"recovery":{"taskId":"../other","applied":false}}}), + ] { write_json(&dir.join("creation.json"), &malformed).unwrap(); assert!(idle(&dir).is_err()); } @@ -393,23 +645,42 @@ mod tests { } #[test] fn linked_projects_merge_only_after_cloud_confirmation_and_tombstones_preserve_unknown_tasks() { - let mut b=fixture(); let (doc,mut local)=create(&mut b); - let remote=json!({"id":Uuid::new_v4().to_string(),"source":"web","revision":3,"deleteBlocked":true,"deleteBlockedReason":"pending_request"}); - let dir=b.project_dir(doc.id.0).unwrap(); - let mut context=read_json(&dir.join("context.json")).unwrap(); context["project"]=remote.clone(); write_json(&dir.join("context.json"),&context).unwrap(); - local["cloudLink"]=remote.clone(); - let merged=b.reconcile_projects(vec![local.clone()],vec![remote.clone()],&[]).unwrap(); - assert_eq!(merged.len(),1); assert_eq!(merged[0]["cloudPresent"],true); - assert_eq!(merged[0]["deleteBlocked"],true); assert_eq!(merged[0]["deleteBlockedReason"],"pending_request"); - assert_eq!(b.reconcile_projects(vec![local.clone()],vec![],&[]).unwrap()[0]["cloudPresent"],false); + let mut b = fixture(); + let (doc, mut local) = create(&mut b); + let remote = json!({"id":Uuid::new_v4().to_string(),"source":"web","revision":3,"deleteBlocked":true,"deleteBlockedReason":"pending_request"}); + let dir = b.project_dir(doc.id.0).unwrap(); + let mut context = read_json(&dir.join("context.json")).unwrap(); + context["project"] = remote.clone(); + write_json(&dir.join("context.json"), &context).unwrap(); + local["cloudLink"] = remote.clone(); + let merged = b.reconcile_projects(vec![local.clone()], vec![remote.clone()], &[]).unwrap(); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0]["cloudPresent"], true); + assert_eq!(merged[0]["deleteBlocked"], true); + assert_eq!(merged[0]["deleteBlockedReason"], "pending_request"); + assert_eq!(b.reconcile_projects(vec![local.clone()], vec![], &[]).unwrap()[0]["cloudPresent"], false); assert!(b.delete_project(&local).unwrap_err().contains("尚未核对")); - write_json(&dir.join("task.json"),&json!({"phase":"unknown","requestId":"original"})).unwrap(); - let kept=b.reconcile_projects(vec![local.clone()],vec![],std::slice::from_ref(&remote)).unwrap(); - assert_eq!(kept[0]["cloudDeleted"],true); - assert_eq!(read_json(&dir.join("task.json")).unwrap()["requestId"],"original"); - write_json(&dir.join("task.json"),&json!({"phase":"failed","requestId":"original"})).unwrap(); - assert!(b.reconcile_projects(vec![local],vec![],&[remote]).unwrap().is_empty()); - assert!(b.save(doc,2,json!({})).is_err()); + // A clean linked copy is still protected by an original unknown request. + assert_eq!(context["syncDirty"], false); + write_json(&dir.join("task.json"), &json!({"phase":"unknown","requestId":"original"})).unwrap(); + let kept = b.reconcile_projects(vec![local.clone()], vec![], std::slice::from_ref(&remote)).unwrap(); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0]["cloudDeleted"], true); + assert_eq!(read_json(&dir.join("task.json")).unwrap()["requestId"], "original"); + write_json(&dir.join("task.json"), &json!({"phase":"failed","requestId":"original"})).unwrap(); + // The fixture's first save was clean. Mark a real unsynced edit explicitly + // before testing the separate dirty-draft retention rule. + context["syncDirty"] = json!(true); + local["syncDirty"] = json!(true); + write_json(&dir.join("context.json"), &context).unwrap(); + let kept = b.reconcile_projects(vec![local.clone()], vec![], std::slice::from_ref(&remote)).unwrap(); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0]["retainedReason"], "unsynced_draft"); + assert!(dir.join("canvas.pcraft").exists(), "a cloud tombstone cannot discard an unsynced local draft"); + context["syncDirty"] = json!(false); + write_json(&dir.join("context.json"), &context).unwrap(); + assert!(b.reconcile_projects(vec![local], vec![], &[remote]).unwrap().is_empty()); + assert!(b.save(doc, 2, json!({})).is_err()); fs::remove_dir_all(&b.root).unwrap(); } #[test] @@ -473,7 +744,8 @@ mod tests { let expected_id = id.clone(); let server = std::thread::spawn(move || { let mut requests = Vec::new(); - for (status, response) in [(200, json!({"account":{"id":"local-fixture"}})), (409, json!({"error":"项目已有新版本", "code":"revision_conflict"}))] { + for (status, response) in [(200, json!({"account":{"id":"local-fixture"}})), (409, json!({"error":"项目已有新版本", "code":"revision_conflict"}))] + { let (mut socket, _) = listener.accept().unwrap(); socket.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); let mut raw = Vec::new(); diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_recovery.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_recovery.rs index 201545b2..58918bc5 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_recovery.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_recovery.rs @@ -9,184 +9,13 @@ fn uuid(value: &Value) -> R<&str> { // original task terminal too; a crash between those writes is repaired on open. fn settle_applied_recovery_locked(dir: &Path, creation: &Value) -> R { let recovery = &creation["generation"]["recovery"]; - if recovery["applied"] != true { return Ok(false); } + if recovery["applied"] != true { + return Ok(false); + } images::mark_applied_locked(dir, uuid(&recovery["taskId"])?)?; Ok(true) } -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn an_already_applied_creation_repairs_its_original_image_journal_on_restart() { - let dir = std::env::temp_dir().join(format!("recovery-applied-repair-{}", Uuid::new_v4())); - write_json(&dir.join("context.json"), &json!({})).unwrap(); - let task = json!({"id":Uuid::new_v4().to_string(),"phase":"complete","requestId":"cc6a9ed9-863b-419f-bd1a-b126833227fb"}); - images::job_save(&dir, &task).unwrap(); - let c = json!({"version":1,"revision":8,"generation":{"recovery":{"taskId":task["id"],"applied":true,"state":"already-applied","recoveryApplyCount":1}}}); - write_json(&dir.join("creation.json"), &c).unwrap(); - let _guard = sync::context_guard(); - assert!(settle_applied_recovery_locked(&dir, &creation::load(&dir).unwrap()).unwrap()); - let file = dir.join("image-task.json"); - let before = fs::read(&file).unwrap(); - assert_eq!(read_json(&file).unwrap()["phase"], "applied"); - assert!(settle_applied_recovery_locked(&dir, &c).unwrap()); - assert_eq!(fs::read(&file).unwrap(), before); - assert_eq!(creation::load(&dir).unwrap(), c, "journal repair must not reapply the canvas or increment creation"); - fs::remove_dir_all(dir).unwrap(); - } - #[test] - fn an_unapplied_or_conflicting_recovery_keeps_the_original_image_ready() { - let dir = std::env::temp_dir().join(format!("recovery-unapplied-ready-{}", Uuid::new_v4())); - write_json(&dir.join("context.json"), &json!({})).unwrap(); - let task = json!({"id":Uuid::new_v4().to_string(),"phase":"complete"}); - images::job_save(&dir, &task).unwrap(); - let _guard = sync::context_guard(); - for state in ["recovered", "conflict"] { - let c = json!({"generation":{"recovery":{"taskId":task["id"],"applied":false,"state":state}}}); - assert!(!settle_applied_recovery_locked(&dir, &c).unwrap()); - let current = read_json(&dir.join("image-task.json")).unwrap(); - assert_eq!(current, task); - assert!(images::require_ready_image(¤t, &json!({"taskId":task["id"]})).is_ok()); - } - fs::remove_dir_all(dir).unwrap(); - } - fn legacy_fixture() -> (PathBuf, Value, Value) { - let dir = std::env::temp_dir().join(format!("restart-association-{}", Uuid::new_v4())); - let mut c = json!({"version":1,"revision":0,"width":1440,"brief":{},"sellingPoints":{"selected":[],"batches":[]},"plan":{}}); - c["brief"]["name"] = json!("真实产品"); - c["plan"]["screens"] = json!([{"id":"5814971b-4a4c-415e-a4bc-df6cceae932d","name":"首屏","title":"原标题","body":"事实说明","imagePrompt":"首屏产品"},{"id":"6e877a5f-e83c-4d62-a6bf-33af0a401288","name":"人物细节","title":"细节","body":"实物细节","imagePrompt":"原产品的真实细节"}]); - c["plan"]["status"] = json!("confirmed"); - let plan = creation::plan_hash(&c).unwrap(); - c["plan"]["confirmedInput"] = json!(plan); - c["generation"] = json!({"run":"7862bc99-7485-420c-bbcd-df13a2c117fe","planHash":plan,"lastTaskId":"fb4696a5-16d1-4510-85b3-6c91b6c62d19","screens":{"5814971b-4a4c-415e-a4bc-df6cceae932d":{"taskId":"fb4696a5-16d1-4510-85b3-6c91b6c62d19"}},"pending":{"baseRevision":3,"previousTaskId":"fb4696a5-16d1-4510-85b3-6c91b6c62d19","screenId":"6e877a5f-e83c-4d62-a6bf-33af0a401288","sequence":1}}); - let reference = dir.join("image-jobs/42e84e04-4738-48c0-b3c0-31ee097b7a35/reference-0.png"); - fs::create_dir_all(reference.parent().unwrap()).unwrap(); - fs::write(&reference, b"original-pixels").unwrap(); - let task = json!({"id":"42e84e04-4738-48c0-b3c0-31ee097b7a35","phase":"dispatched","revision":3,"mode":"generate","target":null,"requestId":null,"prompt":"项目:真实产品\n当前屏:人物细节\n要求:原产品的真实细节","references":[{"path":reference.to_string_lossy(),"sha256":fingerprint(b"original-pixels")}]}); - write_json(&dir.join("image-jobs/42e84e04-4738-48c0-b3c0-31ee097b7a35/task.json"), &task).unwrap(); - (dir, c, task) - } - #[test] - fn legacy_without_request_id_task_id_or_timestamp_is_uniquely_bound() { - let (dir, c, task) = legacy_fixture(); - assert!(c["generation"]["pending"]["taskId"].is_null()); - assert!(task["requestId"].is_null()); - assert!(task["createdAt"].is_null()); - let binding = legacy_binding(&dir, &c, &task, "024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692", "scope", "https://sc.jiansun.vip").unwrap(); - assert_eq!(binding["screenId"], "6e877a5f-e83c-4d62-a6bf-33af0a401288"); - assert_eq!(binding["legacyVerified"], true); - let mut bound = task.clone(); - bound["creationBinding"] = binding; - assert!(association(&c, &bound, "024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692", "scope", "https://sc.jiansun.vip").is_ok()); - assert!(association(&c, &bound, "different-project", "scope", "https://sc.jiansun.vip").is_err()); - assert!(association(&c, &bound, "024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692", "other-account", "https://sc.jiansun.vip").is_err()); - let mut changed = c.clone(); - changed["plan"]["screens"][1]["title"] = json!("新方案"); - assert!(association(&changed, &bound, "024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692", "scope", "https://sc.jiansun.vip").is_err()); - fs::remove_dir_all(dir).unwrap(); - } - #[test] - fn legacy_ambiguity_reference_changes_and_wrong_sequence_stay_unknown() { - let (dir, c, task) = legacy_fixture(); - let mut wrong = c.clone(); - wrong["generation"]["pending"]["sequence"] = json!(0); - assert!(legacy_binding(&dir, &wrong, &task, "project", "scope", "origin").is_err()); - let mut duplicate = task.clone(); - duplicate["id"] = json!(Uuid::new_v4().to_string()); - let duplicate_file = dir.join("image-jobs").join(duplicate["id"].as_str().unwrap()).join("task.json"); - write_json(&duplicate_file, &duplicate).unwrap(); - assert!(legacy_binding(&dir, &c, &task, "project", "scope", "origin").is_err()); - fs::remove_file(duplicate_file).unwrap(); - fs::write(task["references"][0]["path"].as_str().unwrap(), b"modified").unwrap(); - assert!(legacy_binding(&dir, &c, &task, "project", "scope", "origin").is_err()); - fs::remove_dir_all(dir).unwrap(); - } - #[test] - fn creation_revision_cas_preserves_edits_made_during_original_get() { - let (dir, mut c, _) = legacy_fixture(); - creation::save(&dir, &mut c).unwrap(); - let mut stale = c.clone(); - c["brief"]["name"] = json!("用户的新产品资料"); - creation::save(&dir, &mut c).unwrap(); - stale["generation"]["pending"] = Value::Null; - assert!(creation::save(&dir, &mut stale).is_err()); - assert_eq!(creation::load(&dir).unwrap(), c); - fs::remove_dir_all(dir).unwrap(); - } - #[test] - fn failed_permission_or_original_key_checks_do_not_consume_the_recovery_slot() { - let attempts = std::sync::Mutex::new(std::collections::HashSet::new()); - let key = "account|project|original-task".to_owned(); - for message in ["授权校验连接中断", "请切回原个人Key"] { - assert!(reserve_attempt(&attempts, key.clone(), Err(message.into())).is_err()); - assert!(attempts.lock().unwrap().is_empty()); - } - assert!(reserve_attempt(&attempts, key.clone(), Ok(())).unwrap()); - assert!(!reserve_attempt(&attempts, key, Ok(())).unwrap()); - assert_eq!(attempts.lock().unwrap().len(), 1); - } - #[test] - fn legacy_numeric_projects_remain_readable_without_becoming_recent_uuid_projects() { - assert!(!rememberable_project("123456").unwrap()); - assert!(rememberable_project("024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692").unwrap()); - assert!(rememberable_project("../outside").is_err()); - let binding=json!({"accountScope":"own","origin":"https://sc.jiansun.vip","accountId":"owner","localId":"123456"}); - assert_eq!(open_binding_local(&binding,"own","https://sc.jiansun.vip",&json!({"id":"owner"})).unwrap(),"123456"); - } - #[test] - fn a_late_original_get_cannot_resurrect_a_deleted_project() { - let root = std::env::temp_dir().join(format!("restart-deleted-{}", Uuid::new_v4())); - let account = root.join("account"); - let local = Uuid::new_v4().to_string(); - let dir = account.join("projects").join(&local); - write_json(&dir.join("context.json"), &json!({"localId":local,"revision":1})).unwrap(); - let task = json!({"id":Uuid::new_v4().to_string(),"phase":"complete","creationBinding":{"localProjectId":local}}); - images::job_save(&dir, &task).unwrap(); - let trash = account.join("trash").join(&local); - fs::create_dir_all(trash.parent().unwrap()).unwrap(); - { - let _guard = sync::context_guard(); - fs::rename(&dir, &trash).unwrap(); - } - let mut late = task.clone(); - late["message"] = json!("GET arrived after deletion"); - assert!(images::job_save(&dir, &late).is_err()); - assert!(receipt_save(&dir, task["id"].as_str().unwrap(), &json!({"state":"recovered"})).is_err()); - let mut stale = json!({"version":1,"revision":0}); - assert!(creation::save(&dir, &mut stale).is_err()); - assert!(!dir.exists()); - assert_eq!(read_json(&trash.join("image-task.json")).unwrap(), task); - fs::remove_dir_all(root).unwrap(); - } - #[test] - fn single_screen_patch_keeps_other_chinese_edits_ids_and_moves_later_screens() { - let screen = |name: &str, height: u32, title: &str| json!({"name":name,"width":1440,"height":height,"background":"#ffffff","layers":[{"kind":"text","name":"标题","text":title,"font":"NotoSansSC-Regular","fontSize":36,"x":20,"y":20,"width":1000,"height":80},{"kind":"shape","shape":"rect","name":"画面","color":"#204060","x":0,"y":120,"width":1440,"height":height-120}]}); - let original = json!({"name":"三屏样张","screens":[screen("首屏",600,"原标题"),screen("细节",400,"细节"),screen("设备",500,"设备")]}); - let baseline = crate::jiansun_document::import(&original, &HashMap::new()).unwrap(); - let mut current = baseline.clone(); - let child = current.layers[0].children_mut().unwrap().iter_mut().find(|l| matches!(l.content, photocraft_doc::LayerContent::Text(_))).unwrap(); - if let photocraft_doc::LayerContent::Text(t) = &mut child.content { - t.text = "照见生活".into(); - } - let first = current.layers[0].clone(); - let third_id = current.layers[2].id; - let mut desired_json = original; - desired_json["screens"][1] = screen("细节", 800, "细节"); - let desired = crate::jiansun_document::import(&desired_json, &HashMap::new()).unwrap(); - let recovered = patch_screen(current.clone(), &baseline, &desired, 1).unwrap(); - assert_eq!(recovered.layers[0], first); - assert_eq!(recovered.layers[2].id, third_id); - assert_eq!(recovered.layers.len(), 3); - assert_eq!(recovered.size.height, 1900); - assert_eq!(recovered.layers[1].id, current.layers[1].id); - let mut ids = std::collections::HashSet::new(); - assert!(recovered.walk().iter().all(|(_, _, l)| ids.insert(l.id))); - current.layers[1].name = "用户改过目标屏".into(); - assert!(patch_screen(current.clone(), &baseline, &desired, 1).is_err()); - assert_eq!(current.layers[0], first); - } -} fn fingerprint(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } @@ -194,9 +23,11 @@ fn reserve_attempt(attempts: &std::sync::Mutex permitted?; Ok(attempts.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key)) } -fn rememberable_project(local:&str)->R { - if local.parse::().is_ok(){return Ok(false);} - Uuid::parse_str(local).map(|_|true).map_err(|_|"项目身份无效".into()) +fn rememberable_project(local: &str) -> R { + if local.parse::().is_ok() { + return Ok(false); + } + Uuid::parse_str(local).map(|_| true).map_err(|_| "项目身份无效".into()) } fn receipt_save(dir: &Path, task: &str, value: &Value) -> R<()> { let _guard = sync::context_guard(); @@ -363,7 +194,9 @@ fn association(c: &Value, task: &Value, local: &str, scope: &str, origin: &str) impl Backend { pub(super) fn remember_project(&self, local: &str) -> R<()> { - if !rememberable_project(local)? {return Ok(());} + if !rememberable_project(local)? { + return Ok(()); + } write_json(&self.dir().join("last-project.json"), &json!({"id":local,"source":"native"})) } pub(super) fn resume_project(&self) -> Value { @@ -450,11 +283,17 @@ impl Backend { fn auto_recover_project_window(&mut self, local: &str, apply: bool, window: Duration, tx: &mpsc::Sender, ctx: &egui::Context) -> R { // A different project without a pending image must open immediately while // the original project's bounded GET is still running in another worker. - Uuid::parse_str(local).map_err(|_|"项目身份无效")?; - let preview_dir=self.dir().join("projects").join(local); - if !preview_dir.join("creation.json").exists(){return Ok(Value::Null);} - let preview=creation::load(&preview_dir)?; - if !preview["generation"]["pending"].is_object() && (!apply || preview["generation"]["recovery"]["applied"]==true || !preview["generation"]["recovery"].is_object()) {return self.apply_recovered_canvas(local,apply);} + Uuid::parse_str(local).map_err(|_| "项目身份无效")?; + let preview_dir = self.dir().join("projects").join(local); + if !preview_dir.join("creation.json").exists() { + return Ok(Value::Null); + } + let preview = creation::load(&preview_dir)?; + if !preview["generation"]["pending"].is_object() + && (!apply || preview["generation"]["recovery"]["applied"] == true || !preview["generation"]["recovery"].is_object()) + { + return self.apply_recovered_canvas(local, apply); + } let serial = self.recovery_serial.clone(); let _flight = serial.lock().unwrap_or_else(std::sync::PoisonError::into_inner); Uuid::parse_str(local).map_err(|_| "项目身份无效")?; @@ -663,3 +502,178 @@ impl Backend { Ok(receipt) } } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn an_already_applied_creation_repairs_its_original_image_journal_on_restart() { + let dir = std::env::temp_dir().join(format!("recovery-applied-repair-{}", Uuid::new_v4())); + write_json(&dir.join("context.json"), &json!({})).unwrap(); + let task = json!({"id":Uuid::new_v4().to_string(),"phase":"complete","requestId":"cc6a9ed9-863b-419f-bd1a-b126833227fb"}); + images::job_save(&dir, &task).unwrap(); + let c = + json!({"version":1,"revision":8,"generation":{"recovery":{"taskId":task["id"],"applied":true,"state":"already-applied","recoveryApplyCount":1}}}); + write_json(&dir.join("creation.json"), &c).unwrap(); + let _guard = sync::context_guard(); + assert!(settle_applied_recovery_locked(&dir, &creation::load(&dir).unwrap()).unwrap()); + let file = dir.join("image-task.json"); + let before = fs::read(&file).unwrap(); + assert_eq!(read_json(&file).unwrap()["phase"], "applied"); + assert!(settle_applied_recovery_locked(&dir, &c).unwrap()); + assert_eq!(fs::read(&file).unwrap(), before); + assert_eq!(creation::load(&dir).unwrap(), c, "journal repair must not reapply the canvas or increment creation"); + fs::remove_dir_all(dir).unwrap(); + } + #[test] + fn an_unapplied_or_conflicting_recovery_keeps_the_original_image_ready() { + let dir = std::env::temp_dir().join(format!("recovery-unapplied-ready-{}", Uuid::new_v4())); + write_json(&dir.join("context.json"), &json!({})).unwrap(); + let task = json!({"id":Uuid::new_v4().to_string(),"phase":"complete"}); + images::job_save(&dir, &task).unwrap(); + let _guard = sync::context_guard(); + for state in ["recovered", "conflict"] { + let c = json!({"generation":{"recovery":{"taskId":task["id"],"applied":false,"state":state}}}); + assert!(!settle_applied_recovery_locked(&dir, &c).unwrap()); + let current = read_json(&dir.join("image-task.json")).unwrap(); + assert_eq!(current, task); + assert!(images::require_ready_image(¤t, &json!({"taskId":task["id"]})).is_ok()); + } + fs::remove_dir_all(dir).unwrap(); + } + fn legacy_fixture() -> (PathBuf, Value, Value) { + let dir = std::env::temp_dir().join(format!("restart-association-{}", Uuid::new_v4())); + let mut c = json!({"version":1,"revision":0,"width":1440,"brief":{},"sellingPoints":{"selected":[],"batches":[]},"plan":{}}); + c["brief"]["name"] = json!("真实产品"); + c["plan"]["screens"] = json!([{"id":"5814971b-4a4c-415e-a4bc-df6cceae932d","name":"首屏","title":"原标题","body":"事实说明","imagePrompt":"首屏产品"},{"id":"6e877a5f-e83c-4d62-a6bf-33af0a401288","name":"人物细节","title":"细节","body":"实物细节","imagePrompt":"原产品的真实细节"}]); + c["plan"]["status"] = json!("confirmed"); + let plan = creation::plan_hash(&c).unwrap(); + c["plan"]["confirmedInput"] = json!(plan); + c["generation"] = json!({"run":"7862bc99-7485-420c-bbcd-df13a2c117fe","planHash":plan,"lastTaskId":"fb4696a5-16d1-4510-85b3-6c91b6c62d19","screens":{"5814971b-4a4c-415e-a4bc-df6cceae932d":{"taskId":"fb4696a5-16d1-4510-85b3-6c91b6c62d19"}},"pending":{"baseRevision":3,"previousTaskId":"fb4696a5-16d1-4510-85b3-6c91b6c62d19","screenId":"6e877a5f-e83c-4d62-a6bf-33af0a401288","sequence":1}}); + let reference = dir.join("image-jobs/42e84e04-4738-48c0-b3c0-31ee097b7a35/reference-0.png"); + fs::create_dir_all(reference.parent().unwrap()).unwrap(); + fs::write(&reference, b"original-pixels").unwrap(); + let task = json!({"id":"42e84e04-4738-48c0-b3c0-31ee097b7a35","phase":"dispatched","revision":3,"mode":"generate","target":null,"requestId":null,"prompt":"项目:真实产品\n当前屏:人物细节\n要求:原产品的真实细节","references":[{"path":reference.to_string_lossy(),"sha256":fingerprint(b"original-pixels")}]}); + write_json(&dir.join("image-jobs/42e84e04-4738-48c0-b3c0-31ee097b7a35/task.json"), &task).unwrap(); + (dir, c, task) + } + #[test] + fn legacy_without_request_id_task_id_or_timestamp_is_uniquely_bound() { + let (dir, c, task) = legacy_fixture(); + assert!(c["generation"]["pending"]["taskId"].is_null()); + assert!(task["requestId"].is_null()); + assert!(task["createdAt"].is_null()); + let binding = legacy_binding(&dir, &c, &task, "024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692", "scope", "https://sc.jiansun.vip").unwrap(); + assert_eq!(binding["screenId"], "6e877a5f-e83c-4d62-a6bf-33af0a401288"); + assert_eq!(binding["legacyVerified"], true); + let mut bound = task.clone(); + bound["creationBinding"] = binding; + assert!(association(&c, &bound, "024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692", "scope", "https://sc.jiansun.vip").is_ok()); + assert!(association(&c, &bound, "different-project", "scope", "https://sc.jiansun.vip").is_err()); + assert!(association(&c, &bound, "024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692", "other-account", "https://sc.jiansun.vip").is_err()); + let mut changed = c.clone(); + changed["plan"]["screens"][1]["title"] = json!("新方案"); + assert!(association(&changed, &bound, "024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692", "scope", "https://sc.jiansun.vip").is_err()); + fs::remove_dir_all(dir).unwrap(); + } + #[test] + fn legacy_ambiguity_reference_changes_and_wrong_sequence_stay_unknown() { + let (dir, c, task) = legacy_fixture(); + let mut wrong = c.clone(); + wrong["generation"]["pending"]["sequence"] = json!(0); + assert!(legacy_binding(&dir, &wrong, &task, "project", "scope", "origin").is_err()); + let mut duplicate = task.clone(); + duplicate["id"] = json!(Uuid::new_v4().to_string()); + let duplicate_file = dir.join("image-jobs").join(duplicate["id"].as_str().unwrap()).join("task.json"); + write_json(&duplicate_file, &duplicate).unwrap(); + assert!(legacy_binding(&dir, &c, &task, "project", "scope", "origin").is_err()); + fs::remove_file(duplicate_file).unwrap(); + fs::write(task["references"][0]["path"].as_str().unwrap(), b"modified").unwrap(); + assert!(legacy_binding(&dir, &c, &task, "project", "scope", "origin").is_err()); + fs::remove_dir_all(dir).unwrap(); + } + #[test] + fn creation_revision_cas_preserves_edits_made_during_original_get() { + let (dir, mut c, _) = legacy_fixture(); + creation::save(&dir, &mut c).unwrap(); + let mut stale = c.clone(); + c["brief"]["name"] = json!("用户的新产品资料"); + creation::save(&dir, &mut c).unwrap(); + stale["generation"]["pending"] = Value::Null; + assert!(creation::save(&dir, &mut stale).is_err()); + assert_eq!(creation::load(&dir).unwrap(), c); + fs::remove_dir_all(dir).unwrap(); + } + #[test] + fn failed_permission_or_original_key_checks_do_not_consume_the_recovery_slot() { + let attempts = std::sync::Mutex::new(std::collections::HashSet::new()); + let key = "account|project|original-task".to_owned(); + for message in ["授权校验连接中断", "请切回原个人Key"] { + assert!(reserve_attempt(&attempts, key.clone(), Err(message.into())).is_err()); + assert!(attempts.lock().unwrap().is_empty()); + } + assert!(reserve_attempt(&attempts, key.clone(), Ok(())).unwrap()); + assert!(!reserve_attempt(&attempts, key, Ok(())).unwrap()); + assert_eq!(attempts.lock().unwrap().len(), 1); + } + #[test] + fn legacy_numeric_projects_remain_readable_without_becoming_recent_uuid_projects() { + assert!(!rememberable_project("123456").unwrap()); + assert!(rememberable_project("024b4ed8-6b7b-4a33-8f4f-ebe5c9d4c692").unwrap()); + assert!(rememberable_project("../outside").is_err()); + let binding = json!({"accountScope":"own","origin":"https://sc.jiansun.vip","accountId":"owner","localId":"123456"}); + assert_eq!(open_binding_local(&binding, "own", "https://sc.jiansun.vip", &json!({"id":"owner"})).unwrap(), "123456"); + } + #[test] + fn a_late_original_get_cannot_resurrect_a_deleted_project() { + let root = std::env::temp_dir().join(format!("restart-deleted-{}", Uuid::new_v4())); + let account = root.join("account"); + let local = Uuid::new_v4().to_string(); + let dir = account.join("projects").join(&local); + write_json(&dir.join("context.json"), &json!({"localId":local,"revision":1})).unwrap(); + let task = json!({"id":Uuid::new_v4().to_string(),"phase":"complete","creationBinding":{"localProjectId":local}}); + images::job_save(&dir, &task).unwrap(); + let trash = account.join("trash").join(&local); + fs::create_dir_all(trash.parent().unwrap()).unwrap(); + { + let _guard = sync::context_guard(); + fs::rename(&dir, &trash).unwrap(); + } + let mut late = task.clone(); + late["message"] = json!("GET arrived after deletion"); + assert!(images::job_save(&dir, &late).is_err()); + assert!(receipt_save(&dir, task["id"].as_str().unwrap(), &json!({"state":"recovered"})).is_err()); + let mut stale = json!({"version":1,"revision":0}); + assert!(creation::save(&dir, &mut stale).is_err()); + assert!(!dir.exists()); + assert_eq!(read_json(&trash.join("image-task.json")).unwrap(), task); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn single_screen_patch_keeps_other_chinese_edits_ids_and_moves_later_screens() { + let screen = |name: &str, height: u32, title: &str| json!({"name":name,"width":1440,"height":height,"background":"#ffffff","layers":[{"kind":"text","name":"标题","text":title,"font":"NotoSansSC-Regular","fontSize":36,"x":20,"y":20,"width":1000,"height":80},{"kind":"shape","shape":"rect","name":"画面","color":"#204060","x":0,"y":120,"width":1440,"height":height-120}]}); + let original = json!({"name":"三屏样张","screens":[screen("首屏",600,"原标题"),screen("细节",400,"细节"),screen("设备",500,"设备")]}); + let baseline = crate::jiansun_document::import(&original, &HashMap::new()).unwrap(); + let mut current = baseline.clone(); + let child = current.layers[0].children_mut().unwrap().iter_mut().find(|l| matches!(l.content, photocraft_doc::LayerContent::Text(_))).unwrap(); + if let photocraft_doc::LayerContent::Text(t) = &mut child.content { + t.text = "照见生活".into(); + } + let first = current.layers[0].clone(); + let third_id = current.layers[2].id; + let mut desired_json = original; + desired_json["screens"][1] = screen("细节", 800, "细节"); + let desired = crate::jiansun_document::import(&desired_json, &HashMap::new()).unwrap(); + let recovered = patch_screen(current.clone(), &baseline, &desired, 1).unwrap(); + assert_eq!(recovered.layers[0], first); + assert_eq!(recovered.layers[2].id, third_id); + assert_eq!(recovered.layers.len(), 3); + assert_eq!(recovered.size.height, 1900); + assert_eq!(recovered.layers[1].id, current.layers[1].id); + let mut ids = std::collections::HashSet::new(); + assert!(recovered.walk().iter().all(|(_, _, l)| ids.insert(l.id))); + current.layers[1].name = "用户改过目标屏".into(); + assert!(patch_screen(current.clone(), &baseline, &desired, 1).is_err()); + assert_eq!(current.layers[0], first); + } +} diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_rule_cache.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_rule_cache.rs index f76028c6..ce78780b 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_rule_cache.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_rule_cache.rs @@ -2,7 +2,10 @@ //! These are rule/screen manifests, not complete PSD or reference-image packs. use serde_json::{Value, json}; use sha2::{Digest, Sha256}; -use std::{fs, path::{Path, PathBuf}}; +use std::{ + fs, + path::{Path, PathBuf}, +}; type R = Result; const LIMIT: u64 = 2 * 1024 * 1024; @@ -13,40 +16,60 @@ fn digest(value: &Value) -> R { Ok(format!("{:x}", Sha256::digest(bytes))) } fn id(value: &Value) -> R<&str> { - value.as_str().filter(|s| !s.is_empty() && s.len() <= 100 && s.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_'))).ok_or("规则编号无效".into()) + value + .as_str() + .filter(|s| !s.is_empty() && s.len() <= 100 && s.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_'))) + .ok_or("规则编号无效".into()) } fn sha(value: &Value) -> R<&str> { value.as_str().filter(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())).ok_or("规则内容校验值无效".into()) } fn read(file: &Path) -> R { - if fs::metadata(file).map_err(|_| "规则缓存无法读取")?.len() > LIMIT { return Err("规则缓存超过限制".into()); } + if fs::metadata(file).map_err(|_| "规则缓存无法读取")?.len() > LIMIT { + return Err("规则缓存超过限制".into()); + } serde_json::from_slice(&fs::read(file).map_err(|_| "规则缓存无法读取")?).map_err(|_| "规则缓存格式无效".into()) } fn write(file: &Path, value: &Value) -> R<()> { let bytes = serde_json::to_vec(value).map_err(|_| "规则缓存无法保存")?; - if bytes.len() as u64 > LIMIT { return Err("规则缓存超过限制".into()); } + if bytes.len() as u64 > LIMIT { + return Err("规则缓存超过限制".into()); + } let parent = file.parent().ok_or("规则缓存位置无效")?; fs::create_dir_all(parent).map_err(|_| "规则缓存目录无法创建")?; let temp = parent.join(format!(".rules-{}.tmp", uuid::Uuid::new_v4())); fs::write(&temp, bytes).map_err(|_| "规则缓存无法保存")?; - if fs::rename(&temp, file).is_err() { let _ = fs::remove_file(&temp); return Err("规则缓存无法提交".into()); } + if fs::rename(&temp, file).is_err() { + let _ = fs::remove_file(&temp); + return Err("规则缓存无法提交".into()); + } Ok(()) } fn only<'a>(value: &'a Value, field: &str) -> R<&'a Value> { value[field].as_array().filter(|a| a.len() == 1).and_then(|a| a.first()).ok_or("规则必须属于唯一品牌和品类".into()) } -pub(super) fn project_brand(item: &Value) -> &Value { item.get("projectBrandId").unwrap_or(&item["shopId"]) } +pub(super) fn project_brand(item: &Value) -> &Value { + item.get("projectBrandId").unwrap_or(&item["shopId"]) +} pub(super) fn validate_index(value: &Value) -> R<()> { sha(&value["revision"])?; let items = value["items"].as_array().filter(|a| a.len() <= 1000).ok_or("规则目录格式无效")?; let mut seen = std::collections::HashSet::new(); for item in items { - if !seen.insert(id(&item["id"])?) { return Err("规则目录包含重复编号".into()); } - for field in ["shopId", "categoryId"] { uuid::Uuid::parse_str(item[field].as_str().ok_or("规则身份无效")?).map_err(|_| "规则身份无效")?; } - if project_brand(item) != &item["shopId"] || item["brandId"] != format!("enterprise:{}", item["shopId"].as_str().ok_or("品牌身份无效")?) { return Err("规则品牌身份不一致".into()); } + if !seen.insert(id(&item["id"])?) { + return Err("规则目录包含重复编号".into()); + } + for field in ["shopId", "categoryId"] { + uuid::Uuid::parse_str(item[field].as_str().ok_or("规则身份无效")?).map_err(|_| "规则身份无效")?; + } + if project_brand(item) != &item["shopId"] || item["brandId"] != format!("enterprise:{}", item["shopId"].as_str().ok_or("品牌身份无效")?) { + return Err("规则品牌身份不一致".into()); + } sha(&item["version"])?; - if !item["contentHash"].is_null() { sha(&item["contentHash"])?; } + if !item["contentHash"].is_null() { + sha(&item["contentHash"])?; + } } Ok(()) } @@ -56,17 +79,25 @@ pub(super) fn store_index(root: &Path, value: &Value, at: u64) -> R<()> { } pub(super) fn read_index(root: &Path) -> R> { let file = root.join("index.json"); - if !file.exists() { return Ok(None); } + if !file.exists() { + return Ok(None); + } let value = read(&file)?; - if value["sha256"] != digest(&value["index"])? { return Err("规则目录缓存已损坏,未使用".into()); } + if value["sha256"] != digest(&value["index"])? { + return Err("规则目录缓存已损坏,未使用".into()); + } validate_index(&value["index"])?; Ok(Some((value["index"].clone(), value["checkedAt"].as_u64().ok_or("规则检查时间无效")?))) } pub(super) fn matching<'a>(index: &'a Value, brand: &str, category: &str) -> R> { - if brand.is_empty() || category.is_empty() { return Ok(None); } + if brand.is_empty() || category.is_empty() { + return Ok(None); + } let mut matches = index["items"].as_array().ok_or("规则目录格式无效")?.iter().filter(|item| project_brand(item) == brand && item["categoryId"] == category); let found = matches.next(); - if matches.next().is_some() { return Err("此品牌与品类存在多个模板,需先明确项目模板,未自动替选".into()); } + if matches.next().is_some() { + return Err("此品牌与品类存在多个模板,需先明确项目模板,未自动替选".into()); + } Ok(found) } pub(super) fn package(id_value: &str, value: &Value, item: Option<&Value>) -> R { @@ -76,20 +107,41 @@ pub(super) fn package(id_value: &str, value: &Value, item: Option<&Value>) -> R< let shop = only(catalog, "shops")?; let brand = only(catalog, "brands")?; let category = only(catalog, "categories")?; - if binding["id"] != id_value || binding["state"] != "ready" || binding["shopId"] != shop["id"] || binding["categoryId"] != category["id"] || shop["brandId"] != brand["id"] || brand["id"] != format!("enterprise:{}", shop["id"].as_str().ok_or("品牌身份无效")?) { return Err("规则快照的品牌、品类或模板身份不一致".into()); } + if binding["id"] != id_value + || binding["state"] != "ready" + || binding["shopId"] != shop["id"] + || binding["categoryId"] != category["id"] + || shop["brandId"] != brand["id"] + || brand["id"] != format!("enterprise:{}", shop["id"].as_str().ok_or("品牌身份无效")?) + { + return Err("规则快照的品牌、品类或模板身份不一致".into()); + } sha(&binding["revision"])?; sha(&value["revision"])?; let hash = digest(catalog)?; - if value.get("contentHash").is_some_and(|v| v != &hash) { return Err("规则内容校验失败,未写入缓存".into()); } + if value.get("contentHash").is_some_and(|v| v != &hash) { + return Err("规则内容校验失败,未写入缓存".into()); + } if let Some(item) = item - && (item["id"] != id_value || item["shopId"] != shop["id"] || item["categoryId"] != category["id"] || item["brandId"] != brand["id"] || item["version"] != binding["revision"] || item.get("contentHash").is_some_and(|v| v != &hash)) { return Err("模板版本已更新,请重新读取目录,未替换项目快照".into()); } + && (item["id"] != id_value + || item["shopId"] != shop["id"] + || item["categoryId"] != category["id"] + || item["brandId"] != brand["id"] + || item["version"] != binding["revision"] + || item.get("contentHash").is_some_and(|v| v != &hash)) + { + return Err("模板版本已更新,请重新读取目录,未替换项目快照".into()); + } Ok(json!({"schemaVersion":1,"id":id_value,"catalog":catalog,"revision":value["revision"],"contentHash":hash})) } pub(super) fn matches_project(saved: &Value, brand: &str, category: &str) -> bool { saved["catalog"]["shops"].as_array().and_then(|a| a.first()).is_some_and(|s| s["id"] == brand) && saved["catalog"]["categories"].as_array().and_then(|a| a.first()).is_some_and(|s| s["id"] == category) } -fn pointer(root: &Path, id_value: &str) -> R { id(&json!(id_value))?; Ok(root.join("bindings").join(format!("{id_value}.json"))) } +fn pointer(root: &Path, id_value: &str) -> R { + id(&json!(id_value))?; + Ok(root.join("bindings").join(format!("{id_value}.json"))) +} pub(super) fn store_package(root: &Path, saved: &Value) -> R<()> { let normalized = package(id(&saved["id"])?, saved, None)?; let hash = sha(&normalized["contentHash"])?; @@ -98,17 +150,26 @@ pub(super) fn store_package(root: &Path, saved: &Value) -> R<()> { } pub(super) fn read_package(root: &Path, item: &Value) -> R> { let p = pointer(root, id(&item["id"])?)?; - if !p.exists() { return Ok(None); } + if !p.exists() { + return Ok(None); + } let ptr = read(&p)?; let hash = sha(&ptr["contentHash"])?; - if item.get("contentHash").is_some_and(|expected| expected != hash) { return Ok(None); } + if item.get("contentHash").is_some_and(|expected| expected != hash) { + return Ok(None); + } let saved = read(&root.join("objects").join(format!("{hash}.json")))?; let normalized = package(id(&item["id"])?, &saved, Some(item))?; - if normalized["contentHash"] != hash { return Err("规则包缓存已损坏,未使用".into()); } + if normalized["contentHash"] != hash { + return Err("规则包缓存已损坏,未使用".into()); + } Ok(Some(normalized)) } pub(super) fn update_available(saved: &Value, item: Option<&Value>) -> bool { - item.is_some_and(|item| item["id"] == saved["id"] && item.get("contentHash").map_or_else(|| item["version"] != saved["catalog"]["bindings"][0]["revision"], |hash| hash != &saved["contentHash"])) + item.is_some_and(|item| { + item["id"] == saved["id"] + && item.get("contentHash").map_or_else(|| item["version"] != saved["catalog"]["bindings"][0]["revision"], |hash| hash != &saved["contentHash"]) + }) } #[cfg(test)] @@ -117,7 +178,8 @@ mod tests { fn fixture() -> (Value, Value) { let shop = "42bb46b2-89cd-47cf-9ad8-f2e95b8c5a3a"; let category = "78f8044c-62d3-49fa-9b25-3e011bc0a0cf"; - let binding = json!({"id":"REAL-DESK","shopId":shop,"categoryId":category,"revision":"a".repeat(64),"state":"ready","rules":["test rule metadata only"]}); + let binding = + json!({"id":"REAL-DESK","shopId":shop,"categoryId":category,"revision":"a".repeat(64),"state":"ready","rules":["test rule metadata only"]}); let catalog = json!({"brands":[{"id":format!("enterprise:{shop}")}],"shops":[{"id":shop,"brandId":format!("enterprise:{shop}")}],"categories":[{"id":category}],"bindings":[binding]}); let package = json!({"revision":"b".repeat(64),"catalog":catalog,"contentHash":digest(&catalog).unwrap()}); let index = json!({"revision":"b".repeat(64),"items":[{"id":"REAL-DESK","shopId":shop,"projectBrandId":shop,"brandId":format!("enterprise:{shop}"),"categoryId":category,"version":"a".repeat(64),"contentHash":package["contentHash"]}]}); @@ -126,47 +188,68 @@ mod tests { fn temp() -> PathBuf { let root = std::env::var_os("JIANSUN_RULE_TEST_ROOT").expect("test output root is required"); let path = PathBuf::from(root).join(format!("case-{}", uuid::Uuid::new_v4())); - fs::create_dir_all(&path).unwrap(); path + fs::create_dir_all(&path).unwrap(); + path } #[test] fn identity_is_exact_and_missing_or_ambiguous_category_never_borrows_another_template() { - let (mut index, _) = fixture(); validate_index(&index).unwrap(); - let item = &index["items"][0]; let brand = item["shopId"].as_str().unwrap().to_owned(); let category = item["categoryId"].as_str().unwrap().to_owned(); + let (mut index, _) = fixture(); + validate_index(&index).unwrap(); + let item = &index["items"][0]; + let brand = item["shopId"].as_str().unwrap().to_owned(); + let category = item["categoryId"].as_str().unwrap().to_owned(); assert!(matching(&index, &brand, "").unwrap().is_none()); assert!(matching(&index, &brand, "化妆台").unwrap().is_none()); assert!(matching(&index, "顾家", &category).unwrap().is_none()); assert!(matching(&index, &brand, &category).unwrap().is_some()); - let duplicate = index["items"][0].clone(); index["items"].as_array_mut().unwrap().push(duplicate); + let duplicate = index["items"][0].clone(); + index["items"].as_array_mut().unwrap().push(duplicate); assert!(matching(&index, &brand, &category).is_err()); } #[test] fn local_cache_round_trip_and_tampered_content_fail_closed() { - let root = temp(); let (index, value) = fixture(); let item = &index["items"][0]; - store_index(&root, &index, 123).unwrap(); assert_eq!(read_index(&root).unwrap().unwrap(), (index.clone(),123)); - let saved = package("REAL-DESK", &value, Some(item)).unwrap(); store_package(&root, &saved).unwrap(); + let root = temp(); + let (index, value) = fixture(); + let item = &index["items"][0]; + store_index(&root, &index, 123).unwrap(); + assert_eq!(read_index(&root).unwrap().unwrap(), (index.clone(), 123)); + let saved = package("REAL-DESK", &value, Some(item)).unwrap(); + store_package(&root, &saved).unwrap(); assert_eq!(read_package(&root, item).unwrap().unwrap(), saved); - let mut altered = saved.clone(); altered["catalog"]["bindings"][0]["rules"] = json!(["changed"]); + let mut altered = saved.clone(); + altered["catalog"]["bindings"][0]["rules"] = json!(["changed"]); assert!(package("REAL-DESK", &altered, Some(item)).is_err()); assert!(package("../other", &saved, None).is_err()); } #[test] fn downloaded_update_never_rewrites_the_projects_fixed_snapshot() { - let root = temp(); let (mut index, mut value) = fixture(); + let root = temp(); + let (mut index, mut value) = fixture(); let old = package("REAL-DESK", &value, Some(&index["items"][0])).unwrap(); - let project = root.join("project-agent-binding.json"); write(&project, &old).unwrap(); store_package(&root, &old).unwrap(); - value["catalog"]["bindings"][0]["revision"] = json!("c".repeat(64)); value["contentHash"] = json!(digest(&value["catalog"]).unwrap()); - index["items"][0]["version"] = json!("c".repeat(64)); index["items"][0]["contentHash"] = value["contentHash"].clone(); + let project = root.join("project-agent-binding.json"); + write(&project, &old).unwrap(); + store_package(&root, &old).unwrap(); + value["catalog"]["bindings"][0]["revision"] = json!("c".repeat(64)); + value["contentHash"] = json!(digest(&value["catalog"]).unwrap()); + index["items"][0]["version"] = json!("c".repeat(64)); + index["items"][0]["contentHash"] = value["contentHash"].clone(); assert!(read_package(&root, &index["items"][0]).unwrap().is_none()); assert!(update_available(&old, Some(&index["items"][0]))); - let new = package("REAL-DESK", &value, Some(&index["items"][0])).unwrap(); store_package(&root, &new).unwrap(); - assert_eq!(read(&project).unwrap(), old); assert_ne!(read_package(&root, &index["items"][0]).unwrap().unwrap(), old); + let new = package("REAL-DESK", &value, Some(&index["items"][0])).unwrap(); + store_package(&root, &new).unwrap(); + assert_eq!(read(&project).unwrap(), old); + assert_ne!(read_package(&root, &index["items"][0]).unwrap().unwrap(), old); } #[test] fn mismatched_scope_and_changed_package_revisions_are_rejected() { - let (index, mut value) = fixture(); let item = &index["items"][0]; - value["catalog"]["categories"][0]["id"] = json!("other"); value.as_object_mut().unwrap().remove("contentHash"); + let (index, mut value) = fixture(); + let item = &index["items"][0]; + value["catalog"]["categories"][0]["id"] = json!("other"); + value.as_object_mut().unwrap().remove("contentHash"); assert!(package("REAL-DESK", &value, Some(item)).is_err()); - let (_, mut value) = fixture(); value["catalog"]["bindings"][0]["revision"] = json!("c".repeat(64)); value.as_object_mut().unwrap().remove("contentHash"); + let (_, mut value) = fixture(); + value["catalog"]["bindings"][0]["revision"] = json!("c".repeat(64)); + value.as_object_mut().unwrap().remove("contentHash"); assert!(package("REAL-DESK", &value, Some(item)).is_err()); } #[test] @@ -174,8 +257,8 @@ mod tests { let file = std::env::var_os("JIANSUN_REAL_RULE_FIXTURE").expect("real rule fixture path is required"); let value = read(Path::new(&file)).unwrap(); let saved = package("GJ-DESK-DNZ0385", &value, None).unwrap(); - assert_eq!(saved["contentHash"],value["contentHash"]); - assert_eq!(saved["catalog"]["bindings"].as_array().unwrap().len(),1); - assert_eq!(saved["catalog"]["shops"][0]["name"],"顾家"); + assert_eq!(saved["contentHash"], value["contentHash"]); + assert_eq!(saved["catalog"]["bindings"].as_array().unwrap().len(), 1); + assert_eq!(saved["catalog"]["shops"][0]["name"], "顾家"); } } diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_slot_canvas.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_slot_canvas.rs new file mode 100644 index 00000000..d185ac30 --- /dev/null +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_slot_canvas.rs @@ -0,0 +1,524 @@ +//! Materialize an explicitly selected, versioned slot application as editable native layers. +//! +//! This module does no network I/O and never replaces an open document. The caller admits the +//! returned document through the normal engine/UI path. Candidate creation and selection belong +//! to the durable workflow journal, not to canvas construction. +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_engine::Session; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{HashMap, HashSet}, + fs, + io::Read, + path::{Component, Path}, +}; + +type R = Result; +const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024; +const MAX_FIELDS: usize = 120; +const MAX_OUTPUTS: usize = 64; + +fn sha(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +fn digest(value: &Value, key: &str) -> R { + let mut value = value.clone(); + value.as_object_mut().ok_or("回填内容不是对象")?.remove(key); + Ok(sha(value.to_string().as_bytes())) +} +fn hex(value: &Value, label: &str) -> R { + value + .as_str() + .filter(|s| s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))) + .map(str::to_owned) + .ok_or_else(|| format!("{label}校验码无效")) +} +fn identity(value: &Value, label: &str) -> R { + value + .as_str() + .filter(|s| !s.is_empty() && s.len() <= 160 && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'/'))) + .map(str::to_owned) + .ok_or_else(|| format!("{label}编号无效")) +} +fn number(value: &Value, label: &str, min: f64, max: f64) -> R { + value.as_f64().filter(|n| n.is_finite() && *n >= min && *n <= max).ok_or_else(|| format!("{label}超出范围")) +} +fn color(value: &Value, label: &str) -> R { + value + .as_str() + .filter(|s| s.len() == 7 && s.starts_with('#') && s.bytes().skip(1).all(|b| b.is_ascii_hexdigit())) + .map(str::to_owned) + .ok_or_else(|| format!("{label}格式无效")) +} +fn title(value: &Value, default: &str) -> R { + match value.as_str() { + Some(s) if s.chars().count() <= 200 && !s.chars().any(|c| c.is_control()) => Ok(s.to_owned()), + None if value.is_null() => Ok(default.to_owned()), + _ => Err("图位名称无效".into()), + } +} +fn image_bytes(dir: &Path, image: &Value) -> R> { + let relative = image["path"].as_str().ok_or("请先选择该图位的成功候选")?; + let path = Path::new(relative); + let parts: Vec<_> = path.components().collect(); + let task = image["taskId"].as_str().and_then(|s| uuid::Uuid::parse_str(s).ok()).ok_or("候选原请求编号无效")?.to_string(); + if parts.len() != 3 + || parts.iter().any(|c| !matches!(c, Component::Normal(_))) + || parts.first().and_then(|c| c.as_os_str().to_str()) != Some("image-jobs") + || parts.get(1).and_then(|c| c.as_os_str().to_str()) != Some(task.as_str()) + || parts.get(2).and_then(|c| c.as_os_str().to_str()) != Some("generated.png") + { + return Err("候选图片必须来自本项目的原请求结果".into()); + } + let root = dir.canonicalize().map_err(|_| "本机项目目录不可读")?; + let mut current = root.clone(); + for part in parts { + current.push(part.as_os_str()); + let metadata = fs::symlink_metadata(¤t).map_err(|_| "候选图片或目录不可读")?; + if metadata.file_type().is_symlink() { + return Err("候选图片路径含链接,未读取外部文件".into()); + } + } + let resolved = current.canonicalize().map_err(|_| "候选图片不可读")?; + if !resolved.starts_with(&root) || fs::metadata(&resolved).map_err(|_| "候选图片不可读")?.len() > MAX_IMAGE_BYTES { + return Err("候选图片超出项目或大小限制".into()); + } + let mut bytes = Vec::new(); + fs::File::open(resolved).map_err(|_| "候选图片不可读")?.take(MAX_IMAGE_BYTES + 1).read_to_end(&mut bytes).map_err(|_| "候选图片读取中断")?; + if bytes.len() as u64 > MAX_IMAGE_BYTES || hex(&image["sha256"], "候选图片")? != sha(&bytes) { + return Err("候选图片已变化,未回填画布".into()); + } + // Check PNG dimensions before the importer allocates image surfaces. + if bytes.get(..16) != Some(&[137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, b'I', b'H', b'D', b'R'][..]) { + return Err("候选结果不是有效PNG".into()); + } + let w = u32::from_be_bytes(bytes.get(16..20).and_then(|b| b.try_into().ok()).ok_or("候选PNG尺寸不完整")?); + let h = u32::from_be_bytes(bytes.get(20..24).and_then(|b| b.try_into().ok()).ok_or("候选PNG尺寸不完整")?); + if w == 0 || h == 0 || w > 8192 || h > 8192 || u64::from(w).saturating_mul(u64::from(h)) > 24_000_000 { + return Err("候选图片超过2400万像素".into()); + } + for (key, actual) in [("width", w), ("height", h)] { + if !image[key].is_null() && image[key].as_u64() != Some(u64::from(actual)) { + return Err("候选图片尺寸与原结果不一致".into()); + } + } + Ok(bytes) +} +fn xml(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} +fn metadata(doc: &mut Document, mappings: Value) { + doc.metadata.xmp = Some(format!( + "{}", + xml(&mappings.to_string()) + )); + doc.metadata.text.push(("jiansun.slot-mapping.v1".into(), mappings.to_string())); +} +fn layer_ids(layer: &mut Layer, seen: &mut HashSet) -> R<()> { + let bytes = Sha256::digest(layer.name.as_bytes()); + let mut id = u32::from_be_bytes(bytes.get(..4).and_then(|b| b.try_into().ok()).ok_or("图层编号无法生成")?) & 0x7fff_ffff; + id = id.max(1); + while !seen.insert(id) { + id = id.checked_add(1).ok_or("图层编号超出范围")?; + } + layer.psd_id = Some(id); + if let LayerContent::Group(g) = &mut layer.content { + for child in &mut g.children { + layer_ids(child, seen)?; + } + } + Ok(()) +} + +/// Create one distinct output, such as S01, M01 or K01. The application is immutable input. +pub fn from_application(application: &Value, dir: &Path) -> R { + if application["version"] != 1 || hex(&application["applicationHash"], "回填内容")? != digest(application, "applicationHash")? { + return Err("回填版本或内容已变化,未创建画布".into()); + } + let template = identity(&application["templateId"], "模板")?; + let version = identity(&application["templateVersion"], "模板版本")?; + let template_hash = hex(&application["templateHash"], "模板")?; + let output = identity(&application["outputId"], "输出")?; + if !matches!(application["component"].as_str(), Some("detail" | "main_images" | "sku_images")) { + return Err("输出类型无效".into()); + } + let width = application["width"].as_u64().filter(|w| [750, 1440].contains(w)).ok_or("输出宽度无效")?; + let height = application["height"].as_u64().filter(|h| (1..=30000).contains(h)).ok_or("输出高度无效")?; + let fields = application["fields"].as_array().filter(|a| !a.is_empty() && a.len() <= MAX_FIELDS).ok_or("图位数量无效")?; + let name = title(&application["name"], &output)?; + let mut layers = Vec::new(); + let mut assets = HashMap::new(); + let mut field_ids = HashSet::new(); + let mut slots = HashSet::new(); + let mut styles = Vec::new(); + let mut mapping = Vec::new(); + if let Some(decorations) = application.get("decorations") { + let decorations = decorations.as_array().filter(|a| a.len() <= 120).ok_or("模板装饰数量无效")?; + let mut decoration_ids = HashSet::new(); + for decoration in decorations { + let id = identity(&decoration["id"], "装饰")?; + if !id.starts_with(&format!("{output}-D")) || !decoration_ids.insert(id.clone()) { + return Err("装饰编号无效或重复".into()); + } + let kind = decoration["kind"].as_str().filter(|s| matches!(*s, "rect" | "line")).ok_or("模板装饰仅支持矩形和线段")?; + let frame = &decoration["frame"]; + let x = number(&frame["x"], "装饰横坐标", 0., width as f64)?; + let y = number(&frame["y"], "装饰纵坐标", 0., height as f64)?; + let w = number(&frame["width"], "装饰宽度", 1., width as f64)?; + let h = number(&frame["height"], "装饰高度", if kind == "line" { 0. } else { 1. }, height as f64)?; + if x + w > width as f64 || y + h > height as f64 { + return Err("模板装饰超出画布".into()); + } + let opacity = number(&decoration["opacity"], "装饰透明度", 0., 1.)? as f32; + // PSD layer opacity is one byte. Materialize an exactly representable + // native value so exporting/reopening cannot shift the composite. + // The immutable application and its content hash remain unchanged. + let opacity = (opacity * 255.0).round() / 255.0; + if decoration.get("aboveImages").is_some_and(|v| !v.is_boolean()) { + return Err("装饰层级无效".into()); + } + layers.push(json!({"kind":"shape","shape":kind,"name":format!("{id} · 图框"),"x":x,"y":y,"width":w,"height":h,"color":color(&decoration["color"],"装饰颜色")?,"opacity":opacity,"aboveImages":decoration["aboveImages"]==true})); + } + } + for field in fields { + let id = identity(&field["fieldId"], "图位")?; + let slot = hex(&field["slotId"], "图位")?; + if !id.starts_with(&format!("{output}-")) || !field_ids.insert(id.clone()) || !slots.insert(slot.clone()) { + return Err("图位编号重复或不属于该输出".into()); + } + let frame = &field["frame"]; + let x = number(&frame["x"], "图位横坐标", 0., width as f64)?; + let y = number(&frame["y"], "图位纵坐标", 0., height as f64)?; + let w = number(&frame["width"], "图位宽度", 1., width as f64)?; + let h = number(&frame["height"], "图位高度", 1., height as f64)?; + if x + w > width as f64 + 0.001 || y + h > height as f64 + 0.001 { + return Err("图位超出画布,未回填".into()); + } + let layer_name = format!("{id} · {}", title(&field["name"], if field["kind"] == "text" { "文字" } else { "图片" })?); + let mut layer = json!({"name":layer_name,"x":x,"y":y,"width":w,"height":h}); + match field["kind"].as_str() { + Some("image") => { + let bytes = image_bytes(dir, &field["image"])?; + if !matches!(field["crop"].as_str(), Some("cover" | "contain")) { + return Err("图位裁切方式无效".into()); + } + layer["kind"] = json!("image"); + layer["source"] = json!(slot); + layer["crop"] = json!({"fit":field["crop"],"zoom":1,"x":0.5,"y":0.5}); + assets.insert(slot.clone(), bytes); + } + Some("text") => { + let text = field["text"].as_str().ok_or("图位文案缺失")?; + let max_chars = field["maxChars"].as_u64().filter(|v| (1..=3000).contains(v)).ok_or("文字长度约束无效")?; + let max_lines = field["maxLines"].as_u64().filter(|v| (1..=100).contains(v)).ok_or("文字行数约束无效")?; + if text.chars().count() as u64 > max_chars + || text.lines().count().max(1) as u64 > max_lines + || text.chars().any(|c| c.is_control() && c != '\n') + { + return Err(format!("{id} 文案超出图位约束,请先精简")); + } + let font = &field["font"]; + if font["family"] != "Noto Sans SC" { + return Err("此原生映射仅支持已配置的Noto Sans SC字体,不会冒充源模板字体".into()); + } + let size = number(&font["size"], "文字字号", 8., 240.)?; + let weight = font["weight"].as_u64().filter(|w| (100..=900).contains(w)).ok_or("文字字重无效")?; + let align = field["align"].as_str().unwrap_or("left"); + if !["left", "center", "right"].contains(&align) { + return Err("文字对齐方式无效".into()); + } + let leading = field.get("lineHeight").map_or(Ok(1.2), |v| number(v, "文字行距", 1., 3.))?; + layer["kind"] = json!("text"); + layer["text"] = json!(text); + layer["font"] = json!(if weight >= 700 { + "NotoSansSC-Bold" + } else if weight >= 500 { + "NotoSansSC-Medium" + } else { + "NotoSansSC-Regular" + }); + layer["fontSize"] = json!(size); + layer["lineHeight"] = json!(leading); + layer["color"] = json!(color(&font["color"], "文字颜色")?); + layer["align"] = json!(align); + styles.push((layer_name.clone(), weight)); + } + _ => return Err("图位类型无效".into()), + } + mapping.push(json!({"fieldId":id,"slotId":slot,"layerName":layer_name,"kind":field["kind"]})); + layers.push(layer); + } + // Image fields are inserted first: source order must never accidentally cover editable type. + layers.sort_by_key(|l| { + if l["kind"] == "shape" && l["aboveImages"] != true { + 0 + } else if l["kind"] == "image" { + 1 + } else if l["kind"] == "shape" { + 2 + } else { + 3 + } + }); + let background = application.get("background").map_or(Ok("#ffffff".to_owned()), |v| color(v, "画布底色"))?; + let mut doc = crate::jiansun_document::import( + &json!({"name":name,"screens":[{"name":format!("{output} · {name}"),"width":width,"height":height,"background":background,"layers":layers}]}), + &assets, + )?; + let mut session = Session::new(); + session.add_document(doc, None); + for (name, weight) in styles { + let id = session.active().and_then(|a| a.doc.walk().into_iter().find(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id.0)).ok_or("文字图位未创建")?; + session.execute("type.setStyle", json!({"layer":id,"weight":weight})).map_err(|e| format!("文字字重无法应用:{e}"))?; + } + doc = session.active().ok_or("输出画布未创建")?.doc.as_ref().clone(); + let mut used = HashSet::new(); + for layer in &mut doc.layers { + layer_ids(layer, &mut used)?; + } + for row in &mut mapping { + let layer = doc.walk().into_iter().find(|(_, _, l)| l.name == row["layerName"].as_str().unwrap_or("")).map(|(_, _, l)| l).ok_or("图位图层映射缺失")?; + row["nativeLayerId"] = json!(layer.id.0); + row["psdLayerId"] = json!(layer.psd_id); + } + metadata( + &mut doc, + json!({"version":1,"templateId":template,"templateVersion":version,"templateHash":template_hash,"applicationHash":application["applicationHash"],"outputId":output,"component":application["component"],"fields":mapping}), + ); + Ok(doc) +} + +/// Stack outputs of one component while preserving every output's own editable group. +/// This is a grouped preview/export document; each output may also be opened/exported alone. +pub fn from_component(applications: &[Value], dir: &Path) -> R { + let first = applications.first().filter(|_| applications.len() <= MAX_OUTPUTS).ok_or("请选择有效的详情输出")?; + if applications.iter().any(|a| { + a["component"] != first["component"] + || a["templateId"] != first["templateId"] + || a["templateVersion"] != first["templateVersion"] + || a["templateHash"] != first["templateHash"] + || a["width"] != first["width"] + }) { + return Err("合并输出必须属于同一类型、模板版本和画布宽度".into()); + } + let mut ids = HashSet::new(); + let mut total = 0u64; + let mut groups = Vec::new(); + let mut mappings = Vec::new(); + for app in applications { + if !ids.insert(identity(&app["outputId"], "输出")?) { + return Err("详情输出重复,未合并".into()); + } + let doc = from_application(app, dir)?; + let top = total; + total = total.checked_add(u64::from(doc.size.height)).filter(|h| *h <= 30000).ok_or("详情总高超过30000px,请分册导出;未丢弃任何输出")?; + let mut s = Session::new(); + s.add_document(doc, None); + if top > 0 { + let layer = s.active().and_then(|a| a.doc.layers.first()).map(|l| l.id.0).ok_or("详情分组缺失")?; + s.execute("edit.transform", json!({"layer":layer,"matrix":[1,0,0,1,0,top]})).map_err(|e| format!("详情图位无法定位:{e}"))?; + } + let part = s.active().ok_or("详情分组缺失")?.doc.as_ref(); + groups.extend(part.layers.clone()); + mappings.push(json!({"outputId":app["outputId"],"top":top,"height":app["height"],"applicationHash":app["applicationHash"],"mapping":part.metadata.text.iter().find(|(k,_)|k=="jiansun.slot-mapping.v1").and_then(|(_,v)|serde_json::from_str::(v).ok())})); + } + let width = first["width"].as_u64().ok_or("详情宽度无效")?; + let mut s = Session::new(); + let name = match first["component"].as_str() { + Some("detail") => "详情页", + Some("main_images") => "主图", + Some("sku_images") => "SKU", + _ => return Err("输出类型无效".into()), + }; + s.execute("file.new", json!({"name":name,"width":width,"height":total,"background":"transparent"})).map_err(|e| e.to_string())?; + let mut doc = s.active().ok_or("详情画布未创建")?.doc.as_ref().clone(); + doc.layers = groups; + let mut used = HashSet::new(); + for layer in &mut doc.layers { + layer_ids(layer, &mut used)?; + } + metadata( + &mut doc, + json!({"version":1,"templateId":first["templateId"],"templateVersion":first["templateVersion"],"templateHash":first["templateHash"],"component":first["component"],"outputs":mappings}), + ); + Ok(doc) +} + +#[cfg(test)] +mod tests { + use super::*; + struct ImageFixture { + dir: std::path::PathBuf, + job: std::path::PathBuf, + image: Value, + } + impl ImageFixture { + fn new() -> Self { + let task = uuid::Uuid::new_v4().to_string(); + let dir = std::env::var_os("JIANSUN_SLOT_TEST_ROOT") + .map(std::path::PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join(format!("photocraft-slot-{task}")); + let job = dir.join("image-jobs").join(&task); + fs::create_dir_all(&job).unwrap(); + let pixels = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgba, [12, 80, 120, 255].repeat(32)).unwrap(); + let bytes = photocraft_codecs::encode(&pixels, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + fs::write(job.join("generated.png"), &bytes).unwrap(); + Self { dir, job, image: json!({"path":format!("image-jobs/{task}/generated.png"),"taskId":task,"sha256":sha(&bytes),"width":8,"height":4}) } + } + } + impl Drop for ImageFixture { + fn drop(&mut self) { + // This fixture owns exactly one tiny file. Never recursively remove a profile. + let _ = fs::remove_file(self.job.join("generated.png")); + let _ = fs::remove_dir(&self.job); + let _ = fs::remove_dir(self.dir.join("image-jobs")); + let _ = fs::remove_dir(&self.dir); + } + } + fn app(id: &str, component: &str) -> Value { + let mut v = json!({"version":1,"templateId":"GJ-DESK-NATIVE","templateVersion":"1","templateHash":"a".repeat(64),"outputId":id,"component":component,"name":"中文图位测试","width":750,"height":500,"fields":[{"fieldId":format!("{id}-T01"),"slotId":"b".repeat(64),"kind":"text","name":"产品标题","frame":{"x":30,"y":30,"width":650,"height":80},"font":{"family":"Noto Sans SC","size":32,"weight":600,"color":"#242424"},"maxLines":2,"maxChars":30,"text":"中文产品\n尺寸信息"}]}); + v["applicationHash"] = json!(digest(&v, "applicationHash").unwrap()); + v + } + fn rehash(v: &mut Value) { + v["applicationHash"] = json!(digest(v, "applicationHash").unwrap()); + } + #[test] + fn chinese_fields_stay_editable_and_one_edit_undoes() { + let doc = from_application(&app("S01", "detail"), Path::new("not-read-for-text")).unwrap(); + assert_eq!(doc.layers.len(), 1); + assert!(doc.layers[0].name.starts_with("S01 ·")); + let layer = doc.walk().into_iter().find(|(_, _, l)| l.name.starts_with("S01-T01 ·")).unwrap().2; + let id = layer.id.0; + assert!(matches!(&layer.content,LayerContent::Text(t) if t.text=="中文产品\n尺寸信息" && t.char_runs().iter().all(|r|r.style.weight==600))); + let mut s = Session::new(); + s.add_document(doc, None); + s.execute("type.edit", json!({"layer":id,"text":"修改中文"})).unwrap(); + assert!(s.undo()); + assert!(matches!(&s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content,LayerContent::Text(t) if t.text=="中文产品\n尺寸信息")); + } + #[test] + fn psd_reopen_preserves_field_groups_type_and_mapping() { + let doc = from_application(&app("M01", "main_images"), Path::new("not-read-for-text")).unwrap(); + let exported = photocraft_io::export(&doc, "fields.psd", &Default::default()).unwrap(); + let reopened = photocraft_io::import("fields.psd", &exported.bytes).unwrap().document; + assert!( + reopened + .walk() + .iter() + .any(|(_, _, l)| l.name.starts_with("M01-T01 ·") && matches!(&l.content,LayerContent::Text(t) if t.text=="中文产品\n尺寸信息")) + ); + assert!(reopened.layers.iter().any(|l| l.is_group() && l.name.starts_with("M01 ·"))); + assert!(reopened.metadata.xmp.as_ref().is_some_and(|m| m.contains("M01-T01") && m.contains("templateHash"))); + } + fn decorated_application() -> Value { + let mut application = app("S01", "detail"); + application["background"] = json!("#196e3e"); + application["decorations"] = json!([ + {"id":"S01-D01","kind":"rect","frame":{"x":0,"y":20,"width":750,"height":150},"color":"#000000","opacity":0.18,"aboveImages":true}, + {"id":"S01-D02","kind":"rect","frame":{"x":0,"y":220,"width":750,"height":150},"color":"#000000","opacity":0.25,"aboveImages":true} + ]); + rehash(&mut application); + application + } + #[test] + fn translucent_template_decorations_keep_exact_cpu_pixels_after_real_psd_roundtrip() { + let application = decorated_application(); + let original = application.clone(); + let doc = from_application(&application, Path::new("not-read-for-text")).unwrap(); + let before = photocraft_compose::flatten(&doc).to_rgba8(); + let exported = photocraft_io::export(&doc, "decorations.psd", &Default::default()).unwrap(); + let reopened = photocraft_io::import("decorations.psd", &exported.bytes).unwrap().document; + let after = photocraft_compose::flatten(&reopened).to_rgba8(); + assert_eq!(doc.size, reopened.size); + assert!(before.pixels == after.pixels, "PSD roundtrip changed actual CPU-composited RGBA8 bytes"); + assert!(reopened.walk().iter().any(|(_, _, layer)| matches!(&layer.content, LayerContent::Text(text) if text.text == "中文产品\n尺寸信息"))); + for (name, opacity) in [("S01-D01 · 图框", 46.0f32 / 255.0), ("S01-D02 · 图框", 64.0f32 / 255.0)] { + let native = doc.walk().into_iter().find(|(_, _, layer)| layer.name == name).unwrap().2; + let restored = reopened.walk().into_iter().find(|(_, _, layer)| layer.name == name).unwrap().2; + assert_eq!(native.opacity, opacity); + assert_eq!(restored.opacity, native.opacity); + } + assert_eq!(application, original, "render normalization must not rewrite immutable inputs or hashes"); + assert!( + doc.metadata.text.iter().any(|(key, value)| key == "jiansun.slot-mapping.v1" + && serde_json::from_str::(value).unwrap()["applicationHash"] == original["applicationHash"]) + ); + } + #[test] + fn decoration_opacity_keeps_zero_one_and_valid_values_but_rejects_invalid_inputs() { + for (value, expected) in [(0.0, 0.0f32), (1.0, 1.0f32), (0.4, 102.0f32 / 255.0)] { + let mut application = decorated_application(); + application["decorations"][0]["opacity"] = json!(value); + rehash(&mut application); + let doc = from_application(&application, Path::new("not-read-for-text")).unwrap(); + let decoration = doc.walk().into_iter().find(|(_, _, layer)| layer.name == "S01-D01 · 图框").unwrap().2; + assert_eq!(decoration.opacity, expected); + } + for value in [json!(-0.01), json!(1.001), json!("0.25"), Value::Null] { + let mut application = decorated_application(); + application["decorations"][0]["opacity"] = value; + rehash(&mut application); + assert!(from_application(&application, Path::new("not-read-for-text")).unwrap_err().contains("装饰透明度")); + } + } + #[test] + fn applications_reject_tampering_frames_and_duplicate_fields() { + let mut v = app("K01", "sku_images"); + v["fields"][0]["text"] = json!("已篡改"); + assert!(from_application(&v, Path::new("unused")).is_err()); + rehash(&mut v); + v["fields"][0]["frame"]["width"] = json!(9999); + rehash(&mut v); + assert!(from_application(&v, Path::new("unused")).is_err()); + let mut v = app("K01", "sku_images"); + let field = v["fields"][0].clone(); + v["fields"].as_array_mut().unwrap().push(field); + rehash(&mut v); + assert!(from_application(&v, Path::new("unused")).is_err()); + } + #[test] + fn components_keep_all_outputs_and_reject_silent_truncation() { + let mut b = app("S02", "detail"); + b["fields"][0]["slotId"] = json!("c".repeat(64)); + rehash(&mut b); + let doc = from_component(&[app("S01", "detail"), b], Path::new("unused")).unwrap(); + assert_eq!(doc.size.height, 1000); + assert_eq!(doc.layers.len(), 2); + assert!(doc.walk().iter().any(|(_, _, l)| l.name.starts_with("S02-T01 ·"))); + let main = from_component(&[app("M01", "main_images"), app("M02", "main_images")], Path::new("unused")).unwrap(); + assert_eq!(main.layers.len(), 2); + assert!(from_component(&[app("M01", "main_images"), app("K01", "sku_images")], Path::new("unused")).is_err()); + let mut a = app("S01", "detail"); + a["height"] = json!(30000); + rehash(&mut a); + let mut b = app("S02", "detail"); + b["fields"][0]["slotId"] = json!("d".repeat(64)); + rehash(&mut b); + assert!(from_component(&[a, b], Path::new("unused")).unwrap_err().contains("分册")); + } + #[test] + fn image_sources_never_escape_the_original_project_job() { + for path in ["../image.png", "image-jobs/../../secret", "https://example.com/a.png", "image-jobs/not-a-uuid/generated.png"] { + assert!(image_bytes(Path::new("unused"), &json!({"taskId":"00000000-0000-4000-8000-000000000001","path":path,"sha256":"0".repeat(64)})).is_err()); + } + } + #[test] + fn selected_images_remain_embedded_smart_layers_after_psd_reopen() { + let fixture = ImageFixture::new(); + let mut application = app("K01", "sku_images"); + application["fields"].as_array_mut().unwrap().push(json!({"fieldId":"K01-I01","slotId":"c".repeat(64),"kind":"image","name":"产品全貌","frame":{"x":30,"y":130,"width":400,"height":300},"crop":"contain","image":fixture.image})); + rehash(&mut application); + let doc = from_application(&application, &fixture.dir).unwrap(); + let image = doc.walk().into_iter().find(|(_, _, l)| l.name.starts_with("K01-I01 ·")).unwrap().2; + assert!(matches!(&image.content, LayerContent::Smart(_))); + let bytes = photocraft_io::export(&doc, "sku.psd", &Default::default()).unwrap().bytes; + let reopened = photocraft_io::import("sku.psd", &bytes).unwrap().document; + assert!(reopened.walk().iter().any(|(_, _, l)| l.name.starts_with("K01-I01 ·") && matches!(&l.content, LayerContent::Smart(_)))); + assert!(reopened.walk().iter().any(|(_, _, l)| l.name.starts_with("K01-T01 ·") && matches!(&l.content, LayerContent::Text(_)))); + fs::write(fixture.job.join("generated.png"), b"modified").unwrap(); + assert!(from_application(&application, &fixture.dir).unwrap_err().contains("已变化")); + } +} diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_stream.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_stream.rs index 55eafd5f..8dba64db 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_stream.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_stream.rs @@ -5,6 +5,28 @@ use std::io::{BufRead, BufReader, Read}; type R = Result; const LIMIT: u64 = 2 * 1024 * 1024; +/// Classify transport failures without including reqwest's URL or error text. +/// A blocking response wraps its reqwest timeout in an io::Error, so inspect +/// both the owned inner error and ordinary sources before losing that identity. +pub(super) fn transport_error(error: &(dyn std::error::Error + 'static)) -> String { + let mut source = Some(error); + for _ in 0..32 { + let Some(error) = source else { break }; + if error.downcast_ref::().is_some_and(reqwest::Error::is_timeout) { + return "客户端等待达到时限,原请求结果待核查,不会重发".into(); + } + if let Some(error) = error.downcast_ref::() { + if error.kind() == std::io::ErrorKind::TimedOut { + return "客户端等待达到时限,原请求结果待核查,不会重发".into(); + } + source = error.get_ref().map(|inner| inner as &(dyn std::error::Error + 'static)).or_else(|| std::error::Error::source(error)); + } else { + source = error.source(); + } + } + "连接中断,原请求结果未知;不会重发".into() +} + /// Extract only the top-level JSON `reply` string, including partial but complete escapes. pub(super) fn partial_reply(content: &str) -> String { let s = content.trim().trim_start_matches("```json").trim_start_matches("```").trim_start(); @@ -76,7 +98,7 @@ pub(super) fn read(reader: impl Read, anthropic: bool, mut delta: impl FnMut(&st let mut model = String::new(); loop { line.clear(); - let n = reader.read_line(&mut line).map_err(|_| "模型流连接中断,保留原请求等待核查")?; + let n = reader.read_line(&mut line).map_err(|error| transport_error(&error))?; if n == 0 { break; } @@ -194,4 +216,14 @@ mod tests { assert_eq!(partial_reply("{\"edits\":[{\"reply\":\"假的\"}]"), ""); assert_eq!(partial_reply("{\"reply\":\"\\uD83D\\uDE00"), "😀"); } + #[test] + fn timeout_classification_preserves_the_cause_without_exposing_transport_details() { + let timeout = std::io::Error::new(std::io::ErrorKind::TimedOut, "private-url-and-key-must-not-be-displayed"); + let wrapped = std::io::Error::other(timeout); + assert_eq!(transport_error(&wrapped), "客户端等待达到时限,原请求结果待核查,不会重发"); + for kind in [std::io::ErrorKind::ConnectionReset, std::io::ErrorKind::UnexpectedEof, std::io::ErrorKind::WouldBlock] { + let error = std::io::Error::new(kind, "https://private-host/path?api_key=secret"); + assert_eq!(transport_error(&error), "连接中断,原请求结果未知;不会重发"); + } + } } diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_sync.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_sync.rs index 7739458f..7212424a 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_sync.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_sync.rs @@ -35,15 +35,52 @@ fn file_digest(path: &Path) -> R<(String, u64)> { } let mut h = Sha256::new(); let mut buf = [0; 65536]; + let mut read = 0u64; loop { let n = f.read(&mut buf).map_err(|_| "同步文件读取中断")?; if n == 0 { break; } + read = read.saturating_add(n as u64); + if read > size || read > FILE_LIMIT { + return Err("同步文件在读取期间变化,本机草稿保留".into()); + } h.update(&buf[..n]); } + if read != size || f.metadata().map_err(|_| "同步文件不可核对")?.len() != size { + return Err("同步文件在读取期间变化,本机草稿保留".into()); + } Ok((format!("{:x}", h.finalize()), size)) } + +/// An asynchronous open may only adopt the exact canvas/context pair it read. +/// The caller holds context_guard while stamping or verifying this identity. +pub(super) fn opened_copy_identity(dir: &Path) -> R { + let meta = fs::symlink_metadata(dir).map_err(|_| "原项目目录不可读取,未打开")?; + if !meta.is_dir() || meta.file_type().is_symlink() { + return Err("原项目目录包含链接或异常,未打开".into()); + } + let root = dir.canonicalize().map_err(|_| "原项目目录不可核对,未打开")?; + let mut files = Vec::new(); + for (name, max) in [("context.json", 2_000_000), ("canvas.pcraft", FILE_LIMIT)] { + let path = dir.join(name); + let meta = fs::symlink_metadata(&path).map_err(|_| "原项目文件缺失,未打开")?; + if !meta.is_file() + || meta.file_type().is_symlink() + || meta.len() == 0 + || meta.len() > max + || path.canonicalize().map_err(|_| "原项目文件不可核对,未打开")?.parent() != Some(root.as_path()) + { + return Err("原项目文件包含链接或超出范围,未打开".into()); + } + let (sha, size) = file_digest(&path)?; + if size > max { + return Err("原项目文件在核对期间超出范围,未打开".into()); + } + files.push(json!({"path":name,"size":size,"sha256":sha})); + } + Ok(digest(serde_json::to_string(&json!({"version":1,"files":files})).map_err(|_| "原项目身份不可读取")?.as_bytes())) +} fn safe_path(s: &str) -> R<&str> { if s.is_empty() || s.len() > 360 @@ -57,8 +94,17 @@ fn safe_path(s: &str) -> R<&str> { Ok(s) } fn allowed_file(path: &str) -> bool { - let roots = ["agent/", "image-jobs/", "creation-tasks/", "creation-assets/", "creation-generated/"]; - let exact = ["agent-binding.json", "task.json", "image-task.json", "creation.json", "creation-task.json", "creation-canvas.pcraft"]; + let roots = ["agent/", "image-jobs/", "creation-tasks/", "creation-assets/", "creation-generated/", "template-outputs/"]; + let exact = [ + "agent-binding.json", + "task.json", + "image-task.json", + "creation.json", + "creation-task.json", + "creation-canvas.pcraft", + "template-slots.json", + "template-input.json", + ]; (exact.contains(&path) || roots.iter().any(|p| path.starts_with(p))) && matches!(Path::new(path).extension().and_then(|e| e.to_str()), Some("json" | "jsonl" | "wasm" | "png" | "jpg" | "jpeg" | "webp" | "pcraft")) } @@ -236,7 +282,9 @@ fn recovery_summary(dir: &Path, requests: &Path) -> R> { for barrier in super::projects::creation_barriers(dir)? { if let Some(existing) = entries.iter_mut().find(|entry| entry["id"] == barrier["id"]) { existing["pending"] = json!(true); - if barrier["unknown"] == true { existing["unknown"] = json!(true); } + if barrier["unknown"] == true { + existing["unknown"] = json!(true); + } } else { entries.push(barrier); } @@ -253,7 +301,12 @@ fn recovery_summary(dir: &Path, requests: &Path) -> R> { } fn context_identity(value: &Value, dir: &Path) -> R { let creation = if dir.join("creation.json").exists() { file_digest(&dir.join("creation.json"))?.0 } else { String::new() }; - Ok(digest(serde_json::to_string(&json!({"revision":value["revision"],"name":value["name"],"composer":value["composer"],"messages":value["messages"],"creation":value["creation"],"creationDraft":value["creationDraft"],"creationFile":creation})).map_err(|_|"项目身份无法读取")?.as_bytes())) + // Choosing A/B or editing a template field changes durable project data + // without changing the current canvas/creation revision. Include both fixed + // template inputs and the slot journal in the exact upload receipt identity. + let template_slots = if dir.join("template-slots.json").exists() { file_digest(&dir.join("template-slots.json"))?.0 } else { String::new() }; + let template_input = if dir.join("template-input.json").exists() { file_digest(&dir.join("template-input.json"))?.0 } else { String::new() }; + Ok(digest(serde_json::to_string(&json!({"revision":value["revision"],"name":value["name"],"composer":value["composer"],"messages":value["messages"],"creation":value["creation"],"creationDraft":value["creationDraft"],"creationFile":creation,"templateSlotsFile":template_slots,"templateInputFile":template_input})).map_err(|_|"项目身份无法读取")?.as_bytes())) } fn finish_sync(dir: &Path, cloud_id: &str, cloud_revision: u64, revision: u64, state: &str, source_web: &Value, identity: &str) -> R { let _guard = context_guard(); @@ -278,6 +331,194 @@ fn finish_sync(dir: &Path, cloud_id: &str, cloud_revision: u64, revision: u64, s Ok(current) } +struct ExistingCloudCopy { + local_id: String, + dir: PathBuf, + context_identity: String, + canvas_sha: String, + request_files: Vec, + had_requests: bool, + task_identity: String, +} + +fn settled_task_identity(dir: &Path, requests: &[Value]) -> R { + let mut paths = Vec::new(); + for namespace in ["image-jobs", "creation-tasks"] { + let root = dir.join(namespace); + if !root.exists() { + continue; + } + if fs::symlink_metadata(&root).map_err(|_| "原任务目录不可读取")?.file_type().is_symlink() + || root.canonicalize().map_err(|_| "原任务目录不可读取")?.parent() != Some(dir.canonicalize().map_err(|_| "项目目录不可读取")?.as_path()) + { + return Err("原任务目录包含链接,未覆盖".into()); + } + for entry in fs::read_dir(&root).map_err(|_| "原任务目录不可读取")? { + let entry = entry.map_err(|_| "原任务目录不可读取")?; + if entry.file_type().map_err(|_| "原任务目录不可读取")?.is_symlink() { + return Err("原任务记录包含链接,未覆盖".into()); + } + let path = if namespace == "image-jobs" { entry.path().join("task.json") } else { entry.path() }; + if path.extension().is_some_and(|e| e == "json") && path.exists() { + paths.push(path); + if paths.len() > FILES_LIMIT { + return Err("原任务记录过多,未覆盖".into()); + } + } + } + } + paths.sort(); + let mut identities = Vec::new(); + for path in paths { + if fs::symlink_metadata(&path).map_err(|_| "原任务记录不可读取")?.file_type().is_symlink() + || fs::metadata(&path).map_err(|_| "原任务记录不可读取")?.len() > 2_000_000 + || !path.canonicalize().map_err(|_| "原任务记录不可读取")?.starts_with(dir.canonicalize().map_err(|_| "项目目录不可读取")?) + { + return Err("原任务记录异常,未覆盖".into()); + } + let task = read_json(&path)?; + if !matches!(task["phase"].as_str(), Some("complete" | "applied" | "failed" | "rejected" | "not_dispatched")) { + return Err("项目有未结束或结果未知的原任务,未读取云端新版;原请求未重发".into()); + } + identities.push(json!({"path":path.strip_prefix(dir).map_err(|_| "原任务路径无效")?.to_string_lossy(),"sha256":file_digest(&path)?.0})); + } + for request in requests { + let bytes = STANDARD.decode(request["data"].as_str().ok_or("原请求内容缺失")?).map_err(|_| "原请求编码无效")?; + let task: Value = serde_json::from_slice(&bytes).map_err(|_| "原请求记录无效")?; + if !matches!(task["phase"].as_str(), Some("complete" | "applied" | "failed" | "rejected" | "not_dispatched")) { + return Err("项目有未结束或结果未知的Pi原请求,未读取云端新版;原请求未重发".into()); + } + } + Ok(digest(serde_json::to_string(&identities).map_err(|_| "原任务身份无法核对")?.as_bytes())) +} + +fn request_files(account: &Path, local_id: &str) -> R<(bool, Vec)> { + let requests = account.join("pi-validation").join(local_id).join("requests"); + let mut files = Vec::new(); + let mut total = 0; + collect_files(&requests, &requests, "requests/", 0, &mut files, &mut total)?; + Ok((requests.exists(), files)) +} + +fn unchanged_cloud_copy(account: &Path, existing: &ExistingCloudCopy) -> R<()> { + let dir = super::projects::local_path(&account.join("projects"), &existing.local_id)?; + let context = read_json(&dir.join("context.json"))?; + if context["syncDirty"] != false + || context["revision"].as_u64().is_none() + || context["revision"] != context["syncedRevision"] + || context_identity(&context, &dir)? != existing.context_identity + || file_digest(&dir.join("canvas.pcraft"))?.0 != existing.canvas_sha + { + return Err("本机项目在读取新版期间已有修改,未覆盖;请保存后重新核对".into()); + } + super::projects::idle(&dir)?; + let (had_requests, files) = request_files(account, &existing.local_id)?; + if had_requests != existing.had_requests || files != existing.request_files { + return Err("本机原请求记录在读取新版期间已变化,未覆盖".into()); + } + if settled_task_identity(&dir, &files)? != existing.task_identity { + return Err("本机原任务记录在读取新版期间已变化,未覆盖".into()); + } + Ok(()) +} + +fn owned_child(parent: &Path, name: &str) -> R { + let child = parent.join(name); + if let Ok(meta) = fs::symlink_metadata(&child) { + if !meta.is_dir() || meta.file_type().is_symlink() { + return Err("项目版本回退目录存在链接或异常,未替换本机项目".into()); + } + } else { + fs::create_dir(&child).map_err(|_| "无法创建项目版本回退目录,未替换本机项目")?; + } + if child.canonicalize().map_err(|_| "项目回退目录不可读取")?.parent() != Some(parent.canonicalize().map_err(|_| "账户目录不可读取")?.as_path()) + { + return Err("项目版本回退目录超出账户范围,未替换本机项目".into()); + } + Ok(child) +} + +/// A failed rename restores the old pair. A successful update retains that pair +/// as a complete per-project revision, including every old request identity. +fn commit_restored_copy( + account: &Path, + staging: &Path, + destination: &Path, + local_id: &str, + existing: Option<&ExistingCloudCopy>, + retirement: Option<(&std::sync::Mutex>, &[u64])>, +) -> R> { + let _guard = context_guard(); + let requests = account.join("pi-validation").join(local_id).join("requests"); + if let Some(existing) = existing { + unchanged_cloud_copy(account, existing)?; + if destination != account.join("projects").join(&existing.local_id) || existing.dir != destination.canonicalize().map_err(|_| "原项目不可读取")? + { + return Err("本机项目提交目标变化,未覆盖".into()); + } + } else if destination.exists() || requests.exists() { + return Err("本机已有此项目或原请求记录,未覆盖".into()); + } + fs::create_dir_all(account.join("projects")).map_err(|_| "项目目录不可写")?; + if staging.join("requests").exists() { + let validation = owned_child(account, "pi-validation")?; + owned_child(&validation, local_id)?; + } + // Match save's context-before-owner checks. Poisoning fails before moving + // anything; retired IDs become visible before the context lock is released. + let mut retired = + if let Some((mutex, _)) = retirement { Some(mutex.lock().map_err(|_| "旧画布写入保护不可用,未替换项目")?) } else { None }; + let rollback = if existing.is_some() { + let versions = owned_child(account, "project-revisions")?; + let project_versions = owned_child(&versions, local_id)?; + Some(owned_child(&project_versions, &Uuid::new_v4().to_string())?) + } else { + None + }; + let mut moved_project = false; + let mut moved_requests = false; + let mut installed = false; + let commit = (|| -> R<()> { + if let Some(rollback) = &rollback { + fs::rename(destination, rollback.join("project")).map_err(|_| "原项目版本无法保留,未替换")?; + moved_project = true; + if requests.exists() { + fs::rename(&requests, rollback.join("requests")).map_err(|_| "原请求记录无法保留,未替换")?; + moved_requests = true; + } + } + fs::rename(staging, destination).map_err(|_| "新版工程提交失败,原工程保留")?; + installed = true; + if destination.join("requests").exists() { + fs::rename(destination.join("requests"), &requests).map_err(|_| "新版请求记录提交失败,原工程保留")?; + } + Ok(()) + })(); + if let Err(error) = commit { + let mut restored = true; + if installed { + restored &= fs::rename(destination, staging).is_ok(); + } + if let Some(rollback) = &rollback { + if moved_requests { + restored &= fs::rename(rollback.join("requests"), &requests).is_ok(); + } + if moved_project { + restored &= fs::rename(rollback.join("project"), destination).is_ok(); + } + } + return Err(if restored { + error + } else { + "新版提交未完成,原工程及原请求仍保留在项目版本回退目录;请停止编辑并核查".into() + }); + } + if let (Some(retired), Some((_, ids))) = (&mut retired, retirement) { + retired.extend(ids.iter().copied()); + } + Ok(rollback) +} + impl Backend { pub(super) fn project_state(&mut self, input: &Value) -> R { let _guard = context_guard(); @@ -531,11 +772,56 @@ impl Backend { } Ok(bytes) } + #[cfg(test)] pub(super) fn restore_native_project(&mut self, project: &Value) -> R<(Document, Value)> { + self.restore_native_project_checked(project, &Value::Null) + } + pub(super) fn restore_native_project_checked(&mut self, project: &Value, input: &Value) -> R<(Document, Value)> { let id = project["id"].as_str().filter(|s| Uuid::parse_str(s).is_ok()).ok_or("云项目身份无效")?; - if self.native_projects()?.iter().any(|local| local["cloudLink"]["source"] == "desktop" && local["cloudLink"]["id"] == id) { - return Err("本机已有此项目,未覆盖;请从我的项目打开或先另存当前修改".into()); - } + let linked = + self.native_projects()?.into_iter().filter(|local| local["cloudLink"]["source"] == "desktop" && local["cloudLink"]["id"] == id).collect::>(); + let existing = if input["updateExisting"] == true { + let _guard = context_guard(); + if input["source"] != "desktop" || input["id"] != id || input["accountId"] != self.auth["account"]["id"] || input["origin"] != self.origin { + return Err("云端更新的账户、素材库或项目身份已变化,未覆盖本机项目".into()); + } + if project["ownerId"] != self.auth["account"]["id"] || linked.len() != 1 || linked[0]["id"] != input["localId"] { + return Err("本机副本与云项目账户关联不一致,未覆盖".into()); + } + let local_id = input["localId"].as_str().filter(|s| Uuid::parse_str(s).is_ok()).ok_or("本机项目身份无效")?.to_owned(); + let dir = super::projects::local_path(&self.dir().join("projects"), &local_id)?; + let context = read_json(&dir.join("context.json"))?; + let current_revision = context["project"]["revision"].as_u64().filter(|v| *v > 0).ok_or("本机云版本缺失,未覆盖")?; + let incoming_revision = project["revision"].as_u64().filter(|v| *v > current_revision).ok_or("云端没有可读取的新版本")?; + if input["revision"].as_u64() != Some(incoming_revision) + || input["localCloudRevision"].as_u64() != Some(current_revision) + || input["localRevision"] != context["revision"] + || context["project"]["source"] != "desktop" + || context["project"]["id"] != id + || context["revision"].as_u64().is_none() + || context["revision"] != context["syncedRevision"] + || context["syncDirty"] != false + { + return Err("本机有未同步修改或版本已变化,未覆盖;请先保存并刷新核对".into()); + } + super::projects::idle(&dir)?; + let (had_requests, request_files) = request_files(&self.dir(), &local_id)?; + let task_identity = settled_task_identity(&dir, &request_files)?; + Some(ExistingCloudCopy { + local_id, + context_identity: context_identity(&context, &dir)?, + canvas_sha: file_digest(&dir.join("canvas.pcraft"))?.0, + dir, + request_files, + had_requests, + task_identity, + }) + } else { + if !linked.is_empty() { + return Err("本机已有此项目,未覆盖;请保存并关闭本机项目后使用读取新版".into()); + } + None + }; let manifest = project["document"]["native"]["files"].as_array().filter(|f| f.len() == 3).ok_or("云同步尚未完成,不能恢复")?; let mut data = HashMap::new(); let mut total = 0u64; @@ -562,19 +848,23 @@ impl Backend { let context_bytes = data.get("context.json").filter(|b| b.len() <= 2_000_000).ok_or("工程缺少上下文或超过限制")?; let mut context: Value = serde_json::from_slice(context_bytes).map_err(|_| "工程上下文无效")?; no_credentials(&context)?; - let local_id = context["localId"].as_str().filter(|s| Uuid::parse_str(s).is_ok()).ok_or("工程本机身份无效")?.to_owned(); + let source_local_id = context["localId"].as_str().filter(|s| Uuid::parse_str(s).is_ok()).ok_or("工程本机身份无效")?.to_owned(); + if existing.as_ref().is_some_and(|copy| copy.local_id != source_local_id) { + return Err("新版工程的本机身份与已关联副本不同,未覆盖;请先核对原项目".into()); + } + let local_id = existing.as_ref().map(|copy| copy.local_id.clone()).unwrap_or_else(|| source_local_id.clone()); let account = self.dir(); let destination = account.join("projects").join(&local_id); - if destination.exists() || account.join("trash").join(&local_id).exists() { + if (destination.exists() && existing.is_none()) || account.join("trash").join(&local_id).exists() { return Err("本机已有此项目或回收副本,未覆盖;请从我的项目打开或先另存当前修改".into()); } let backup: Value = serde_json::from_slice(data.get("session-backup.json").ok_or("工程缺少会话")?).map_err(|_| "会话备份无效")?; - if backup["version"] != 1 || backup["localId"] != local_id || backup["accountScope"] != self.scope() { + if backup["version"] != 1 || backup["localId"] != source_local_id || backup["accountScope"] != self.scope() { return Err("会话备份账户或项目不一致".into()); } let files = backup["files"].as_array().filter(|v| v.len() <= FILES_LIMIT).ok_or("会话备份清单无效")?; let request_target = account.join("pi-validation").join(&local_id).join("requests"); - if request_target.exists() { + if request_target.exists() && existing.is_none() { return Err("本机已有此项目原请求日志,未覆盖;请先保留并核对原项目".into()); } let old_root = backup["oldProjectRoot"].as_str().unwrap_or(""); @@ -582,6 +872,11 @@ impl Backend { remap_paths(&mut context, old_root, &new_root); let staging = account.join(format!("restore-{}", Uuid::new_v4())); fs::create_dir_all(&staging).map_err(|_| "无法准备恢复目录")?; + let superseded = if existing.is_some() { + self.locations.iter().filter(|(_, location)| *location == &local_id).map(|(doc, _)| *doc).collect::>() + } else { + Vec::new() + }; let restore = (|| -> R { let mut seen = std::collections::HashSet::new(); let mut total = 0u64; @@ -616,10 +911,31 @@ impl Backend { } photocraft_format::atomic_write(&target, &bytes).map_err(|_| "会话恢复未完成,本机旧文件保留")?; } + if let Some(existing) = &existing { + // Keep terminal original request journals active as well as in + // the rollback copy. Differing records require explicit review. + for file in &existing.request_files { + let name = safe_path(file["path"].as_str().ok_or("本机原请求路径无效")?)?; + let bytes = STANDARD.decode(file["data"].as_str().ok_or("本机原请求内容缺失")?).map_err(|_| "本机原请求编码无效")?; + let bytes = restored_bytes(name, &bytes, old_root, &new_root)?; + let target = staging.join(name); + if target.exists() { + if fs::read(&target).map_err(|_| "恢复请求记录无法读取")? != bytes { + return Err("本机与云端的原请求记录不同,未覆盖;请保留两个版本核查".into()); + } + } else { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|_| "原请求目录无法准备")?; + } + photocraft_format::atomic_write(&target, &bytes).map_err(|_| "原请求记录无法保留")?; + } + } + } photocraft_format::atomic_write(&staging.join("canvas.pcraft"), data.get("canvas.pcraft").ok_or("工程文件缺失")?).map_err(|_| "工程写入失败")?; let mut doc = photocraft_format::load_path(&staging.join("canvas.pcraft")).map_err(|_| "云工程格式不可读取,未替换旧项目")?; doc.id = photocraft_doc::DocId::fresh(); context["doc"] = json!(doc.id.0); + context["localId"] = json!(local_id); // Keep the saved canvas revision across devices. The UI admits this // revision as its clean baseline; resetting it to zero makes opening // a completed project look like an edit on the first autosave. @@ -637,24 +953,50 @@ impl Backend { context["time"] = json!(now().to_string()); context["path"] = json!(destination.join("canvas.pcraft").to_string_lossy()); write_json(&staging.join("context.json"), &context)?; - fs::create_dir_all(account.join("projects")).map_err(|_| "项目目录不可写")?; - fs::rename(&staging, &destination).map_err(|_| "恢复工程提交失败,旧项目保留")?; - let request_stage = destination.join("requests"); - if request_stage.exists() { - if let Some(parent) = request_target.parent() { - fs::create_dir_all(parent).map_err(|_| "原请求目录不可写")?; + // Stamp the exact completed staging pair, before it is renamed. + // Reading after promotion could accidentally bless a newer writer. + let open_identity = opened_copy_identity(&staging)?; + if existing.is_some() { + // Recheck live authorization and exact cloud revision after the + // bounded downloads, before any local version is replaced. + self.validate()?; + let current = self.cloud_snapshot(id)?.ok_or("云项目已删除或权限变化,未替换本机项目")?; + if current["project"]["revision"] != project["revision"] || current["project"]["document"]["native"] != project["document"]["native"] { + return Err("云项目在下载期间已更新,未替换本机项目;请刷新核对".into()); } - fs::rename(request_stage, &request_target).map_err(|_| "原请求恢复未完成,未发起任何模型调用")?; } + let rollback = commit_restored_copy( + &account, + &staging, + &destination, + &local_id, + existing.as_ref(), + existing.as_ref().map(|_| (self.superseded_docs.as_ref(), superseded.as_slice())), + )?; + if let Some(rollback) = rollback { + context["replacedLocalId"] = json!(local_id); + context["previousVersionPath"] = json!(rollback.to_string_lossy()); + } + context["openCopyIdentity"] = json!(open_identity); Ok(doc) })(); if restore.is_err() && staging.exists() { let _ = fs::remove_dir_all(&staging); } let doc = restore?; + for old in &superseded { + self.locations.remove(old); + self.owners.remove(old); + self.last_saved.remove(old); + } + if !superseded.is_empty() { + context["supersededDocIds"] = json!(superseded); + } self.owners.insert(doc.id.0, self.scope()); self.locations.insert(doc.id.0, local_id.clone()); context["localId"] = json!(local_id); + context["accountId"] = self.auth["account"]["id"].clone(); + context["origin"] = json!(self.origin); Ok((doc, context)) } } @@ -663,6 +1005,27 @@ impl Backend { mod tests { use super::*; #[test] + fn opened_copy_identity_changes_with_replaced_canvas_or_context_and_never_creates_missing_files() { + let dir = std::env::temp_dir().join(format!("native-open-identity-{}", Uuid::new_v4())); + assert!(opened_copy_identity(&dir).is_err()); + assert!(!dir.exists()); + fs::create_dir_all(&dir).unwrap(); + write_json(&dir.join("context.json"), &json!({"revision":4,"syncDirty":false})).unwrap(); + fs::write(dir.join("canvas.pcraft"), b"the original saved canvas").unwrap(); + let original = opened_copy_identity(&dir).unwrap(); + assert_eq!(opened_copy_identity(&dir).unwrap(), original); + photocraft_format::atomic_write(&dir.join("canvas.pcraft"), b"the cloud updated canvas").unwrap(); + let changed_canvas = opened_copy_identity(&dir).unwrap(); + assert_ne!(changed_canvas, original, "a delayed open from an old file pair must not adopt over the newer cloud copy"); + write_json(&dir.join("context.json"), &json!({"revision":8,"syncDirty":false})).unwrap(); + let changed_context = opened_copy_identity(&dir).unwrap(); + assert_ne!(changed_context, changed_canvas); + fs::remove_file(dir.join("canvas.pcraft")).unwrap(); + assert!(opened_copy_identity(&dir).is_err()); + assert!(!dir.join("canvas.pcraft").exists()); + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn draft_recovery_summary_preserves_unapplied_identity_without_claiming_unknown() { let dir = std::env::temp_dir().join(format!("draft-unapplied-summary-{}", Uuid::new_v4())); let id = Uuid::new_v4().to_string(); @@ -679,8 +1042,10 @@ mod tests { assert_eq!(summary[0]["pending"], true); assert_eq!(summary[0]["unknown"], false); assert!(summary[0]["text"].as_str().unwrap().contains("尚待应用")); - let mut unknown = task.clone(); unknown["phase"] = json!("unknown"); - write_json(&archive, &unknown).unwrap(); write_json(&dir.join("image-task.json"), &unknown).unwrap(); + let mut unknown = task.clone(); + unknown["phase"] = json!("unknown"); + write_json(&archive, &unknown).unwrap(); + write_json(&dir.join("image-task.json"), &unknown).unwrap(); let summary = recovery_summary(&dir, &requests).unwrap(); assert_eq!(summary.len(), 1); assert_eq!(summary[0]["id"], id); @@ -694,7 +1059,8 @@ mod tests { write_json(&dir.join("creation.json"), &c).unwrap(); let summary = recovery_summary(&dir, &requests).unwrap(); assert_eq!(summary[0]["id"], "creation-pending"); - assert_eq!(summary[0]["pending"], true); assert_eq!(summary[0]["unknown"], false); + assert_eq!(summary[0]["pending"], true); + assert_eq!(summary[0]["unknown"], false); c["generation"]["pending"]["taskId"] = json!("../invalid"); write_json(&dir.join("creation.json"), &c).unwrap(); assert!(recovery_summary(&dir, &requests).is_err()); @@ -725,6 +1091,245 @@ mod tests { write!(socket, "HTTP/1.1 {code} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", bytes.len()).unwrap(); socket.write_all(bytes).unwrap(); } + fn linked_update_fixture(origin: &str) -> (Backend, Arc, Value, Value, HashMap>) { + let mut backend = fixture(origin); + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"name":"本机旧版","width":1440,"height":400})).unwrap(); + let old = session.active().unwrap().doc.clone(); + backend.save(old.clone(), 4, json!({"messages":[{"role":"user","text":"旧产品事实"}]})).unwrap(); + let local_id = backend.locations[&old.id.0].clone(); + let dir = backend.project_dir(old.id.0).unwrap(); + let cloud_id = Uuid::new_v4().to_string(); + let mut context = read_json(&dir.join("context.json")).unwrap(); + context["project"] = json!({"source":"desktop","id":cloud_id,"revision":2}); + context["syncDirty"] = json!(false); + context["syncedRevision"] = json!(4); + write_json(&dir.join("context.json"), &context).unwrap(); + write_json(&dir.join("template-input.json"), &json!({"revision":1,"selected":"A","text":"旧选中内容"})).unwrap(); + let original = json!({"id":"original","phase":"complete","requestId":"original-request","idempotencyKey":"original-idempotency"}); + let request_path = backend.dir().join("pi-validation").join(&local_id).join("requests").join("original.json"); + write_json(&request_path, &original).unwrap(); + let new = crate::jiansun_document::import( + &json!({"name":"云端新版中文","screens":[{"width":1440,"height":400,"background":"#ffeecc","layers":[]}]}), + &HashMap::new(), + ) + .unwrap(); + context["name"] = json!(new.name); + context["revision"] = json!(8); + context["messages"] = json!([{"role":"user","text":"新版产品事实"}]); + context["project"] = json!({}); + let backup = json!({"version":1,"localId":local_id,"accountScope":backend.scope(),"oldProjectRoot":dir.to_string_lossy(),"files":[ + {"path":"project/template-input.json","sha256":digest(br#"{"revision":2,"selected":"B","text":"new choice"}"#),"data":STANDARD.encode(br#"{"revision":2,"selected":"B","text":"new choice"}"#)}, + {"path":"requests/original.json","sha256":digest(&serde_json::to_vec(&original).unwrap()),"data":STANDARD.encode(serde_json::to_vec(&original).unwrap())} + ]}); + let entries = [ + ("canvas.pcraft", photocraft_format::save_to_bytes(&new, &Default::default()).unwrap()), + ("context.json", serde_json::to_vec(&context).unwrap()), + ("session-backup.json", serde_json::to_vec(&backup).unwrap()), + ]; + let mut files = HashMap::new(); + let manifest = entries + .into_iter() + .map(|(name, bytes)| { + let sha = digest(&bytes); + let entry = json!({"path":name,"source":format!("native:{sha}"),"sha256":sha,"size":bytes.len()}); + files.insert(format!("native:{sha}"), bytes); + entry + }) + .collect::>(); + let project = json!({"id":cloud_id,"ownerId":"sync-fixture","revision":3,"status":"completed","sourceWebId":null, + "document":{"version":3,"id":cloud_id,"name":"云端新版中文","screens":[],"native":{"format":"photocraft","schemaVersion":1,"documentSource":manifest[0]["source"],"files":manifest}}}); + let input = json!({"source":"desktop","id":cloud_id,"revision":3,"updateExisting":true,"localId":local_id,"localRevision":4, + "localCloudRevision":2,"accountId":"sync-fixture","origin":origin}); + (backend, old, project, input, files) + } + fn serve_update_snapshot( + listener: TcpListener, + project: Value, + files: HashMap>, + mode: &'static str, + changed_context: Option, + ) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + let mut calls = Vec::new(); + for index in 0..if mode == "download-failure" { + 1 + } else if mode == "revoked" { + 4 + } else { + 5 + } { + let (mut socket, _) = listener.accept().unwrap(); + let (head, payload) = read_request(&mut socket); + let request = head.lines().next().unwrap(); + assert!(request.starts_with("GET "), "updates may only read snapshots, never dispatch or mutate"); + assert!(head.to_ascii_lowercase().contains("authorization: bearer fixture-token")); + assert!(payload.is_empty()); + calls.push(request.to_owned()); + let path = request.split_whitespace().nth(1).unwrap(); + if index < 3 { + if index == 2 + && let Some(path) = &changed_context + { + let mut value = read_json(path).unwrap(); + value["syncDirty"] = json!(true); + value["messages"] = json!([{"role":"user","text":"下载时新增本机事实"}]); + write_json(path, &value).unwrap(); + } + let bytes = + if mode == "download-failure" { b"not the registered native file".to_vec() } else { files[path.rsplit('/').next().unwrap()].clone() }; + reply(&mut socket, 200, &bytes); + } else if path == "/api/desktop/session" { + reply(&mut socket, if mode == "revoked" { 401 } else { 200 }, br#"{"account":{"id":"sync-fixture"}}"#); + } else { + let mut latest = project.clone(); + if mode == "cloud-changed" { + latest["revision"] = json!(4) + } + reply(&mut socket, 200, json!({"project":latest}).to_string().as_bytes()); + } + } + calls + }) + } + #[test] + fn clean_existing_copy_reads_verified_cloud_update_and_keeps_old_project_and_original_requests() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let (mut backend, old, project, input, files) = linked_update_fixture(&origin); + let local_id = input["localId"].as_str().unwrap(); + let destination = backend.dir().join("projects").join(local_id); + let old_canvas = fs::read(destination.join("canvas.pcraft")).unwrap(); + let old_context = fs::read(destination.join("context.json")).unwrap(); + let old_identity = opened_copy_identity(&destination).unwrap(); + let stale_backend = backend.clone(); + let server = serve_update_snapshot(listener, project.clone(), files, "ok", None); + let (updated, context) = backend.restore_native_project_checked(&project, &input).unwrap(); + assert_eq!(server.join().unwrap().len(), 5); + assert_eq!(updated.name, "云端新版中文"); + assert_eq!(context["localId"], local_id); + assert_eq!(context["path"], destination.join("canvas.pcraft").to_string_lossy().as_ref()); + assert_eq!(context["messages"][0]["text"], "新版产品事实"); + assert_eq!(context["revision"], 8); + assert_eq!(context["syncedRevision"], 8); + assert_eq!(context["status"], "completed"); + assert_eq!(context["syncDirty"], false); + assert_eq!(context["project"]["revision"], 3); + assert_eq!(context["openCopyIdentity"], opened_copy_identity(&destination).unwrap()); + assert_ne!(context["openCopyIdentity"], old_identity); + assert!(read_json(&destination.join("context.json")).unwrap()["openCopyIdentity"].is_null(), "open stamp is a return-only adoption token"); + assert_eq!(read_json(&destination.join("template-input.json")).unwrap()["selected"], "B"); + let rollback = PathBuf::from(context["previousVersionPath"].as_str().unwrap()); + assert_eq!(fs::read(rollback.join("project/canvas.pcraft")).unwrap(), old_canvas); + assert_eq!(fs::read(rollback.join("project/context.json")).unwrap(), old_context); + assert_eq!(read_json(&rollback.join("project/template-input.json")).unwrap()["selected"], "A"); + for path in [rollback.join("requests/original.json"), backend.dir().join("pi-validation").join(local_id).join("requests/original.json")] { + assert_eq!(read_json(&path).unwrap()["idempotencyKey"], "original-idempotency"); + } + assert!(context["supersededDocIds"].as_array().unwrap().contains(&json!(old.id.0))); + assert!(stale_backend.ensure_owner(old.id.0).is_err(), "shared retired IDs also protect earlier backend clones"); + assert!(!backend.locations.contains_key(&old.id.0)); + assert!(!backend.owners.contains_key(&old.id.0)); + assert_eq!(backend.native_projects().unwrap().len(), 1); + fs::remove_dir_all(&backend.root).unwrap(); + } + #[test] + fn cloud_update_rejects_dirty_unknown_running_stale_and_cross_account_before_any_download() { + for mode in ["dirty", "unsaved", "unknown", "archived-running", "pi-prepared", "account", "origin", "owner", "stale"] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let (mut backend, old, mut project, mut input, _) = linked_update_fixture(&origin); + let dir = backend.project_dir(old.id.0).unwrap(); + let mut context = read_json(&dir.join("context.json")).unwrap(); + match mode { + "dirty" => { + context["syncDirty"] = json!(true); + write_json(&dir.join("context.json"), &context).unwrap(); + } + "unsaved" => { + context["revision"] = json!(5); + write_json(&dir.join("context.json"), &context).unwrap(); + } + "unknown" => write_json(&dir.join("task.json"), &json!({"phase":"unknown","idempotencyKey":"retained"})).unwrap(), + "archived-running" => { + write_json(&dir.join("image-jobs").join(Uuid::new_v4().to_string()).join("task.json"), &json!({"phase":"running","requestId":"retained"})) + .unwrap() + } + "pi-prepared" => write_json( + &backend.dir().join("pi-validation").join(input["localId"].as_str().unwrap()).join("requests/original.json"), + &json!({"phase":"prepared","idempotencyKey":"retained"}), + ) + .unwrap(), + "account" => input["accountId"] = json!("other-account"), + "origin" => input["origin"] = json!("https://other.invalid"), + "owner" => project["ownerId"] = json!("other-account"), + "stale" => input["revision"] = json!(2), + _ => panic!("unsupported test case: {mode}"), + } + let canvas = fs::read(dir.join("canvas.pcraft")).unwrap(); + let saved = fs::read(dir.join("context.json")).unwrap(); + assert!(backend.restore_native_project_checked(&project, &input).is_err(), "{mode}"); + assert!(matches!(listener.accept(), Err(e) if e.kind() == std::io::ErrorKind::WouldBlock), "{mode}: no GET should be sent"); + assert_eq!(fs::read(dir.join("canvas.pcraft")).unwrap(), canvas); + assert_eq!(fs::read(dir.join("context.json")).unwrap(), saved); + assert!(!backend.dir().join("project-revisions").exists()); + fs::remove_dir_all(&backend.root).unwrap(); + } + } + #[test] + fn cloud_update_download_revocation_cloud_drift_and_late_local_edits_keep_the_original() { + for mode in ["download-failure", "revoked", "cloud-changed", "local-changed"] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let (mut backend, old, project, input, files) = linked_update_fixture(&origin); + let dir = backend.project_dir(old.id.0).unwrap(); + let canvas = fs::read(dir.join("canvas.pcraft")).unwrap(); + let old_context = fs::read(dir.join("context.json")).unwrap(); + let server = serve_update_snapshot(listener, project.clone(), files, mode, (mode == "local-changed").then(|| dir.join("context.json"))); + assert!(backend.restore_native_project_checked(&project, &input).is_err(), "{mode}"); + server.join().unwrap(); + assert_eq!(fs::read(dir.join("canvas.pcraft")).unwrap(), canvas, "{mode}"); + if mode == "local-changed" { + assert_eq!(read_json(&dir.join("context.json")).unwrap()["messages"][0]["text"], "下载时新增本机事实"); + } else { + assert_eq!(fs::read(dir.join("context.json")).unwrap(), old_context, "{mode}"); + } + assert!(backend.locations.contains_key(&old.id.0)); + assert!(!backend.dir().join("project-revisions").exists()); + assert!(!fs::read_dir(backend.dir()).unwrap().any(|entry| entry.unwrap().file_name().to_string_lossy().starts_with("restore-"))); + fs::remove_dir_all(&backend.root).unwrap(); + } + } + #[test] + fn failed_local_promotion_restores_the_exact_old_directory_and_requests() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let (backend, old, _, input, _) = linked_update_fixture(&origin); + let account = backend.dir(); + let local_id = input["localId"].as_str().unwrap().to_owned(); + let dir = backend.project_dir(old.id.0).unwrap().canonicalize().unwrap(); + let context = read_json(&dir.join("context.json")).unwrap(); + let (had_requests, requests) = request_files(&account, &local_id).unwrap(); + let existing = ExistingCloudCopy { + local_id: local_id.clone(), + context_identity: context_identity(&context, &dir).unwrap(), + canvas_sha: file_digest(&dir.join("canvas.pcraft")).unwrap().0, + task_identity: settled_task_identity(&dir, &requests).unwrap(), + dir: dir.clone(), + request_files: requests, + had_requests, + }; + let bytes = fs::read(dir.join("canvas.pcraft")).unwrap(); + let context_bytes = fs::read(dir.join("context.json")).unwrap(); + let destination = account.join("projects").join(&local_id); + let absent_staging = account.join("restore-intentionally-missing"); + assert!(commit_restored_copy(&account, &absent_staging, &destination, &local_id, Some(&existing), None).is_err()); + assert_eq!(fs::read(dir.join("canvas.pcraft")).unwrap(), bytes); + assert_eq!(fs::read(dir.join("context.json")).unwrap(), context_bytes); + assert_eq!(read_json(&account.join("pi-validation").join(&local_id).join("requests/original.json")).unwrap()["idempotencyKey"], "original-idempotency"); + fs::remove_dir_all(&backend.root).unwrap(); + } #[test] fn backups_reject_paths_and_credentials_but_preserve_request_fingerprints() { for p in ["../settings.json", "agent/../../vault.json", "C:/secret", "agent\\a.json", "agent//x.json"] { @@ -926,6 +1531,82 @@ mod tests { fs::remove_dir_all(&b.root).unwrap(); } #[test] + fn template_choices_and_text_change_sync_identity_without_a_canvas_revision() { + let mut b = fixture("http://127.0.0.1:3138"); + let mut engine = photocraft_engine::Session::new(); + engine.execute("file.new", json!({"width":1440,"height":400})).unwrap(); + let doc = engine.active().unwrap().doc.clone(); + let id = doc.id.0; + b.save(doc, 4, json!({"messages":[]})).unwrap(); + let dir = b.project_dir(id).unwrap(); + let context = read_json(&dir.join("context.json")).unwrap(); + let canvas = file_digest(&dir.join("canvas.pcraft")).unwrap(); + let path = dir.join("template-slots.json"); + let original = json!({"version":1,"revision":1,"slots":{"stable-field":{"selected":"A","text":"原文案"}}}); + write_json(&path, &original).unwrap(); + let baseline = context_identity(&context, &dir).unwrap(); + let mut changed = original.clone(); + changed["slots"]["stable-field"]["selected"] = json!("B"); + write_json(&path, &changed).unwrap(); + assert_ne!(context_identity(&context, &dir).unwrap(), baseline, "an A/B choice belongs to the cloud snapshot even before canvas application"); + changed = original; + changed["slots"]["stable-field"]["text"] = json!("用户改过的文案"); + write_json(&path, &changed).unwrap(); + assert_ne!(context_identity(&context, &dir).unwrap(), baseline, "template text changes cannot look already synced"); + assert_eq!(read_json(&dir.join("context.json")).unwrap(), context); + assert_eq!(file_digest(&dir.join("canvas.pcraft")).unwrap(), canvas); + fs::remove_dir_all(&b.root).unwrap(); + } + #[test] + fn late_upload_receipt_keeps_template_changes_dirty_at_the_same_canvas_revision() { + for file in ["template-slots.json", "template-input.json"] { + let mut b = fixture("http://127.0.0.1:3138"); + let mut engine = photocraft_engine::Session::new(); + engine.execute("file.new", json!({"width":1440,"height":400})).unwrap(); + let doc = engine.active().unwrap().doc.clone(); + let id = doc.id.0; + b.save(doc, 4, json!({"messages":[]})).unwrap(); + let dir = b.project_dir(id).unwrap(); + let path = dir.join(file); + let original = json!({"revision":1,"templateHash":"original-template","selected":"A","text":"原文案"}); + write_json(&path, &original).unwrap(); + let context = read_json(&dir.join("context.json")).unwrap(); + let uploading_identity = context_identity(&context, &dir).unwrap(); + let canvas = file_digest(&dir.join("canvas.pcraft")).unwrap(); + // The network upload has this old identity; the local user or a + // recovered original candidate finishes before its receipt arrives. + let latest = json!({"revision":2,"templateHash":"updated-template","selected":"B","text":"本机的新文案"}); + write_json(&path, &latest).unwrap(); + let saved = finish_sync(&dir, "cloud", 9, 4, "completed", &Value::Null, &uploading_identity).unwrap(); + assert_eq!(saved["revision"], 4); + assert_eq!(saved["syncedRevision"], 4); + assert_eq!(saved["syncDirty"], true, "late upload of {file} must not clear newer local template changes"); + assert_eq!(saved["status"], "draft"); + assert_eq!(saved["project"]["revision"], 9, "the successfully uploaded old revision still has a valid cloud receipt"); + assert_eq!(read_json(&path).unwrap(), latest); + assert_eq!(file_digest(&dir.join("canvas.pcraft")).unwrap(), canvas); + fs::remove_dir_all(&b.root).unwrap(); + } + } + #[test] + fn unchanged_template_files_allow_the_matching_cloud_receipt_to_finish() { + let mut b = fixture("http://127.0.0.1:3138"); + let mut engine = photocraft_engine::Session::new(); + engine.execute("file.new", json!({"width":1440,"height":400})).unwrap(); + let doc = engine.active().unwrap().doc.clone(); + let id = doc.id.0; + b.save(doc, 4, json!({"messages":[]})).unwrap(); + let dir = b.project_dir(id).unwrap(); + for file in ["template-slots.json", "template-input.json"] { + write_json(&dir.join(file), &json!({"revision":1,"selected":"A"})).unwrap(); + } + let identity = context_identity(&read_json(&dir.join("context.json")).unwrap(), &dir).unwrap(); + let saved = finish_sync(&dir, "cloud", 9, 4, "completed", &Value::Null, &identity).unwrap(); + assert_eq!(saved["syncDirty"], false); + assert_eq!(saved["status"], "completed"); + fs::remove_dir_all(&b.root).unwrap(); + } + #[test] fn revocation_and_cloud_conflict_preserve_local_files_without_retry() { for revoked in [true, false] { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_template_cache.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_template_cache.rs new file mode 100644 index 00000000..a1a6da0d --- /dev/null +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_template_cache.rs @@ -0,0 +1,293 @@ +//! Account/origin-scoped, immutable template packages. A project installs a +//! fixed copy separately; a cache refresh never edits that project or canvas. +use super::*; + +const CHECK_SECONDS: u64 = 300; +const MAX_REFRESHES: usize = 4; +static REFRESHING: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +fn uuid_id(value: &str, label: &str) -> R { + Uuid::parse_str(value).map(|id| id.to_string()).map_err(|_| format!("{label}须为有效编号")) +} +fn identity(origin: &str, account: &str) -> R { + let checked = portal_origin(origin)?; + if checked != origin { + return Err("模板缓存来源不一致".into()); + } + uuid_id(account, "账户")?; + Ok(json!({"origin":origin,"accountId":account,"accountScope":hash(&format!("{origin}|{account}"))})) +} +fn pair(brand: &str, category: &str) -> R<(String, String)> { + Ok((uuid_id(brand, "品牌")?, uuid_id(category, "品类")?)) +} +fn pointer(root: &Path, brand: &str, category: &str) -> PathBuf { + root.join("bindings").join(format!("{}.json", hash(&json!([brand, category]).to_string()))) +} +fn hex(value: &Value) -> R<&str> { + value.as_str().filter(|s| s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))).ok_or_else(|| "模板缓存校验值无效".into()) +} +fn matching_pack(pack: &Value, brand: &str, category: &str) -> R<()> { + template_slots::validate_pack(pack)?; + if pack["brandId"] != brand || pack["categoryId"] != category { + return Err("模板包与当前品牌和品类不一致,未使用其他模板".into()); + } + Ok(()) +} +fn response_pack(response: &Value, brand: &str, category: &str) -> R { + let rows = response["items"].as_array().filter(|rows| rows.len() == 1).ok_or("此品牌与品类未配置唯一图位模板")?; + let row = rows.first().ok_or("图位模板缺失")?; + let pack = &row["pack"]; + matching_pack(pack, brand, category)?; + if row["id"] != pack["id"] || row["version"] != pack["version"] || row["contentHash"] != pack["contentHash"] { + return Err("模板目录与内容身份不一致,未写入缓存".into()); + } + Ok(pack.clone()) +} +fn check_index(index: &Value, owner: &Value, brand: &str, category: &str) -> R<()> { + if index["schemaVersion"] != 1 + || index["owner"] != *owner + || index["brandId"] != brand + || index["categoryId"] != category + || index["checkedAt"].as_u64().is_none() + || !index["id"].is_string() + || !index["version"].is_string() + { + return Err("模板缓存账户、来源或目录身份不一致,未读取缓存".into()); + } + hex(&index["contentHash"])?; + Ok(()) +} +fn read_cache(root: &Path, owner: &Value, brand: &str, category: &str) -> R> { + let file = pointer(root, brand, category); + if !file.exists() { + return Ok(None); + } + let index = read_json(&file)?; + check_index(&index, owner, brand, category)?; + let hash = hex(&index["contentHash"])?; + let pack = read_json(&root.join("objects").join(format!("{hash}.json")))?; + matching_pack(&pack, brand, category)?; + if pack["contentHash"] != hash || pack["id"] != index["id"] || pack["version"] != index["version"] { + return Err("模板缓存内容已损坏,未使用也未静默替换".into()); + } + Ok(Some((pack, index["checkedAt"].as_u64().ok_or("模板检查时间无效")?))) +} +fn store_cache(root: &Path, owner: &Value, brand: &str, category: &str, pack: &Value, checked_at: u64) -> R<()> { + matching_pack(pack, brand, category)?; + let digest = hex(&pack["contentHash"])?; + let _guard = sync::context_guard(); + let index = pointer(root, brand, category); + if index.exists() { + let (_, old_at) = read_cache(root, owner, brand, category)?.ok_or("原模板缓存目录缺失")?; + // Do not let a response started earlier regress a newer local check. + if old_at > checked_at { + return Ok(()); + } + } + let object = root.join("objects").join(format!("{digest}.json")); + if object.exists() { + let saved = read_json(&object)?; + matching_pack(&saved, brand, category)?; + if saved != *pack { + return Err("同校验值的模板缓存内容不一致,未覆盖原文件".into()); + } + } else { + write_json(&object, pack)?; + } + write_json( + &index, + &json!({"schemaVersion":1,"owner":owner,"brandId":brand,"categoryId":category,"id":pack["id"],"version":pack["version"],"contentHash":digest,"checkedAt":checked_at}), + ) +} +fn refresh(root: &Path, owner: &Value, brand: &str, category: &str, at: u64, get: impl FnOnce() -> R) -> R { + let pack = response_pack(&get()?, brand, category)?; + store_cache(root, owner, brand, category, &pack, at)?; + Ok(pack) +} +fn fresh(checked_at: u64, at: u64) -> R { + if checked_at > at.saturating_add(60) { + return Err("模板缓存检查时间晚于本机时间,请核对系统时钟".into()); + } + Ok(at.saturating_sub(checked_at) < CHECK_SECONDS) +} +fn refresh_key(root: &Path, owner: &Value, brand: &str, category: &str) -> String { + hash(&json!([root.to_string_lossy(), owner, brand, category]).to_string()) +} +struct RefreshPermit(String); +impl Drop for RefreshPermit { + fn drop(&mut self) { + if let Ok(mut active) = REFRESHING.lock() { + active.retain(|key| key != &self.0); + } + } +} +fn permit(key: String) -> Option { + let mut active = REFRESHING.lock().ok()?; + if active.contains(&key) || active.len() >= MAX_REFRESHES { + return None; + } + active.push(key.clone()); + Some(RefreshPermit(key)) +} +fn background_refresh(backend: &Backend, root: PathBuf, owner: Value, brand: String, category: String) { + let Some(permit) = permit(refresh_key(&root, &owner, &brand, &category)) else { + return; + }; + let backend = backend.clone(); + std::thread::spawn(move || { + let _permit = permit; + let result = refresh(&root, &owner, &brand, &category, now(), || backend.json(&format!("template-packs?brandId={brand}&categoryId={category}"))); + let status = match result { + Ok(pack) => json!({"state":"ready","checkedAt":now(),"contentHash":pack["contentHash"]}), + Err(error) => json!({"state":"failed","checkedAt":now(),"error":error}), + }; + // Status is cache diagnostics only. No project snapshot or canvas path is + // available to this worker, and the captured Backend cannot cross scope. + let _ = write_json(&root.join("checks").join(format!("{}.json", hash(&json!([brand, category]).to_string()))), &status); + }); +} + +impl Backend { + /// Caller checks current desktop authority before use (slot-install already + /// calls validate). The endpoint independently rechecks every network GET. + pub(super) fn template_pack(&self, brand: &str, category: &str) -> R { + let (brand, category) = pair(brand, category)?; + let owner = identity(&self.origin, self.auth["account"]["id"].as_str().ok_or("请先登录素材库")?)?; + if owner["accountScope"] != self.scope() { + return Err("模板缓存账户绑定不一致".into()); + } + let root = self.dir().join("template-cache"); + if let Some((pack, at)) = read_cache(&root, &owner, &brand, &category)? { + if !fresh(at, now())? { + background_refresh(self, root, owner, brand, category); + } + return Ok(pack); + } + refresh(&root, &owner, &brand, &category, now(), || self.json(&format!("template-packs?brandId={brand}&categoryId={category}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + struct Temp(PathBuf); + impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + fn temp() -> Temp { + let base = std::env::var_os("JIANSUN_RULE_TEST_ROOT").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); + let root = base.join(format!("template-cache-{}", Uuid::new_v4())); + fs::create_dir_all(&root).unwrap(); + Temp(root) + } + fn fixture() -> (Value, String, String, Value) { + let pack: Value = serde_json::from_str(include_str!("../../../../../lib/desktop-client/template-packs/yjd-desk-dnz0385-native-v1.json")).unwrap(); + let owner = identity("https://sc.jiansun.vip", "2fee4985-3001-4131-94e6-586162f9c868").unwrap(); + (owner, pack["brandId"].as_str().unwrap().into(), pack["categoryId"].as_str().unwrap().into(), pack) + } + fn response(pack: &Value) -> Value { + json!({"items":[{"id":pack["id"],"version":pack["version"],"contentHash":pack["contentHash"],"pack":pack}]}) + } + #[test] + fn real_pack_cached_by_hash_and_exact_brand_category() { + let root = temp(); + let (owner, brand, category, pack) = fixture(); + assert!(read_cache(&root.0, &owner, &brand, &category).unwrap().is_none()); + let saved = refresh(&root.0, &owner, &brand, &category, 100, || Ok(response(&pack))).unwrap(); + assert_eq!(saved, pack); + assert!(root.0.join("objects").join(format!("{}.json", pack["contentHash"].as_str().unwrap())).is_file()); + assert_eq!(read_cache(&root.0, &owner, &brand, &category).unwrap().unwrap(), (pack, 100)); + assert!(read_cache(&root.0, &owner, "42bb46b2-89cd-47cf-9ad8-f2e95b8c5a3a", &category).unwrap().is_none()); + } + #[test] + fn cached_pack_is_local_first_for_five_minutes_and_future_time_is_not_trusted() { + assert!(fresh(100, 100).unwrap()); + assert!(fresh(100, 399).unwrap()); + assert!(!fresh(100, 400).unwrap()); + assert!(fresh(500, 100).is_err()); + let root = temp(); + let (owner, brand, category, pack) = fixture(); + store_cache(&root.0, &owner, &brand, &category, &pack, 100).unwrap(); + let (read, at) = read_cache(&root.0, &owner, &brand, &category).unwrap().unwrap(); + assert!(fresh(at, 150).unwrap()); + assert_eq!(read, pack); + } + #[test] + fn tampered_or_missing_object_fails_closed_without_fetch_or_replacement() { + let root = temp(); + let (owner, brand, category, pack) = fixture(); + store_cache(&root.0, &owner, &brand, &category, &pack, 100).unwrap(); + let path = root.0.join("objects").join(format!("{}.json", pack["contentHash"].as_str().unwrap())); + let mut altered = pack.clone(); + altered["name"] = json!("tampered"); + write_json(&path, &altered).unwrap(); + assert!(read_cache(&root.0, &owner, &brand, &category).is_err()); + assert!(store_cache(&root.0, &owner, &brand, &category, &pack, 101).is_err()); + fs::remove_file(path).unwrap(); + assert!(read_cache(&root.0, &owner, &brand, &category).is_err()); + } + #[test] + fn copied_pointer_cannot_cross_account_or_origin() { + let root = temp(); + let (owner, brand, category, pack) = fixture(); + store_cache(&root.0, &owner, &brand, &category, &pack, 100).unwrap(); + let other = identity("https://sc.jiansun.vip", "00000000-0000-4000-8000-000000000000").unwrap(); + assert!(read_cache(&root.0, &other, &brand, &category).is_err()); + let local = identity("http://127.0.0.1:3137", "2fee4985-3001-4131-94e6-586162f9c868").unwrap(); + assert!(read_cache(&root.0, &local, &brand, &category).is_err()); + assert!(identity("https://other.example", "2fee4985-3001-4131-94e6-586162f9c868").is_err()); + } + #[test] + fn server_must_return_unique_matching_metadata_and_verified_content() { + let (_, brand, category, pack) = fixture(); + assert!(response_pack(&json!({"items":[]}), &brand, &category).is_err()); + let mut value = response(&pack); + let duplicate = value["items"][0].clone(); + value["items"].as_array_mut().unwrap().push(duplicate); + assert!(response_pack(&value, &brand, &category).is_err()); + let mut value = response(&pack); + value["items"][0]["version"] = json!("other"); + assert!(response_pack(&value, &brand, &category).is_err()); + assert!(response_pack(&response(&pack), "42bb46b2-89cd-47cf-9ad8-f2e95b8c5a3a", &category).is_err()); + } + #[test] + fn refreshed_pointer_preserves_immutable_old_object_and_project_snapshot() { + let root = temp(); + let (owner, brand, category, pack) = fixture(); + store_cache(&root.0, &owner, &brand, &category, &pack, 100).unwrap(); + let project = root.0.join("existing-project-template.json"); + write_json(&project, &pack).unwrap(); + let mut next = pack.clone(); + next["version"] = json!("20261023.2"); + next["contentHash"] = json!(template_slots::pack_hash(&next).unwrap()); + refresh(&root.0, &owner, &brand, &category, 401, || Ok(response(&next))).unwrap(); + assert_eq!(read_cache(&root.0, &owner, &brand, &category).unwrap().unwrap().0, next); + assert_eq!(read_json(&project).unwrap(), pack); + assert_eq!(read_json(&root.0.join("objects").join(format!("{}.json", pack["contentHash"].as_str().unwrap()))).unwrap(), pack); + store_cache(&root.0, &owner, &brand, &category, &pack, 100).unwrap(); + assert_eq!(read_cache(&root.0, &owner, &brand, &category).unwrap().unwrap().0, next); + } + #[test] + fn failed_update_keeps_valid_original_pointer_and_error_is_returned() { + let root = temp(); + let (owner, brand, category, pack) = fixture(); + store_cache(&root.0, &owner, &brand, &category, &pack, 100).unwrap(); + assert!(refresh(&root.0, &owner, &brand, &category, 401, || Err("授权已撤销".into())).is_err()); + let mut broken = pack.clone(); + broken["name"] = json!("bad hash"); + assert!(refresh(&root.0, &owner, &brand, &category, 402, || Ok(response(&broken))).is_err()); + assert_eq!(read_cache(&root.0, &owner, &brand, &category).unwrap().unwrap(), (pack, 100)); + } + #[test] + fn refresh_deduplication_is_per_scope_pair_and_bounded() { + let prefix = Uuid::new_v4().to_string(); + let keys: Vec<_> = (0..MAX_REFRESHES).map(|n| format!("{prefix}-{n}")).collect(); + let guards: Vec<_> = keys.iter().map(|key| permit(key.clone()).unwrap()).collect(); + assert!(permit(keys[0].clone()).is_none()); + assert!(permit(format!("{prefix}-overflow")).is_none()); + drop(guards); + assert!(permit(keys[0].clone()).is_some()); + } +} diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_template_local_qa.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_template_local_qa.rs new file mode 100644 index 00000000..e4b5fd71 --- /dev/null +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_template_local_qa.rs @@ -0,0 +1,168 @@ +//! Explicit, ignored local acceptance bootstrap. Never compiled into a production binary. +//! Secrets move only between the current process and Windows Credential Manager. The loopback +//! adapter sees the existing production bearer for authenticated GETs, never the personal Key. +#![cfg(test)] +use super::*; + +const MARKER: &str = "gongxiangp2-template-slots-20261023-local-qa-v1"; +const ORIGIN: &str = "http://127.0.0.1:3149"; +const SOURCE: &str = "D:\\AI Code\\gongxiangp2\\output\\photocraft-development-20261021\\profile"; +const TARGET: &str = "D:\\AI Code\\gongxiangp2\\output\\template-slots-20261023\\profile-local-qa"; + +fn absent(entry: &keyring::Entry) -> R<()> { + match entry.get_password() { + Err(keyring::Error::NoEntry) => Ok(()), + Ok(_) => Err("3149验收凭据已存在,未覆盖;请先核对原验收归属".into()), + Err(_) => Err("验收凭据存在性无法核对,未写入".into()), + } +} +fn local_entry(kind: &str, account: &str) -> R { + match kind { + "account" => vault(&format!("account:{}", hash(ORIGIN))), + "models" => vault(&format!("models:{}", hash(&format!("{ORIGIN}|{account}")))), + "owner" => vault(&format!("qa-owner:{}", hash(ORIGIN))), + _ => Err("验收凭据类型无效".into()), + } +} +fn target_path() -> R { + let target = PathBuf::from(TARGET); + let parent = target.parent().ok_or("固定验收profile位置无效")?.canonicalize().map_err(|_| "本任务输出目录未准备")?; + if target.exists() && target.canonicalize().map_err(|_| "验收profile无法解析")? != parent.join("profile-local-qa") { + return Err("验收profile指向其他目录,未写入或清理".into()); + } + let root = target.join("jiansun"); + if root.exists() && root.canonicalize().map_err(|_| "验收资料目录无法解析")? != parent.join("profile-local-qa").join("jiansun") { + return Err("验收资料目录含外部链接,未写入或清理".into()); + } + Ok(target) +} +fn identity(source: &Backend) -> R<()> { + let response = source.http.get(format!("{ORIGIN}/__qa_identity")).send().map_err(|_| "3149验收适配器未就绪,未复制凭据")?; + let (status, value) = body(response, 8192)?; + if status != 200 || value["marker"] != MARKER || value["origin"] != ORIGIN || value["readOnly"] != true || value["modelProxy"] != false { + return Err("3149不是本任务只读适配器,未复制凭据".into()); + } + Ok(()) +} +fn bootstrap() -> R { + if std::env::var("PHOTOCRAFT_TEMPLATE_QA_BOOTSTRAP").ok().as_deref() != Some(MARKER) { + return Err("需要本任务显式验收开关,未读取或复制凭据".into()); + } + let source_profile = PathBuf::from(SOURCE); + let target_profile = target_path()?; + if source_profile == target_profile || !source_profile.is_dir() { + return Err("固定正式客户端资料目录无效".into()); + } + if target_profile.join("jiansun/settings.json").exists() { + return Err("固定验收profile已有连接配置,未覆盖".into()); + } + let mut source = Backend::new(source_profile.join("jiansun"))?; + if source.origin != "https://sc.jiansun.vip" { + return Err("源连接不是正式素材库,未复制".into()); + } + identity(&source)?; + source.validate()?; + let account = source.auth["account"]["id"].as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("正式账户编号无效")?.to_owned(); + if !source.auth["access_token"].as_str().is_some_and(|s| s.starts_with("dcs_") && s.len() == 68) { + return Err("正式客户端授权无效".into()); + } + let source_auth_entry = vault(&format!("account:{}", hash(&source.origin)))?; + let source_models_entry = vault(&format!("models:{}", source.scope()))?; + let source_auth_raw = source_auth_entry.get_password().map_err(|_| "正式授权不可读")?; + let source_models_raw = source_models_entry.get_password().map_err(|_| "正式个人模型连接不可读")?; + let personal: Value = serde_json::from_str(&source_models_raw).map_err(|_| "正式个人模型连接格式无效")?; + if !personal["key"].as_str().is_some_and(|s| (12..=500).contains(&s.len())) { + return Err("正式客户端尚未连接个人Key,未复制".into()); + } + let account_entry = local_entry("account", &account)?; + let models_entry = local_entry("models", &account)?; + let owner_entry = local_entry("owner", &account)?; + absent(&account_entry)?; + absent(&models_entry)?; + absent(&owner_entry)?; + let mut target = Backend::new(target_profile.join("jiansun"))?; + target.origin = ORIGIN.into(); + target.auth = source.auth.clone(); + target.validate()?; + if target.auth["account"]["id"] != account { + return Err("隔离转发后的账户不一致,未写入".into()); + } + let account_raw = target.auth.to_string(); + let models_raw = personal.to_string(); + let owner = json!({"marker":MARKER,"origin":ORIGIN,"accountId":account,"accountDigest":hash(&account_raw),"modelsDigest":hash(&models_raw)}).to_string(); + // Recheck immediately before writing. Existing localhost credentials are never overwritten. + absent(&account_entry)?; + absent(&models_entry)?; + absent(&owner_entry)?; + let mut written: Vec<(&keyring::Entry, &str)> = Vec::new(); + let write = (|| -> R<()> { + owner_entry.set_password(&owner).map_err(|_| "本任务归属无法写入凭据库")?; + written.push((&owner_entry, &owner)); + account_entry.set_password(&account_raw).map_err(|_| "隔离授权无法写入凭据库")?; + written.push((&account_entry, &account_raw)); + models_entry.set_password(&models_raw).map_err(|_| "隔离个人连接无法写入凭据库")?; + written.push((&models_entry, &models_raw)); + if source_auth_entry.get_password().map_err(|_| "源授权无法复核")? != source_auth_raw + || source_models_entry.get_password().map_err(|_| "源个人连接无法复核")? != source_models_raw + { + return Err("源凭据同期发生变化,已停止隔离验收".into()); + } + write_json(&target.root.join("settings.json"), &json!({"origin":ORIGIN,"qaMarker":MARKER,"qaAccountId":account}))?; + Ok(()) + })(); + if let Err(error) = write { + // Roll back only values this call wrote and which are still byte-for-byte ours. + for (entry, value) in written.into_iter().rev() { + if entry.get_password().ok().as_deref() == Some(value) { + let _ = entry.delete_credential(); + } + } + return Err(error); + } + Ok( + json!({"copied":true,"marker":MARKER,"origin":ORIGIN,"accountId":account,"sourceSessionChanged":false,"sourceKeyChanged":false,"secretsExported":false,"modelRequests":0}), + ) +} +fn cleanup() -> R { + if std::env::var("PHOTOCRAFT_TEMPLATE_QA_CLEANUP").ok().as_deref() != Some(MARKER) { + return Err("需要本任务显式清理开关,未删除凭据".into()); + } + let settings = target_path()?.join("jiansun/settings.json"); + let saved = read_json(&settings)?; + if saved["origin"] != ORIGIN || saved["qaMarker"] != MARKER { + return Err("隔离profile归属不符,未清理".into()); + } + let account = saved["qaAccountId"].as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("隔离账户标记无效")?; + let owner_entry = local_entry("owner", account)?; + let owner: Value = serde_json::from_str(&owner_entry.get_password().map_err(|_| "本任务凭据归属不可读,未清理")?).map_err(|_| "本任务凭据归属格式无效")?; + if owner["marker"] != MARKER || owner["origin"] != ORIGIN || owner["accountId"] != account { + return Err("本任务凭据归属不符,未清理".into()); + } + let account_entry = local_entry("account", account)?; + let models_entry = local_entry("models", account)?; + let account_raw = account_entry.get_password().map_err(|_| "隔离授权缺失,未扩大清理范围")?; + let models_raw = models_entry.get_password().map_err(|_| "隔离个人连接缺失,未扩大清理范围")?; + if owner["accountDigest"] != hash(&account_raw) || owner["modelsDigest"] != hash(&models_raw) { + return Err("隔离凭据已变化,未删除用户的新连接".into()); + } + models_entry.delete_credential().map_err(|_| "隔离个人连接尚未清理")?; + account_entry.delete_credential().map_err(|_| "隔离授权尚未清理")?; + owner_entry.delete_credential().map_err(|_| "隔离归属标记尚未清理")?; + fs::remove_file(&settings).map_err(|_| "隔离连接配置尚未清理")?; + Ok( + json!({"cleaned":true,"marker":MARKER,"origin":ORIGIN,"sourceSessionTouched":false,"sourceKeyTouched":false,"projectFilesRemoved":false,"remoteRevocationSent":false,"modelRequests":0}), + ) +} + +#[test] +#[ignore = "requires exact task marker and owned localhost:3149 adapter; copies only to Windows vault"] +fn template_qa_copy_personal_connection_to_owned_local_adapter() { + let result = bootstrap().unwrap_or_else(|error| panic!("{error}")); + println!("{result}"); +} +#[test] +#[ignore = "run only after local GUI/adapter shutdown with explicit task marker; removes owned localhost vault copies"] +fn template_qa_cleanup_owned_local_adapter_connection() { + let result = cleanup().unwrap_or_else(|error| panic!("{error}")); + println!("{result}"); +} diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_template_slots.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_template_slots.rs new file mode 100644 index 00000000..28289476 --- /dev/null +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_template_slots.rs @@ -0,0 +1,793 @@ +//! Versioned template fields and explicit A/B selection. This module performs no +//! network operation: a reserved UUID is passed to the existing image journal. +//! Reserving, selecting and applying are separate durable user decisions. +use super::*; + +const FILE: &str = "template-slots.json"; + +pub(super) fn pack_hash(pack: &Value) -> R { + let mut pack = pack.clone(); + pack.as_object_mut().ok_or("模板包格式无效")?.remove("contentHash"); + Ok(hash(&pack.to_string())) +} + +fn short(value: &Value, max: usize, label: &str) -> R { + value.as_str().filter(|s| !s.trim().is_empty() && s.chars().count() <= max).map(str::to_owned).ok_or_else(|| format!("{label}无效")) +} +fn number(value: &Value, label: &str) -> R { + value.as_f64().filter(|n| n.is_finite()).ok_or_else(|| format!("{label}无效")) +} +fn hex(value: &Value) -> bool { + value.as_str().is_some_and(|s| s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))) +} + +/// The hash covers layout, fonts and all extra business metadata. Updating any +/// of those creates a new fixed package; saved projects never adopt it silently. +pub(super) fn validate_pack(pack: &Value) -> R<()> { + if pack["schemaVersion"] != 1 || pack.to_string().len() > 600_000 { + return Err("模板包版本或大小无效".into()); + } + for (field, max) in [("id", 120), ("version", 80), ("name", 160), ("brandId", 150), ("categoryId", 150)] { + short(&pack[field], max, field)?; + } + if !hex(&pack["contentHash"]) || pack["contentHash"] != pack_hash(pack)? { + return Err("模板包校验值不一致,未覆盖项目模板".into()); + } + let outputs = pack["outputs"].as_array().filter(|a| !a.is_empty() && a.len() <= 40).ok_or("模板须含1至40项产出")?; + let mut fields = std::collections::HashSet::new(); + let mut ids = std::collections::HashSet::new(); + let mut details = 0; + let mut detail_height = 0u64; + for output in outputs { + let id = short(&output["id"], 80, "产出编号")?; + if !ids.insert(id) { + return Err("产出编号重复".into()); + } + short(&output["name"], 160, "产出名称")?; + if !matches!(output["component"].as_str(), Some("detail" | "main_images" | "sku_images")) { + return Err("产出类型无效".into()); + } + let width = output["width"].as_u64().filter(|n| matches!(*n, 750 | 1440)).ok_or("模板宽度须为750或1440")?; + let height = output["height"].as_u64().filter(|n| (1..=30_000).contains(n)).ok_or("模板高度无效")?; + if width * height > 43_200_000 { + return Err("模板画布超出限制".into()); + } + if output["component"] == "detail" { + details += 1; + detail_height += height; + } + if let Some(decorations) = output.get("decorations") { + let decorations = decorations.as_array().filter(|a| a.len() <= 120).ok_or("模板装饰图层数量无效")?; + let mut decoration_ids = std::collections::HashSet::new(); + for item in decorations { + if !decoration_ids.insert(short(&item["id"], 120, "装饰图层编号")?) { + return Err("模板装饰图层编号重复".into()); + } + let f = &item["frame"]; + let (x, y, w, h) = + (number(&f["x"], "装饰横坐标")?, number(&f["y"], "装饰纵坐标")?, number(&f["width"], "装饰宽度")?, number(&f["height"], "装饰高度")?); + let color = item["color"].as_str().unwrap_or(""); + if !matches!(item["kind"].as_str(), Some("rect" | "line")) + || item["kind"] == "rect" && h <= 0.0 + || x < 0.0 + || y < 0.0 + || w <= 0.0 + || h < 0.0 + || x + w > width as f64 + 0.01 + || y + h > height as f64 + 0.01 + || color.len() != 7 + || !color.starts_with('#') + || !color[1..].bytes().all(|b| b.is_ascii_hexdigit()) + || !(0.0..=1.0).contains(&number(&item["opacity"], "装饰透明度")?) + || item.get("path").is_some() + || item.get("url").is_some() + { + return Err("模板装饰图层应为画布内的纯矢量色块或线段".into()); + } + } + } + let slots = output["slots"].as_array().filter(|a| !a.is_empty() && a.len() <= 120).ok_or("每项产出须含1至120个字段")?; + for slot in slots { + let field = short(&slot["fieldId"], 120, "字段编号")?; + if !fields.insert(field) { + return Err("模板字段编号重复".into()); + } + let f = &slot["frame"]; + let (x, y, w, h) = + (number(&f["x"], "字段横坐标")?, number(&f["y"], "字段纵坐标")?, number(&f["width"], "字段宽度")?, number(&f["height"], "字段高度")?); + if x < 0.0 || y < 0.0 || w <= 0.0 || h <= 0.0 || x + w > width as f64 + 0.01 || y + h > height as f64 + 0.01 { + return Err("模板字段超出画布,请先明确裁切范围".into()); + } + match slot["kind"].as_str() { + Some("image") if matches!(slot["crop"].as_str(), Some("cover" | "contain")) => {} + Some("text") => { + if slot["font"]["family"] != "Noto Sans SC" { + return Err("模板须明确采用可用的Noto Sans SC字体".into()); + } + let size = number(&slot["font"]["size"], "字号")?; + let color = slot["font"]["color"].as_str().unwrap_or(""); + if !(8.0..=600.0).contains(&size) + || color.len() != 7 + || !color.starts_with('#') + || !color[1..].bytes().all(|b| b.is_ascii_hexdigit()) + || !slot["font"]["weight"].as_u64().is_some_and(|n| (100..=900).contains(&n)) + || !slot["maxLines"].as_u64().is_some_and(|n| (1..=100).contains(&n)) + || !slot["maxChars"].as_u64().is_some_and(|n| (1..=6000).contains(&n)) + { + return Err("文字字段排版约束无效".into()); + } + } + _ => return Err("模板字段类型或裁切方式无效".into()), + } + } + } + if details > 14 || detail_height > 30_000 { + return Err("详情最多14个逻辑屏,总高不超过30000px;请明确合并而非截断".into()); + } + Ok(()) +} + +pub(super) fn slot_id(pack: &Value, field_id: &str) -> String { + hash(&json!([pack["id"], pack["version"], pack["contentHash"], field_id]).to_string()) +} +fn empty() -> Value { + json!({"version":1,"revision":0,"template":null,"slots":{},"applied":{}}) +} +pub(super) fn load(dir: &Path) -> R { + original_project_present(dir)?; + let path = dir.join(FILE); + let state = if path.exists() { read_json(&path)? } else { empty() }; + if state["version"] != 1 || state["revision"].as_u64().is_none() || !state["slots"].is_object() || !state["applied"].is_object() { + return Err("模板图位记录损坏,请保留项目".into()); + } + if !state["template"].is_null() { + validate_pack(&state["template"])?; + } + Ok(state) +} +fn checked(dir: &Path, revision: u64) -> R { + let state = load(dir)?; + if state["revision"] != revision { + return Err("图位已有新修改,请刷新;原候选保留".into()); + } + Ok(state) +} +fn persist(dir: &Path, mut state: Value) -> R { + original_project_present(dir)?; + let n = state["revision"].as_u64().and_then(|n| n.checked_add(1)).filter(|n| *n < 2_147_483_647).ok_or("图位版本已达上限")?; + state["revision"] = json!(n); + if state.to_string().len() > 1_900_000 { + return Err("图位任务记录接近本机文件上限,原草稿保留;请精简提示词或另建项目版本".into()); + } + write_json(&dir.join(FILE), &state)?; + Ok(state) +} + +pub(super) fn install(dir: &Path, pack: &Value, revision: u64) -> R { + validate_pack(pack)?; + let _guard = sync::context_guard(); + let mut state = checked(dir, revision)?; + if state["template"] == *pack { + return Ok(state); + } + if !state["template"].is_null() { + return Err("项目已绑定固定模板;升级须另建版本,原候选和回填保留".into()); + } + let mut slots = serde_json::Map::new(); + for output in pack["outputs"].as_array().ok_or("模板产出缺失")? { + for field in output["slots"].as_array().ok_or("模板字段缺失")? { + let key = slot_id(pack, field["fieldId"].as_str().ok_or("字段编号缺失")?); + slots.insert( + key, + json!({"fieldId":field["fieldId"],"outputId":output["id"],"kind":field["kind"],"text":"","candidates":{"A":null,"B":null},"selected":null}), + ); + } + } + state["template"] = pack.clone(); + state["slots"] = Value::Object(slots); + persist(dir, state) +} + +fn label(value: &str) -> R<&str> { + if matches!(value, "A" | "B") { Ok(value) } else { Err("候选只能为A或B".into()) } +} +fn field<'a>(state: &'a Value, slot: &str) -> R<&'a Value> { + let item = state["slots"].get(slot).filter(|v| v.is_object()).ok_or("图位不存在或属于其他模板版本")?; + if slot != slot_id(&state["template"], item["fieldId"].as_str().ok_or("图位编号损坏")?) { + return Err("图位与固定模板身份不一致".into()); + } + Ok(item) +} +pub(super) fn binding(state: &Value, slot: &str, candidate: &str) -> R { + let item = field(state, slot)?; + let c = &item["candidates"][label(candidate)?]; + if !c.is_object() { + return Err("此图位候选尚未预留任务".into()); + } + Ok( + json!({"templateId":state["template"]["id"],"templateVersion":state["template"]["version"],"templateHash":state["template"]["contentHash"],"slotId":slot,"fieldId":item["fieldId"],"outputId":item["outputId"],"label":candidate,"taskId":c["taskId"],"promptHash":c["promptHash"]}), + ) +} + +/// Durable barrier. Even a process exit before POST cannot cause an automatic +/// resend. Recovery must query the original UUID through the existing journal. +pub(super) fn reserve_candidate(dir: &Path, slot: &str, candidate: &str, model: &str, prompt: &str, references: &[String], revision: u64) -> R { + label(candidate)?; + if model.trim().is_empty() + || model.chars().count() > 150 + || prompt.trim().is_empty() + || prompt.chars().count() > 12_000 + || references.is_empty() + || references.len() > 8 + { + return Err("模型、提示词或参考图无效".into()); + } + let mut seen = std::collections::HashSet::new(); + if references.iter().any(|s| !seen.insert(s) || !s.strip_prefix("local:").is_some_and(|id| id.len() == 64 && id.bytes().all(|b| b.is_ascii_hexdigit()))) { + return Err("图位参考图须为已保存的本机产品资产,不能使用外部文件路径".into()); + } + let _guard = sync::context_guard(); + let mut state = checked(dir, revision)?; + let item = field(&state, slot)?; + if item["kind"] != "image" { + return Err("文字字段不派发生图".into()); + } + if !item["candidates"][candidate].is_null() { + return Err("此候选已有原任务或结果,请只读核查;不会重发".into()); + } + let task = Uuid::new_v4().to_string(); + state["slots"][slot]["candidates"][candidate] = json!({"taskId":task,"modelId":model,"prompt":prompt,"promptHash":hash(prompt),"references":references,"phase":"dispatched","requestId":null,"artifact":null,"sha256":null,"createdAt":now(),"updatedAt":now()}); + persist(dir, state) +} + +pub(super) fn validate_dispatch(dir: &Path, input: &Value) -> R<()> { + let state = load(dir)?; + let slot = short(&input["slotId"], 64, "图位身份")?; + let candidate = short(&input["label"], 1, "候选身份")?; + if binding(&state, &slot, &candidate)? != *input || state["slots"][&slot]["candidates"][&candidate]["phase"] != "dispatched" { + return Err("原图位任务身份或状态已变化;未派发新请求".into()); + } + let id = input["taskId"].as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("原图位请求身份无效")?; + if dir.join("image-jobs").join(id).join("task.json").exists() { + return Err("原图位任务已有归档,只能查询原结果;不会重发".into()); + } + Ok(()) +} + +/// Second check immediately before POST, after images::job_save has created the +/// original archive. The initial validate_dispatch call must have passed while +/// that archive did not exist; this check is not permission to resume POST. +pub(super) fn validate_journal_binding(dir: &Path, task: &Value) -> R<()> { + let state = load(dir)?; + let b = &task["slotBinding"]; + let slot = short(&b["slotId"], 64, "图位身份")?; + let candidate = short(&b["label"], 1, "候选身份")?; + let c = &field(&state, &slot)?["candidates"][label(&candidate)?]; + if binding(&state, &slot, &candidate)? != *b + || task["id"] != b["taskId"] + || c["phase"] != "dispatched" + || !matches!(task["phase"].as_str(), Some("prepared" | "dispatched")) + || !task["requestId"].is_null() + || !task["upstreamTaskId"].is_null() + || c["modelId"] != task["modelId"] && c["modelId"] != task["modelKey"] + { + return Err("派发前原图位、模型或请求身份已变化;未发送新请求".into()); + } + let id = b["taskId"].as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("原候选请求身份无效")?; + let task_dir = dir.join("image-jobs").join(id); + let saved = read_json(&task_dir.join("task.json"))?; + if saved["id"] != task["id"] + || saved["slotBinding"] != *b + || !matches!(saved["phase"].as_str(), Some("prepared" | "dispatched")) + || !saved["requestId"].is_null() + || !saved["upstreamTaskId"].is_null() + || task_dir.join("response.json").exists() + || task_dir.join("generated.png").exists() + { + return Err("原候选任务已返回结果或已有其他状态,只能只读核查;不会重发".into()); + } + Ok(()) +} + +fn artifact(dir: &Path, task: &str, journal: &Value) -> R { + let task = Uuid::parse_str(task).map_err(|_| "候选原任务身份无效")?.to_string(); + let relative = format!("image-jobs/{task}/generated.png"); + let path = dir.join(&relative); + for part in [dir.join("image-jobs"), dir.join("image-jobs").join(&task), path.clone()] { + if part.symlink_metadata().map_err(|_| "候选图片或目录不可读取")?.file_type().is_symlink() { + return Err("候选图片路径含链接,未读取其他任务文件".into()); + } + } + let actual = fs::canonicalize(&path).map_err(|_| "候选图片缺失,原结果保留")?; + let expected_dir = fs::canonicalize(dir.join("image-jobs").join(&task)).map_err(|_| "候选图片目录缺失")?; + let project = fs::canonicalize(dir).map_err(|_| "原项目目录不可读取")?; + if !actual.starts_with(&project) + || actual.parent() != Some(expected_dir.as_path()) + || !expected_dir.starts_with(&project) + || path.symlink_metadata().map_err(|_| "候选图片不可读取")?.file_type().is_symlink() + { + return Err("候选图片不属于原项目任务".into()); + } + if let Some(declared) = journal["artifact"].as_str() + && fs::canonicalize(declared).map_err(|_| "候选原文件不可读取")? != actual + { + return Err("候选图片与原任务文件不一致".into()); + } + let meta = fs::metadata(&actual).map_err(|_| "候选图片不可读取")?; + if meta.len() == 0 || meta.len() > 20 * 1024 * 1024 { + return Err("候选图片大小无效".into()); + } + let mut bytes = Vec::new(); + fs::File::open(actual).map_err(|_| "候选图片读取失败")?.take(20 * 1024 * 1024 + 1).read_to_end(&mut bytes).map_err(|_| "候选图片读取中断")?; + if bytes.len() > 20 * 1024 * 1024 { + return Err("候选图片读取时超出20MiB,未回填".into()); + } + let digest = format!("{:x}", Sha256::digest(&bytes)); + if !hex(&journal["sha256"]) || journal["sha256"] != digest { + return Err("候选图片校验失败,未回填".into()); + } + Ok(json!({"taskId":task,"path":relative,"sha256":digest})) +} + +/// Feed the durable original task journal after dispatch or GET-only recovery. +/// A completed candidate is terminal: delayed unknown/running replies cannot +/// remove it, change its request identity, or select/apply it automatically. +pub(super) fn reconcile_candidate(dir: &Path, journal: &Value, revision: u64) -> R { + let _guard = sync::context_guard(); + let mut state = checked(dir, revision)?; + let b = &journal["slotBinding"]; + let slot = short(&b["slotId"], 64, "图位身份")?; + let candidate = short(&b["label"], 1, "候选身份")?; + if binding(&state, &slot, &candidate)? != *b || journal["id"] != b["taskId"] { + return Err("候选结果与原图位任务不一致,未写入其他图位".into()); + } + let old = &state["slots"][&slot]["candidates"][&candidate]; + if old["requestId"].is_string() && journal["requestId"].is_string() && old["requestId"] != journal["requestId"] { + return Err("候选原客户请求号不一致".into()); + } + if old["phase"] == "complete" { + return Ok(state); + } + let phase = match journal["phase"].as_str() { + Some("prepared") if journal["notDispatched"] == true => "failed", + Some("prepared" | "dispatched") => "dispatched", + Some("running") => "running", + Some("unknown") => "unknown", + Some("failed") => "failed", + Some("complete" | "applied") => "complete", + _ => return Err("候选原任务状态无效".into()), + }; + let result = if phase == "complete" { Some(artifact(dir, b["taskId"].as_str().ok_or("原任务身份缺失")?, journal)?) } else { None }; + let c = &mut state["slots"][&slot]["candidates"][&candidate]; + c["phase"] = json!(phase); + c["updatedAt"] = json!(now()); + if journal["requestId"].is_string() { + c["requestId"] = journal["requestId"].clone(); + } + if let Some(result) = result { + c["artifact"] = result["path"].clone(); + c["sha256"] = result["sha256"].clone(); + } + persist(dir, state) +} + +pub(super) fn select_candidate(dir: &Path, slot: &str, candidate: &str, revision: u64) -> R { + label(candidate)?; + let _guard = sync::context_guard(); + let mut state = checked(dir, revision)?; + let item = field(&state, slot)?; + let c = &item["candidates"][candidate]; + if item["kind"] != "image" || c["phase"] != "complete" { + return Err("此候选尚未成功,不能采用;原请求不会重发".into()); + } + artifact(dir, c["taskId"].as_str().ok_or("候选任务无效")?, &json!({"sha256":c["sha256"]}))?; + if item["selected"] == candidate { + return Ok(state); + } + state["slots"][slot]["selected"] = json!(candidate); + persist(dir, state) +} + +/// Explicit user reuse for a template-declared common source (for example the +/// same SKU product image in square and portrait outputs). This copies no pixel +/// file and dispatches no request. Adoption remains a separate selection step. +pub(super) fn reuse_candidate(dir: &Path, slot: &str, candidate: &str, source_slot: &str, source_candidate: &str, revision: u64) -> R { + label(candidate)?; + label(source_candidate)?; + let _guard = sync::context_guard(); + let mut state = checked(dir, revision)?; + let target = field(&state, slot)?; + if target["kind"] != "image" || !target["candidates"][candidate].is_null() { + return Err("目标候选已有任务或不是图片,未覆盖原记录".into()); + } + let source = &field(&state, source_slot)?["candidates"][source_candidate]; + if source["phase"] != "complete" { + return Err("来源候选尚未成功,不能复用".into()); + } + let target_def = definition(&state, slot)?; + let source_def = definition(&state, source_slot)?; + let group = target_def["reuseGroup"].as_str().filter(|s| !s.is_empty()).ok_or("此图位未明确声明共用素材范围,请独立制作")?; + if source_def["reuseGroup"] != group { + return Err("图位不在模板声明的同一复用范围内".into()); + } + artifact(dir, source["taskId"].as_str().ok_or("来源任务身份损坏")?, &json!({"sha256":source["sha256"]}))?; + let mut reused = source.clone(); + reused["reusedFrom"] = json!({"slotId":source_slot,"label":source_candidate,"binding":binding(&state,source_slot,source_candidate)?}); + reused["updatedAt"] = json!(now()); + state["slots"][slot]["candidates"][candidate] = reused; + persist(dir, state) +} +fn definition<'a>(state: &'a Value, slot: &str) -> R<&'a Value> { + let item = field(state, slot)?; + state["template"]["outputs"] + .as_array() + .into_iter() + .flatten() + .flat_map(|o| o["slots"].as_array().into_iter().flatten()) + .find(|s| s["fieldId"] == item["fieldId"]) + .ok_or_else(|| "图位字段约束缺失".into()) +} +fn text_constraints(field: &Value, text: &str) -> R<()> { + let lines = text.split('\n').count() as u64; + if text.chars().count() as u64 > field["maxChars"].as_u64().unwrap_or(0) + || lines > field["maxLines"].as_u64().unwrap_or(0) + || text.contains('\r') + || text.chars().any(|c| c.is_control() && c != '\n' && c != '\t') + { + return Err("文案超出此文字框的字数或行数约束,请精简后再回填".into()); + } + Ok(()) +} +#[cfg(test)] +pub(super) fn set_text(dir: &Path, slot: &str, text: &str, revision: u64) -> R { + let _guard = sync::context_guard(); + let mut state = checked(dir, revision)?; + let field = definition(&state, slot)?; + if field["kind"] != "text" { + return Err("图片图位不能填写文字".into()); + } + text_constraints(field, text)?; + if state["slots"][slot]["text"] == text { + return Ok(state); + } + state["slots"][slot]["text"] = json!(text); + persist(dir, state) +} + +pub(super) fn set_texts(dir: &Path, fields: &Value, baseline: &Value, revision: u64) -> R { + let _guard = sync::context_guard(); + let mut state = checked(dir, revision)?; + let fields = fields.as_array().filter(|a| !a.is_empty() && a.len() <= 80).ok_or("文字字段无效")?; + let baseline = baseline.as_array().filter(|a| a.len() == fields.len()).ok_or("文字编辑基线缺失,请重新读取")?; + let mut seen = std::collections::HashSet::new(); + let mut updates = Vec::with_capacity(fields.len()); + for item in fields { + let id = item["id"].as_str().ok_or("文字字段身份无效")?; + if !seen.insert(id) { + return Err("文字字段重复,原稿未修改".into()); + } + let prior = baseline.iter().find(|b| b["id"] == id).ok_or("文字基线不完整")?; + if state["slots"][id]["text"] != prior["value"] { + return Err("文字已有新修改,未用旧草稿覆盖;请重新读取后合并".into()); + } + let def = definition(&state, id)?; + if def["kind"] != "text" { + return Err("图片图位不能填写文字".into()); + } + text_constraints(def, item["value"].as_str().ok_or("文字内容无效")?)?; + updates.push((id.to_owned(), item["value"].clone())); + } + let slots = state.get_mut("slots").and_then(Value::as_object_mut).ok_or("图位记录无效,原稿未修改")?; + for (id, value) in updates { + let target = slots.get_mut(&id).and_then(Value::as_object_mut).ok_or("文字图位缺失,原稿未修改")?; + target.insert("text".into(), value); + } + persist(dir, state) +} + +/// Build a new output document, never mutate the active canvas. The application +/// hash captures human choice plus exact pixel bytes and text, not just task IDs. +pub(super) fn application(dir: &Path, output: &str, active_hash: &str) -> R { + let state = load(dir)?; + let pack = &state["template"]; + if pack["contentHash"] != active_hash { + return Err("当前项目模板版本不同,旧候选保留但不可自动回填".into()); + } + let target = pack["outputs"].as_array().into_iter().flatten().find(|o| o["id"] == output).ok_or("模板产出不存在")?; + let mut fields = Vec::new(); + for def in target["slots"].as_array().ok_or("模板字段缺失")? { + let key = slot_id(pack, def["fieldId"].as_str().ok_or("字段编号缺失")?); + let item = field(&state, &key)?; + let mut row = def.clone(); + row["slotId"] = json!(key); + if def["kind"] == "text" { + let text = item["text"].as_str().ok_or("字段文字损坏")?; + text_constraints(def, text)?; + row["text"] = json!(text); + } else { + let selected = item["selected"].as_str().ok_or_else(|| format!("请先采用{}的A/B候选", def["fieldId"]))?; + let c = &item["candidates"][label(selected)?]; + if c["phase"] != "complete" { + return Err("已选候选不再是成功结果,未回填".into()); + } + let mut image = artifact(dir, c["taskId"].as_str().ok_or("候选身份损坏")?, &json!({"sha256":c["sha256"]}))?; + image["label"] = json!(selected); + row["image"] = image; + } + fields.push(row); + } + let mut result = json!({"version":1,"templateId":pack["id"],"templateVersion":pack["version"],"templateHash":pack["contentHash"],"outputId":target["id"],"component":target["component"],"name":target["name"],"width":target["width"],"height":target["height"],"background":target.get("background").cloned().unwrap_or(json!("#ffffff")),"decorations":target.get("decorations").cloned().unwrap_or(json!([])),"fields":fields}); + result["applicationHash"] = json!(hash(&result.to_string())); + Ok(result) +} +pub(super) fn mark_applied(dir: &Path, output: &str, application_hash: &str, revision: u64) -> R { + let _guard = sync::context_guard(); + let mut state = checked(dir, revision)?; + let current = application(dir, output, state["template"]["contentHash"].as_str().ok_or("固定模板缺失")?)?; + if current["applicationHash"] != application_hash { + return Err("采用或文字已变化,未将旧回填标记为新版本".into()); + } + let mut selections = serde_json::Map::new(); + for field in current["fields"].as_array().ok_or("回填字段缺失")? { + if field["kind"] == "image" { + selections.insert(field["slotId"].as_str().ok_or("回填图位缺失")?.to_owned(), field["image"]["taskId"].clone()); + } + } + state["applied"][output] = json!({"applicationHash":application_hash,"selections":selections,"at":now()}); + persist(dir, state) +} + +#[cfg(test)] +mod tests { + use super::*; + struct Temp(PathBuf); + impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + fn project() -> Temp { + let base = std::env::var_os("JIANSUN_SLOT_TEST_ROOT").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); + let dir = base.join(format!("jiansun-slots-test-{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + write_json(&dir.join("context.json"), &json!({"id":Uuid::new_v4().to_string()})).unwrap(); + Temp(dir) + } + fn pack() -> Value { + let mut p = json!({"schemaVersion":1,"id":"GJ-TEST","version":"1","name":"字段约束测试,不是产品样张","brandId":"brand","categoryId":"category","outputs":[{"id":"S01","component":"detail","name":"首屏","width":1440,"height":2160,"slots":[{"fieldId":"S01-I01","kind":"image","crop":"cover","frame":{"x":0,"y":0,"width":1440,"height":2160}},{"fieldId":"S01-T01","kind":"text","frame":{"x":40,"y":30,"width":1200,"height":100},"font":{"family":"Noto Sans SC","size":48,"weight":500,"color":"#242424"},"maxLines":1,"maxChars":12}]}]}); + p["contentHash"] = json!(pack_hash(&p).unwrap()); + p + } + fn revision(s: &Value) -> u64 { + s["revision"].as_u64().unwrap() + } + fn reserved(dir: &Path, p: &Value, letter: &str) -> (String, Value) { + let s = if dir.join(FILE).exists() { load(dir).unwrap() } else { install(dir, p, 0).unwrap() }; + let slot = slot_id(p, "S01-I01"); + let s = reserve_candidate(dir, &slot, letter, "nano-banana-2", "本测试不调用模型", &[format!("local:{}", "a".repeat(64))], revision(&s)).unwrap(); + (slot, s) + } + fn complete(dir: &Path, state: &Value, slot: &str, letter: &str, bytes: &[u8]) -> Value { + let b = binding(state, slot, letter).unwrap(); + let task = b["taskId"].as_str().unwrap(); + let path = dir.join("image-jobs").join(task).join("generated.png"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, bytes).unwrap(); + json!({"id":task,"slotBinding":b,"phase":"complete","requestId":Uuid::new_v4().to_string(),"artifact":path.to_string_lossy(),"sha256":format!("{:x}",Sha256::digest(bytes))}) + } + #[test] + fn pack_identity_and_geometry_are_verified() { + let p = pack(); + validate_pack(&p).unwrap(); + let mut forged = p.clone(); + forged["outputs"][0]["slots"][0]["frame"]["width"] = json!(2000); + assert!(validate_pack(&forged).is_err()); + forged["contentHash"] = json!(pack_hash(&forged).unwrap()); + assert!(validate_pack(&forged).unwrap_err().contains("超出")); + let mut next = p.clone(); + next["version"] = json!("2"); + next["contentHash"] = json!(pack_hash(&next).unwrap()); + assert_ne!(slot_id(&p, "S01-I01"), slot_id(&next, "S01-I01")); + } + #[test] + fn shipped_gujia_pack_preserves_distinct_outputs_and_explicit_reuse() { + let p: Value = serde_json::from_str(include_str!("../../../../../lib/desktop-client/template-packs/gj-desk-dnz0385-native-v1.json")).unwrap(); + validate_pack(&p).unwrap(); + assert_eq!(p["source"]["templateId"], "GJ-DESK-DNZ0385"); + let outputs = p["outputs"].as_array().unwrap(); + for component in ["detail", "main_images", "sku_images"] { + assert!(outputs.iter().any(|o| o["component"] == component)); + } + let slots: Vec<&Value> = outputs.iter().flat_map(|o| o["slots"].as_array().unwrap()).collect(); + let group = |id: &str| slots.iter().find(|s| s["fieldId"] == id).unwrap()["reuseGroup"].clone(); + assert_eq!(group("K01-I01"), group("K02-I01")); + assert_eq!(group("K01-I02"), group("K02-I02")); + assert_ne!(group("K01-I01"), group("K01-I02")); + } + #[test] + fn no_silent_template_upgrade_or_revision_overwrite() { + let d = project(); + let p = pack(); + let s = install(&d.0, &p, 0).unwrap(); + assert!(set_text(&d.0, &slot_id(&p, "S01-T01"), "产品", 0).is_err()); + let mut next = p.clone(); + next["version"] = json!("2"); + next["contentHash"] = json!(pack_hash(&next).unwrap()); + assert!(install(&d.0, &next, revision(&s)).is_err()); + assert_eq!(load(&d.0).unwrap(), s); + } + #[test] + fn unknown_restart_never_reserves_original_again_but_keeps_independent_b() { + let d = project(); + let p = pack(); + let (slot, s) = reserved(&d.0, &p, "A"); + let b = binding(&s, &slot, "A").unwrap(); + let s = reconcile_candidate(&d.0, &json!({"id":b["taskId"],"slotBinding":b,"phase":"unknown","requestId":null}), revision(&s)).unwrap(); + assert_eq!(load(&d.0).unwrap(), s); + assert!( + reserve_candidate(&d.0, &slot, "A", "nano-banana-2", "不要重发", &[format!("local:{}", "a".repeat(64))], revision(&s)) + .unwrap_err() + .contains("不会重发") + ); + let (_, s2) = reserved(&d.0, &p, "B"); + assert_ne!(s["slots"][&slot]["candidates"]["A"]["taskId"], s2["slots"][&slot]["candidates"]["B"]["taskId"]); + assert!(application(&d.0, "S01", p["contentHash"].as_str().unwrap()).is_err()); + } + #[test] + fn paid_dispatch_barrier_rejects_archived_or_changed_identity() { + let d = project(); + let p = pack(); + let (slot, s) = reserved(&d.0, &p, "A"); + let b = binding(&s, &slot, "A").unwrap(); + validate_dispatch(&d.0, &b).unwrap(); + let mut changed = b.clone(); + changed["promptHash"] = json!("wrong"); + assert!(validate_dispatch(&d.0, &changed).is_err()); + let path = d.0.join("image-jobs").join(b["taskId"].as_str().unwrap()).join("task.json"); + write_json(&path, &json!({"phase":"unknown"})).unwrap(); + assert!(validate_dispatch(&d.0, &b).unwrap_err().contains("不会重发")); + } + #[test] + fn journal_barrier_checks_paid_model_and_original_archive() { + let d = project(); + let p = pack(); + let (slot, s) = reserved(&d.0, &p, "A"); + let b = binding(&s, &slot, "A").unwrap(); + let mut task = json!({"id":b["taskId"],"slotBinding":b,"modelId":"nano-banana-2","phase":"dispatched","requestId":null,"upstreamTaskId":null}); + let path = d.0.join("image-jobs").join(task["id"].as_str().unwrap()).join("task.json"); + write_json(&path, &task).unwrap(); + validate_journal_binding(&d.0, &task).unwrap(); + task["modelId"] = json!("different-paid-model"); + assert!(validate_journal_binding(&d.0, &task).is_err()); + task["modelId"] = json!("nano-banana-2"); + write_json(&path.parent().unwrap().join("response.json"), &json!({"data":[]})).unwrap(); + assert!(validate_journal_binding(&d.0, &task).is_err()); + } + #[test] + fn confirmed_before_post_failure_is_visible_without_permitting_automatic_retry() { + let d = project(); + let p = pack(); + let (slot, s) = reserved(&d.0, &p, "A"); + let b = binding(&s, &slot, "A").unwrap(); + let s = reconcile_candidate(&d.0, &json!({"id":b["taskId"],"slotBinding":b,"phase":"prepared","notDispatched":true,"requestId":null}), revision(&s)) + .unwrap(); + assert_eq!(s["slots"][&slot]["candidates"]["A"]["phase"], "failed"); + assert!(reserve_candidate(&d.0, &slot, "A", "nano-banana-2", "不要重发", &[format!("local:{}", "a".repeat(64))], revision(&s)).is_err()); + } + #[test] + fn candidate_selection_is_distinct_from_generation_and_survives_restart() { + let d = project(); + let p = pack(); + let (slot, s) = reserved(&d.0, &p, "A"); + let journal = complete(&d.0, &s, &slot, "A", b"test pixels A"); + let s = reconcile_candidate(&d.0, &journal, revision(&s)).unwrap(); + assert!(s["slots"][&slot]["selected"].is_null()); + assert!(application(&d.0, "S01", p["contentHash"].as_str().unwrap()).is_err()); + let s = select_candidate(&d.0, &slot, "A", revision(&s)).unwrap(); + let s = set_text(&d.0, &slot_id(&p, "S01-T01"), "四腿支撑结构", revision(&s)).unwrap(); + let app = application(&d.0, "S01", p["contentHash"].as_str().unwrap()).unwrap(); + assert_eq!(app["fields"][1]["text"], "四腿支撑结构"); + assert_eq!(app["fields"][0]["image"]["label"], "A"); + assert!(load(&d.0).unwrap()["applied"].as_object().unwrap().is_empty()); + let s = mark_applied(&d.0, "S01", app["applicationHash"].as_str().unwrap(), revision(&s)).unwrap(); + assert_eq!(load(&d.0).unwrap(), s); + } + #[test] + fn switching_ab_changes_application_without_mutating_original_pixels_or_tasks() { + let d = project(); + let p = pack(); + let (slot, s) = reserved(&d.0, &p, "A"); + let a = complete(&d.0, &s, &slot, "A", b"pixels A"); + reconcile_candidate(&d.0, &a, revision(&s)).unwrap(); + let (_, s) = reserved(&d.0, &p, "B"); + let b = complete(&d.0, &s, &slot, "B", b"pixels B"); + let s = reconcile_candidate(&d.0, &b, revision(&s)).unwrap(); + let s = select_candidate(&d.0, &slot, "A", revision(&s)).unwrap(); + let first = application(&d.0, "S01", p["contentHash"].as_str().unwrap()).unwrap(); + let s = select_candidate(&d.0, &slot, "B", revision(&s)).unwrap(); + let second = application(&d.0, "S01", p["contentHash"].as_str().unwrap()).unwrap(); + assert_ne!(first["applicationHash"], second["applicationHash"]); + assert_eq!(s["slots"][&slot]["candidates"]["A"]["taskId"], a["id"]); + assert_eq!(s["slots"][&slot]["candidates"]["B"]["taskId"], b["id"]); + assert_eq!(fs::read(a["artifact"].as_str().unwrap()).unwrap(), b"pixels A"); + assert_eq!(fs::read(b["artifact"].as_str().unwrap()).unwrap(), b"pixels B"); + } + #[test] + fn delayed_unknown_does_not_remove_success_and_identity_cannot_cross_slots() { + let d = project(); + let p = pack(); + let (slot, s) = reserved(&d.0, &p, "A"); + let mut journal = complete(&d.0, &s, &slot, "A", b"test pixels"); + let s = reconcile_candidate(&d.0, &journal, revision(&s)).unwrap(); + journal["phase"] = json!("unknown"); + assert_eq!(reconcile_candidate(&d.0, &journal, revision(&s)).unwrap(), s); + journal["slotBinding"]["label"] = json!("B"); + assert!(reconcile_candidate(&d.0, &journal, revision(&s)).is_err()); + } + #[test] + fn corrupt_pixels_wrong_version_and_stale_apply_are_blocked() { + let d = project(); + let p = pack(); + let (slot, s) = reserved(&d.0, &p, "A"); + let j = complete(&d.0, &s, &slot, "A", b"test pixels"); + let s = reconcile_candidate(&d.0, &j, revision(&s)).unwrap(); + let s = select_candidate(&d.0, &slot, "A", revision(&s)).unwrap(); + let app = application(&d.0, "S01", p["contentHash"].as_str().unwrap()).unwrap(); + assert!(application(&d.0, "S01", &"0".repeat(64)).is_err()); + let s = set_text(&d.0, &slot_id(&p, "S01-T01"), "改字", revision(&s)).unwrap(); + assert!(mark_applied(&d.0, "S01", app["applicationHash"].as_str().unwrap(), revision(&s)).is_err()); + fs::write(j["artifact"].as_str().unwrap(), b"changed pixels").unwrap(); + assert!(application(&d.0, "S01", p["contentHash"].as_str().unwrap()).unwrap_err().contains("校验")); + } + #[test] + fn text_line_character_and_reference_constraints_preserve_draft() { + let d = project(); + let p = pack(); + let s = install(&d.0, &p, 0).unwrap(); + let t = slot_id(&p, "S01-T01"); + assert!(set_text(&d.0, &t, "第一行\n第二行", revision(&s)).is_err()); + assert!(set_text(&d.0, &t, &"字".repeat(13), revision(&s)).is_err()); + assert!(reserve_candidate(&d.0, &slot_id(&p, "S01-I01"), "A", "nano", "画面", &["C:/other-project/private.png".into()], revision(&s)).is_err()); + assert_eq!(load(&d.0).unwrap(), s); + } + #[test] + fn main_and_sku_are_distinct_outputs_and_only_declared_reuse_avoids_redispatch() { + let d = project(); + let mut p = pack(); + p["outputs"][0]["slots"][0]["reuseGroup"] = json!("SKU01-product"); + let mut main = p["outputs"][0].clone(); + main["id"] = json!("M01"); + main["component"] = json!("main_images"); + main["height"] = json!(1440); + main["slots"] = json!([{"fieldId":"M01-I01","kind":"image","crop":"cover","frame":{"x":0,"y":0,"width":1440,"height":1440}}]); + let mut sku = main.clone(); + sku["id"] = json!("K01"); + sku["component"] = json!("sku_images"); + sku["slots"][0]["fieldId"] = json!("K01-I01"); + sku["slots"][0]["reuseGroup"] = json!("SKU01-product"); + p["outputs"].as_array_mut().unwrap().extend([main, sku]); + p["contentHash"] = json!(pack_hash(&p).unwrap()); + let (slot, s) = reserved(&d.0, &p, "A"); + let j = complete(&d.0, &s, &slot, "A", b"product pixels"); + let s = reconcile_candidate(&d.0, &j, revision(&s)).unwrap(); + let sku = slot_id(&p, "K01-I01"); + let main = slot_id(&p, "M01-I01"); + assert!(reuse_candidate(&d.0, &main, "A", &slot, "A", revision(&s)).is_err()); + let s = reuse_candidate(&d.0, &sku, "A", &slot, "A", revision(&s)).unwrap(); + assert!(s["slots"][&sku]["selected"].is_null()); + assert_eq!(s["slots"][&sku]["candidates"]["A"]["taskId"], j["id"]); + let _s = select_candidate(&d.0, &sku, "A", revision(&s)).unwrap(); + assert_eq!(application(&d.0, "K01", p["contentHash"].as_str().unwrap()).unwrap()["component"], "sku_images"); + assert!(application(&d.0, "M01", p["contentHash"].as_str().unwrap()).is_err()); + } + #[test] + fn deleted_project_is_not_recreated_by_late_task_result() { + let d = project(); + let p = pack(); + let (slot, s) = reserved(&d.0, &p, "A"); + let j = complete(&d.0, &s, &slot, "A", b"pixels"); + fs::remove_file(d.0.join("context.json")).unwrap(); + assert!(reconcile_candidate(&d.0, &j, revision(&s)).is_err()); + assert!(!d.0.join("context.json").exists()); + } +} diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_template_workflow.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_template_workflow.rs new file mode 100644 index 00000000..fb9154dc --- /dev/null +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_template_workflow.rs @@ -0,0 +1,923 @@ +//! Account-scoped template candidates. Every image POST remains in the existing 98k adapter. +use super::*; + +fn rev(value: &Value) -> u64 { + value["revision"].as_u64().unwrap_or(0) +} +fn mapping(doc: &Document) -> Option { + doc.metadata.text.iter().find(|(k, _)| k == "jiansun.slot-mapping.v1").and_then(|(_, v)| serde_json::from_str(v).ok()) +} +fn output_storage_key(pack: &Value, component: &str, document: Option<&str>) -> R { + if !["detail", "main_images", "sku_images"].contains(&component) { + return Err("请选择输出类型".into()); + } + if let Some(document) = document { + if !pack["outputs"].as_array().is_some_and(|outputs| outputs.iter().any(|output| output["id"] == document && output["component"] == component)) { + return Err("当前屏不属于此输出类型,未回填".into()); + } + // Package identifiers are not filesystem paths. A stable digest keeps + // single-output drafts separate from a combined component and other screens. + return Ok(format!("{component}-output-{}", hash(document))); + } + Ok(component.to_owned()) +} +fn output_applications(dir: &Path, pack: &Value, component: &str, document: Option<&str>) -> R> { + output_storage_key(pack, component, document)?; + let hash = pack["contentHash"].as_str().ok_or("模板版本缺失")?; + let applications = pack["outputs"] + .as_array() + .ok_or("模板输出缺失")? + .iter() + .filter(|output| output["component"] == component && document.is_none_or(|id| output["id"] == id)) + .map(|output| template_slots::application(dir, output["id"].as_str().unwrap_or(""), hash)) + .collect::>>()?; + if applications.is_empty() { + return Err("模板没有此类输出".into()); + } + Ok(applications) +} +fn edited_mapping_matches(meta: &Value, pack: &Value, component: &str, document: Option<&str>, apps: &[Value]) -> bool { + if meta["templateHash"] != pack["contentHash"] || meta["component"] != component { + return false; + } + if let Some(document) = document { + return apps.len() == 1 && meta["outputId"] == document && meta["applicationHash"] == apps[0]["applicationHash"]; + } + meta["outputId"].is_null() + && meta["outputs"].as_array().is_some_and(|rows| { + rows.len() == apps.len() + && apps.iter().all(|app| rows.iter().any(|row| row["outputId"] == app["outputId"] && row["applicationHash"] == app["applicationHash"])) + }) +} +fn materialize_output(dir: &Path, pack: &Value, component: &str, document: Option<&str>, apps: &[Value]) -> R { + let key = output_storage_key(pack, component, document)?; + let base = dir.join("template-outputs"); + let edited = base.join(format!("{key}-edited.pcraft")); + let edited_meta = base.join(format!("{key}-edited.json")); + if edited.exists() || edited_meta.exists() { + if !edited.is_file() || !edited_meta.is_file() { + return Err("当前输出编辑稿保存不完整,原稿已保留".into()); + } + let meta = read_json(&edited_meta)?; + if !edited_mapping_matches(&meta, pack, component, document, apps) { + return Err(if document.is_some() { + "当前屏已有手动编辑,原稿已保留;图片或文字已变化,未覆盖" + } else { + "当前输出已有手动编辑,原稿已保留;图片或文字已变化,未覆盖" + } + .into()); + } + let native = photocraft_format::load_path(&edited).map_err(|_| "已编辑输出无法读取,未用默认版替换")?; + if mapping(&native).as_ref() != Some(&meta) { + return Err("编辑稿与保存关联不一致,原稿已保留".into()); + } + return Ok(native); + } + if document.is_some() { + slot_canvas::from_application(apps.first().ok_or("当前屏回填内容缺失")?, dir) + } else { + slot_canvas::from_component(apps, dir) + } +} +pub(super) fn save_edited_output(dir: &Path, doc: &Document, bytes: &[u8]) -> R<()> { + let Some(meta) = mapping(doc) else { + return Ok(()); + }; + let Some(component) = meta["component"].as_str().filter(|s| ["detail", "main_images", "sku_images"].contains(s)) else { + return Ok(()); + }; + let state = template_slots::load(dir)?; + if state["template"]["contentHash"] != meta["templateHash"] { + return Err("输出模板版本与项目不一致,未覆盖制作文件".into()); + } + let document = meta["outputId"].as_str(); + let key = output_storage_key(&state["template"], component, document)?; + let base = dir.join("template-outputs"); + photocraft_format::atomic_write(&base.join(format!("{key}-edited.pcraft")), bytes).map_err(|_| "当前输出编辑未能保存,请保留画布")?; + write_json(&base.join(format!("{key}-edited.json")), &meta) +} +pub(super) fn validate_inputs(dir: &Path) -> R<()> { + let c = creation::load(dir)?; + let fixed = read_json(&dir.join("template-input.json"))?; + if c["plan"]["status"] != "confirmed" || fixed["planHash"] != creation::plan_hash(&c)? || c["plan"]["confirmedInput"] != fixed["planHash"] { + return Err("产品资料、卖点或页面方案已变化,旧图位结果保留;请另建制作批次".into()); + } + Ok(()) +} +fn field<'a>(pack: &'a Value, id: &str) -> R<(&'a Value, &'a Value)> { + for output in pack["outputs"].as_array().ok_or("模板输出缺失")? { + for item in output["slots"].as_array().ok_or("模板字段缺失")? { + if template_slots::slot_id(pack, item["fieldId"].as_str().ok_or("模板字段身份无效")?) == id { + return Ok((output, item)); + } + } + } + Err("模板图位不存在".into()) +} +fn image_prompt(slot: &Value, output: &Value, plan: &[Value]) -> String { + format!( + "图位 {}({}),用途 {}。画幅 {}×{}。{}\n图位要求:{},产品用途:{}\n本次只制作当前图位,构图以当前图位、输出用途和画幅为主。下列完整确认方案只作为全项目参考,未建立图位与方案屏的逐项对应,不按屏号猜测映射,也不把其他屏专属的人物、道具、镜头或场景一并搬到当前图位。跨屏共用的品牌调性、光线风格和产品外观约束必须继承;原方案中适用的禁止增绘、禁止改动外观和无字要求不能省略或反向改写。纯产品/SKU图仍按本图位的纯展示用途构图。\n本图位只使用产品参考的真实外观;不继承模板示例的材质、参数或设备。所有生成素材保持无字,文案由独立文字层承担。\n用户已确认的完整方案(含各屏原始imagePrompt,参考上下文):{}", + slot["fieldId"], + slot["name"].as_str().or(slot["label"].as_str()).unwrap_or("产品外观"), + output["name"], + slot["frame"]["width"], + slot["frame"]["height"], + slot["prompt"].as_str().unwrap_or("保持产品完整轮廓,统一真实摄影与柔和光影"), + slot["requirements"], + slot["role"], + json!(plan) + ) +} +fn generation_instructions(input: &Value) -> R> { + let Some(value) = input.get("instructions") else { return Ok(None) }; + let text = value.as_str().ok_or("调整要求须为纯文本")?; + if text.chars().count() > 2000 || text.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t')) { + return Err("调整要求最多2000字,且不能包含控制字符".into()); + } + Ok((!text.trim().is_empty()).then_some(text.trim())) +} +fn adjusted_image_prompt(slot: &Value, output: &Value, plan: &[Value], instructions: Option<&str>) -> String { + let mut prompt = image_prompt(slot, output, plan); + if let Some(instructions) = instructions { + prompt.push_str("\n\n本图位本次人工外观核对与调整要求(仅用于本次新候选,不改动历史候选):\n"); + prompt.push_str(instructions); + prompt.push_str("\n本次人工核对明确的产品外观优先于历史方案中未经参考图证实的推断。仍须忠实使用本产品参考图,保持已核实的结构、材质、颜色和比例;不能把缺口或遮挡推断为新增部件。继续遵守本图位用途、品牌调性、禁止增绘和无字约束,文案只由独立文字层承担。"); + } + prompt +} +fn reuse_action(dir: &Path, state: &Value, input: &Value) -> R { + validate_inputs(dir)?; + let creation = creation::load(dir)?; + if state["template"]["brandId"] != creation["brief"]["brandId"] || state["template"]["categoryId"] != creation["brief"]["categoryId"] { + return Err("项目品牌或品类已变化,旧模板不能复用新结果".into()); + } + let target = input["slotId"].as_str().ok_or("请选择目标图位")?; + let source = input["sourceSlotId"].as_str().ok_or("请选择来源图位")?; + if target == source { + return Err("不能把同一图位的已有结果复制为另一候选".into()); + } + let source_id = input["sourceCandidateId"].as_str().filter(|id| Uuid::parse_str(id).is_ok()).ok_or("来源候选身份无效")?; + if ["A", "B"].iter().any(|letter| state["slots"][target]["candidates"][*letter]["taskId"] == source_id) { + return Err("此原结果已在目标图位中,不能作为第二份候选".into()); + } + let source_letter = + ["A", "B"].into_iter().find(|letter| state["slots"][source]["candidates"][*letter]["taskId"] == source_id).ok_or("来源图位与原候选身份不匹配")?; + let target_letter = input["candidate"].as_str().ok_or("请选择A或B")?; + // The core validates the declared reuseGroup, complete source, empty target, + // original pixel hash and CAS revision. It reuses the original UUID/file; + // no new task is reserved, dispatched, selected or applied here. + template_slots::reuse_candidate(dir, target, target_letter, source, source_letter, rev(state)) +} +fn other_pending(dir: &Path, creation: &Value, state: &Value, except: Option<&Value>) -> R { + if images::pending(dir)? { + let task = read_json(&dir.join("image-task.json"))?; + if !except.is_some_and(|b| task["id"] == b["taskId"] && task["slotBinding"] == *b && task["phase"] == "dispatched") { + return Ok(true); + } + } + if creation["generation"]["pending"].is_object() { + return Ok(true); + } + let legacy = dir.join("task.json"); + if legacy.exists() && !matches!(read_json(&legacy)?["phase"].as_str(), Some("complete" | "applied" | "failed" | "rejected" | "not_dispatched")) { + return Ok(true); + } + if dir.parent().and_then(Path::file_name).is_some_and(|name| name == "projects") + && let (Some(account), Some(project)) = (dir.parent().and_then(Path::parent), dir.file_name()) + && !pi::unresolved(&account.join("pi-validation").join(project).join("requests"))?.is_empty() + { + return Ok(true); + } + for (slot, row) in state["slots"].as_object().into_iter().flatten() { + for (label, candidate) in row["candidates"].as_object().into_iter().flatten().filter(|(_, c)| c.is_object()) { + if !matches!(candidate["phase"].as_str(), Some("complete" | "failed")) { + let own = except.is_some_and(|b| { + b["slotId"] == slot.as_str() + && b["label"] == label.as_str() + && b["taskId"] == candidate["taskId"] + && candidate["phase"] == "dispatched" + && template_slots::binding(state, slot, label).is_ok_and(|actual| actual == *b) + }); + if !own { + return Ok(true); + } + } + } + } + Ok(false) +} +fn confirmed(dir: &Path, creation: &Value, state: &Value) -> bool { + state["template"].is_object() + && validate_inputs(dir).is_ok() + && creation["brief"]["brandId"] == state["template"]["brandId"] + && creation["brief"]["categoryId"] == state["template"]["categoryId"] +} +fn capabilities(dir: &Path, creation: &Value, state: &Value) -> R { + let other = other_pending(dir, creation, state, None)?; + let pending = other || !creation::pending_text_tasks(dir)?.is_empty(); + let ready = confirmed(dir, creation, state); + let image = ready && !other && creation::slot_image_text_allowed(dir, creation, &state["template"])?; + Ok(json!({"pending":pending,"capabilities":{"imageDispatchAllowed":image,"copyDispatchAllowed":ready&&!pending,"localApplyAllowed":image}})) +} +/// Only a verified immutable slot binding can ignore its OWN pre-dispatch +/// reservation. Every other image/Agent task remains a dispatch barrier. +pub(super) fn validate_image_context(dir: &Path, except: Option<&Value>) -> R<()> { + let creation = creation::load(dir)?; + let state = template_slots::load(dir)?; + if !confirmed(dir, &creation, &state) { + return Err("请先核对当前资料、确认方案和固定模板,再制作图位".into()); + } + if let Some(binding) = except { + let slot = binding["slotId"].as_str().ok_or("图位原任务身份缺失")?; + let label = binding["label"].as_str().ok_or("图位原候选身份缺失")?; + if template_slots::binding(&state, slot, label)? != *binding { + return Err("图位原任务关联已变化,未发送请求".into()); + } + } + if other_pending(dir, &creation, &state, except)? || !creation::slot_image_text_allowed(dir, &creation, &state["template"])? { + return Err("有依赖的原请求待核查,未发送新图位请求".into()); + } + Ok(()) +} +pub(super) fn snapshot(dir: &Path, creation: &Value) -> R { + project_not_trashed(dir)?; + // A newly created, unsaved document has no optional template workflow yet. + // Read it as an empty stage without creating context or reviving a project. + // Existing records still pass the strict original-project and JSON checks. + if !dir.join("template-slots.json").try_exists().map_err(|_| "模板图位记录无法核对,请保留原项目")? { + let mut result = json!({"revision":0,"confirmed":false,"slots":[]}); + let policy = capabilities(dir, creation, &json!({}))?; + result["capabilities"] = policy["capabilities"].clone(); + result["pending"] = policy["pending"].clone(); + return Ok(result); + } + let mut state = template_slots::load(dir)?; + // Reconcile only unresolved journals already present on disk. A background + // GET recovery or a prior process may have completed them; never issue a POST. + let unresolved: Vec = state["slots"] + .as_object() + .into_iter() + .flat_map(|m| m.values()) + .flat_map(|s| s["candidates"].as_object().into_iter().flat_map(|m| m.values())) + .filter(|c| matches!(c["phase"].as_str(), Some("dispatched" | "unknown" | "running"))) + .cloned() + .collect(); + for c in unresolved.into_iter().take(64) { + let Some(id) = c["taskId"].as_str().filter(|s| Uuid::parse_str(s).is_ok()) else { continue }; + let file = dir.join("image-jobs").join(id).join("task.json"); + if file.is_file() { + let journal = read_json(&file)?; + if journal["phase"] != c["phase"] || journal["requestId"] != c["requestId"] { + state = template_slots::reconcile_candidate(dir, &journal, rev(&state))?; + } + } + } + if state["template"].is_null() { + let policy = capabilities(dir, creation, &state)?; + return Ok(json!({"revision":0,"confirmed":false,"slots":[],"capabilities":policy["capabilities"],"pending":policy["pending"]})); + } + let pack = &state["template"]; + let confirmed = confirmed(dir, creation, &state); + let policy = capabilities(dir, creation, &state)?; + let mut slots = Vec::new(); + for output in pack["outputs"].as_array().ok_or("模板输出缺失")? { + let texts:Vec=output["slots"].as_array().ok_or("模板字段缺失")?.iter().filter(|f|f["kind"]=="text").map(|f| { + let id=template_slots::slot_id(pack,f["fieldId"].as_str().unwrap_or("")); + json!({"id":id,"fieldId":f["fieldId"],"label":f.get("label").unwrap_or(&f["fieldId"]),"value":state["slots"][&id]["text"],"maxChars":f["maxChars"],"maxLines":f["maxLines"]}) + }).collect(); + for item in output["slots"].as_array().ok_or("模板字段缺失")?.iter().filter(|f| f["kind"] == "image") { + let id = template_slots::slot_id(pack, item["fieldId"].as_str().ok_or("图位身份无效")?); + let row = &state["slots"][&id]; + let mut candidates = Vec::new(); + for letter in ["A", "B"] { + let c = &row["candidates"][letter]; + if !c.is_object() { + continue; + } + candidates.push(json!({"id":c["taskId"],"variant":letter,"status":c["phase"],"requestId":c["requestId"],"error":c["message"],"reusedFrom":c["reusedFrom"],"previewUri":format!("bytes://template-candidate-{}",c["taskId"].as_str().unwrap_or(""))})); + } + let selected = row["selected"].as_str().map(|label| row["candidates"][label]["taskId"].clone()).unwrap_or(Value::Null); + slots.push(json!({"id":id,"fieldId":item["fieldId"],"outputId":output["component"],"documentId":output["id"],"required":true,"reuseGroup":item["reuseGroup"],"label":format!("{} · {}",output["name"].as_str().unwrap_or(""),item["name"].as_str().or(item["label"].as_str()).unwrap_or(item["fieldId"].as_str().unwrap_or(""))),"width":item["frame"]["width"],"height":item["frame"]["height"],"fields":texts,"candidates":candidates,"selectedCandidateId":selected,"appliedCandidateId":state["applied"][output["id"].as_str().unwrap_or("")]["selections"][&id]})); + } + } + let outputs = pack["outputs"].as_array().ok_or("模板输出缺失")?.iter().map(|output| { + let document=output["id"].as_str().ok_or("输出身份缺失")?; + let component=output["component"].as_str().ok_or("输出类型缺失")?; + let images:Vec<_>=slots.iter().filter(|slot|slot["documentId"]==document).collect(); + let selected=images.iter().filter(|slot| { + slot["selectedCandidateId"].is_string() && slot["candidates"].as_array().is_some_and(|rows|rows.iter().any(|candidate|candidate["id"]==slot["selectedCandidateId"]&&candidate["status"]=="complete")) + }).count(); + let pending=images.iter().any(|slot|slot["candidates"].as_array().is_some_and(|rows|rows.iter().any(|candidate|!matches!(candidate["status"].as_str(),Some("complete"|"failed"))))); + let key=output_storage_key(pack,component,Some(document))?; + let base=dir.join("template-outputs"); + Ok(json!({"documentId":document,"outputId":component,"name":output["name"],"requiredImages":images.len(),"selectedImages":selected,"ready":confirmed&&!pending&&selected==images.len(),"hasEditedDraft":base.join(format!("{key}-edited.pcraft")).exists()||base.join(format!("{key}-edited.json")).exists(),"appliedApplicationHash":state["applied"][document]["applicationHash"]})) + }).collect::>>()?; + let copy_ready = creation["templateCopy"]["fields"].is_array() + && creation["templateCopy"]["templateInput"]["templateHash"] == pack["contentHash"] + && creation["templateCopy"]["taskId"] != state["copyApplied"]; + let copy_fields:Vec=creation["templateCopy"]["fields"].as_array().into_iter().flatten().map(|f|{ + let def=field(pack,f["id"].as_str().unwrap_or("")).ok(); + json!({"id":f["id"],"text":f["text"],"fieldId":def.map(|(_,d)|d["fieldId"].clone()).unwrap_or(Value::Null),"label":def.and_then(|(_,d)|d.get("label").or(d.get("name"))).cloned().unwrap_or(Value::Null)}) + }).collect(); + Ok( + json!({"revision":state["revision"],"templateId":pack["id"],"templateVersion":pack["version"],"templateHash":pack["contentHash"],"confirmed":confirmed,"copyReady":copy_ready,"copyFields":copy_fields,"copyWarnings":creation["templateCopy"]["warnings"],"slots":slots,"outputs":outputs,"capabilities":policy["capabilities"],"pending":policy["pending"]}), + ) +} + +impl Backend { + pub(super) fn slot_result(&self, doc: u64, dir: &Path) -> R { + let c = creation::load(dir)?; + let workflow = snapshot(dir, &c)?; + Ok(json!({"doc":doc,"accountId":self.auth["account"]["id"],"origin":self.origin,"workflow":workflow,"creation":c,"pending":workflow["pending"]})) + } + pub(super) fn slot_action(&mut self, method: &str, input: &Value, events: &mpsc::Sender, ctx: &egui::Context) -> R { + // Validate optional text before any candidate reservation or transport. + // Recovery continues to use the original immutable prompt and task ID. + let instructions = if method == "slot-generate" { generation_instructions(input)? } else { None }; + let doc = input["doc"].as_u64().ok_or("请先打开项目")?; + self.ensure_owner(doc)?; + let dir = self.project_dir(doc)?; + original_project_present(&dir)?; + if method == "slot-read" { + return self.slot_result(doc, &dir); + } + let mut state = template_slots::load(&dir)?; + if input["slotRevision"].as_u64() != Some(rev(&state)) { + return Err("图位已有新版本,请重新读取;候选已保留".into()); + } + let mut c = creation::load(&dir)?; + if method == "slot-install" { + self.validate()?; + if !state["template"].is_null() { + return self.slot_result(doc, &dir); + } + if c["plan"]["status"] != "confirmed" || c["plan"]["confirmedInput"] != creation::plan_hash(&c)? { + return Err("请先确认资料和页面方案,再载入图位".into()); + } + let brand = c["brief"]["brandId"].as_str().filter(|s| Uuid::parse_str(s).is_ok()).ok_or("请先保存项目品牌")?; + let category = c["brief"]["categoryId"].as_str().filter(|s| Uuid::parse_str(s).is_ok()).ok_or("请先保存项目品类")?; + let pack = self.template_pack(brand, category)?; + if pack["brandId"] != brand || pack["categoryId"] != category { + return Err("模板与当前项目不匹配".into()); + } + template_slots::install(&dir, &pack, rev(&state))?; + write_json(&dir.join("template-input.json"), &json!({"planHash":creation::plan_hash(&c)?,"templateHash":pack["contentHash"]}))?; + } else if method == "slot-copy" { + validate_inputs(&dir)?; + self.creation_guard(doc, &dir)?; + let mut fields = Vec::new(); + let mut baseline = Vec::new(); + for output in state["template"]["outputs"].as_array().ok_or("模板输出缺失")? { + for f in output["slots"].as_array().ok_or("模板图位缺失")?.iter().filter(|f| f["kind"] == "text") { + let id = template_slots::slot_id(&state["template"], f["fieldId"].as_str().ok_or("字段身份缺失")?); + fields.push(json!({"id":id,"fieldId":f["fieldId"],"label":f.get("label").or(f.get("name")).unwrap_or(&f["fieldId"]),"documentId":output["id"],"outputName":output["name"],"role":f["role"],"maxChars":f["maxChars"],"maxLines":f["maxLines"]})); + baseline.push(json!({"id":id,"value":state["slots"][&id]["text"]})); + } + } + c["templateCopyInput"] = + json!({"templateHash":state["template"]["contentHash"],"slotRevision":state["revision"],"fields":fields,"baseFields":baseline}); + creation::save(&dir, &mut c)?; + self.creation_text("creation-slot-copy", &json!({"doc":doc,"revision":c["revision"]}), events, ctx)?; + } else if method == "slot-copy-apply" { + validate_inputs(&dir)?; + let copy = &c["templateCopy"]; + if copy["templateInput"]["templateHash"] != state["template"]["contentHash"] { + return Err("文案候选模板版本不匹配".into()); + } + let fields: Vec = copy["fields"].as_array().ok_or("文案候选尚未完成")?.iter().map(|f| json!({"id":f["id"],"value":f["text"]})).collect(); + template_slots::set_texts(&dir, &json!(fields), ©["templateInput"]["baseFields"], rev(&state))?; + let _guard = sync::context_guard(); + let mut next = template_slots::load(&dir)?; + next["copyApplied"] = copy["taskId"].clone(); + write_json(&dir.join("template-slots.json"), &next)?; + } else if method == "slot-text-save" { + template_slots::set_texts(&dir, &input["fields"], &input["baseFields"], rev(&state))?; + } else if method == "slot-select" { + let id = input["slotId"].as_str().ok_or("请选择图位")?; + let row = &state["slots"][id]; + let letter = ["A", "B"].into_iter().find(|l| row["candidates"][*l]["taskId"] == input["candidateId"]).ok_or("候选身份不匹配")?; + template_slots::select_candidate(&dir, id, letter, rev(&state))?; + } else if method == "slot-reuse" { + reuse_action(&dir, &state, input)?; + } else if method == "slot-generate" || method == "slot-recover" { + let id = input["slotId"].as_str().ok_or("请选择图位")?; + let (output, slot) = field(&state["template"], id)?; + let output = output.clone(); + let slot = slot.clone(); + if slot["kind"] != "image" { + return Err("请选择图片图位".into()); + } + let result = if method == "slot-recover" { + let letter = ["A", "B"].into_iter().find(|l| state["slots"][id]["candidates"][*l]["taskId"] == input["candidateId"]).ok_or("原候选不存在")?; + let b = template_slots::binding(&state, id, letter)?; + self.image_job(None, &json!({"doc":doc,"taskId":b["taskId"]}), true, events, ctx)? + } else { + validate_inputs(&dir)?; + if state["template"]["brandId"] != c["brief"]["brandId"] || state["template"]["categoryId"] != c["brief"]["categoryId"] { + return Err("项目品牌或品类已变化,旧模板不能生成新结果".into()); + } + self.slot_image_guard(doc, &dir)?; + self.validate()?; + let settings = self.settings()?; + if input["imageModel"] != settings["imageModel"] { + return Err("请先保存生图模型选择".into()); + } + let (references, ids) = creation::reference_canvas(&c, &dir, doc)?; + let refs: Vec = + c["brief"]["images"].as_array().ok_or("产品参考图缺失")?.iter().filter_map(|i| i["source"].as_str().map(str::to_owned)).collect(); + let plan = c["plan"]["screens"].as_array().ok_or("页面方案缺失")?; + let prompt = adjusted_image_prompt(&slot, &output, plan, instructions); + let letter = input["candidate"].as_str().ok_or("请选择A或B")?; + state = + template_slots::reserve_candidate(&dir, id, letter, settings["imageModel"].as_str().ok_or("请选择生图模型")?, &prompt, &refs, rev(&state))?; + let binding = template_slots::binding(&state, id, letter)?; + let _ = events.send(Event::Json("slot-progress".into(), self.slot_result(doc, &dir))); + ctx.request_repaint(); + let profile = &c["designSnapshot"]["brand"]["profile"]; + let visual = + profile["visualRules"].as_object().map(|m| m.values().filter_map(Value::as_str).collect::>().join("\n")).unwrap_or_default(); + let brand = json!({"name":c["brief"]["brandName"],"tone":profile["tone"],"visualRules":visual.chars().take(4000).collect::(),"fontFamily":"Noto Sans SC"}); + let call=self.image_job(Some(Arc::new(references)),&json!({"doc":doc,"revision":input["baseRevision"],"model":settings["imageModel"],"mode":"generate","text":prompt,"screen":output["name"],"references":ids,"brand":brand,"facts":c["facts"],"slotBinding":binding,"slotFrame":slot["frame"]}),false,events,ctx); + match call { + Ok(v) => v, + Err(error) => { + let archive = dir.join("image-jobs").join(binding["taskId"].as_str().ok_or("任务身份缺失")?).join("task.json"); + let journal = if archive.exists() { + read_json(&archive)? + } else { + json!({"id":binding["taskId"],"slotBinding":binding,"phase":"prepared","notDispatched":true}) + }; + state = template_slots::load(&dir)?; + template_slots::reconcile_candidate(&dir, &journal, rev(&state))?; + return Err(error); + } + } + }; + let task = result["taskId"].as_str().ok_or("原候选请求号缺失,不能重发")?; + let journal = read_json(&dir.join("image-jobs").join(task).join("task.json"))?; + state = template_slots::load(&dir)?; + template_slots::reconcile_candidate(&dir, &journal, rev(&state))?; + if result["phase"] == "complete" + && let Ok((mut reference, image)) = self.agent_image_preview(&json!({"doc":doc,"taskId":task})) + { + reference["purpose"] = json!("template-candidate"); + let _ = events.send(Event::Preview(reference, image)); + } + } else if method == "slot-apply" { + self.slot_image_guard(doc, &dir)?; + let component = input["outputId"].as_str().filter(|s| ["detail", "main_images", "sku_images"].contains(s)).ok_or("请选择输出类型")?; + let document = match input.get("documentId") { + None | Some(Value::Null) => None, + Some(value) => Some(value.as_str().filter(|id| !id.is_empty()).ok_or("请选择有效的当前屏")?), + }; + let pack = &state["template"]; + if pack["brandId"] != c["brief"]["brandId"] || pack["categoryId"] != c["brief"]["categoryId"] { + return Err("旧模板与当前项目不匹配".into()); + } + let key = output_storage_key(pack, component, document)?; + let apps = output_applications(&dir, pack, component, document)?; + let mut native = materialize_output(&dir, pack, component, document, &apps)?; + native.id = photocraft_doc::DocId(doc); + let data = photocraft_format::save_to_bytes(&native, &Default::default()).map_err(|_| "回填文档不能保存")?; + let path = dir.join("template-outputs").join(format!("{key}.pcraft")); + photocraft_format::atomic_write(&path, &data).map_err(|_| "回填结果无法保存,原画布未修改")?; + let mut value = self.slot_result(doc, &dir)?; + value["baseRevision"] = input["baseRevision"].clone(); + value["applications"] = json!(apps); + value["outputId"] = json!(component); + value["documentId"] = json!(document); + events.send(Event::SlotCanvas(Box::new(native), value)).map_err(|_| "工作台已关闭,回填结果已保留")?; + } else if method == "slot-applied" { + self.slot_image_guard(doc, &dir)?; + for app in input["applications"].as_array().filter(|a| a.len() <= 32).ok_or("回填关联无效")? { + state = template_slots::mark_applied( + &dir, + app["outputId"].as_str().ok_or("输出身份无效")?, + app["applicationHash"].as_str().ok_or("回填哈希无效")?, + rev(&state), + )?; + } + } else { + return Err("图位操作不存在".into()); + } + ctx.request_repaint(); + self.slot_result(doc, &dir) + } +} + +#[cfg(test)] +mod tests { + use super::*; + struct Temp(PathBuf); + impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + fn fixture() -> (Temp, Value, Value) { + let base = std::env::var_os("JIANSUN_RULE_TEST_ROOT").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); + let dir = base.join(format!("workflow-{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + write_json(&dir.join("context.json"), &json!({"localId":Uuid::new_v4().to_string()})).unwrap(); + let pack: Value = serde_json::from_str(include_str!("../../../../../lib/desktop-client/template-packs/yjd-desk-dnz0385-native-v1.json")).unwrap(); + let mut c = json!({"version":1,"revision":1,"brief":{"brandId":pack["brandId"],"categoryId":pack["categoryId"],"name":"真实字段契约测试(不是生成结果)"},"facts":{"items":[],"status":"ready"},"sellingPoints":{"batches":[],"selected":[]},"plan":{"status":"confirmed","screens":[]},"width":1440,"screenCount":5,"designSnapshot":{}}); + c["plan"]["confirmedInput"] = json!(creation::plan_hash(&c).unwrap()); + write_json(&dir.join("creation.json"), &c).unwrap(); + template_slots::install(&dir, &pack, 0).unwrap(); + write_json(&dir.join("template-input.json"), &json!({"planHash":creation::plan_hash(&c).unwrap(),"templateHash":pack["contentHash"]})).unwrap(); + (Temp(dir), pack, c) + } + #[test] + fn a_fresh_unsaved_document_reads_empty_template_stage_without_creating_directories() { + let base = std::env::var_os("JIANSUN_RULE_TEST_ROOT").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); + let root = Temp(base.join(format!("workflow-fresh-{}", Uuid::new_v4()))); + let project = root.0.join("accounts").join("owned").join("projects").join("new-project"); + assert!(!root.0.exists()); + assert_eq!( + snapshot(&project, &json!({})).unwrap(), + json!({"revision":0,"confirmed":false,"slots":[],"pending":false,"capabilities":{"imageDispatchAllowed":false,"copyDispatchAllowed":false,"localApplyAllowed":false}}) + ); + assert!(!project.exists()); + assert!(!root.0.exists(), "reading an uninitialized stage performs no filesystem mutation"); + } + #[test] + fn an_absent_template_record_never_bypasses_account_trash_protection() { + let base = std::env::var_os("JIANSUN_RULE_TEST_ROOT").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); + let root = Temp(base.join(format!("workflow-trash-{}", Uuid::new_v4()))); + let account = root.0.join("accounts").join("owned"); + let project = account.join("projects").join("deleted-project"); + fs::create_dir_all(account.join("trash").join("deleted-project")).unwrap(); + assert!(snapshot(&project, &json!({})).unwrap_err().contains("已删除")); + assert!(!project.exists(), "reading cannot recreate the deleted project"); + } + #[test] + fn an_existing_corrupt_template_record_is_an_error_instead_of_an_empty_stage() { + let (dir, _, c) = fixture(); + fs::write(dir.0.join("template-slots.json"), b"{incomplete-json").unwrap(); + assert!(snapshot(&dir.0, &c).is_err()); + assert_eq!(fs::read(dir.0.join("template-slots.json")).unwrap(), b"{incomplete-json"); + } + #[test] + fn changing_facts_invalidates_old_template_candidates_without_removing_them() { + let (dir, _, mut c) = fixture(); + assert!(validate_inputs(&dir.0).is_ok()); + c["facts"]["items"] = json!([{"text":"用户更新了产品"}]); + write_json(&dir.0.join("creation.json"), &c).unwrap(); + assert!(validate_inputs(&dir.0).is_err()); + assert_eq!(snapshot(&dir.0, &c).unwrap()["confirmed"], false); + assert!(dir.0.join("template-slots.json").is_file()); + } + #[test] + fn batch_text_validation_is_atomic_and_stale_baselines_do_not_overwrite_new_edits() { + let (dir, pack, _) = fixture(); + let fields: Vec<_> = + pack["outputs"].as_array().unwrap().iter().flat_map(|o| o["slots"].as_array().unwrap()).filter(|f| f["kind"] == "text").take(2).collect(); + let ids: Vec<_> = fields.iter().map(|f| template_slots::slot_id(&pack, f["fieldId"].as_str().unwrap())).collect(); + let baseline = json!([{"id":ids[0],"value":""},{"id":ids[1],"value":""}]); + let before = template_slots::load(&dir.0).unwrap(); + assert!( + template_slots::set_texts(&dir.0, &json!([{"id":ids[0],"value":"桌"},{"id":ids[1],"value":"字".repeat(1000)}]), &baseline, rev(&before)).is_err() + ); + assert_eq!(template_slots::load(&dir.0).unwrap(), before); + let next = template_slots::set_texts(&dir.0, &json!([{"id":ids[0],"value":"桌"},{"id":ids[1],"value":"简洁"}]), &baseline, rev(&before)).unwrap(); + assert!(template_slots::set_texts(&dir.0, &json!([{"id":ids[0],"value":"旧"},{"id":ids[1],"value":"旧"}]), &baseline, rev(&next)).is_err()); + assert_eq!(template_slots::load(&dir.0).unwrap(), next); + } + #[test] + fn each_output_keeps_its_edited_native_document_instead_of_overwriting_other_components() { + let (dir, pack, _) = fixture(); + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"name":"本地编辑","width":1440,"height":900})).unwrap(); + let mut doc = s.active().unwrap().doc.as_ref().clone(); + for component in ["detail", "main_images", "sku_images"] { + doc.metadata.text = + vec![("jiansun.slot-mapping.v1".into(), json!({"component":component,"templateHash":pack["contentHash"],"outputs":[]}).to_string())]; + let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap(); + save_edited_output(&dir.0, &doc, &bytes).unwrap(); + } + for component in ["detail", "main_images", "sku_images"] { + assert!(dir.0.join("template-outputs").join(format!("{component}-edited.pcraft")).is_file()); + } + } + fn complete_output_candidate(dir: &Path, pack: &Value, field: &str, letter: &str, color: [u8; 4]) -> Value { + let slot = template_slots::slot_id(pack, field); + let state = template_slots::load(dir).unwrap(); + let state = template_slots::reserve_candidate( + dir, + &slot, + letter, + "fixture-no-network", + "本机生成的测试像素,不是模型结果", + &[format!("local:{}", "a".repeat(64))], + rev(&state), + ) + .unwrap(); + let binding = template_slots::binding(&state, &slot, letter).unwrap(); + let task = binding["taskId"].as_str().unwrap(); + let job = dir.join("image-jobs").join(task); + fs::create_dir_all(&job).unwrap(); + let pixels = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgba, color.repeat(32)).unwrap(); + let bytes = photocraft_codecs::encode(&pixels, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + fs::write(job.join("generated.png"), &bytes).unwrap(); + let journal = + json!({"id":task,"slotBinding":binding,"phase":"complete","requestId":Uuid::new_v4().to_string(),"sha256":format!("{:x}",Sha256::digest(&bytes))}); + write_json(&job.join("task.json"), &journal).unwrap(); + template_slots::reconcile_candidate(dir, &journal, rev(&state)).unwrap() + } + #[test] + fn a_ready_single_screen_applies_without_fabricating_the_unfinished_component() { + let (dir, pack, c) = fixture(); + let slot = template_slots::slot_id(&pack, "S01-I01"); + let state = complete_output_candidate(&dir.0, &pack, "S01-I01", "A", [10, 30, 60, 255]); + template_slots::select_candidate(&dir.0, &slot, "A", rev(&state)).unwrap(); + let applications = output_applications(&dir.0, &pack, "detail", Some("S01")).unwrap(); + assert_eq!(applications.len(), 1); + assert_eq!(applications[0]["outputId"], "S01"); + let native = materialize_output(&dir.0, &pack, "detail", Some("S01"), &applications).unwrap(); + assert_eq!(mapping(&native).unwrap()["outputId"], "S01"); + assert_eq!(native.size.height, pack["outputs"][0]["height"].as_u64().unwrap() as u32); + assert!(native.layers.iter().all(|layer| layer.name.starts_with("S01 ·"))); + assert!(output_applications(&dir.0, &pack, "detail", None).is_err(), "unfinished S02 and other screens remain required for the combined component"); + assert!(output_applications(&dir.0, &pack, "main_images", Some("S01")).is_err(), "an output cannot cross its component"); + let view = snapshot(&dir.0, &c).unwrap(); + let outputs = view["outputs"].as_array().unwrap(); + let current = outputs.iter().find(|output| output["documentId"] == "S01").unwrap(); + assert_eq!(current["requiredImages"], 1); + assert_eq!(current["selectedImages"], 1); + assert_eq!(current["ready"], true); + assert_eq!(current["hasEditedDraft"], false); + assert_eq!(outputs.iter().find(|output| output["documentId"] == "S02").unwrap()["ready"], false); + assert!(view["slots"].as_array().unwrap().iter().all(|slot| slot["required"] == true)); + } + #[test] + fn a_multi_image_output_cannot_apply_from_only_one_selected_position() { + let (dir, pack, c) = fixture(); + let field = pack["outputs"].as_array().unwrap().iter().find(|output| output["id"] == "S02").unwrap()["slots"] + .as_array() + .unwrap() + .iter() + .find(|field| field["kind"] == "image") + .unwrap()["fieldId"] + .as_str() + .unwrap(); + let slot = template_slots::slot_id(&pack, field); + let state = complete_output_candidate(&dir.0, &pack, field, "A", [30, 60, 90, 255]); + template_slots::select_candidate(&dir.0, &slot, "A", rev(&state)).unwrap(); + assert!(output_applications(&dir.0, &pack, "detail", Some("S02")).is_err()); + let view = snapshot(&dir.0, &c).unwrap(); + let output = view["outputs"].as_array().unwrap().iter().find(|output| output["documentId"] == "S02").unwrap(); + assert_eq!(output["requiredImages"], 6); + assert_eq!(output["selectedImages"], 1); + assert_eq!(output["ready"], false); + assert!(template_slots::load(&dir.0).unwrap()["applied"].as_object().unwrap().is_empty()); + } + #[test] + fn a_single_screen_restores_manual_edits_after_reload_and_never_overwrites_them_on_ab_change() { + let (dir, pack, c) = fixture(); + let slot = template_slots::slot_id(&pack, "S01-I01"); + complete_output_candidate(&dir.0, &pack, "S01-I01", "A", [10, 30, 60, 255]); + let state = complete_output_candidate(&dir.0, &pack, "S01-I01", "B", [80, 100, 120, 255]); + let state = template_slots::select_candidate(&dir.0, &slot, "A", rev(&state)).unwrap(); + let preserved_unknown = json!({"id":Uuid::new_v4().to_string(),"stage":"slot-copy","phase":"unknown","partialText":"原片段保留,不得重发"}); + write_json(&dir.0.join("creation-task.json"), &preserved_unknown).unwrap(); + let unknown_bytes = fs::read(dir.0.join("creation-task.json")).unwrap(); + let apps = output_applications(&dir.0, &pack, "detail", Some("S01")).unwrap(); + let mut session = photocraft_engine::Session::new(); + session.add_document(materialize_output(&dir.0, &pack, "detail", Some("S01"), &apps).unwrap(), None); + let text = session.active().unwrap().doc.walk().into_iter().find(|(_, _, layer)| layer.name.starts_with("S01-T01 ·")).unwrap().2.id.0; + session.execute("type.edit", json!({"layer":text,"text":"本地手动标题"})).unwrap(); + assert!(session.undo()); + assert!(session.redo()); + let native = session.active().unwrap().doc.as_ref().clone(); + let bytes = photocraft_format::save_to_bytes(&native, &Default::default()).unwrap(); + save_edited_output(&dir.0, &native, &bytes).unwrap(); + let key = output_storage_key(&pack, "detail", Some("S01")).unwrap(); + let edited = dir.0.join("template-outputs").join(format!("{key}-edited.pcraft")); + let saved = fs::read(&edited).unwrap(); + drop(session); + let reopened = materialize_output(&dir.0, &pack, "detail", Some("S01"), &apps).unwrap(); + assert!(reopened.walk().iter().any(|(_, _, layer)| matches!(&layer.content,photocraft_doc::LayerContent::Text(text) if text.text=="本地手动标题"))); + assert!(!dir.0.join("template-outputs/detail-edited.pcraft").exists(), "single screen draft never takes the combined component filename"); + assert_ne!(key, output_storage_key(&pack, "detail", Some("S10")).unwrap()); + let state = template_slots::select_candidate(&dir.0, &slot, "B", rev(&state)).unwrap(); + let changed = output_applications(&dir.0, &pack, "detail", Some("S01")).unwrap(); + assert_ne!(apps[0]["applicationHash"], changed[0]["applicationHash"]); + assert!(materialize_output(&dir.0, &pack, "detail", Some("S01"), &changed).unwrap_err().contains("当前屏已有手动编辑,原稿已保留")); + assert_eq!(fs::read(&edited).unwrap(), saved); + assert_eq!(fs::read(dir.0.join("creation-task.json")).unwrap(), unknown_bytes); + assert_eq!(state["slots"][&slot]["candidates"]["A"]["phase"], "complete"); + assert_eq!(state["slots"][&slot]["candidates"]["B"]["phase"], "complete"); + assert_eq!(snapshot(&dir.0, &c).unwrap()["outputs"][0]["hasEditedDraft"], true); + } + #[test] + fn image_positions_include_their_distinct_visual_purpose_in_the_real_dispatch_prompt() { + let (_, pack, _) = fixture(); + let output = pack["outputs"].as_array().unwrap().iter().find(|o| o["id"] == "S02").unwrap(); + let fields: Vec<_> = output["slots"].as_array().unwrap().iter().filter(|f| f["kind"] == "image").collect(); + assert_eq!(fields.len(), 6); + for slot in fields { + let text = image_prompt(slot, output, &[]); + assert!(text.contains(slot["name"].as_str().unwrap())); + assert!(text.contains(slot["fieldId"].as_str().unwrap())); + assert!(text.contains("不继承模板示例")); + } + } + #[test] + fn absent_or_blank_adjustments_leave_the_existing_prompt_byte_identical() { + let (_, pack, _) = fixture(); + let output = &pack["outputs"][0]; + let slot = output["slots"].as_array().unwrap().iter().find(|f| f["kind"] == "image").unwrap(); + let plan = vec![json!({"imagePrompt":"保留原始的完整约束"})]; + let original = image_prompt(slot, output, &plan); + for input in [json!({}), json!({"instructions":""}), json!({"instructions":" \n\t "})] { + assert_eq!(adjusted_image_prompt(slot, output, &plan, generation_instructions(&input).unwrap()), original); + } + assert!(generation_instructions(&json!({"instructions":"字".repeat(2000)})).is_ok()); + } + #[test] + fn invalid_adjustments_are_rejected_before_a_candidate_can_be_reserved() { + let (dir, pack, _) = fixture(); + let output = &pack["outputs"][0]; + let slot = output["slots"].as_array().unwrap().iter().find(|f| f["kind"] == "image").unwrap(); + let id = template_slots::slot_id(&pack, slot["fieldId"].as_str().unwrap()); + let file = dir.0.join("template-slots.json"); + let before = fs::read(&file).unwrap(); + for invalid in [Value::Null, json!(true), json!(2000), json!([]), json!({}), json!("字".repeat(2001)), json!("不允许\0控制字符")] { + let input = json!({"instructions":invalid}); + let result = generation_instructions(&input).and_then(|instructions| { + let prompt = adjusted_image_prompt(slot, output, &[], instructions); + template_slots::reserve_candidate(&dir.0, &id, "A", "fixture-no-network", &prompt, &[format!("local:{}", "a".repeat(64))], 1) + }); + assert!(result.is_err()); + assert_eq!(fs::read(&file).unwrap(), before, "invalid input must not reserve a UUID or alter the candidate journal"); + assert!(!dir.0.join("image-jobs").exists()); + } + } + #[test] + fn adjustments_are_logged_in_the_new_candidate_without_rewriting_the_existing_success() { + let (dir, pack, _) = fixture(); + let (state, id, _) = complete_reuse_source(&dir.0, &pack); + let original = state["slots"][&id]["candidates"]["A"].clone(); + let (output, slot) = field(&pack, &id).unwrap(); + let input = json!({"instructions":"桌面后沿是一个开放的走线缺口,不是两个圆孔。\n保留原产品外观。"}); + let prompt = adjusted_image_prompt(slot, output, &[], generation_instructions(&input).unwrap()); + assert!(prompt.contains(input["instructions"].as_str().unwrap())); + assert!(prompt.contains("优先于历史方案中未经参考图证实的推断")); + assert!(prompt.contains("品牌调性、禁止增绘和无字约束")); + let next = + template_slots::reserve_candidate(&dir.0, &id, "B", "fixture-no-network", &prompt, &[format!("local:{}", "b".repeat(64))], rev(&state)).unwrap(); + assert_eq!(next["slots"][&id]["candidates"]["A"], original); + assert_eq!(next["slots"][&id]["candidates"]["B"]["prompt"], prompt); + assert_eq!(next["slots"][&id]["candidates"]["B"]["promptHash"], hash(&prompt)); + let before = fs::read(dir.0.join("template-slots.json")).unwrap(); + assert!( + template_slots::reserve_candidate(&dir.0, &id, "A", "fixture-no-network", &prompt, &[format!("local:{}", "b".repeat(64))], rev(&next)).is_err() + ); + assert_eq!(fs::read(dir.0.join("template-slots.json")).unwrap(), before, "adjustments never authorize overwriting or replaying A"); + } + #[test] + fn image_prompt_keeps_the_entire_confirmed_plan_without_guessing_a_screen_mapping() { + let (_, pack, _) = fixture(); + let output = pack["outputs"].as_array().unwrap().iter().find(|o| o["id"] == "K01").unwrap(); + let slot = output["slots"].as_array().unwrap().iter().find(|f| f["fieldId"] == "K01-I01").unwrap(); + let plan = vec![ + json!({"id":"original-first-screen","name":"原始首屏","title":"产品全貌","body":"用户确认的文案","imagePrompt":"全套沿用浅灰环境与柔暖光;不增绘桌面配件,不能改变产品颜色;本屏可以出现阅读人物。","image":true}), + json!({"id":"original-detail-screen","name":"原始细节屏","title":"桌边细节","body":"独立细节说明","imagePrompt":"延续同一产品外观和结构比例,不加入其他腿型。此屏只拍桌边近景,图片中不要文字。","image":true}), + ]; + let text = image_prompt(slot, output, &plan); + assert!(text.contains("完整商品图")); + assert!(text.contains("构图以当前图位、输出用途和画幅为主")); + assert!(text.contains("未建立图位与方案屏的逐项对应,不按屏号猜测映射")); + assert!(text.contains("不把其他屏专属的人物、道具、镜头或场景一并搬到当前图位")); + assert!(text.contains("跨屏共用的品牌调性、光线风格和产品外观约束必须继承")); + assert!(text.contains("纯产品/SKU图仍按本图位的纯展示用途构图")); + let reference = text.split("用户已确认的完整方案(含各屏原始imagePrompt,参考上下文):").nth(1).unwrap(); + assert_eq!(serde_json::from_str::(reference).unwrap(), json!(plan), "all original fields and per-screen negative constraints remain intact"); + } + #[test] + fn image_prompt_does_not_truncate_a_negative_constraint_at_the_end_of_long_context() { + let (_, pack, _) = fixture(); + let output = &pack["outputs"][0]; + let slot = output["slots"].as_array().unwrap().iter().find(|f| f["kind"] == "image").unwrap(); + let negative = "不得新增抽屉,不得增加桌腿,不要任何文字,不改变参考图的颜色。"; + let plan = vec![json!({"title":"产品场景","body":"完整说明","imagePrompt":format!("{}{}","柔和光影与产品环境。".repeat(250),negative)})]; + let text = image_prompt(slot, output, &plan); + assert!(text.contains(negative)); + assert!(text.ends_with(&json!(plan).to_string())); + } + #[test] + fn an_oversized_full_plan_stops_at_existing_reservation_preflight_without_truncation_or_dispatch() { + let (dir, pack, _) = fixture(); + let output = &pack["outputs"][0]; + let slot = output["slots"].as_array().unwrap().iter().find(|f| f["kind"] == "image").unwrap(); + let plan = vec![json!({"title":"产品场景","body":"完整说明","imagePrompt":format!("{}不得增绘任何部件。","完整的已确认上下文。".repeat(1500))})]; + let prompt = image_prompt(slot, output, &plan); + assert!(prompt.chars().count() > 12_000); + assert!(prompt.contains("不得增绘任何部件。")); + let path = dir.0.join("template-slots.json"); + let before = fs::read(&path).unwrap(); + let state = template_slots::load(&dir.0).unwrap(); + let id = template_slots::slot_id(&pack, slot["fieldId"].as_str().unwrap()); + assert!( + template_slots::reserve_candidate(&dir.0, &id, "A", "test-image-no-network", &prompt, &[format!("local:{}", "a".repeat(64))], rev(&state)).is_err() + ); + assert_eq!(fs::read(path).unwrap(), before, "no reserved UUID or candidate is persisted when the full context is too long"); + assert!(!dir.0.join("image-jobs").exists()); + } + fn complete_reuse_source(dir: &Path, pack: &Value) -> (Value, String, String) { + let source = template_slots::slot_id(pack, "K01-I01"); + let state = template_slots::load(dir).unwrap(); + let state = + template_slots::reserve_candidate(dir, &source, "A", "fixture-no-model", "本机复用身份测试", &[format!("local:{}", "b".repeat(64))], rev(&state)) + .unwrap(); + let binding = template_slots::binding(&state, &source, "A").unwrap(); + let task_id = binding["taskId"].as_str().unwrap().to_owned(); + let folder = dir.join("image-jobs").join(&task_id); + fs::create_dir_all(&folder).unwrap(); + let pixels = b"synthetic-file-hash-test-not-a-model-result"; + fs::write(folder.join("generated.png"), pixels).unwrap(); + let journal = json!({"id":task_id,"slotBinding":binding,"phase":"complete","requestId":Uuid::new_v4().to_string(),"sha256":format!("{:x}",Sha256::digest(pixels))}); + write_json(&folder.join("task.json"), &journal).unwrap(); + let state = template_slots::reconcile_candidate(dir, &journal, rev(&state)).unwrap(); + (state, source, task_id) + } + #[test] + fn explicit_same_group_reuse_keeps_the_original_uuid_and_file_without_selecting_or_applying() { + let (dir, pack, c) = fixture(); + let (state, source, task_id) = complete_reuse_source(&dir.0, &pack); + let target = template_slots::slot_id(&pack, "K02-I01"); + let next = reuse_action(&dir.0, &state, &json!({"slotId":target,"sourceSlotId":source,"sourceCandidateId":task_id,"candidate":"B"})).unwrap(); + let reused = &next["slots"][&target]["candidates"]["B"]; + assert_eq!(reused["taskId"], task_id); + assert_eq!(reused["phase"], "complete"); + assert_eq!(reused["artifact"], state["slots"][&source]["candidates"]["A"]["artifact"]); + assert_eq!(reused["sha256"], state["slots"][&source]["candidates"]["A"]["sha256"]); + assert_eq!(reused["reusedFrom"]["slotId"], source); + assert!(next["slots"][&target]["selected"].is_null()); + assert_eq!(next["applied"], state["applied"]); + assert_eq!(fs::read_dir(dir.0.join("image-jobs")).unwrap().count(), 1, "reuse creates no second archive or pixel file"); + let visible = snapshot(&dir.0, &c).unwrap(); + let rows = visible["slots"].as_array().unwrap(); + let target_row = rows.iter().find(|s| s["id"] == target).unwrap(); + assert_eq!(target_row["reuseGroup"], "SKU01-product"); + assert_eq!(target_row["candidates"][0]["reusedFrom"]["slotId"], source); + assert!(target_row["selectedCandidateId"].is_null()); + assert!(rows.iter().find(|s| s["fieldId"] == "S01-I01").unwrap()["reuseGroup"].is_null()); + } + #[test] + fn reuse_rejects_wrong_candidate_identity_different_purpose_and_same_slot_without_mutation() { + let (dir, pack, _) = fixture(); + let (state, source, task_id) = complete_reuse_source(&dir.0, &pack); + let target = template_slots::slot_id(&pack, "K02-I01"); + let wrong_purpose = template_slots::slot_id(&pack, "K02-I02"); + for input in [ + json!({"slotId":target,"sourceSlotId":source,"sourceCandidateId":Uuid::new_v4().to_string(),"candidate":"A"}), + json!({"slotId":wrong_purpose,"sourceSlotId":source,"sourceCandidateId":task_id,"candidate":"A"}), + json!({"slotId":source,"sourceSlotId":source,"sourceCandidateId":task_id,"candidate":"B"}), + ] { + assert!(reuse_action(&dir.0, &state, &input).is_err()); + assert_eq!(template_slots::load(&dir.0).unwrap(), state); + } + } + #[test] + fn reuse_cannot_make_a_second_candidate_with_the_same_original_uuid_or_replace_a_reserved_target() { + let (dir, pack, _) = fixture(); + let (state, source, task_id) = complete_reuse_source(&dir.0, &pack); + let target = template_slots::slot_id(&pack, "K02-I01"); + let state = reuse_action(&dir.0, &state, &json!({"slotId":target,"sourceSlotId":source,"sourceCandidateId":task_id,"candidate":"A"})).unwrap(); + assert!( + reuse_action(&dir.0, &state, &json!({"slotId":target,"sourceSlotId":source,"sourceCandidateId":task_id,"candidate":"B"})) + .unwrap_err() + .contains("第二份候选") + ); + assert_eq!(template_slots::load(&dir.0).unwrap(), state); + let reserved = template_slots::slot_id(&pack, "S17-I01"); + let state = + template_slots::reserve_candidate(&dir.0, &reserved, "B", "fixture-no-model", "已预留原任务", &[format!("local:{}", "c".repeat(64))], rev(&state)) + .unwrap(); + assert!(reuse_action(&dir.0, &state, &json!({"slotId":reserved,"sourceSlotId":source,"sourceCandidateId":task_id,"candidate":"B"})).is_err()); + assert_eq!(template_slots::load(&dir.0).unwrap(), state); + } + #[test] + fn reuse_never_treats_an_unknown_original_request_as_a_completed_source() { + let (dir, pack, _) = fixture(); + let source = template_slots::slot_id(&pack, "K01-I01"); + let target = template_slots::slot_id(&pack, "K02-I01"); + let state = template_slots::load(&dir.0).unwrap(); + let state = template_slots::reserve_candidate( + &dir.0, + &source, + "A", + "fixture-no-model", + "原任务未知不能重发", + &[format!("local:{}", "d".repeat(64))], + rev(&state), + ) + .unwrap(); + let binding = template_slots::binding(&state, &source, "A").unwrap(); + let state = template_slots::reconcile_candidate(&dir.0, &json!({"id":binding["taskId"],"slotBinding":binding,"phase":"unknown"}), rev(&state)).unwrap(); + assert!(reuse_action(&dir.0, &state, &json!({"slotId":target,"sourceSlotId":source,"sourceCandidateId":binding["taskId"],"candidate":"A"})).is_err()); + assert_eq!(template_slots::load(&dir.0).unwrap(), state); + assert!(!dir.0.join("image-jobs").exists()); + } +} diff --git a/apps/detail-photocraft/apps/photocraft/src/main.rs b/apps/detail-photocraft/apps/photocraft/src/main.rs index d9b33509..3d84d4c1 100644 --- a/apps/detail-photocraft/apps/photocraft/src/main.rs +++ b/apps/detail-photocraft/apps/photocraft/src/main.rs @@ -83,6 +83,7 @@ mod single_instance; #[cfg(any(target_os = "macos", target_os = "linux", test))] mod tablet; mod ui_state; +mod ui_scale_policy; mod window_frame; use photocraft_engine::Session; @@ -423,6 +424,28 @@ fn main() -> eframe::Result { #[cfg(target_os = "macos")] let _ = (new_instance, &launch_options); + // Run once before any saved preference is read. Subsequent launches preserve + // explicit manual scale choices; normal Auto follows winit's per-monitor DPI. + let ui_scale_dir = services::config_dir(); + match ui_scale_policy::apply(ui_scale_dir.as_deref()) { + Ok(status) => { + log::info!("UI scale policy: {}", status.description()); + } + Err(message) => { + // Do not construct the editor: its normal save path could otherwise + // overwrite a malformed preferences file with defaults on exit. + let description = ui_scale_policy::failure_description(ui_scale_dir.as_deref(), &message); + log::error!("UI scale policy: {description}"); + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title("界面缩放设置未完成") + .set_description(&description) + .set_buttons(rfd::MessageButtons::Ok) + .show(); + return Err(eframe::Error::AppCreation(Box::new(std::io::Error::other(description)))); + } + } + // The display server to open the window on: Xwayland for a pen on Wayland, which gives this // app no pen input (#639). #[cfg(target_os = "linux")] diff --git a/apps/detail-photocraft/apps/photocraft/src/ui_scale_policy.rs b/apps/detail-photocraft/apps/photocraft/src/ui_scale_policy.rs new file mode 100644 index 00000000..1e73b23e --- /dev/null +++ b/apps/detail-photocraft/apps/photocraft/src/ui_scale_policy.rs @@ -0,0 +1,322 @@ +//! One-time beta migration to the native system-DPI policy, before preferences load. +//! The version marker preserves every later explicit Preferences choice. No document, +//! canvas resolution, font size or OS scaling setting is changed here. + +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use photocraft_engine::prefs::UiScale; +use serde_json::{Value, json}; + +const VERSION: u64 = 1; +const POLICY: &str = "native-system-dpi"; +const MARKER: &str = "ui-scale-policy-v1.json"; +const BACKUP: &str = "preferences.before-ui-scale-policy-v1.json"; +const LOCK: &str = "ui-scale-policy-v1.lock"; +const MAX_PREFERENCES: u64 = 8 * 1024 * 1024; + +#[derive(Debug, PartialEq, Eq)] +pub enum Status { + Current, + Initialized, + Migrated, +} + +impl Status { + pub fn description(&self) -> &'static str { + match self { + Self::Current => "already initialized; explicit user scale preserved", + Self::Initialized => "native Auto default recorded; preferences unchanged", + Self::Migrated => "legacy beta scale changed to Auto; original preferences backed up", + } + } +} + +struct Lock(PathBuf); +impl Drop for Lock { + fn drop(&mut self) { + if fs::remove_file(&self.0).is_err() { + log::warn!("UI scale policy lock could not be removed; next launch will keep existing preferences"); + } + } +} + +fn has_marker(dir: &Path) -> Result { + let path = dir.join(MARKER); + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(_) => return Err("无法读取界面缩放策略记录,已保留原设置。".into()), + }; + if bytes.len() > 4096 { + return Err("界面缩放策略记录异常,已保留原设置。".into()); + } + let value: Value = serde_json::from_slice(&bytes).map_err(|_| "界面缩放策略记录损坏,已保留原设置。")?; + if value["policy"] == POLICY && value["version"].as_u64().is_some_and(|v| v >= VERSION) { + Ok(true) + } else { + Err("界面缩放策略记录无法识别,已保留原设置。".into()) + } +} + +/// GUI launches call this after single-instance handoff and before any saved preference is read. +pub fn apply(dir: Option<&Path>) -> Result { + let dir = dir.ok_or("本机偏好目录不可用,界面缩放保持原设置。")?; + apply_with_writer(dir, &photocraft_format::atomic_write) +} + +/// Used by the native startup failure dialog before an editor can load/save defaults. +pub fn failure_description(dir: Option<&Path>, reason: &str) -> String { + match dir { + Some(dir) => format!( + "{reason}\n\n为保护已有偏好,本次未打开工作台。\n偏好文件:{}\n原始备份(如已建立):{}", + dir.join("preferences.json").display(), + dir.join(BACKUP).display() + ), + None => format!("{reason}\n\n为保护已有偏好,本次未打开工作台;未能定位偏好目录。"), + } +} + +fn read_preferences(dir: &Path) -> Result>, String> { + let prefs = dir.join("preferences.json"); + match fs::metadata(&prefs) { + Ok(meta) if meta.is_file() && meta.len() <= MAX_PREFERENCES => { + let bytes = fs::read(&prefs).map_err(|_| "无法读取原偏好文件,已保留原设置。")?; + let value: Value = serde_json::from_slice(&bytes).map_err(|_| "原偏好文件损坏,未清空或替换。")?; + if !value.is_object() { + return Err("原偏好文件结构异常,未清空或替换。".into()); + } + Ok(Some(bytes)) + } + Ok(_) => Err("原偏好文件异常,未清空或替换。".into()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(_) => Err("无法核对原偏好文件,已保留原设置。".into()), + } +} + +fn apply_with_writer(dir: &Path, write: &dyn Fn(&Path, &[u8]) -> io::Result<()>) -> Result { + if has_marker(dir)? { + // A marker must not let malformed JSON reach the normal load/default/save path. + read_preferences(dir)?; + return Ok(Status::Current); + } + fs::create_dir_all(dir).map_err(|_| "无法准备界面缩放策略目录,已保留原设置。")?; + let lock_path = dir.join(LOCK); + let mut lock = fs::OpenOptions::new().write(true).create_new(true).open(&lock_path).map_err(|_| "界面缩放策略锁已存在或不可写;本次保留原设置。")?; + let _lock = Lock(lock_path); + lock.write_all(b"native-system-dpi-v1\n").and_then(|_| lock.sync_all()).map_err(|_| "无法记录界面缩放策略锁,已保留原设置。")?; + // Another process may have completed between the first check and acquiring the lock. + if has_marker(dir)? { + read_preferences(dir)?; + return Ok(Status::Current); + } + let prefs = dir.join("preferences.json"); + let original = read_preferences(dir)?; + let marker = serde_json::to_vec_pretty(&json!({"version":VERSION,"policy":POLICY,"initialScale":"auto"})).map_err(|_| "无法准备界面缩放策略记录。")?; + let Some(original) = original else { + write(&dir.join(MARKER), &marker).map_err(|_| "无法保存界面缩放策略记录;新用户仍使用系统默认缩放。")?; + return Ok(Status::Initialized); + }; + let mut value: Value = serde_json::from_slice(&original).map_err(|_| "原偏好文件损坏,未清空或替换。")?; + let root = value.as_object_mut().ok_or("原偏好文件结构异常,未清空或替换。")?; + let scale = match root.get_mut("interface") { + None => None, + Some(interface) => { + let interface = interface.as_object_mut().ok_or("原界面偏好结构异常,未清空或替换。")?; + match interface.get("uiScale") { + None => None, + Some(v) => Some(UiScale::parse(v.as_str().ok_or("原缩放设置异常,已保留原设置。")?).ok_or("原缩放设置无法识别,已保留原设置。")?), + } + } + }; + if scale.is_none_or(|s| s == UiScale::Auto) { + write(&dir.join(MARKER), &marker).map_err(|_| "无法保存界面缩放策略记录,原偏好未改动。")?; + return Ok(Status::Initialized); + } + // Never overwrite the sole original backup, including a previous incomplete attempt. + let backup = dir.join(BACKUP); + match fs::OpenOptions::new().write(true).create_new(true).open(&backup) { + Ok(mut file) => file.write_all(&original).and_then(|_| file.sync_all()).map_err(|_| "原偏好备份未写完,未修改缩放设置。")?, + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + if fs::read(&backup).map_err(|_| "原偏好备份无法核对,未修改缩放设置。")? != original { + return Err("已有不同版本的原偏好备份,未覆盖备份或修改缩放设置。".into()); + } + } + Err(_) => return Err("无法备份原偏好,未修改缩放设置。".into()), + } + value["interface"]["uiScale"] = json!("auto"); + let next = serde_json::to_vec_pretty(&value).map_err(|_| "无法准备界面缩放设置,原偏好未改动。")?; + write(&prefs, &next).map_err(|_| "无法原子保存界面缩放设置,原偏好和备份均已保留。")?; + if write(&dir.join(MARKER), &marker).is_err() { + return if write(&prefs, &original).is_ok() { + Err("无法保存界面缩放策略记录,已还原原偏好;备份仍保留。".into()) + } else { + Err("缩放已切换为跟随系统,但策略记录和自动还原失败;原偏好备份仍保留,请检查偏好目录。".into()) + }; + } + Ok(Status::Migrated) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] + use super::*; + struct Temp(PathBuf); + impl Temp { + fn new() -> Self { + let base = std::env::var_os("JIANSUN_RULE_TEST_ROOT").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); + let path = base.join(format!("photocraft-ui-scale-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&path).unwrap(); + Self(path.canonicalize().unwrap()) + } + fn write(&self, bytes: &[u8]) { + fs::write(self.0.join("preferences.json"), bytes).unwrap(); + } + fn bytes(&self) -> Vec { + fs::read(self.0.join("preferences.json")).unwrap() + } + } + impl Drop for Temp { + fn drop(&mut self) { + if self.0.canonicalize().ok().as_ref() == Some(&self.0) + && self.0.file_name().is_some_and(|s| s.to_string_lossy().starts_with("photocraft-ui-scale-")) + { + let _ = fs::remove_dir_all(&self.0); + } + } + } + fn fixture(scale: &str) -> Vec { + serde_json::to_vec( + &json!({"interface":{"uiScale":scale,"uiFontSize":"large","theme":"pro"},"units":{"ruler":"mm"},"custom":{"中文":"保留","items":[1,true,null]}}), + ) + .unwrap() + } + #[test] + fn migrates_only_the_scale_field_and_keeps_one_exact_original_backup() { + let d = Temp::new(); + let original = fixture("100"); + d.write(&original); + assert_eq!(apply(Some(&d.0)), Ok(Status::Migrated)); + assert_eq!(fs::read(d.0.join(BACKUP)).unwrap(), original); + let mut expected: Value = serde_json::from_slice(&original).unwrap(); + expected["interface"]["uiScale"] = json!("auto"); + assert_eq!(serde_json::from_slice::(&d.bytes()).unwrap(), expected); + assert_eq!(serde_json::from_slice::(&fs::read(d.0.join(MARKER)).unwrap()).unwrap()["version"], VERSION); + } + #[test] + fn once_initialized_a_later_manual_scale_is_never_reset_or_backed_up_again() { + let d = Temp::new(); + d.write(&fixture("100")); + apply(Some(&d.0)).unwrap(); + let backup = fs::read(d.0.join(BACKUP)).unwrap(); + let marker = fs::read(d.0.join(MARKER)).unwrap(); + for choice in ["100", "125"] { + let manual = fixture(choice); + d.write(&manual); + assert_eq!(apply(Some(&d.0)), Ok(Status::Current)); + assert_eq!(d.bytes(), manual); + } + assert_eq!(fs::read(d.0.join(BACKUP)).unwrap(), backup); + assert_eq!(fs::read(d.0.join(MARKER)).unwrap(), marker); + } + #[test] + fn new_users_keep_the_native_auto_default_without_a_synthetic_preferences_file() { + let d = Temp::new(); + assert_eq!(apply(Some(&d.0)), Ok(Status::Initialized)); + assert!(!d.0.join("preferences.json").exists()); + assert!(!d.0.join(BACKUP).exists()); + assert_eq!(photocraft_engine::prefs::Preferences::default().interface.ui_scale, UiScale::Auto); + d.write(&fixture("150")); + assert_eq!(apply(Some(&d.0)), Ok(Status::Current)); + assert_eq!(d.bytes(), fixture("150")); + } + #[test] + fn existing_auto_or_missing_scale_keeps_every_original_byte() { + for original in [fixture("auto"), br#"{ "interface": {"theme":"pro"}, "custom": 42 }"#.to_vec()] { + let d = Temp::new(); + d.write(&original); + assert_eq!(apply(Some(&d.0)), Ok(Status::Initialized)); + assert_eq!(d.bytes(), original); + assert!(!d.0.join(BACKUP).exists()); + } + } + #[test] + fn corrupt_preferences_or_invalid_scale_are_not_cleared_or_marked_complete() { + for original in [b"{broken".to_vec(), b"[]".to_vec(), br#"{"interface":null}"#.to_vec(), fixture("not-a-scale")] { + let d = Temp::new(); + d.write(&original); + assert!(apply(Some(&d.0)).is_err()); + assert_eq!(d.bytes(), original); + assert!(!d.0.join(MARKER).exists()); + assert!(!d.0.join(BACKUP).exists()); + } + } + #[test] + fn a_conflicting_backup_is_never_overwritten() { + let d = Temp::new(); + let original = fixture("100"); + d.write(&original); + fs::write(d.0.join(BACKUP), b"older-backup").unwrap(); + assert!(apply(Some(&d.0)).is_err()); + assert_eq!(d.bytes(), original); + assert_eq!(fs::read(d.0.join(BACKUP)).unwrap(), b"older-backup"); + } + #[test] + fn a_preferences_write_error_keeps_original_bytes_and_does_not_mark_success() { + let d = Temp::new(); + let original = fixture("100"); + d.write(&original); + let write = |p: &Path, b: &[u8]| { + if p.file_name().is_some_and(|n| n == "preferences.json") { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else { + photocraft_format::atomic_write(p, b) + } + }; + assert!(apply_with_writer(&d.0, &write).is_err()); + assert_eq!(d.bytes(), original); + assert!(!d.0.join(MARKER).exists()); + assert_eq!(fs::read(d.0.join(BACKUP)).unwrap(), original); + } + #[test] + fn a_marker_write_error_rolls_back_the_original_and_reports_failure() { + let d = Temp::new(); + let original = fixture("100"); + d.write(&original); + let write = |p: &Path, b: &[u8]| { + if p.file_name().is_some_and(|n| n == MARKER) { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else { + photocraft_format::atomic_write(p, b) + } + }; + assert!(apply_with_writer(&d.0, &write).is_err()); + assert_eq!(d.bytes(), original); + assert!(!d.0.join(MARKER).exists()); + assert_eq!(fs::read(d.0.join(BACKUP)).unwrap(), original); + } + #[test] + fn an_existing_lock_or_corrupt_marker_preserves_the_user_choice() { + let d = Temp::new(); + let original = fixture("100"); + d.write(&original); + fs::write(d.0.join(LOCK), b"other-start").unwrap(); + assert!(apply(Some(&d.0)).is_err()); + assert_eq!(d.bytes(), original); + fs::remove_file(d.0.join(LOCK)).unwrap(); + fs::write(d.0.join(MARKER), b"broken").unwrap(); + assert!(apply(Some(&d.0)).is_err()); + assert_eq!(d.bytes(), original); + } + #[test] + fn corrupt_preferences_are_a_startup_error_even_after_the_policy_was_initialized() { + let d = Temp::new(); + assert_eq!(apply(Some(&d.0)), Ok(Status::Initialized)); + d.write(b"{broken"); + assert!(apply(Some(&d.0)).is_err()); + assert_eq!(d.bytes(), b"{broken"); + let description = failure_description(Some(&d.0), "保存失败"); + assert!(description.contains(BACKUP) && description.contains("本次未打开工作台")); + } +} diff --git a/apps/detail-photocraft/apps/photocraft/src/window_frame.rs b/apps/detail-photocraft/apps/photocraft/src/window_frame.rs index e418049d..896cde85 100644 --- a/apps/detail-photocraft/apps/photocraft/src/window_frame.rs +++ b/apps/detail-photocraft/apps/photocraft/src/window_frame.rs @@ -29,23 +29,151 @@ pub use platform::service; mod platform { use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle}; use photocraft_ui_egui::WindowFrameFn; - use winsafe::{HWND, HwndPlace, POINT, SIZE, co}; + use winsafe::prelude::Handle as _; + use winsafe::{HDC, HWND, HwndPlace, POINT, SIZE, co}; - /// A guard that keeps the window borderless each frame, or `None` when the app uses the - /// system title bar (the window is decorated) or its handle can't be found. + /// Correct inaccessible restored geometry once, using the real window and monitor work + /// area in physical pixels. Custom chrome also gets its existing per-frame border guard. pub fn service(cc: &eframe::CreationContext<'_>, custom_titlebar: bool) -> Option { - if !custom_titlebar { - return None; - } let target = match cc.window_handle().ok()?.as_raw() { RawWindowHandle::Win32(h) => h.hwnd.get(), _ => return None, }; let hwnd = find_window(target)?; - reapply(&hwnd); + if custom_titlebar { + reapply(&hwnd); + } + restore_startup_window(&hwnd, cc.egui_ctx.input(|input| input.viewport().fullscreen == Some(true))); + if !custom_titlebar { + return None; + } Some(Box::new(move || reapply(&hwnd))) } + /// eframe clamps saved geometry before our minimum-size hook expands it. That expansion + /// keeps the old centre and can put the title bar outside the screen again. Only after + /// creation do we know the final HWND rectangle and its actual monitor's work area. + /// Do not poll this during dragging or resizing: this is a startup correction only. + fn restore_startup_window(hwnd: &HWND, fullscreen: bool) { + if !can_restore_startup(hwnd.IsZoomed(), hwnd.IsIconic(), fullscreen) { + return; + } + let (Ok(rect), Ok(monitor)) = (hwnd.GetWindowRect(), hwnd.MonitorFromWindow(co::MONITOR::DEFAULTTONEAREST).GetMonitorInfo()) else { + return; + }; + let work = monitor.rcWork; + // A user's restored window can legitimately span adjacent displays. If + // enumeration is incomplete, leave it alone rather than assuming only + // the current display exists and moving a potentially valid placement. + let Some(work_areas) = display_work_areas() else { return }; + match desktop_startup_placement((rect.left, rect.top, rect.right, rect.bottom), (work.left, work.top, work.right, work.bottom), &work_areas) { + Some(StartupPlacement::Center(left, top)) => { + let _ = hwnd.SetWindowPos( + HwndPlace::None, + POINT { x: left, y: top }, + SIZE::new(), + co::SWP::NOSIZE | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE, + ); + } + Some(StartupPlacement::Maximize) => { + hwnd.ShowWindow(co::SW::SHOWMAXIMIZED); + } + None => {} + } + } + + fn display_work_areas() -> Option> { + let mut areas = Vec::new(); + let mut complete = true; + let result = HDC::NULL.EnumDisplayMonitors(None, |monitor, _, _| match monitor.GetMonitorInfo() { + Ok(info) if info.rcWork.left < info.rcWork.right && info.rcWork.top < info.rcWork.bottom => { + let work = info.rcWork; + areas.push((work.left, work.top, work.right, work.bottom)); + true + } + _ => { + complete = false; + false + } + }); + (result.is_ok() && complete && !areas.is_empty()).then_some(areas) + } + + fn desktop_startup_placement( + rect: (i32, i32, i32, i32), + current_work: (i32, i32, i32, i32), + work_areas: &[(i32, i32, i32, i32)], + ) -> Option { + if work_areas_cover(rect, work_areas) { None } else { startup_placement(rect, current_work) } + } + + /// Remove each real work area from the still-uncovered pieces. A bounding + /// desktop rectangle is insufficient: it includes gaps and taskbar regions. + fn work_areas_cover(rect: (i32, i32, i32, i32), work_areas: &[(i32, i32, i32, i32)]) -> bool { + if rect.0 >= rect.2 || rect.1 >= rect.3 { + return false; + } + let mut uncovered = vec![rect]; + for &(wl, wt, wr, wb) in work_areas { + if wl >= wr || wt >= wb { + continue; + } + let mut next = Vec::new(); + for (left, top, right, bottom) in uncovered { + let (il, it, ir, ib) = (left.max(wl), top.max(wt), right.min(wr), bottom.min(wb)); + if il >= ir || it >= ib { + next.push((left, top, right, bottom)); + continue; + } + if top < it { + next.push((left, top, right, it)); + } + if ib < bottom { + next.push((left, ib, right, bottom)); + } + if left < il { + next.push((left, it, il, ib)); + } + if ir < right { + next.push((ir, it, right, ib)); + } + } + if next.is_empty() { + return true; + } + uncovered = next; + } + false + } + + fn can_restore_startup(maximized: bool, minimized: bool, fullscreen: bool) -> bool { + !maximized && !minimized && !fullscreen + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum StartupPlacement { + Center(i32, i32), + Maximize, + } + + /// Both rectangles are physical desktop coordinates, including negative monitor origins. + /// Keep valid user positions exactly; recover an inaccessible one to the work-area centre. + fn startup_placement(rect: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option { + let (left, top, right, bottom) = rect; + let (wl, wt, wr, wb) = work; + let (width, height, work_width, work_height) = (right.checked_sub(left)?, bottom.checked_sub(top)?, wr.checked_sub(wl)?, wb.checked_sub(wt)?); + if width <= 0 || height <= 0 || work_width <= 0 || work_height <= 0 { + return None; + } + if left >= wl && top >= wt && right <= wr && bottom <= wb { + return None; + } + if width > work_width || height > work_height { + return Some(StartupPlacement::Maximize); + } + Some(StartupPlacement::Center(wl.checked_add((work_width - width) / 2)?, wt.checked_add((work_height - height) / 2)?)) + } + /// The top-level window with the given raw handle. `EnumWindows` hands out real `HWND`s, so /// this matches winit's own window without wrapping a raw handle (which needs `unsafe`). fn find_window(target: isize) -> Option { @@ -162,7 +290,7 @@ mod platform { #[cfg(test)] mod tests { - use super::{borderless, clamped_rect}; + use super::{StartupPlacement, borderless, can_restore_startup, clamped_rect, desktop_startup_placement, startup_placement, work_areas_cover}; use winsafe::co; #[test] @@ -178,6 +306,101 @@ mod platform { assert_eq!(bare.raw(), borderless(bare).raw()); } + #[test] + fn an_inaccessible_restored_window_is_centred_in_the_actual_work_area() { + assert_eq!(startup_placement((-510, 0, 590, 720), (0, 0, 2560, 1032)), Some(StartupPlacement::Center(730, 156))); + assert_eq!( + startup_placement((3500, -100, 4600, 620), (0, 0, 2560, 1032)), + Some(StartupPlacement::Center(730, 156)), + "disconnected monitor position" + ); + // Reserved taskbars on the top and left are part of the real work area. + assert_eq!(startup_placement((0, 0, 1100, 720), (80, 64, 2560, 1080)), Some(StartupPlacement::Center(770, 212))); + } + + #[test] + fn valid_normal_and_negative_monitor_positions_are_preserved_exactly() { + for (rect, work) in [ + ((300, 100, 1400, 820), (0, 0, 2560, 1032)), + ((-2400, 100, -1300, 820), (-2560, 0, 0, 1032)), + ((-1800, -1000, -700, -280), (-1920, -1080, 0, -48)), + ((0, 0, 2560, 1032), (0, 0, 2560, 1032)), + ] { + assert_eq!(startup_placement(rect, work), None); + } + } + + #[test] + fn a_valid_window_spanning_adjacent_or_negative_displays_is_preserved() { + let left = (0, 0, 2560, 1032); + let right = (2560, 0, 5120, 1032); + let rect = (2000, 100, 3100, 820); + assert!(work_areas_cover(rect, &[left, right])); + assert_eq!(desktop_startup_placement(rect, left, &[left, right]), None); + assert_eq!(desktop_startup_placement(rect, right, &[right, left]), None, "enumeration order and selected monitor do not change coverage"); + let negative = (-2560, 0, 0, 1032); + for rect in [(-600, 100, 500, 820), (-510, 0, 590, 720)] { + assert!(work_areas_cover(rect, &[negative, left])); + assert_eq!(desktop_startup_placement(rect, left, &[negative, left]), None); + } + } + + #[test] + fn gaps_and_staggered_work_areas_are_not_mistaken_for_usable_desktop() { + let left = (0, 0, 2560, 1032); + let gap_right = (2760, 0, 5320, 1032); + let rect = (2200, 100, 3300, 820); + assert!(!work_areas_cover(rect, &[left, gap_right])); + assert_eq!(desktop_startup_placement(rect, left, &[left, gap_right]), Some(StartupPlacement::Center(730, 156))); + let staggered = (2560, 500, 5120, 1532); + assert!(!work_areas_cover((2000, 100, 3100, 820), &[left, staggered])); + assert_eq!(desktop_startup_placement((2000, 100, 3100, 820), left, &[left, staggered]), Some(StartupPlacement::Center(730, 156))); + // The original report remains invalid when no left-hand display exists. + assert_eq!(desktop_startup_placement((-510, 0, 590, 720), left, &[left]), Some(StartupPlacement::Center(730, 156))); + } + + #[test] + fn union_coverage_handles_overlaps_and_rejects_empty_or_invalid_rectangles() { + let areas = [(0, 0, 1500, 1000), (1200, 0, 3000, 1000)]; + assert!(work_areas_cover((900, 100, 2100, 820), &areas)); + assert!(!work_areas_cover((900, 100, 3100, 820), &areas)); + assert!(!work_areas_cover((0, 0, 0, 100), &areas)); + assert!(!work_areas_cover((0, 0, 100, 100), &[])); + assert!(!work_areas_cover((0, 0, 100, 100), &[(100, 0, 0, 100)])); + } + + #[test] + fn a_window_larger_than_its_current_dpi_work_area_is_maximized() { + assert_eq!(startup_placement((0, 0, 3000, 720), (0, 0, 2560, 1032)), Some(StartupPlacement::Maximize)); + assert_eq!(startup_placement((100, 100, 2300, 1540), (0, 0, 2560, 1032)), Some(StartupPlacement::Maximize), "physical size after a DPI change"); + assert_eq!(startup_placement((-2700, 0, 300, 720), (-2560, 0, 0, 1032)), Some(StartupPlacement::Maximize)); + } + + #[test] + fn native_maximized_minimized_and_fullscreen_states_never_get_startup_moves() { + assert!(can_restore_startup(false, false, false)); + for (maximized, minimized, fullscreen) in [(true, false, false), (false, true, false), (false, false, true), (true, true, true)] { + assert!(!can_restore_startup(maximized, minimized, fullscreen)); + } + // This is the reported maximized native border, not a corrupt (-7,-7) position. + // The native state guard prevents even consulting/repositioning this rectangle. + let native_maximized = (-7, -7, 2567, 1039); + assert_eq!(startup_placement(native_maximized, (0, 0, 2560, 1032)), Some(StartupPlacement::Maximize)); + assert!(!can_restore_startup(true, false, false)); + } + + #[test] + fn invalid_or_overflowing_native_rectangles_are_not_repositioned() { + for (rect, work) in [ + ((10, 10, 0, 0), (0, 0, 2560, 1032)), + ((0, 0, 1100, 720), (100, 0, 0, 1032)), + ((0, 0, 1100, 720), (0, 0, 2560, 0)), + ((i32::MIN, 0, i32::MAX, 720), (0, 0, 2560, 1032)), + ] { + assert_eq!(startup_placement(rect, work), None); + } + } + #[test] fn a_maximize_respects_taskbars_on_every_edge() { let monitor = (0, 0, 1920, 1080); diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun.rs index eb20cac6..59d8db49 100644 --- a/apps/detail-photocraft/crates/ui-egui/src/jiansun.rs +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun.rs @@ -12,8 +12,10 @@ use std::{ mod agent_panel; mod creation; +mod creation_reads; mod pi_bridge; mod project_browser; +mod template_slots; /// egui 0.36 recolors hint atoms with weak_text_color, even for explicitly colored RichText. /// Scope the native token to the input so hints remain readable without changing other chrome. @@ -39,6 +41,7 @@ pub enum Event { Preview(Value, egui::ColorImage), PiAction(String, Value, mpsc::Sender>), CreationCanvas(Box, Value), + SlotCanvas(Box, Value), } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -54,6 +57,34 @@ enum CenterView { Materials, } +#[derive(Clone, PartialEq, Eq, Hash)] +struct CreationUiScope { + account: String, + origin: String, + doc: u64, +} + +#[derive(Clone)] +struct RetainedCreationUi { + canonical: Value, + draft: Value, +} + +fn template_snapshot_not_older(current: &Value, incoming: &Value) -> bool { + incoming.is_object() && incoming["revision"].as_u64().is_some_and(|revision| revision >= current["revision"].as_u64().unwrap_or(0)) +} + +fn slot_action_allowed(workflow: &Value, unknown: bool, method: &str) -> bool { + match method { + // These permissions come from the current backend snapshot. An unknown + // copy request is never reclassified as a successful or retryable task. + "slot-copy" => !unknown && template_slots::capability(workflow, "copyDispatchAllowed"), + "slot-generate" => template_slots::capability(workflow, "imageDispatchAllowed"), + "slot-apply" => template_slots::capability(workflow, "localApplyAllowed"), + _ => !unknown || matches!(method, "slot-read" | "slot-recover" | "slot-text-save" | "slot-select" | "slot-copy-apply" | "slot-reuse"), + } +} + /// Startup may resume only an account-owned local row; it must never navigate for an /// already active editor or a person who has started another task. fn resume_candidate(value: &Value, account: &Value, origin: &str, projects: &[Value], quiet: bool) -> Option { @@ -112,6 +143,499 @@ fn saved_project_state( Some((next, dirty)) } +fn project_ref_matches(local: &Value, remote: &Value) -> bool { + let link = &local["cloudLink"]; + link["id"].is_string() + && ((link["id"] == remote["id"] && link["source"] == remote["source"]) || (link["source"] == "web" && link["id"] == remote["sourceWebId"])) +} + +fn same_project_ref(a: &Value, b: &Value) -> bool { + a["id"].is_string() && a["source"].is_string() && a["id"] == b["id"] && a["source"] == b["source"] +} + +fn older_project_ref(incoming: &Value, current: &Value) -> bool { + same_project_ref(incoming, current) && incoming["revision"].as_u64().unwrap_or(0) < current["revision"].as_u64().unwrap_or(0) +} + +fn local_project_time(row: &Value) -> Option { + row["updatedAt"].as_u64().or_else(|| row["updatedAt"].as_str().and_then(|time| time.parse().ok())) +} + +fn keep_current_local(current: &Value, incoming: &Value, complete: bool) -> bool { + match current["revision"].as_u64().unwrap_or(0).cmp(&incoming["revision"].as_u64().unwrap_or(0)) { + std::cmp::Ordering::Greater => return true, + std::cmp::Ordering::Less => return false, + std::cmp::Ordering::Equal => {} + } + // A sync ACK advances the cloud revision without changing the canvas or + // context save time. An older GET cannot undo that ACK's metadata. + if older_project_ref(&incoming["cloudLink"], ¤t["cloudLink"]) { + return true; + } + if older_project_ref(¤t["cloudLink"], &incoming["cloudLink"]) { + return false; + } + if let (Some(current_time), Some(incoming_time)) = (local_project_time(current), local_project_time(incoming)) + && current_time != incoming_time + { + return current_time > incoming_time; + } + // Context timestamps have second precision. For an ambiguous background + // read retain unsynced work; an explicit Save/Sync receipt may clear it. + complete && current["syncDirty"] != false && incoming["syncDirty"] == false +} + +fn merge_cloud_update_offer(local: &mut Value, current: &Value, incoming: &Value) { + let offer = |row: &Value| { + let reference = &row["cloudUpdateRef"]; + (reference["source"] == "desktop" + && reference["updateExisting"] == true + && reference["localId"] == local["id"] + && reference["accountId"].is_string() + && reference["origin"].is_string() + && same_project_ref(reference, &local["cloudLink"])) + .then(|| reference.clone()) + }; + let old = offer(current); + let next = offer(incoming); + let compatible = + |a: &Value, b: &Value| same_project_ref(a, b) && a["accountId"] == b["accountId"] && a["origin"] == b["origin"] && a["localId"] == b["localId"]; + let (reference, metadata) = match (old.as_ref(), next.as_ref()) { + (Some(old), Some(next)) if compatible(next, old) && next["revision"].as_u64().unwrap_or(0) >= old["revision"].as_u64().unwrap_or(0) => (next, incoming), + (Some(old), _) => (old, current), + (None, Some(next)) => (next, incoming), + (None, None) => return, + }; + // Missing decoration is authoritative only after the local backup actually + // acknowledges that offered cloud version. A late GET must not erase it. + if local["cloudLink"]["revision"].as_u64().unwrap_or(0) >= reference["revision"].as_u64().unwrap_or(0) { + for key in ["cloudUpdateAvailable", "cloudUpdateBlocked", "cloudUpdateRef"] { + if let Some(object) = local.as_object_mut() { + object.remove(key); + } + } + return; + } + local["cloudUpdateRef"] = reference.clone(); + local["cloudUpdateAvailable"] = metadata["cloudUpdateAvailable"].clone(); + local["cloudUpdateBlocked"] = metadata["cloudUpdateBlocked"].clone(); + if local["localPending"] == true { + local["cloudUpdateAvailable"] = json!(false); + local["cloudUpdateBlocked"] = json!("pending_request"); + } else if local["syncDirty"] != false { + local["cloudUpdateAvailable"] = json!(false); + local["cloudUpdateBlocked"] = json!("unsynced_draft"); + } else if reference["localRevision"] != local["revision"] { + local["cloudUpdateAvailable"] = json!(false); + local["cloudUpdateBlocked"] = json!("backup_pending"); + } +} + +/// A cloud snapshot only controls cloud rows. Local files are never removed by +/// a read response, and only an explicit tombstone can hide a clean local copy. +fn merge_project_snapshot(current: &[Value], locals: &[Value], cloud: &[Value], deleted: &[Value], open: &[String], complete: bool) -> Vec { + // Preserve only identities still present in this response. A complete + // omission or explicit tombstone is not reversed by a cached cloud row. + let cloud: Vec = cloud.iter().map(|incoming| current.iter().find(|row| older_project_ref(incoming, row)).unwrap_or(incoming).clone()).collect(); + let mut native = std::collections::BTreeMap::new(); + for row in locals.iter().filter(|p| p["source"] == "native") { + native.insert(row["id"].as_str().unwrap_or("").to_owned(), row.clone()); + } + for row in current.iter().filter(|p| p["source"] == "native") { + let id = row["id"].as_str().unwrap_or("").to_owned(); + if native.get(&id).is_none_or(|p| keep_current_local(row, p, complete)) { + let mut kept = row.clone(); + if let Some(latest) = native.get(&id) { + kept["localPending"] = json!(row["localPending"] == true || latest["localPending"] == true); + for key in ["cloudLink", "cloudRef"] { + if older_project_ref(&kept[key], &latest[key]) { + kept[key] = latest[key].clone(); + } + } + merge_cloud_update_offer(&mut kept, row, latest); + } + native.insert(id, kept); + } else if let Some(latest) = native.get_mut(&id) { + latest["cloudPresent"] = row["cloudPresent"].clone(); + if !latest["cloudRef"]["id"].is_string() || older_project_ref(&latest["cloudRef"], &row["cloudRef"]) { + latest["cloudRef"] = row["cloudRef"].clone(); + } + if older_project_ref(&latest["cloudLink"], &row["cloudLink"]) { + latest["cloudLink"] = row["cloudLink"].clone(); + } + let incoming = latest.clone(); + merge_cloud_update_offer(latest, row, &incoming); + } + } + let mut out = Vec::new(); + let mut represented = std::collections::HashSet::new(); + for (id, mut row) in native { + if deleted.iter().any(|d| d["source"] == "native" && d["id"] == id) { + continue; + } + let tombstone = row["cloudDeleted"] == true || deleted.iter().any(|d| project_ref_matches(&row, d)); + if tombstone { + let pending = row["localPending"] == true; + let dirty = row["syncDirty"] != false; + if !pending && !dirty && !open.contains(&id) { + continue; + } + row["cloudDeleted"] = json!(true); + row["cloudPresent"] = json!(false); + row["retainedReason"] = json!(if pending { + "pending_request" + } else if dirty { + "unsynced_draft" + } else { + "open_document" + }); + } else if let Some(remote) = cloud.iter().find(|r| project_ref_matches(&row, r)) { + row["cloudPresent"] = json!(true); + let acknowledged = [&row["cloudRef"], &row["cloudLink"]] + .into_iter() + .filter(|reference| same_project_ref(reference, remote)) + .max_by_key(|reference| reference["revision"].as_u64().unwrap_or(0)); + let (reference, stale) = match acknowledged { + Some(reference) if older_project_ref(remote, reference) => (reference, true), + _ => (remote, false), + }; + row["cloudRef"] = json!({"id":reference["id"],"source":reference["source"],"revision":reference["revision"]}); + if !stale { + row["deleteBlocked"] = remote["deleteBlocked"].clone(); + row["deleteBlockedReason"] = remote["deleteBlockedReason"].clone(); + } + represented.insert(format!("{}:{}", remote["source"], remote["id"])); + } else if complete { + row["cloudPresent"] = json!(false); + } + out.push(row); + } + out.extend( + cloud + .iter() + .filter(|r| { + !deleted.iter().any(|d| d["source"] == r["source"] && d["id"] == r["id"]) && !represented.contains(&format!("{}:{}", r["source"], r["id"])) + }) + .cloned(), + ); + out +} + +fn project_read_matches(pending: &Value, value: &Value, account: &Value, origin: &str) -> bool { + pending["readId"].as_u64().is_some() + && pending["readId"] == value["readId"] + && pending["accountId"] == value["accountId"] + && value["accountId"] == account["id"] + && pending["origin"] == value["origin"] + && value["origin"] == origin +} + +fn project_refresh_due(visible: bool, focused: bool, was_visible: bool, was_focused: bool, requested: bool, elapsed: Duration) -> bool { + visible && focused && (requested || !was_visible || !was_focused || elapsed >= Duration::from_secs(30)) +} + +#[cfg(test)] +mod project_refresh_tests { + use super::*; + fn workspace() -> (Workspace, mpsc::Receiver, mpsc::Sender) { + let (tx, requests) = mpsc::channel(); + let (events, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + let _ = requests.try_recv(); + w.account = json!({"id":"owner"}); + w.busy = None; + w.project_refresh = Instant::now() - Duration::from_secs(31); + (w, requests, events) + } + fn local(id: &str) -> Value { + json!({"source":"native","id":id,"revision":4,"syncDirty":false,"localPending":false,"cloudLink":{"source":"web","id":"cloud"}}) + } + #[test] + fn project_reads_deduplicate_without_waiting_for_or_clearing_a_model_task() { + let (mut w, requests, _events) = workspace(); + w.busy = Some("creation-points".into()); + w.saving = Some((17, 3)); + w.unknown = true; + w.send("projects", json!({})); + let Request::Action(method, value) = requests.try_recv().unwrap() else { panic!("expected read action") }; + assert_eq!(method, "project-list"); + assert_eq!(value["accountId"], "owner"); + assert_eq!(value["origin"], w.origin); + w.send("projects", json!({})); + assert!(requests.try_recv().is_err()); + assert_eq!(w.busy.as_deref(), Some("creation-points")); + assert_eq!(w.saving, Some((17, 3))); + assert!(w.unknown); + } + #[test] + fn list_entry_focus_and_periodic_reads_stop_outside_the_project_view() { + let short = Duration::from_secs(2); + assert!(project_refresh_due(true, true, false, true, false, short)); + assert!(project_refresh_due(true, true, true, false, false, short)); + assert!(project_refresh_due(true, true, true, true, false, Duration::from_secs(30))); + assert!(!project_refresh_due(true, true, true, true, false, short)); + assert!(!project_refresh_due(false, true, false, false, true, Duration::from_secs(90))); + assert!(!project_refresh_due(true, false, false, false, true, Duration::from_secs(90))); + let (mut w, requests, _events) = workspace(); + w.material = true; + w.send("projects", json!({})); + assert!(requests.try_recv().is_err()); + w.material = false; + w.creation_open = true; + w.send("projects", json!({})); + assert!(requests.try_recv().is_err()); + } + #[test] + fn failed_incomplete_or_old_scope_reads_leave_the_visible_list_intact() { + let (mut w, _requests, _events) = workspace(); + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let old = vec![json!({"source":"web","id":"cloud"})]; + w.projects = old.clone(); + w.project_read = Some(json!({"readId":8,"accountId":"owner","origin":w.origin})); + let mut value = json!({"readId":8,"accountId":"other","origin":w.origin,"complete":true,"items":[],"locals":[],"deleted":[]}); + assert!(!w.receive_project_list(&app, &value)); + value["accountId"] = json!("owner"); + value["origin"] = json!("http://127.0.0.1:3138"); + assert!(!w.receive_project_list(&app, &value)); + value["origin"] = json!(w.origin); + value["readId"] = json!(7); + assert!(!w.receive_project_list(&app, &value)); + value["readId"] = json!(8); + value["error"] = json!("offline"); + assert!(w.receive_project_list(&app, &value)); + assert_eq!(w.projects, old); + assert!(w.project_list_error.contains("原列表保留")); + w.project_read = Some(json!({"readId":9,"accountId":"owner","origin":w.origin})); + value["readId"] = json!(9); + value.as_object_mut().unwrap().remove("error"); + value["complete"] = json!(false); + assert!(w.receive_project_list(&app, &value)); + assert_eq!(w.projects, old); + } + #[test] + fn remote_omission_preserves_local_files_and_explicit_tombstones_preserve_dirty_pending_and_open_copies() { + let clean = local("local"); + let missing = merge_project_snapshot(std::slice::from_ref(&clean), &[], &[], &[], &[], true); + assert_eq!(missing.len(), 1); + assert_eq!(missing[0]["cloudPresent"], false); + let deleted = vec![json!({"source":"web","id":"cloud"})]; + assert!(merge_project_snapshot(&[], std::slice::from_ref(&clean), &[], &deleted, &[], true).is_empty()); + for reason in ["dirty", "pending", "open"] { + let mut row = clean.clone(); + row["syncDirty"] = json!(reason == "dirty"); + row["localPending"] = json!(reason == "pending"); + let open = if reason == "open" { vec!["local".into()] } else { vec![] }; + let kept = merge_project_snapshot(&[], &[row], &[], &deleted, &open, true); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0]["cloudDeleted"], true); + assert_eq!(kept[0]["cloudPresent"], false); + } + } + #[test] + fn same_canvas_revision_refresh_keeps_new_local_metadata_and_an_unsynced_tombstoned_draft() { + let mut old = local("local"); + old["updatedAt"] = json!("100"); + old["status"] = json!("completed"); + let mut fresh = old.clone(); + fresh["updatedAt"] = json!("101"); + fresh["syncDirty"] = json!(true); + fresh["status"] = json!("draft"); + fresh["name"] = json!("刚保存的产品资料"); + let deleted = vec![json!({"source":"web","id":"cloud"})]; + let rows = merge_project_snapshot(&[old], std::slice::from_ref(&fresh), &[], &deleted, &[], true); + assert_eq!(rows.len(), 1, "a metadata-only edit must not be hidden by a cloud tombstone"); + assert_eq!(rows[0]["syncDirty"], true); + assert_eq!(rows[0]["status"], "draft"); + assert_eq!(rows[0]["name"], fresh["name"]); + assert_eq!(rows[0]["retainedReason"], "unsynced_draft"); + let stale = merge_project_snapshot( + &rows, + &[ + json!({"source":"native","id":"local","revision":4,"updatedAt":"100","syncDirty":false,"localPending":false,"cloudLink":{"source":"web","id":"cloud"}}), + ], + &[], + &deleted, + &[], + true, + ); + assert_eq!(stale.len(), 1); + assert_eq!(stale[0]["syncDirty"], true, "an older context snapshot cannot clear the newer edit"); + } + #[test] + fn same_second_metadata_reads_are_conservative_but_a_real_sync_ack_can_clear_dirty() { + let mut dirty = local("local"); + dirty["updatedAt"] = json!("100"); + dirty["syncDirty"] = json!(true); + dirty["cloudLink"]["revision"] = json!(10); + let mut clean = dirty.clone(); + clean["syncDirty"] = json!(false); + clean["status"] = json!("completed"); + let protected = merge_project_snapshot(std::slice::from_ref(&dirty), std::slice::from_ref(&clean), &[], &[], &[], true); + assert_eq!(protected[0]["syncDirty"], true, "second-resolution ties cannot discard unsynced work"); + let explicit = merge_project_snapshot(&protected, std::slice::from_ref(&clean), &[], &[], &[], false); + assert_eq!(explicit[0]["syncDirty"], false, "an explicit sync receipt must not be permanently OR-ed dirty"); + clean["cloudLink"]["revision"] = json!(11); + let acknowledged = merge_project_snapshot(&[dirty.clone()], std::slice::from_ref(&clean), &[], &[], &[], true); + assert_eq!(acknowledged[0]["syncDirty"], false, "a higher acknowledged cloud revision is newer even in the same second"); + let late = merge_project_snapshot(&acknowledged, &[dirty], &[], &[], &[], true); + assert_eq!(late[0]["syncDirty"], false); + assert_eq!(late[0]["cloudLink"]["revision"], 11); + let mut later = clean.clone(); + later["updatedAt"] = json!("101"); + let mut earlier = clean; + earlier["syncDirty"] = json!(true); + assert_eq!( + merge_project_snapshot(&[earlier], &[later], &[], &[], &[], true)[0]["syncDirty"], + false, + "a demonstrably newer local context can clear the old dirty metadata" + ); + } + #[test] + fn older_cloud_get_cannot_roll_back_a_native_ack_or_its_delete_revision() { + let mut acknowledged = local("local"); + acknowledged["cloudLink"]["revision"] = json!(11); + acknowledged["cloudRef"] = json!({"source":"web","id":"cloud","revision":11}); + acknowledged["deleteBlocked"] = json!(false); + let old_remote = json!({"source":"web","id":"cloud","revision":10,"deleteBlocked":true,"deleteBlockedReason":"task_running"}); + let rows = + merge_project_snapshot(std::slice::from_ref(&acknowledged), std::slice::from_ref(&acknowledged), std::slice::from_ref(&old_remote), &[], &[], true); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["cloudRef"]["revision"], 11); + assert_eq!(rows[0]["deleteBlocked"], false); + let mut newer_canvas = acknowledged.clone(); + newer_canvas["revision"] = json!(5); + newer_canvas["syncDirty"] = json!(true); + newer_canvas["cloudLink"]["revision"] = json!(10); + let rows = merge_project_snapshot(&[acknowledged], &[newer_canvas], &[old_remote], &[], &[], true); + assert_eq!(rows[0]["revision"], 5); + assert_eq!(rows[0]["syncDirty"], true); + assert_eq!(rows[0]["cloudLink"]["revision"], 11); + assert_eq!(rows[0]["cloudRef"]["revision"], 11); + } + + #[test] + fn late_project_reads_merge_new_cloud_offers_without_losing_dirty_work_or_the_highest_scoped_revision() { + let reference = |revision, account| json!({"source":"desktop","id":"cloud","revision":revision,"updateExisting":true,"localId":"local","localRevision":5,"localCloudRevision":10,"accountId":account,"origin":"https://sc.jiansun.vip"}); + let mut current = local("local"); + current["revision"] = json!(5); + current["name"] = json!("当前未同步资料"); + current["syncDirty"] = json!(true); + current["cloudLink"] = json!({"source":"desktop","id":"cloud","revision":10}); + current["cloudUpdateRef"] = reference(12, "owner"); + current["cloudUpdateAvailable"] = json!(false); + current["cloudUpdateBlocked"] = json!("unsynced_draft"); + let mut incoming = current.clone(); + incoming["revision"] = json!(4); + incoming["syncDirty"] = json!(false); + incoming["cloudUpdateRef"] = reference(13, "owner"); + incoming["cloudUpdateRef"]["localRevision"] = json!(4); + incoming["cloudUpdateAvailable"] = json!(true); + incoming["cloudUpdateBlocked"] = Value::Null; + let mut rows = merge_project_snapshot(&[current], &[incoming.clone()], &[], &[], &[], true); + assert_eq!(rows[0]["revision"], 5); + assert_eq!(rows[0]["name"], "当前未同步资料"); + assert_eq!(rows[0]["cloudUpdateRef"]["revision"], 13); + assert_eq!(rows[0]["cloudUpdateAvailable"], false); + assert_eq!(rows[0]["cloudUpdateBlocked"], "unsynced_draft"); + for next in [Some(reference(11, "owner")), Some(reference(14, "other-account")), None] { + let mut late = incoming.clone(); + if let Some(reference) = next { + late["cloudUpdateRef"] = reference; + } else { + for key in ["cloudUpdateRef", "cloudUpdateAvailable", "cloudUpdateBlocked"] { + late.as_object_mut().unwrap().remove(key); + } + } + rows = merge_project_snapshot(&rows, &[late], &[], &[], &[], true); + assert_eq!(rows[0]["cloudUpdateRef"]["revision"], 13, "late, other-account and undecorated GETs cannot clear the known newer backup"); + assert_eq!(rows[0]["cloudUpdateAvailable"], false); + assert_eq!(rows[0]["syncDirty"], true); + } + let mut pending = rows[0].clone(); + pending["revision"] = json!(6); + pending["syncDirty"] = json!(false); + pending["localPending"] = json!(true); + let guarded = merge_project_snapshot(&[pending], &[incoming], &[], &[], &[], true); + assert_eq!(guarded[0]["localPending"], true, "an older read cannot clear a newer unresolved local request"); + assert_eq!(guarded[0]["cloudUpdateAvailable"], false); + assert_eq!(guarded[0]["cloudUpdateBlocked"], "pending_request"); + let mut acknowledged = rows[0].clone(); + acknowledged["syncDirty"] = json!(false); + acknowledged["cloudLink"]["revision"] = json!(13); + for key in ["cloudUpdateRef", "cloudUpdateAvailable", "cloudUpdateBlocked"] { + acknowledged.as_object_mut().unwrap().remove(key); + } + let rows = merge_project_snapshot(&rows, &[acknowledged], &[], &[], &[], true); + assert_eq!(rows[0]["cloudLink"]["revision"], 13); + assert_eq!(rows[0]["syncDirty"], false); + assert!(rows[0].get("cloudUpdateRef").is_none(), "an actual backup acknowledgement clears its fulfilled update offer"); + } + #[test] + fn cloud_rows_keep_the_higher_revision_without_reviving_omitted_or_deleted_projects() { + let current = json!({"source":"desktop","id":"cloud","revision":11,"name":"已同步的新标题","status":"completed"}); + let mut old = current.clone(); + old["revision"] = json!(10); + old["name"] = json!("旧标题"); + old["status"] = json!("draft"); + assert_eq!(merge_project_snapshot(std::slice::from_ref(¤t), &[], std::slice::from_ref(&old), &[], &[], true), vec![current.clone()]); + let mut fresh = current.clone(); + fresh["revision"] = json!(12); + fresh["name"] = json!("后续修改"); + assert_eq!(merge_project_snapshot(std::slice::from_ref(¤t), &[], std::slice::from_ref(&fresh), &[], &[], true), vec![fresh]); + assert!(merge_project_snapshot(std::slice::from_ref(¤t), &[], &[], &[], &[], true).is_empty()); + assert!(merge_project_snapshot(&[current], &[], &[old], &[json!({"source":"desktop","id":"cloud"})], &[], true).is_empty()); + } + #[test] + fn cloud_and_local_delete_tombstones_survive_late_save_and_sync_receipts() { + let (mut w, _requests, _events) = workspace(); + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + w.project_tombstones = vec![json!({"source":"native","id":"local"}), json!({"source":"web","id":"cloud"})]; + let rows = vec![local("local"), json!({"source":"web","id":"cloud","revision":1})]; + w.merge_project_receipt(&app, &rows); + w.merge_project_receipt(&app, &rows); + assert!(w.projects.is_empty()); + let mut newer = local("kept"); + newer["syncDirty"] = json!(true); + w.projects = vec![newer.clone()]; + w.project_tombstones.clear(); + newer["syncDirty"] = json!(false); + w.merge_project_receipt(&app, &[newer]); + assert_eq!(w.projects[0]["syncDirty"], false, "a same-revision explicit sync receipt must update local metadata"); + } + #[test] + fn cancelling_an_origin_choice_does_not_change_the_authorized_scope() { + let (mut w, _requests, _events) = workspace(); + let active = w.origin.clone(); + w.login_origin = Some("http://127.0.0.1:3138".into()); + w.config = false; + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| w.header(&mut app, ui)); + output.textures_delta.clear(); + assert_eq!(w.origin, active); + assert!(w.login_origin.is_none()); + assert_eq!(w.account["id"], "owner"); + } + #[test] + fn completed_login_switches_scope_and_rejects_the_previous_origin_read() { + let (mut w, _requests, events) = workspace(); + let old = json!({"readId":4,"accountId":"owner","origin":w.origin,"items":[],"locals":[],"deleted":[],"complete":true}); + w.project_read = Some(old.clone()); + w.project_tombstones = vec![json!({"id":"old","source":"web"})]; + w.login_origin = Some("http://127.0.0.1:3138".into()); + events.send(Event::Json("login".into(), Ok(json!({"account":{"id":"owner"},"origin":"http://127.0.0.1:3138","projects":[]})))).unwrap(); + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| w.tick(&mut app, ui)); + output.textures_delta.clear(); + assert_eq!(w.origin, "http://127.0.0.1:3138"); + assert!(w.login_origin.is_none()); + assert!(w.project_read.is_none()); + assert!(w.project_tombstones.is_empty()); + assert!(!w.receive_project_list(&app, &old)); + } +} + #[cfg(test)] mod startup_resume_tests { use super::*; @@ -242,6 +766,7 @@ pub struct Workspace { material_read_sequence: u64, material_error: String, origin: String, + login_origin: Option, model: String, model_ready: bool, image_models: Vec, @@ -298,7 +823,16 @@ pub struct Workspace { open_projects: std::collections::HashMap, project_geometry: Value, project_refresh: Instant, + project_read: Option, + project_read_sequence: u64, + project_refresh_requested: bool, + project_was_visible: bool, + project_was_focused: bool, + project_open_locals: Vec, + project_tombstones: Vec, + project_list_error: String, pi_run: Option, + pi_write_blocked: bool, pi_touched: Instant, pi_binding: Value, agent_rule: Value, @@ -323,22 +857,101 @@ pub struct Workspace { creation_failed_signature: String, creation_touched: Instant, creation_restore_draft: Value, + creation_ui_scope: Option, + retained_creation_ui: std::collections::HashMap, + pending_project_open: Option, creation_width: u32, creation_count: usize, creation_catalog: std::collections::HashMap>, + creation_reads: creation_reads::UiState, + creation_view_geometry: Value, + navigation_geometry: Value, creation_material_query: String, creation_thumbs: std::collections::HashMap, - creation_thumb_loading: std::collections::HashSet, - creation_thumb_failed: std::collections::HashSet, creation_run: Option, creation_base_revision: u64, creation_pending: Option<(Box, Value)>, creation_progress: String, + template_workflow: Value, + template_ui: template_slots::UiState, + pending_slot_apply: Option, sync_confirm: bool, sync_dirty: bool, sync_request_meta: String, } impl Workspace { + fn receive_template_snapshot(&mut self, incoming: &Value, mutation: bool) -> bool { + if !template_snapshot_not_older(&self.template_workflow, incoming) { + return false; + } + let changed = self.template_workflow != *incoming; + // The first read establishes this project's baseline. A mutating result, + // or any later changed/reconciled snapshot, is a new cloud backup version + // even when the visible canvas has not changed at all. + if changed && (mutation || self.template_workflow.is_object()) { + self.sync_dirty = true; + self.draft_changed = true; + } + self.template_workflow = incoming.clone(); + true + } + + fn slot_action(&mut self, app: &PhotocraftApp, method: &str, mut input: Value) -> Result { + if self.busy.is_some() { + return Err("请等待当前操作完成".into()); + } + if !slot_action_allowed(&self.template_workflow, self.unknown, method) { + return Err(if self.unknown { + "原请求待核查,此操作尚未获准;不会重发" + } else { + "当前阶段不允许此图位操作,请重新读取项目" + } + .into()); + } + let st = app.session.active().ok_or("请先打开项目")?; + input["doc"] = json!(st.doc.id.0); + input["baseRevision"] = json!(st.revision); + input["slotRevision"] = self.template_workflow["revision"].as_u64().map(Value::from).unwrap_or(json!(0)); + input["imageModel"] = json!(self.image_model); + if method == "slot-apply" { + if self.saving.is_some() || self.pending_slot_apply.is_some() { + return Err("请等待本机保存完成,再切换输出".into()); + } + let meta = json!({"composer":self.composer,"messages":self.messages,"project":self.project_context,"syncDirty":self.sync_dirty,"creationDraft":self.creation_draft()}); + self.tx.send(Request::Save(st.doc.clone(), st.revision, meta)).map_err(|_| "当前输出无法保存,未切换画布")?; + input["accountId"] = self.account["id"].clone(); + input["origin"] = json!(self.origin); + self.pending_slot_apply = Some(input); + self.saving = Some((st.doc.id.0, st.revision)); + self.busy = Some("slot-apply-save".into()); + return Ok(json!({"submitted":true,"saving":true})); + } + self.send(method, input); + Ok(json!({"submitted":true})) + } + + fn slot_apply_saved(&mut self, app: &PhotocraftApp, id: u64, revision: u64) { + let Some(pending) = self.pending_slot_apply.as_ref() else { return }; + if pending["doc"].as_u64() != Some(id) || pending["baseRevision"].as_u64() != Some(revision) { + return; // An unrelated or delayed save is not this output's acknowledgement. + } + let Some(pending) = self.pending_slot_apply.take() else { return }; + let waiting = self.busy.as_deref() == Some("slot-apply-save"); + if waiting { + self.busy = None; + } + if !waiting + || !slot_action_allowed(&self.template_workflow, self.unknown, "slot-apply") + || pending["accountId"] != self.account["id"] + || pending["origin"] != self.origin + || pending["slotRevision"] != self.template_workflow["revision"] + || !app.session.active().is_some_and(|s| s.doc.id.0 == id && s.revision == revision) + { + self.error = "当前项目或画布已有新变化,本机保存已完成,未切换输出".into(); + return; + } + self.send("slot-apply", pending); + } pub fn new(tx: mpsc::Sender, rx: mpsc::Receiver) -> Self { let out = Self { tx, @@ -355,6 +968,7 @@ impl Workspace { material_read_sequence: 0, material_error: String::new(), origin: "https://sc.jiansun.vip".into(), + login_origin: None, model: String::new(), model_ready: false, image_models: vec![], @@ -411,7 +1025,16 @@ impl Workspace { open_projects: std::collections::HashMap::new(), project_geometry: json!({}), project_refresh: Instant::now(), + project_read: None, + project_read_sequence: 0, + project_refresh_requested: false, + project_was_visible: false, + project_was_focused: false, + project_open_locals: vec![], + project_tombstones: vec![], + project_list_error: String::new(), pi_run: None, + pi_write_blocked: false, pi_touched: Instant::now(), pi_binding: Value::Null, agent_rule: Value::Null, @@ -436,17 +1059,24 @@ impl Workspace { creation_failed_signature: String::new(), creation_touched: Instant::now(), creation_restore_draft: Value::Null, + creation_ui_scope: None, + retained_creation_ui: Default::default(), + pending_project_open: None, creation_width: 1440, creation_count: 3, creation_catalog: Default::default(), + creation_reads: Default::default(), + creation_view_geometry: json!({}), + navigation_geometry: json!({}), creation_material_query: String::new(), creation_thumbs: Default::default(), - creation_thumb_loading: Default::default(), - creation_thumb_failed: Default::default(), creation_run: None, creation_base_revision: 0, creation_pending: None, creation_progress: String::new(), + template_workflow: Value::Null, + template_ui: template_slots::UiState::default(), + pending_slot_apply: None, sync_confirm: false, sync_dirty: true, sync_request_meta: String::new(), @@ -454,7 +1084,81 @@ impl Workspace { let _ = out.tx.send(Request::Action("restore".into(), json!({}))); out } + fn live_project_locals(&self, app: &PhotocraftApp) -> Vec { + let prefix = self.local_identity(""); + app.session + .documents() + .iter() + .filter_map(|st| self.open_projects.get(&st.doc.id.0)) + .filter_map(|identity| identity.strip_prefix(&prefix).filter(|id| !id.is_empty()).map(str::to_owned)) + .collect() + } + fn dispatch_project_refresh(&mut self) { + if self.project_read.is_some() + || self.account.is_null() + || !self.library + || self.material + || self.creation_open + || self.project_refresh.elapsed() < Duration::from_millis(500) + { + return; + } + self.project_read_sequence = self.project_read_sequence.saturating_add(1); + let input = json!({"readId":self.project_read_sequence,"accountId":self.account["id"],"origin":self.origin}); + self.project_read = Some(input.clone()); + self.project_refresh_requested = false; + self.project_refresh = Instant::now(); + self.project_list_error.clear(); + if self.tx.send(Request::Action("project-list".into(), input)).is_err() { + self.project_read = None; + self.project_list_error = "项目刷新服务已停止,原列表保留".into(); + } + } + fn merge_project_receipt(&mut self, app: &PhotocraftApp, items: &[Value]) { + let mut cloud: Vec = self.projects.iter().filter(|p| p["source"] != "native").cloned().collect(); + for row in items.iter().filter(|p| p["source"] != "native") { + cloud.retain(|old| old["id"] != row["id"] || old["source"] != row["source"]); + cloud.push(row.clone()); + } + self.project_open_locals = self.live_project_locals(app); + self.projects = merge_project_snapshot(&self.projects, items, &cloud, &self.project_tombstones, &self.project_open_locals, false); + } + fn receive_project_list(&mut self, app: &PhotocraftApp, value: &Value) -> bool { + let Some(pending) = self.project_read.as_ref() else { return false }; + if !project_read_matches(pending, value, &self.account, &self.origin) { + return false; + } + self.project_read = None; + self.project_refresh_requested = false; + self.project_refresh = Instant::now(); + if let Some(error) = value["error"].as_str() { + self.project_list_error = format!("项目未能刷新,原列表保留:{error}"); + return true; + } + let (Some(items), Some(locals), Some(deleted)) = (value["items"].as_array(), value["locals"].as_array(), value["deleted"].as_array()) else { + self.project_list_error = "项目列表不完整,原列表保留".into(); + return true; + }; + if value["complete"] != true { + self.project_list_error = "项目分页未完成,原列表保留".into(); + return true; + } + for item in deleted { + if !self.project_tombstones.iter().any(|old| old["id"] == item["id"] && old["source"] == item["source"]) { + self.project_tombstones.push(item.clone()); + } + } + self.project_open_locals = self.live_project_locals(app); + self.projects = merge_project_snapshot(&self.projects, locals, items, &self.project_tombstones, &self.project_open_locals, true); + self.project_list_error.clear(); + true + } fn send(&mut self, method: &str, input: Value) { + if method == "projects" { + self.project_refresh_requested = true; + self.dispatch_project_refresh(); + return; + } if matches!(method, "materials" | "preview") { self.send_material_read(method, input); return; @@ -483,7 +1187,9 @@ impl Workspace { return; } let pending = if method == "materials" { &mut self.material_search_pending } else { &mut self.material_preview_pending }; - if pending.is_some() { return; } + if pending.is_some() { + return; + } self.material_read_sequence = self.material_read_sequence.wrapping_add(1); let read_id = self.material_read_sequence; *pending = Some(read_id); @@ -496,10 +1202,11 @@ impl Workspace { } fn finish_material_read(&mut self, method: &str, value: &Value, active_doc: Option) -> bool { let pending = if method == "materials" { &mut self.material_search_pending } else { &mut self.material_preview_pending }; - if pending.is_none() || value["readId"].as_u64() != *pending { return false; } + if pending.is_none() || value["readId"].as_u64() != *pending { + return false; + } *pending = None; - value["accountId"] == self.account["id"] && value["origin"] == self.origin - && (method != "preview" || value["doc"].as_u64() == active_doc) + value["accountId"] == self.account["id"] && value["origin"] == self.origin && (method != "preview" || value["doc"].as_u64() == active_doc) } pub fn state(&self) -> Value { let mut state = self.base_state(); @@ -512,12 +1219,22 @@ impl Workspace { state["deleteError"] = json!(self.delete_error); state["materialLoading"] = json!({"search":self.material_search_pending.is_some(),"preview":self.material_preview_pending.is_some()}); state["materialError"] = json!(self.material_error); + state["projectList"] = json!({"loading":self.project_read.is_some(),"error":self.project_list_error,"pendingRead":self.project_read.as_ref().map(|p|p["readId"].clone()),"tombstones":self.project_tombstones.len()}); state["materialRows"] = json!(self.material_rows); - state["materialPreview"] = self.preview.as_ref().map(|(reference, texture)| json!({"reference":reference,"size":texture.size()})).unwrap_or(Value::Null); + state["materialPreview"] = + self.preview.as_ref().map(|(reference, texture)| json!({"reference":reference,"size":texture.size()})).unwrap_or(Value::Null); state["piReview"] = json!(self.pi_review); state["piUsage"] = self.pi_usage.clone(); state["creation"] = self.creation.clone(); + state["templateWorkflow"] = self.template_workflow.clone(); + state["templateView"] = self.template_ui.inspect(); + state["slotApplyPending"] = json!(self.pending_slot_apply.is_some()); state["creationView"] = json!({"open":self.creation_open,"step":self.creation_step,"dirty":self.creation_dirty,"progress":self.creation_progress,"pendingCanvas":self.creation_pending.is_some(),"brief":self.creation_brief,"screenCount":self.creation_count,"selected":self.creation_selected}); + state["creationView"]["geometry"] = self.creation_view_geometry.clone(); + state["creationView"]["catalogs"] = json!(self.creation_catalog); + state["creationView"]["reads"] = self.creation_reads_state(); + state["creationView"]["thumbnailReady"] = json!(self.creation_thumbs.keys().collect::>()); + state["navigation"] = json!({"view":if self.material {"materials"}else if self.library {"projects"}else{"creation"},"step":self.creation_step,"geometry":self.navigation_geometry}); state["projectState"] = json!({"syncDirty":self.sync_dirty,"cloudLink":self.project_context["cloudLink"],"syncedRevision":self.project_context["syncedRevision"],"status":self.project_context["status"]}); state["recovery"] = json!({"startupResumeChecked":self.startup_resume_checked,"userInteracted":self.startup_interacted,"lastStatus":self.recovery_status}); @@ -531,6 +1248,9 @@ impl Workspace { self.draft_changed = true; } fn handover(&mut self) { + if self.pi_run.is_some() || self.busy.as_deref() == Some("agent") { + self.pi_write_blocked = true; + } if self.pi_run.is_some() && self.allow_apply { self.note("已接手,Agent 不再修改画布"); } @@ -548,10 +1268,143 @@ impl Workspace { /// The central work area has exactly one destination. A missing document returns to /// projects rather than leaving an apparently selected, empty canvas behind. fn show_center(&mut self, view: CenterView, document_open: bool) { - self.handover(); - self.creation_open = false; - self.material = matches!(view, CenterView::Materials); - self.library = matches!(view, CenterView::Projects) || (!document_open && !self.material); + self.cancel_project_open(); + let material = matches!(view, CenterView::Materials); + let library = matches!(view, CenterView::Projects) || (!document_open && !material); + let creation = document_open && !material && !library; + if self.material != material || self.library != library || self.creation_open != creation || (creation && self.creation_step != 3) { + self.handover(); + } + self.material = material; + self.library = library; + self.creation_open = creation; + if creation { + self.creation_step = 3; + } + } + fn show_creation_step(&mut self, step: usize, document_open: bool) { + self.cancel_project_open(); + if !document_open { + self.show_center(CenterView::Projects, false); + return; + } + let step = step.min(4); + if self.library || self.material || !self.creation_open || self.creation_step != step { + self.handover(); + } + self.library = false; + self.material = false; + self.creation_open = true; + self.creation_step = step; + } + fn current_creation_ui_scope(&self, app: &PhotocraftApp) -> Option { + Some(CreationUiScope { + account: self.account["id"].as_str().filter(|id| !id.is_empty())?.into(), + origin: self.origin.clone(), + doc: app.session.active()?.doc.id.0, + }) + } + fn retain_creation_ui_draft(&mut self, app: &PhotocraftApp) { + // A tab's unsent fields are not part of the native canvas revision. Keep + // them before resetting the active workspace, within their original owner. + let open: std::collections::HashSet = app.session.documents().iter().map(|st| st.doc.id.0).collect(); + self.retained_creation_ui.retain(|scope, _| open.contains(&scope.doc)); + let Some(scope) = self.creation_ui_scope.as_ref().filter(|scope| Some(scope.doc) == self.creation_doc && open.contains(&scope.doc)).cloned() else { + return; + }; + if self.creation_dirty && self.creation.is_object() { + self.retained_creation_ui.insert(scope, RetainedCreationUi { canonical: self.creation.clone(), draft: self.creation_draft() }); + } else { + self.retained_creation_ui.remove(&scope); + } + } + fn receive_creation_ui(&mut self, app: &PhotocraftApp, method: &str, value: &Value) { + let scope = self.current_creation_ui_scope(app); + let bound = scope + .as_ref() + .is_some_and(|scope| value["accountId"] == scope.account && value["origin"] == scope.origin && value["doc"].as_u64() == Some(scope.doc)); + if bound + && method == "creation-read" + && let Some(retained) = scope.as_ref().and_then(|scope| self.retained_creation_ui.get(scope)).cloned() + { + self.creation = retained.canonical; + self.creation_brief = retained.draft["brief"].clone(); + self.creation_selected = retained.draft["selected"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); + self.creation_direction = retained.draft["direction"].as_str().unwrap_or("").into(); + self.creation_plan = retained.draft["screens"].clone(); + self.creation_width = retained.draft["width"].as_u64().unwrap_or(1440) as u32; + self.creation_count = retained.draft["screenCount"].as_u64().unwrap_or(3) as usize; + self.creation_dirty = true; + self.draft_changed = true; + self.sync_dirty = true; + // The in-memory tab is newer than its earlier on-disk debounce + // snapshot. Reuse received's existing dirty-preserving read path. + self.creation_restore_draft = Value::Null; + } + creation::received(self, app, method, value); + if bound && value["creation"].is_object() && self.creation_doc == scope.as_ref().map(|scope| scope.doc) { + self.creation_ui_scope = scope.clone(); + if !self.creation_dirty + && let Some(scope) = scope + { + self.retained_creation_ui.remove(&scope); + } + } + } + fn project_open_save_signature(&self) -> String { + json!({"composer":self.composer,"messages":self.messages,"creationDraft":self.creation_draft()}).to_string() + } + fn cancel_project_open(&mut self) { + if self.pending_project_open.take().is_some() && self.busy.as_deref() == Some("project-open-save") { + self.busy = None; + } + } + fn request_project_open(&mut self, app: &PhotocraftApp, input: Value) -> Result<(), String> { + if self.busy.is_some() || self.saving.is_some() || self.pending_project_open.is_some() { + return Err("请等待当前操作保存完成".into()); + } + let Some(st) = app.session.active() else { + self.send("open", input); + return Ok(()); + }; + self.retain_creation_ui_draft(app); + if self.loaded_draft != Some(st.doc.id.0) + || self.open_projects.get(&st.doc.id.0).is_some_and(|identity| !identity.starts_with(&self.local_identity(""))) + { + // The visible canvas may still belong to the account just signed out. + // Never bind or save that owner's fields into the new account. + self.send("open", input); + return Ok(()); + } + let meta = json!({"composer":self.composer,"messages":self.messages,"project":self.project_context,"syncDirty":self.sync_dirty || self.creation_dirty,"creationDraft":self.creation_draft()}); + self.tx.send(Request::Save(st.doc.clone(), st.revision, meta)).map_err(|_| "当前项目无法保存,未打开其他项目")?; + self.pending_project_open = Some( + json!({"target":input,"accountId":self.account["id"],"origin":self.origin,"doc":st.doc.id.0,"revision":st.revision,"signature":self.project_open_save_signature()}), + ); + self.saving = Some((st.doc.id.0, st.revision)); + self.busy = Some("project-open-save".into()); + Ok(()) + } + fn project_open_saved(&mut self, app: &PhotocraftApp, id: u64, revision: u64) { + let Some(pending) = self.pending_project_open.as_ref() else { return }; + if pending["doc"].as_u64() != Some(id) || pending["revision"].as_u64() != Some(revision) { + return; + } + let Some(pending) = self.pending_project_open.take() else { return }; + let waiting = self.busy.as_deref() == Some("project-open-save"); + if waiting { + self.busy = None; + } + if !waiting + || pending["accountId"] != self.account["id"] + || pending["origin"] != self.origin + || !app.session.active().is_some_and(|st| st.doc.id.0 == id && st.revision == revision) + || pending["signature"].as_str() != Some(self.project_open_save_signature().as_str()) + { + self.error = "项目或资料已变化,未打开过时目标;草稿已保留".into(); + return; + } + self.send("open", pending["target"].clone()); } pub fn generate(&mut self, app: &PhotocraftApp, text: String, mode: &str) { if self.busy.is_some() || self.unknown || !self.queue.is_empty() || self.pending_image.is_some() { @@ -681,10 +1534,6 @@ impl Workspace { self.error = "请在对话栏设置可用的对话模型".into(); return; } - if !self.agent_rule["id"].is_string() { - self.error = "请先选择此项目的品牌和品类规则".into(); - return; - } let context = self.context(app); if context.is_null() { self.error = "请先打开项目".into(); @@ -692,13 +1541,15 @@ impl Workspace { } self.owner_doc = context["id"].as_u64(); self.stream_reply.clear(); - self.allow_apply = true; + self.allow_apply = false; + self.pi_write_blocked = false; self.expected_revision = context["revision"].as_u64().unwrap_or(0); self.messages.push(json!({"role":"user","text":text})); self.draft_changed = true; self.send("agent", json!({"text":text,"context":{"id":context["id"],"revision":context["revision"]},"model":self.model})); } fn tick(&mut self, app: &mut PhotocraftApp, ui: &Ui) { + self.project_open_locals = self.live_project_locals(app); if !self.startup_interacted && ui.input(|input| { input.pointer.any_pressed() @@ -719,10 +1570,59 @@ impl Workspace { let _ = reply.send(result); } Event::CreationCanvas(doc, meta) => creation::canvas_received(self, app, doc, meta), + Event::SlotCanvas(mut doc, meta) => { + let active_doc = app + .session + .active() + .filter(|state| { + meta["accountId"] == self.account["id"] + && meta["origin"] == self.origin + && Some(state.doc.id.0) == meta["doc"].as_u64() + && Some(state.revision) == meta["baseRevision"].as_u64() + }) + .map(|state| state.doc.id); + let Some(active_doc) = active_doc else { + self.error = "回填结果已保存在原项目;当前画布已变化,未覆盖你的编辑".into(); + continue; + }; + doc.id = active_doc; + match app.session.edit("回填模板图位", move |current, selected| { + *current = *doc; + *selected = None; + Ok(()) + }) { + Ok(()) => { + app.sync_views(); + self.creation_open = false; + self.creation_step = 3; + self.library = false; + self.material = false; + self.draft_changed = true; + self.sync_dirty = true; + let mut ack = meta.clone(); + ack["slotRevision"] = self.template_workflow["revision"].clone(); + self.tx.send(Request::Action("slot-applied".into(), ack)).ok(); + } + Err(e) => self.error = e.to_string(), + } + } Event::Json(method, result) => { + if matches!(method.as_str(), "creation-catalog" | "creation-thumbnail") { + if let Ok(value) = result { + self.creation_read_received(app, &method, &value); + } + continue; + } + if method == "project-list" { + if let Ok(value) = result { + self.receive_project_list(app, &value); + } + continue; + } if matches!(method.as_str(), "materials" | "preview") { if let Ok(value) = result - && self.finish_material_read(&method, &value, app.session.active().map(|s| s.doc.id.0)) { + && self.finish_material_read(&method, &value, app.session.active().map(|s| s.doc.id.0)) + { if let Some(error) = value["error"].as_str() { self.material_error = error.into(); } else if method == "materials" { @@ -743,15 +1643,24 @@ impl Workspace { } match result { Err(e) => { + if method == "save" && self.pending_project_open.take().is_some() { + if self.busy.as_deref() == Some("project-open-save") { + self.busy = None; + } + self.draft_changed = true; + } + if method == "save" && self.pending_slot_apply.take().is_some() { + if self.busy.as_deref() == Some("slot-apply-save") { + self.busy = None; + } + self.draft_changed = true; + } if matches!(method.as_str(), "creation-save" | "creation-select" | "creation-plan-edit") { self.creation_failed_signature = self.creation_edit_signature.clone(); } if method == "creation-read" { self.creation_read_failed = true; } - if method == "creation-thumbnail" { - self.creation_thumb_failed.extend(self.creation_thumb_loading.drain()); - } if method == "project-delete" { self.delete_error = e; } else if method == "login" || method == "logout" { @@ -767,7 +1676,28 @@ impl Workspace { } } Ok(v) => match method.as_str() { - name if name.starts_with("creation-") => creation::received(self, app, name, &v), + name if name.starts_with("slot-") => { + if v["accountId"] == self.account["id"] + && v["origin"] == self.origin + && v["doc"].as_u64() == app.session.active().map(|s| s.doc.id.0) + { + if v["creation"].is_object() + && v["creation"]["revision"].as_u64().unwrap_or(0) > self.creation["revision"].as_u64().unwrap_or(0) + { + // Slot-copy owns a canonical creation revision too. Refresh + // it through the existing dirty-preserving rules path. + self.receive_creation_ui(app, "creation-rules", &v); + } + if self.receive_template_snapshot(&v["workflow"], name != "slot-read") { + // A successful independent image does not resolve an + // older copy request. Keep the backend's full pending + // summary, and never clear it when a receipt omits it. + self.unknown = v["pending"].as_bool().unwrap_or(self.unknown); + self.error.clear(); + } + } + } + name if name.starts_with("creation-") => self.receive_creation_ui(app, name, &v), "project-sync" | "project-state" => creation::sync_received(self, app, &v), "login-url" => { if let Some(url) = v.as_str() @@ -779,7 +1709,28 @@ impl Workspace { } "restore" | "login" | "logout" => { self.account_error.clear(); - if self.account["id"] != v["account"]["id"] { + let scope_changed = + self.account["id"] != v["account"]["id"] || v["origin"].as_str().is_some_and(|origin| origin != self.origin); + if scope_changed { + self.retain_creation_ui_draft(app); + self.creation_ui_scope = None; + self.creation_doc = None; + self.creation = Value::Null; + self.creation_brief = creation::empty_brief(); + self.creation_selected.clear(); + self.creation_direction.clear(); + self.creation_plan = json!([]); + self.creation_dirty = false; + self.creation_restore_draft = Value::Null; + self.loaded_draft = None; + self.cancel_project_open(); + self.template_workflow = Value::Null; + self.pending_slot_apply = None; + self.project_read = None; + self.project_tombstones.clear(); + self.project_list_error.clear(); + } + if scope_changed { self.api_key.clear(); self.preview = None; self.material_rows.clear(); @@ -801,7 +1752,13 @@ impl Workspace { if let Some(origin) = v["origin"].as_str() { self.origin = origin.into(); } + if scope_changed { + self.creation_reads_tick(app); + } + self.login_origin = None; self.projects = v["projects"].as_array().cloned().unwrap_or_default(); + self.project_refresh = Instant::now(); + self.project_refresh_requested = false; self.connection_received(&v); self.error = v["message"].as_str().unwrap_or("").into(); if method == "restore" && !self.startup_resume_checked { @@ -918,7 +1875,7 @@ impl Workspace { self.agent_rule = v["rule"].clone(); self.agent_rule_state = v["ruleState"].clone(); if v["creation"].is_object() && v["creation"]["revision"] != self.creation["revision"] { - creation::received(self, app, "creation-rules", &v); + self.receive_creation_ui(app, "creation-rules", &v); } } } @@ -1033,6 +1990,27 @@ impl Workspace { self.library = false; self.material = false; let file = meta["path"].as_str().map(str::to_owned); + let mut retained_superseded_draft = false; + if meta["accountId"].as_str().is_some_and(|account| self.account["id"].as_str() == Some(account)) + && meta["origin"] == self.origin + && let Some(superseded) = meta["supersededDocIds"].as_array() + { + let mut close = Vec::new(); + for (index, old) in app.session.documents().iter().enumerate() { + if old.doc.id != doc.id && superseded.iter().any(|id| id.as_u64() == Some(old.doc.id.0)) { + if old.saved_revision == old.revision { + close.push(index); + } else { + retained_superseded_draft = true; + } + } + } + for index in close.into_iter().rev() { + if let Some(old) = app.session.close(index) { + self.open_projects.remove(&old.doc.id.0); + } + } + } if let Some(local) = meta["localId"].as_str() { self.open_projects.insert(doc.id.0, self.local_identity(local)); } @@ -1052,6 +2030,9 @@ impl Workspace { // per-document view. Keep it aligned before the new tab is drawn. app.sync_views(); self.project_context = meta; + if retained_superseded_draft { + self.error = "旧画布有未保存修改,请另存文件".into(); + } } Event::Saved(id, rev, meta) => { self.saving = None; @@ -1067,37 +2048,33 @@ impl Workspace { self.project_context = context; self.sync_dirty = dirty; } - if let Some(local) = meta["localId"].as_str() { + let save_owned_here = + self.open_projects.get(&id).map_or(self.loaded_draft == Some(id), |identity| identity.starts_with(&self.local_identity(""))); + if let Some(local) = meta["localId"].as_str().filter(|_| save_owned_here) { self.open_projects.insert(id, self.local_identity(local)); } if let Some(items) = meta["projects"].as_array() { - let old = self.projects.clone(); - self.projects.retain(|p| p["source"] != "native"); - self.projects.extend(items.iter().cloned().map(|mut p| { - if let Some(previous) = old.iter().find(|o| o["source"] == "native" && o["id"] == p["id"]) { - p["cloudPresent"] = previous["cloudPresent"].clone(); - p["cloudRef"] = previous["cloudRef"].clone(); - } - p - })); + self.merge_project_receipt(app, items); } + self.slot_apply_saved(app, id, rev); + self.project_open_saved(app, id, rev); } Event::Preview(reference, image) => { if reference["purpose"] == "creation-material" { - creation::thumbnail_received(self, ui, &reference, image); + self.creation_thumbnail_received(app, ui, &reference, image); continue; } if reference["purpose"] == "project-cover" { self.cover_received(ui, &reference, image); continue; } - if reference["purpose"] == "agent-image" { + if reference["purpose"] == "agent-image" || reference["purpose"] == "template-candidate" { if reference["accountId"] == self.account["id"] && reference["origin"] == self.origin && reference["doc"].as_u64() == app.session.active().map(|s| s.doc.id.0) && let Some(id) = reference["taskId"].as_str() { - if self.agent_images.len() >= 16 + if self.agent_images.len() >= 64 && let Some(old) = self.agent_images.keys().next().cloned() { self.agent_images.remove(&old); @@ -1112,6 +2089,9 @@ impl Workspace { } } } + // Process account/document events first, then invalidate the old read/cache + // scope before the same frame can draw a newly authorized workspace. + self.creation_reads_tick(app); let current = app.session.active().map(|s| (s.doc.id.0, s.revision)); if self.busy.as_deref() == Some("creation-generate") && self.allow_apply { let pointer = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| app.last_canvas_rect.contains(p))); @@ -1134,14 +2114,21 @@ impl Workspace { } self.note("本地 Agent 连接已断开,草稿已保留"); } - if self.pi_run.is_some() && self.allow_apply { + if self.pi_run.is_some() { let pointer = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| app.last_canvas_rect.contains(p))); if pointer || current != self.owner_doc.map(|id| (id, self.expected_revision)) { self.handover(); } } if self.observed_doc != current.map(|v| v.0) { + self.retain_creation_ui_draft(app); + self.creation_ui_scope = None; + self.cancel_project_open(); self.handover(); + self.template_workflow = Value::Null; + if self.pending_slot_apply.take().is_some() && self.busy.as_deref() == Some("slot-apply-save") { + self.busy = None; + } self.preview = None; self.material_preview_pending = None; self.observed_doc = current.map(|v| v.0); @@ -1229,14 +2216,21 @@ impl Workspace { if self.busy.is_some() || self.saving.is_some() { ui.ctx().request_repaint_after(Duration::from_millis(100)); } - if self.project_refresh.elapsed() > Duration::from_secs(30) - && !self.account.is_null() - && self.busy.is_none() - && self.saving.is_none() - && self.queue.is_empty() - { - self.project_refresh = Instant::now(); - self.send("projects", json!({})); + let visible = self.library && !self.material && !self.creation_open; + let focused = ui.input(|i| i.focused); + let refresh_due = project_refresh_due( + visible, + focused, + self.project_was_visible, + self.project_was_focused, + self.project_refresh_requested, + self.project_refresh.elapsed(), + ); + self.project_was_visible = visible; + self.project_was_focused = focused; + if refresh_due { + self.project_refresh_requested = true; + self.dispatch_project_refresh(); } if !self.account.is_null() { ui.ctx().request_repaint_after(Duration::from_secs(1)); @@ -1256,6 +2250,7 @@ impl Workspace { } fn navigation(&mut self, app: &mut PhotocraftApp, ui: &mut Ui) { let document_open = app.session.active().is_some(); + self.navigation_geometry = json!({}); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let account_name = self.account["name"].as_str().unwrap_or("登录"); let short_name = @@ -1265,18 +2260,22 @@ impl Workspace { self.config = true; } let projects = self.library || (!document_open && !self.material); - if document_open && ui.selectable_label(self.creation_open && !self.library && !self.material, "产品资料").clicked() { - self.creation_open = !self.creation_open; - self.library = false; - self.material = false; - self.creation_step = 0; + if document_open { + let current = ui.selectable_label(!self.library && !self.material, "当前项目").on_hover_text("继续当前项目的资料、方案与画布"); + self.navigation_geometry["brief"] = agent_panel::rect_value(current.rect); + if current.clicked() { + self.show_creation_step(self.creation_step, document_open); + } } - if ui.selectable_label(self.material, "添加素材").on_hover_text("从素材库取图,置入当前画布").clicked() { - self.show_center(if self.material { CenterView::Canvas } else { CenterView::Materials }, document_open); + let materials = ui.selectable_label(self.material, "添加素材").on_hover_text("从素材库取图,置入当前画布"); + self.navigation_geometry["materials"] = agent_panel::rect_value(materials.rect); + if materials.clicked() { + self.show_center(CenterView::Materials, document_open); } - if ui.selectable_label(projects, "我的项目").on_hover_text(if projects && document_open { "返回当前画布" } else { "查看项目与本机草稿" }).clicked() - { - self.show_center(if projects && !self.material && document_open { CenterView::Canvas } else { CenterView::Projects }, document_open); + let projects_button = ui.selectable_label(projects, "我的项目").on_hover_text("查看项目与本机草稿"); + self.navigation_geometry["projects"] = agent_panel::rect_value(projects_button.rect); + if projects_button.clicked() { + self.show_center(CenterView::Projects, document_open); } if document_open { ui.separator(); @@ -1303,17 +2302,20 @@ impl Workspace { .show(ui, |ui| self.navigation(app, ui)); } if self.config { + self.login_origin.get_or_insert_with(|| self.origin.clone()); let mut open = true; egui::Window::new("素材库账户").open(&mut open).collapsible(false).resizable(false).default_width(380.0).show(ui.ctx(), |ui| { ui.label(self.account["name"].as_str().unwrap_or("未登录素材库")); - egui::ComboBox::from_id_salt("jiansun-origin") - .selected_text(if self.origin.starts_with("https:") { "正式素材库" } else { "本机验收服务" }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut self.origin, "https://sc.jiansun.vip".into(), "正式素材库"); - ui.selectable_value(&mut self.origin, "http://127.0.0.1:3138".into(), "本机验收服务"); - }); + let draft = self.login_origin.get_or_insert_with(|| self.origin.clone()); + egui::ComboBox::from_id_salt("jiansun-origin").selected_text(if draft.starts_with("https:") { "正式素材库" } else { "本机验收服务" }).show_ui( + ui, + |ui| { + ui.selectable_value(draft, "https://sc.jiansun.vip".into(), "正式素材库"); + ui.selectable_value(draft, "http://127.0.0.1:3138".into(), "本机验收服务"); + }, + ); if ui.add_enabled(self.busy.is_none(), egui::Button::new("在素材库登录")).clicked() { - self.send("login", json!({"origin":self.origin})); + self.send("login", json!({"origin":self.login_origin})); } if !self.account.is_null() && ui.add_enabled(self.busy.is_none(), egui::Button::new("退出账户")).clicked() { self.send("logout", json!({})); @@ -1330,7 +2332,10 @@ impl Workspace { }); if !open { self.config = false; + self.login_origin = None; } + } else { + self.login_origin = None; } let _ = app; } @@ -1369,7 +2374,13 @@ impl Workspace { if let Some((reference, texture)) = &self.preview { ui.horizontal(|ui| { ui.add(egui::Image::new(texture).max_size(egui::vec2(280.0, 240.0))); - if ui.add_enabled(self.busy.is_none() && self.material_preview_pending.is_none() && app.session.active().is_some(), egui::Button::new("置入画布")).clicked() { + if ui + .add_enabled( + self.busy.is_none() && self.material_preview_pending.is_none() && app.session.active().is_some(), + egui::Button::new("置入画布"), + ) + .clicked() + { chosen = Some(("place", reference.clone())); } }); @@ -1428,12 +2439,21 @@ pub fn center(app: &mut PhotocraftApp, ui: &mut Ui) -> bool { let Some(mut w) = app.jiansun.take() else { return false; }; - let used = w.material || w.library || (w.creation_open && w.creation_step < 3) || app.session.active().is_none(); + if !w.material && !w.library && app.session.active().is_some() { + if !w.creation_open { + w.creation_step = 3; + w.creation_open = true; + } + egui::Panel::top("jiansun-creation-stages") + .frame(egui::Frame::NONE.inner_margin(egui::Margin { left: 20, right: 20, top: 12, bottom: 10 })) + .show(ui, |ui| w.creation_tabs(app, ui)); + } + let used = w.material || w.library || (w.creation_open && w.creation_step != 3) || app.session.active().is_none(); if w.material { w.materials_ui(app, ui); } else if w.library || app.session.active().is_none() { w.library_ui(app, ui); - } else if w.creation_open && w.creation_step < 3 { + } else if w.creation_open && w.creation_step != 3 { w.creation_ui(app, ui); } app.jiansun = Some(w); @@ -1445,6 +2465,7 @@ pub fn action(app: &mut PhotocraftApp, method: &str, input: Value) -> Result pi_bridge::action(&mut w, app, name, &input), "state" => Ok(w.state()), name if name.starts_with("creation-") => w.creation_action(app, name, input), + name if name.starts_with("slot-") => w.slot_action(app, name, input), "project-sync" | "project-state" => { w.sync_action(app, method, input); Ok(Value::Null) @@ -1478,10 +2499,7 @@ pub fn action(app: &mut PhotocraftApp, method: &str, input: Value) -> Result { - w.send("open", input); - Ok(Value::Null) - } + "open" => w.request_project_open(app, input).map(|()| Value::Null), "recover" => { w.recover(app); Ok(Value::Null) @@ -1530,6 +2548,407 @@ pub fn action(app: &mut PhotocraftApp, method: &str, input: Value) -> Result (Workspace, PhotocraftApp, mpsc::Receiver, mpsc::Sender) { + let (tx, requests) = mpsc::channel(); + let (events, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + let _ = requests.try_recv(); + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.sync_views(); + let id = app.session.active().unwrap().doc.id.0; + w.account = json!({"id":"account-a"}); + w.busy = None; + w.observed_doc = Some(id); + w.loaded_draft = Some(id); + w.creation_doc = Some(id); + w.template_workflow = + json!({"revision":3,"confirmed":true,"capabilities":{"imageDispatchAllowed":true,"copyDispatchAllowed":true,"localApplyAllowed":true},"slots":[]}); + w.creation = json!({"revision":1,"brief":creation::empty_brief(),"sellingPoints":{"selected":[],"direction":""},"plan":{"screens":[]},"width":1440,"screenCount":3}); + w.creation_brief = w.creation["brief"].clone(); + (w, app, requests, events) + } + + fn creation_receipt(w: &Workspace, app: &PhotocraftApp, canonical: &Value) -> Value { + json!({"accountId":w.account["id"],"origin":w.origin,"doc":app.session.active().unwrap().doc.id.0,"creation":canonical}) + } + fn workspace_frame(w: &mut Workspace, app: &mut PhotocraftApp) { + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| w.tick(app, ui)); + output.textures_delta.clear(); + } + #[test] + fn rapid_native_tab_switch_preserves_all_unsent_creation_fields_before_either_debounce() { + let (mut w, mut app, requests, events) = slot_workspace(); + let canonical = w.creation.clone(); + let a = app.session.active().unwrap().doc.id.0; + w.receive_creation_ui(&app, "creation-read", &creation_receipt(&w, &app, &canonical)); + w.creation_brief["name"] = json!("刚输入,尚未保存"); + w.creation_brief["dimensions"]["length"] = json!("1200"); + w.creation_selected = vec!["batch-a:2".into()]; + w.creation_direction = "保留桌面细节".into(); + w.creation_plan = json!([{"id":"first","title":"刚修改的方案"}]); + w.creation_width = 750; + w.creation_count = 5; + w.creation_dirty = true; + w.touched = Instant::now(); + let unsent = w.creation_draft(); + app.session.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); + app.sync_views(); + let b = app.session.active().unwrap().doc.id.0; + workspace_frame(&mut w, &mut app); + assert_eq!(w.retained_creation_ui.len(), 1); + assert_ne!(w.creation_brief["name"], unsent["brief"]["name"], "B never displays A's draft"); + events.send(Event::Json("draft".into(), Ok(json!({"doc":b,"creationDraft":null,"unknown":false})))).unwrap(); + events.send(Event::Json("creation-read".into(), Ok(creation_receipt(&w, &app, &canonical)))).unwrap(); + workspace_frame(&mut w, &mut app); + assert!(app.session.set_active(0)); + app.sync_views(); + workspace_frame(&mut w, &mut app); + events.send(Event::Json("draft".into(), Ok(json!({"doc":a,"creationDraft":{"revision":1,"brief":canonical["brief"]},"unknown":true})))).unwrap(); + let mut restored = creation_receipt(&w, &app, &canonical); + restored["pending"] = json!(true); + events.send(Event::Json("creation-read".into(), Ok(restored))).unwrap(); + workspace_frame(&mut w, &mut app); + assert_eq!(w.creation_draft(), unsent); + assert!(w.creation_dirty && w.unknown, "restoring local fields neither resolves nor replays the old request"); + assert!(!requests.try_iter().any(|request| matches!(request, Request::Action(method, _) if method.starts_with("slot-") || matches!(method.as_str(), "agent" | "image" | "creation-facts" | "creation-points" | "creation-plan")))); + app.session.close(0); + w.retain_creation_ui_draft(&app); + assert!(w.retained_creation_ui.is_empty(), "closed tabs do not accumulate retained memory"); + } + #[test] + fn retained_creation_fields_are_isolated_when_the_account_or_origin_changes() { + for changed_origin in [false, true] { + let (mut w, mut app, _, events) = slot_workspace(); + let canonical = w.creation.clone(); + w.receive_creation_ui(&app, "creation-read", &creation_receipt(&w, &app, &canonical)); + let original_account = w.account.clone(); + let original_origin = w.origin.clone(); + w.creation_brief["name"] = json!("原账户未保存资料"); + w.creation_dirty = true; + let (next_account, next_origin) = + if changed_origin { (w.account.clone(), "http://localhost:3999".to_owned()) } else { (json!({"id":"account-b"}), w.origin.clone()) }; + events.send(Event::Json("login".into(), Ok(json!({"account":next_account,"origin":next_origin,"projects":[]})))).unwrap(); + workspace_frame(&mut w, &mut app); + assert!(w.creation_brief["name"].as_str().unwrap_or("").is_empty()); + events.send(Event::Json("creation-read".into(), Ok(creation_receipt(&w, &app, &canonical)))).unwrap(); + workspace_frame(&mut w, &mut app); + assert!(w.creation_brief["name"].as_str().unwrap_or("").is_empty(), "the same native doc id cannot cross its owner binding"); + events.send(Event::Json("login".into(), Ok(json!({"account":original_account,"origin":original_origin,"projects":[]})))).unwrap(); + workspace_frame(&mut w, &mut app); + events.send(Event::Json("creation-read".into(), Ok(creation_receipt(&w, &app, &canonical)))).unwrap(); + workspace_frame(&mut w, &mut app); + assert_eq!(w.creation_brief["name"], "原账户未保存资料"); + } + } + #[test] + fn opening_another_project_waits_for_the_real_successful_save_ack_and_preserves_unknown() { + let (mut w, mut app, requests, events) = slot_workspace(); + let (id, revision) = { + let st = app.session.active().unwrap(); + (st.doc.id.0, st.revision) + }; + w.creation_brief["name"] = json!("必须先落盘的产品资料"); + w.creation_dirty = true; + w.unknown = true; + let target = json!({"id":"next-owned-project","source":"native"}); + w.request_project_open(&app, target.clone()).unwrap(); + let Request::Save(doc, saved_revision, meta) = requests.try_recv().unwrap() else { panic!("save must precede open") }; + assert_eq!((doc.id.0, saved_revision), (id, revision)); + assert_eq!(meta["creationDraft"]["brief"]["name"], "必须先落盘的产品资料"); + assert!(requests.try_recv().is_err()); + events.send(Event::Saved(id, revision, json!({"time":"saved","syncDirty":true}))).unwrap(); + workspace_frame(&mut w, &mut app); + assert!(matches!(requests.try_recv().unwrap(), Request::Action(method, input) if method == "open" && input == target)); + assert_eq!(app.session.active().unwrap().doc.id.0, id, "the ACK dispatches open; it does not itself replace a document"); + assert!(w.unknown); + } + #[test] + fn failed_or_obsolete_save_ack_cannot_open_a_stale_target() { + for change in ["failure", "document", "account", "origin", "draft", "view"] { + let (mut w, mut app, requests, events) = slot_workspace(); + let (id, revision) = { + let st = app.session.active().unwrap(); + (st.doc.id.0, st.revision) + }; + w.creation_brief["name"] = json!("原草稿"); + w.creation_dirty = true; + w.touched = Instant::now(); + w.request_project_open(&app, json!({"id":"stale-target","source":"native"})).unwrap(); + assert!(matches!(requests.try_recv().unwrap(), Request::Save(..))); + match change { + "failure" => events.send(Event::Json("save".into(), Err("磁盘写入失败".into()))).unwrap(), + "document" => { + app.session.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); + app.sync_views(); + events.send(Event::Saved(id, revision, json!({"time":"late"}))).unwrap(); + } + _ => { + if change == "account" { + w.account = json!({"id":"account-b"}); + } + if change == "origin" { + w.origin = "http://localhost:3999".into(); + } + if change == "draft" { + w.creation_brief["name"] = json!("保存期间又修改的资料"); + } + if change == "view" { + w.show_creation_step(0, true); + } + events.send(Event::Saved(id, revision, json!({"time":"late"}))).unwrap(); + } + } + workspace_frame(&mut w, &mut app); + assert!(w.pending_project_open.is_none(), "{change}"); + assert!(!requests.try_iter().any(|request| matches!(request, Request::Action(method, _) if method == "open")), "{change}: no old target may open"); + if change == "failure" { + assert_eq!(app.session.active().unwrap().doc.id.0, id); + assert_eq!(w.creation_brief["name"], "原草稿"); + assert!(w.error.contains("磁盘写入失败")); + } + } + } + + #[test] + fn unknown_slot_requests_allow_only_the_explicit_readonly_or_local_operations() { + let (mut w, app, requests, _) = slot_workspace(); + w.unknown = true; + w.template_workflow["capabilities"] = json!({"imageDispatchAllowed":false,"copyDispatchAllowed":false,"localApplyAllowed":false}); + for method in ["slot-read", "slot-recover", "slot-text-save", "slot-select", "slot-copy-apply", "slot-reuse"] { + w.busy = None; + assert!(w.slot_action(&app, method, json!({})).is_ok(), "{method} remains usable without a new inference"); + assert!(matches!(requests.try_recv().unwrap(),Request::Action(action,_) if action==method)); + } + for method in ["slot-copy", "slot-generate", "slot-install", "slot-apply"] { + w.busy = None; + assert!(w.slot_action(&app, method, json!({})).is_err(), "{method} must not bypass the unknown barrier"); + assert!(requests.try_recv().is_err()); + } + } + + #[test] + fn a_copy_unknown_allows_only_backend_approved_independent_image_and_local_apply() { + let (mut w, mut app, requests, events) = slot_workspace(); + let st = app.session.active().unwrap(); + let (id, revision) = (st.doc.id.0, st.revision); + let ctx = egui::Context::default(); + w.unknown = true; + w.template_workflow["capabilities"] = json!({"imageDispatchAllowed":true,"copyDispatchAllowed":false,"localApplyAllowed":true}); + assert!(w.slot_action(&app, "slot-generate", json!({"slotId":"first-image","candidate":"A"})).is_ok()); + assert!(matches!(requests.try_recv().unwrap(),Request::Action(method,input) if method=="slot-generate" && input["candidate"]=="A")); + assert!(w.unknown, "allowing a different stage is not recovery of the old request"); + let mut snapshot = w.template_workflow.clone(); + snapshot["revision"] = json!(4); + snapshot["slots"] = json!([{"id":"first-image","candidates":[{"id":"independent-image-task","variant":"A","status":"complete"}]}]); + events + .send(Event::Json("slot-generate".into(), Ok(json!({"doc":id,"accountId":w.account["id"],"origin":w.origin,"workflow":snapshot,"pending":true})))) + .unwrap(); + events.send(Event::Json("slot-read".into(), Ok(json!({"doc":id,"accountId":w.account["id"],"origin":w.origin,"workflow":snapshot})))).unwrap(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| w.tick(&mut app, ui)); + output.textures_delta.clear(); + assert!(w.busy.is_none(), "the independent image's actual receipt releases its own busy state"); + assert!(w.unknown, "an image receipt must preserve the backend's old-copy pending summary"); + assert_eq!(w.template_workflow["slots"][0]["candidates"][0]["status"], "complete"); + assert!(w.slot_action(&app, "slot-copy", json!({})).is_err()); + assert!(requests.try_recv().is_err(), "the original unknown copy is never repeated"); + w.slot_action(&app, "slot-apply", json!({"outputId":"detail"})).unwrap(); + assert!(matches!(requests.try_recv().unwrap(), Request::Save(..))); + assert_eq!(w.saving, Some((id, revision))); + // Deliver the real event rather than calling its continuation directly: + // Event::Saved clears saving before it dispatches the queued local apply. + events.send(Event::Saved(id, revision, json!({"doc":id,"revision":revision,"status":"draft","syncDirty":true,"time":"test-save"}))).unwrap(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| w.tick(&mut app, ui)); + output.textures_delta.clear(); + assert!(w.saving.is_none(), "the matching successful save must release the save gate"); + assert!(matches!(requests.try_recv().unwrap(),Request::Action(method,_) if method=="slot-apply")); + assert!(w.unknown, "the Save ACK does not erase the original copy barrier"); + events + .send(Event::Json("slot-apply".into(), Ok(json!({"doc":id,"accountId":w.account["id"],"origin":w.origin,"workflow":snapshot,"pending":true})))) + .unwrap(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| w.tick(&mut app, ui)); + output.textures_delta.clear(); + assert!(w.busy.is_none() && w.saving.is_none(), "the final assertion must reach the unknown guard, not an unfinished local operation"); + assert!(w.unknown); + w.sync_action(&app, "project-state", json!({"status":"completed"})); + assert!(w.error.contains("核查原请求"), "completion remains blocked while the original copy is unknown"); + assert!(!requests.try_iter().any(|request| matches!(request,Request::Action(method,_) if method=="project-state" || method=="slot-copy"))); + } + + #[test] + fn missing_or_revoked_stage_capabilities_do_not_admit_new_model_or_canvas_replacement() { + let (mut w, app, requests, _) = slot_workspace(); + for unknown in [false, true] { + w.unknown = unknown; + w.template_workflow["capabilities"] = Value::Null; + for method in ["slot-copy", "slot-generate", "slot-apply"] { + assert!(w.slot_action(&app, method, json!({})).is_err(), "{method} requires explicit backend permission"); + } + } + assert!(requests.try_recv().is_err()); + w.unknown = true; + w.template_workflow["capabilities"] = json!({"localApplyAllowed":true}); + w.slot_action(&app, "slot-apply", json!({"outputId":"sku_images"})).unwrap(); + let (id, revision) = match requests.try_recv().unwrap() { + Request::Save(doc, revision, _) => (doc.id.0, revision), + _ => panic!("expected save"), + }; + w.template_workflow["capabilities"]["localApplyAllowed"] = json!(false); + w.slot_apply_saved(&app, id, revision); + assert!(requests.try_recv().is_err(), "a permission revoked during saving cannot switch the canvas"); + assert!(w.pending_slot_apply.is_none()); + assert!(w.unknown); + } + + #[test] + fn slot_apply_dispatches_only_after_the_matching_successful_save_acknowledgement() { + let (mut w, app, requests, _) = slot_workspace(); + let st = app.session.active().unwrap(); + let (id, revision) = (st.doc.id.0, st.revision); + w.slot_action(&app, "slot-apply", json!({"outputId":"detail"})).unwrap(); + assert!(matches!(requests.try_recv().unwrap(),Request::Save(doc,rev,_) if doc.id.0==id && rev==revision)); + assert!(requests.try_recv().is_err(), "a queued save is not proof that the manual output was saved"); + assert_eq!(w.busy.as_deref(), Some("slot-apply-save")); + w.slot_apply_saved(&app, id, revision.saturating_add(10)); + assert!(requests.try_recv().is_err(), "an unrelated revision cannot admit output replacement"); + assert!(w.pending_slot_apply.is_some()); + w.slot_apply_saved(&app, id, revision); + assert!( + matches!(requests.try_recv().unwrap(),Request::Action(method,input) if method=="slot-apply" && input["doc"]==id && input["baseRevision"]==revision) + ); + assert!(w.pending_slot_apply.is_none()); + } + + #[test] + fn a_single_screen_fill_keeps_its_identity_through_save_ack_without_resolving_the_copy_unknown() { + let (mut w, app, requests, _) = slot_workspace(); + let st = app.session.active().unwrap(); + let (id, revision) = (st.doc.id.0, st.revision); + w.unknown = true; + w.template_workflow["capabilities"]["copyDispatchAllowed"] = json!(false); + w.slot_action(&app, "slot-apply", json!({"outputId":"detail","documentId":"S01"})).unwrap(); + assert!(matches!(requests.try_recv().unwrap(),Request::Save(doc,rev,_) if doc.id.0==id && rev==revision)); + assert!(requests.try_recv().is_err(), "one screen still requires the real save acknowledgement"); + w.slot_apply_saved(&app, id, revision); + let Request::Action(method, input) = requests.try_recv().unwrap() else { panic!("expected explicit local output application") }; + assert_eq!(method, "slot-apply"); + assert_eq!(input["outputId"], "detail"); + assert_eq!(input["documentId"], "S01"); + assert_eq!(input["baseRevision"], revision); + assert!(w.unknown, "local output application never changes the original model request's terminal state"); + assert!(requests.try_recv().is_err(), "local application cannot dispatch a new model request"); + } + + #[test] + fn a_failed_local_save_cancels_output_replacement_and_keeps_the_draft_dirty() { + let (mut w, mut app, requests, events) = slot_workspace(); + w.slot_action(&app, "slot-apply", json!({"outputId":"main_images"})).unwrap(); + assert!(matches!(requests.try_recv().unwrap(), Request::Save(..))); + events.send(Event::Json("save".into(), Err("当前人工输出保存失败".into()))).unwrap(); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| w.tick(&mut app, ui)); + output.textures_delta.clear(); + assert!(w.pending_slot_apply.is_none()); + assert!(w.busy.is_none()); + assert!(w.draft_changed); + assert!(w.error.contains("保存失败")); + assert!(!requests.try_iter().any(|request| matches!(request,Request::Action(method,_) if method=="slot-apply"))); + } + + #[test] + fn save_ack_never_applies_after_account_origin_document_revision_or_slot_version_changes() { + for changed in ["account", "origin", "document", "revision", "slots", "capability"] { + let (mut w, mut app, requests, _) = slot_workspace(); + let st = app.session.active().unwrap(); + let (id, revision) = (st.doc.id.0, st.revision); + w.slot_action(&app, "slot-apply", json!({"outputId":"sku_images"})).unwrap(); + let _ = requests.try_recv().unwrap(); + match changed { + "account" => w.account = json!({"id":"account-b"}), + "origin" => w.origin = "http://localhost:3138".into(), + "document" => { + app.session.execute("file.new", json!({"width":1440,"height":900})).unwrap(); + } + "revision" => { + app.session.execute("type.create", json!({"text":"新的人工编辑","font":"Noto Sans SC","size":32,"box":[20,20,400,80]})).unwrap(); + } + "slots" => w.template_workflow["revision"] = json!(4), + _ => w.template_workflow["capabilities"]["localApplyAllowed"] = json!(false), + } + w.slot_apply_saved(&app, id, revision); + assert!(requests.try_recv().is_err(), "{changed} must retain the current canvas"); + assert!(w.pending_slot_apply.is_none()); + assert!(w.error.contains("未切换输出")); + } + } + + #[test] + fn slot_copy_receipt_refreshes_creation_revision_without_erasing_product_and_plan_drafts() { + let (mut w, mut app, _, events) = slot_workspace(); + let id = app.session.active().unwrap().doc.id.0; + w.creation_brief["name"] = json!("尚未保存的产品名称"); + w.creation_plan = json!([{"title":"尚未保存的人工方案"}]); + w.creation_selected = vec!["local:0".into()]; + w.creation_direction = "尚未保存的表达方向".into(); + w.creation_dirty = true; + let draft = w.creation_draft(); + let mut canonical = w.creation.clone(); + canonical["revision"] = json!(7); + canonical["templateCopy"] = json!({"taskId":"copy-original","fields":[]}); + events.send(Event::Json("slot-copy".into(),Ok(json!({"doc":id,"accountId":w.account["id"],"origin":w.origin,"creation":canonical,"workflow":{"revision":4,"copyReady":true},"pending":false})))).unwrap(); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| w.tick(&mut app, ui)); + output.textures_delta.clear(); + assert_eq!(w.creation["revision"], 7); + assert_eq!(w.creation["templateCopy"]["taskId"], "copy-original"); + for key in ["brief", "screens", "direction", "selected"] { + assert_eq!(w.creation_draft()[key], draft[key], "{key}"); + } + assert!(w.creation_dirty); + assert_eq!(w.template_workflow["revision"], 4); + } + + #[test] + fn an_older_worker_snapshot_cannot_roll_back_an_applied_ack_but_can_refresh_newer_creation() { + let (mut w, mut app, _, events) = slot_workspace(); + let id = app.session.active().unwrap().doc.id.0; + let mut first_creation = w.creation.clone(); + first_creation["revision"] = json!(5); + let mut later_creation = first_creation.clone(); + later_creation["revision"] = json!(7); + later_creation["templateCopy"] = json!({"taskId":"new-canonical-copy"}); + for (method, revision, creation) in [("slot-applied", 10, first_creation), ("slot-apply", 8, later_creation)] { + events.send(Event::Json(method.into(),Ok(json!({"doc":id,"accountId":w.account["id"],"origin":w.origin,"creation":creation,"workflow":{"revision":revision,"confirmed":true},"pending":false})))).unwrap(); + } + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| w.tick(&mut app, ui)); + output.textures_delta.clear(); + assert_eq!(w.template_workflow["revision"], 10, "the delayed worker cannot undo the successful ACK snapshot"); + assert_eq!(w.creation["revision"], 7, "workflow and creation revisions are independent clocks"); + assert_eq!(w.creation["templateCopy"]["taskId"], "new-canonical-copy"); + let mut incoming = json!({"doc":id,"accountId":w.account["id"],"origin":w.origin,"creation":w.creation,"templateWorkflow":{"revision":9}}); + incoming["creation"]["revision"] = json!(8); + creation::received(&mut w, &app, "creation-rules", &incoming); + assert_eq!(w.creation["revision"], 8); + assert_eq!(w.template_workflow["revision"], 10, "creation replies must obey the same workflow ordering"); + } + + #[test] + fn switching_documents_resets_the_workflow_clock_before_reading_the_other_project() { + let (mut w, mut app, _, _) = slot_workspace(); + w.template_workflow = json!({"revision":99,"confirmed":true}); + app.session.execute("file.new", json!({"width":1440,"height":900})).unwrap(); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| w.tick(&mut app, ui)); + output.textures_delta.clear(); + assert!(w.template_workflow.is_null()); + assert!(template_snapshot_not_older(&w.template_workflow, &json!({"revision":1}))); + assert!(!template_snapshot_not_older(&json!({"revision":4}), &json!({"revision":3}))); + assert!(!template_snapshot_not_older(&json!({"revision":4}), &json!({"revision":null}))); + } #[test] fn material_search_dispatches_during_preview_and_never_clears_a_model_task() { let (tx, requests) = mpsc::channel(); @@ -1557,8 +2976,18 @@ mod tests { assert!(requests.try_recv().is_err(), "only one preview and one catalogue read may be pending"); workspace.send("place", json!({"doc":doc})); assert!(requests.try_recv().is_err(), "placing still respects the model/write busy gate"); - events.send(Event::Json("materials".into(), Ok(json!({"readId":search["readId"],"accountId":"account-a","origin":workspace.origin,"items":[{"materialId":"found"}]})))).unwrap(); - events.send(Event::Json("preview".into(), Ok(json!({"readId":preview["readId"],"accountId":"account-a","origin":workspace.origin,"doc":doc,"error":"素材正在加载,请稍后再试"})))).unwrap(); + events + .send(Event::Json( + "materials".into(), + Ok(json!({"readId":search["readId"],"accountId":"account-a","origin":workspace.origin,"items":[{"materialId":"found"}]})), + )) + .unwrap(); + events + .send(Event::Json( + "preview".into(), + Ok(json!({"readId":preview["readId"],"accountId":"account-a","origin":workspace.origin,"doc":doc,"error":"素材正在加载,请稍后再试"})), + )) + .unwrap(); let context = egui::Context::default(); let mut output = context.run_ui(egui::RawInput::default(), |ui| workspace.tick(&mut app, ui)); output.textures_delta.clear(); @@ -1613,7 +3042,7 @@ mod tests { harness.run_steps(4); let projects = harness.get_by_label("我的项目").rect(); let materials = harness.get_by_label("添加素材").rect(); - let brief = harness.get_by_label("产品资料").rect(); + let brief = harness.get_by_label("当前项目").rect(); assert!((projects.center().y - materials.center().y).abs() < 1.0); assert!((projects.center().y - brief.center().y).abs() < 1.0); assert!(projects.left() > width - navigation_width(harness.state()) - 1.0, "navigation remains at right, width={width}"); @@ -1734,6 +3163,66 @@ mod tests { assert!(w.library && !w.material); } #[test] + fn project_and_stage_destinations_are_idempotent_and_keep_the_current_draft() { + let (tx, _requests) = mpsc::channel(); + let (_events, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + w.creation_brief = json!({"name":"尚未保存的产品名称","dimensions":{"length":"1200"}}); + w.creation_direction = "自然光与产品细节".into(); + w.creation_selected = vec!["batch-1:0".into()]; + w.creation_dirty = true; + let draft = w.creation_draft(); + for step in [0, 1, 2, 4, 3, 0] { + w.show_creation_step(step, true); + w.allow_apply = true; + // Repeating a selected destination is not another takeover. + w.show_creation_step(step, true); + assert!(w.allow_apply); + assert!(w.creation_open && !w.library && !w.material); + assert_eq!(w.creation_step, step); + for view in [CenterView::Projects, CenterView::Materials] { + w.show_center(view, true); + let projects = w.library; + let material = w.material; + w.show_center(if projects { CenterView::Projects } else { CenterView::Materials }, true); + assert_eq!((w.library, w.material), (projects, material)); + w.show_creation_step(w.creation_step, true); + assert_eq!(w.creation_step, step, "returning to the project keeps its stage"); + } + assert_eq!(w.creation_draft(), draft); + assert!(w.creation_dirty); + } + w.show_creation_step(2, false); + assert!(w.library && !w.material && !w.creation_open); + } + #[test] + fn stage_navigation_remains_available_on_the_actual_canvas() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let (tx, _requests) = mpsc::channel(); + let (_events, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + w.account = json!({"id":"qa"}); + w.busy = None; + w.show_creation_step(3, true); + app.jiansun = Some(w); + let mut harness = Harness::builder().with_size(egui::vec2(1100.0, 720.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + assert!(!center(app, ui), "the native canvas must receive the remaining area"); + }, + app, + ); + PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ProMedium); + harness.run_steps(3); + for label in ["资料", "卖点", "方案", "图位", "画布"] { + let rect = harness.get_by_label(label).rect(); + assert!(rect.min.y >= 0.0 && rect.max.y <= 80.0); + } + assert_eq!(harness.state().jiansun.as_ref().unwrap().creation_step, 3); + } + #[test] fn image_handover_keeps_the_completed_artifact_without_applying_it() { let (tx, _requests) = mpsc::channel(); let (_events, rx) = mpsc::channel(); @@ -1811,6 +3300,63 @@ mod tests { assert!(app.ui.views.get(app.session.active_index().unwrap()).is_some()); } #[test] + fn opening_a_cloud_update_closes_only_clean_superseded_tabs_in_the_matching_account_scope() { + for scope in ["same", "other-account", "other-origin"] { + let ctx = egui::Context::default(); + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); + let clean = session.active().unwrap().doc.id.0; + let saved_revision = session.active().unwrap().revision; + session.active_mut().unwrap().saved_revision = saved_revision; + session.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); + session.execute("type.create", json!({"text":"必须保留的本机修改","size":32})).unwrap(); + let dirty = session.active().unwrap().doc.id.0; + let dirty_revision = session.active().unwrap().revision; + assert_ne!(session.active().unwrap().saved_revision, dirty_revision); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.sync_views(); + let mut other = photocraft_engine::Session::new(); + other.execute("file.new", json!({"width":1440,"height":3000})).unwrap(); + let incoming = other.active().unwrap().doc.as_ref().clone(); + let incoming_id = incoming.id.0; + let (tx, _requests) = mpsc::channel(); + let (events, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + w.account = json!({"id":"account-a"}); + w.busy = None; + w.observed_doc = Some(dirty); + w.open_projects.insert(clean, w.local_identity("local-a")); + w.open_projects.insert(dirty, w.local_identity("local-a")); + let account = if scope == "other-account" { "account-b" } else { "account-a" }; + let origin = if scope == "other-origin" { "https://other.invalid" } else { w.origin.as_str() }; + events + .send(Event::Opened( + Box::new(incoming), + json!({ + "path":"updated-project.pcraft","localId":"local-a","revision":9, + "accountId":account,"origin":origin,"supersededDocIds":[clean,dirty,incoming_id] + }), + )) + .unwrap(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| w.tick(&mut app, ui)); + output.textures_delta.clear(); + let old = app.session.documents().iter().find(|state| state.doc.id.0 == dirty).unwrap(); + assert_eq!(old.revision, dirty_revision, "{scope}"); + assert!( + old.doc.walk().iter().any(|(_, _, layer)| matches!(&layer.content, LayerContent::Text(text) if text.text == "必须保留的本机修改")), + "{scope}" + ); + assert!(w.open_projects.contains_key(&dirty), "{scope}"); + assert_eq!(app.session.documents().iter().any(|state| state.doc.id.0 == clean), scope != "same", "{scope}"); + assert_eq!(w.open_projects.contains_key(&clean), scope != "same", "{scope}"); + assert_eq!(app.session.documents().len(), if scope == "same" { 2 } else { 3 }, "{scope}"); + assert_eq!(app.session.active().unwrap().doc.id.0, incoming_id, "{scope}"); + assert_eq!(app.session.active().unwrap().saved_revision, 9, "{scope}"); + assert_eq!(app.ui.views.len(), app.session.documents().len(), "{scope}"); + assert_eq!(w.error == "旧画布有未保存修改,请另存文件", scope == "same", "{scope}"); + } + } + #[test] fn only_existing_canvas_edits_are_allowed() { let mut s = photocraft_engine::Session::new(); s.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun/agent_panel.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun/agent_panel.rs index 784e3517..d8f77dc4 100644 --- a/apps/detail-photocraft/crates/ui-egui/src/jiansun/agent_panel.rs +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun/agent_panel.rs @@ -362,7 +362,7 @@ impl Workspace { && self.queue.is_empty() && self.pending_image.is_none() && !self.pi_review - && (if self.image_mode == "chat" { self.model_ready && self.agent_rule["id"].is_string() } else { self.image_ready }) + && (if self.image_mode == "chat" { self.model_ready } else { self.image_ready }) && !self.composer.trim().is_empty() } @@ -525,13 +525,7 @@ impl Workspace { let can = self.can_send(app); let response = ui.add_enabled_ui(can, |ui| send_button(ui, t, self.reduce_motion)).inner.on_hover_text("发送 · Ctrl+Enter").on_disabled_hover_text( - if self.unknown { - "先核查原请求结果" - } else if self.image_mode == "chat" && !self.agent_rule["id"].is_string() { - "先在产品资料中选择品牌与品类" - } else { - "打开项目、连接模型并输入要求后发送" - }, + if self.unknown { "先核查原请求结果" } else { "打开项目、连接模型并输入要求后发送" }, ); self.panel_geometry["send"] = rect_value(response.rect); if response.clicked() || (shortcut && can) { @@ -803,6 +797,32 @@ mod tests { assert!(!w.error.is_empty()); } #[test] + fn project_discussion_needs_a_model_but_no_brand_rules_or_canvas_write_grant() { + let (tx, requests) = mpsc::channel(); + let (_, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + let _ = requests.try_recv(); + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); + let app = PhotocraftApp::new(session, crate::Services::default()); + w.busy = None; + w.model_ready = true; + w.image_mode = "chat".into(); + w.composer = "先讨论排版,不修改画布".into(); + assert!(w.agent_rule.is_null()); + assert!(w.can_send(&app)); + w.send_composer(&app); + assert!(matches!(requests.try_recv().unwrap(),Request::Action(method,_) if method=="agent")); + assert!(!w.allow_apply); + assert!(!w.pi_write_blocked); + w.busy = None; + w.unknown = true; + w.composer = "不能借聊天重发原请求".into(); + assert!(!w.can_send(&app)); + w.send_composer(&app); + assert!(requests.try_recv().is_err()); + } + #[test] fn streamed_text_is_scoped_and_cannot_apply_incomplete_edits() { let ctx = egui::Context::default(); let mut session = photocraft_engine::Session::new(); diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation.rs index bab234a5..e4f819d6 100644 --- a/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation.rs +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation.rs @@ -2,120 +2,93 @@ use super::*; use egui::{Align, Layout, vec2}; +const PICKER_HEIGHT: f32 = 280.0; +const PICKER_ROW_HEIGHT: f32 = 36.0; +const MATERIAL_THUMB_SIDE: f32 = 32.0; + +fn rect_value(rect: egui::Rect) -> Value { + json!([rect.min.x, rect.min.y, rect.width(), rect.height()]) +} + +/// Constrain the native ComboBox before its first measuring pass. Its default +/// popup extends labels and shrinks loading content to one row, producing a +/// different menu on first open and after the asynchronous catalogue arrives. +fn picker(ui: &mut Ui, id: &str, width: f32, selected: &str, contents: impl FnOnce(&mut Ui) -> R) -> egui::InnerResponse> { + picker_height(ui, id, width, PICKER_HEIGHT, selected, contents) +} + +fn picker_height(ui: &mut Ui, id: &str, width: f32, height: f32, selected: &str, contents: impl FnOnce(&mut Ui) -> R) -> egui::InnerResponse> { + ui.scope(|ui| { + ui.set_width(width); + egui::ComboBox::from_id_salt(id).width(width).height(height).truncate().selected_text(selected).show_ui(ui, |ui| { + // Reserve the same scrollbar gutter before and after data arrives. + ui.set_width( + (width - ui.spacing().menu_margin.sum().x - ui.spacing().scroll.allocated_width() - ui.spacing().scroll.content_margin.sum().x).max(120.0), + ); + ui.set_min_height(height); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); + contents(ui) + }) + }) + .inner +} + +fn picker_row(ui: &mut Ui, selected: bool, label: &str, enabled: bool) -> egui::Response { + // add_sized centers its child and reports the text's intrinsic width on the + // first sizing pass. An explicit native minimum keeps the whole left-aligned + // row the same size before and after the popup's layout cache is populated. + let size = vec2(ui.available_width(), PICKER_ROW_HEIGHT); + ui.add_enabled(enabled, egui::Button::selectable(selected, label).min_size(size).truncate()) +} + +fn picker_notice(ui: &mut Ui, loading: bool, error: Option<&str>, empty: bool, empty_label: &str) -> bool { + let mut retry = false; + if loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("正在读取…"); + }); + } else if let Some(error) = error { + ui.label(egui::RichText::new("读取失败").color(Tokens::get(ui.ctx()).danger)).on_hover_text(error); + retry = ui.button("重新读取").clicked(); + } else if empty { + ui.weak(empty_label); + retry = ui.button("重新读取").clicked(); + } + retry +} + +fn preview_size(size: egui::Vec2, max: egui::Vec2) -> egui::Vec2 { + if !size.is_finite() || size.x <= 0.0 || size.y <= 0.0 { + return egui::Vec2::ZERO; + } + size * (max.x / size.x).min(max.y / size.y) +} + +fn material_thumbnail(ui: &mut Ui, texture: Option<&egui::TextureHandle>, loading: bool, failed: bool) -> egui::Response { + let (rect, response) = + ui.allocate_exact_size(vec2(MATERIAL_THUMB_SIDE, MATERIAL_THUMB_SIDE), if failed { egui::Sense::click() } else { egui::Sense::hover() }); + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(rect, t.radius_sm, t.field); + ui.painter().rect_stroke(rect, t.radius_sm, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + if let Some(texture) = texture { + let size = preview_size(texture.size_vec2(), rect.size()); + let image_rect = egui::Align2::CENTER_CENTER.align_size_within_rect(size, rect); + ui.painter().image(texture.id(), image_rect, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + } else if loading { + ui.put(rect, egui::Spinner::new().size(14.0)); + } else if failed { + let a = rect.shrink(11.0); + ui.painter().line_segment([a.left_top(), a.right_bottom()], egui::Stroke::new(1.0, t.text_dim)); + ui.painter().line_segment([a.right_top(), a.left_bottom()], egui::Stroke::new(1.0, t.text_dim)); + } + response +} + pub(super) fn empty_brief() -> Value { json!({"source":"upload","name":"","brandId":"","brandName":"","categoryId":"","categoryName":"","identifierType":"sku","identifier":"","variant":"","colorMaterialId":"","colorName":"","dimensions":{"length":"","width":"","height":"","unit":"mm"},"images":[],"note":""}) } -#[cfg(test)] -mod tests { - use super::*; - fn setup() -> (Workspace, PhotocraftApp) { - let (tx, _) = mpsc::channel(); - let (_, rx) = mpsc::channel(); - let mut w = Workspace::new(tx, rx); - let mut session = photocraft_engine::Session::new(); - session.execute("file.new", json!({"name":"流程验收","width":1440,"height":1800})).unwrap(); - let app = PhotocraftApp::new(session, crate::Services::default()); - w.account = json!({"id":"qa"}); - w.origin = "http://localhost:3138".into(); - let st = app.session.active().unwrap(); - w.owner_doc = Some(st.doc.id.0); - w.expected_revision = st.revision; - w.creation_base_revision = st.revision; - w.allow_apply = true; - w.busy = Some("creation-generate".into()); - (w, app) - } - fn reference(w: &Workspace) -> Value { - json!({"doc":w.owner_doc,"accountId":w.account["id"],"origin":w.origin,"run":"00000000-0000-4000-8000-000000000001","baseRevision":w.creation_base_revision,"sequence":1,"creation":{}}) - } - #[test] - fn creation_generation_requires_current_identity_revision_and_human_permission() { - let (mut w, mut app) = setup(); - let v = reference(&w); - assert_eq!(allowed(&mut w, &app, &v).unwrap()["allowed"], true); - let mut changed = v.clone(); - changed["accountId"] = json!("other"); - assert_eq!(allowed(&mut w, &app, &changed).unwrap()["allowed"], false); - changed = v.clone(); - changed["run"] = json!("00000000-0000-4000-8000-000000000002"); - assert_eq!(allowed(&mut w, &app, &changed).unwrap()["allowed"], false); - app.session.execute("type.create", json!({"text":"用户已编辑","font":"Noto Sans SC","size":32,"box":[20,20,500,90]})).unwrap(); - assert_eq!(allowed(&mut w, &app, &v).unwrap()["allowed"], false); - w.expected_revision = app.session.active().unwrap().revision; - w.handover(); - assert_eq!(allowed(&mut w, &app, &v).unwrap()["allowed"], false); - } - #[test] - fn generated_canvas_uses_native_undo_and_preserves_late_human_edits() { - let (mut w, mut app) = setup(); - let v = reference(&w); - allowed(&mut w, &app, &v).unwrap(); - let original = app.session.active().unwrap().doc.id; - let before = app.session.active().unwrap().doc.clone(); - let mut generated = photocraft_engine::Session::new(); - generated.execute("file.new", json!({"name":"实际生成结果","width":1440,"height":3600})).unwrap(); - generated.execute("type.create", json!({"text":"真实可编辑标题","font":"Noto Sans SC","size":32,"box":[20,20,500,90]})).unwrap(); - let result = (*generated.active().unwrap().doc).clone(); - canvas_received(&mut w, &mut app, Box::new(result.clone()), v.clone()); - assert_eq!(app.session.active().unwrap().doc.id, original); - assert_eq!(app.session.active().unwrap().doc.size.height, 3600); - assert!(app.session.active().unwrap().doc.walk().iter().any(|(_, _, l)| matches!(&l.content, LayerContent::Text(_)))); - app.session.execute("edit.undo", json!({})).unwrap(); - assert_eq!(app.session.active().unwrap().doc.size, before.size); - assert_eq!(app.session.active().unwrap().doc.walk().len(), before.walk().len()); - let revision = app.session.active().unwrap().revision; - canvas_received(&mut w, &mut app, Box::new(result), v); - assert!(w.creation_pending.is_some()); - assert_eq!(app.session.active().unwrap().revision, revision); - assert_eq!(app.session.active().unwrap().doc.size, before.size); - } - #[test] - fn sync_does_not_clear_dirty_when_backend_detects_a_newer_snapshot() { - let (mut w, app) = setup(); - w.sync_request_meta = w.sync_signature(); - let mut value = reference(&w); - value["revision"] = json!(app.session.active().unwrap().revision); - value["syncDirty"] = json!(true); - sync_received(&mut w, &app, &value); - assert!(w.sync_dirty); - value["syncDirty"] = json!(false); - sync_received(&mut w, &app, &value); - assert!(!w.sync_dirty); - } - #[test] - fn delayed_rules_refresh_updates_canonical_revision_without_overwriting_the_users_draft() { - let (mut w, app) = setup(); - w.creation = json!({"revision":2,"brief":empty_brief(),"sellingPoints":{"selected":["old:0"],"direction":"原方向"},"plan":{"status":"confirmed","screens":[{"id":"one","title":"原方案"}]},"width":1440,"screenCount":3,"designSnapshot":{"rules":{"id":"old-rule"}}}); - w.creation_brief = w.creation["brief"].clone(); - w.creation_brief["name"] = json!("刚输入的产品名称"); - w.creation_brief["dimensions"]["length"] = json!("1800"); - w.creation_selected = vec!["new:1".into()]; - w.creation_direction = "刚输入的表达方向".into(); - w.creation_plan = json!([{"id":"one","title":"尚未保存的标题"}]); - w.creation_width = 750; - w.creation_count = 6; - w.creation_dirty = true; - let draft = w.creation_draft(); - let mut value = reference(&w); - value["creation"] = w.creation.clone(); - value["creation"]["revision"] = json!(3); - value["creation"]["designSnapshot"]["rules"] = json!({"id":"fixed-rule","contentHash":"a".repeat(64)}); - value["creation"]["plan"]["status"] = json!("stale"); - received(&mut w,&app,"creation-rules",&value); - assert_eq!(w.creation_draft()["brief"],draft["brief"]); - for field in ["screens","direction","selected","width","screenCount"] { assert_eq!(w.creation_draft()[field],draft[field]); } - assert!(w.creation_dirty); - assert_eq!(w.creation["revision"],3); - assert_eq!(w.creation["designSnapshot"]["rules"]["id"],"fixed-rule"); - assert_eq!(w.creation["plan"]["status"],"stale"); - // With no local edits, the normal canonical plan state remains visible. - w.creation_dirty = false; - received(&mut w,&app,"creation-rules",&value); - assert_eq!(w.creation_plan,value["creation"]["plan"]["screens"]); - assert!(!w.creation_dirty); - } -} fn edit(ui: &mut Ui, value: &mut Value, width: f32, multiline: bool) -> bool { let mut text = value.as_str().unwrap_or("").to_owned(); let field = if multiline { @@ -187,6 +160,9 @@ pub(super) fn canvas_received(w: &mut Workspace, app: &mut PhotocraftApp, mut do } } pub(super) fn received(w: &mut Workspace, app: &PhotocraftApp, method: &str, value: &Value) { + if bound(w, app, value) { + w.receive_template_snapshot(&value["templateWorkflow"], false); + } if !bound(w, app, value) { return; } @@ -194,21 +170,6 @@ pub(super) fn received(w: &mut Workspace, app: &PhotocraftApp, method: &str, val w.creation_progress = value["text"].as_str().unwrap_or("").chars().take(4000).collect(); return; } - if method == "creation-catalog" { - if let Some(kind) = value["catalog"]["kind"].as_str() { - w.creation_catalog.insert(kind.into(), value["catalog"]["items"].as_array().cloned().unwrap_or_default()); - } - return; - } - if method == "creation-thumbnail" { - if let Some(id) = value["id"].as_str() { - w.creation_thumb_loading.remove(id); - if value["error"].is_string() { - w.creation_thumb_failed.insert(id.into()); - } - } - return; - } if let Some(images) = value["images"].as_array() { let mut current = w.creation_brief["images"].as_array().cloned().unwrap_or_default(); for image in images { @@ -285,18 +246,6 @@ pub(super) fn received(w: &mut Workspace, app: &PhotocraftApp, method: &str, val let _ = w.tx.send(Request::Action("pi-rules".into(), json!({"doc":value["doc"]}))); } } -pub(super) fn thumbnail_received(w: &mut Workspace, ui: &Ui, value: &Value, image: egui::ColorImage) { - if value["accountId"] != w.account["id"] || value["origin"] != w.origin { - return; - } - if let Some(id) = value["id"].as_str() { - w.creation_thumb_loading.remove(id); - if w.creation_thumbs.len() >= 48 { - w.creation_thumbs.clear(); - } - w.creation_thumbs.insert(id.into(), ui.ctx().load_texture(format!("material-{id}"), image, egui::TextureOptions::LINEAR)); - } -} pub(super) fn sync_received(w: &mut Workspace, app: &PhotocraftApp, value: &Value) { if !bound(w, app, value) { return; @@ -309,7 +258,7 @@ pub(super) fn sync_received(w: &mut Workspace, app: &PhotocraftApp, value: &Valu let same = app.session.active().is_some_and(|s| value["revision"].as_u64() == Some(s.revision)); w.sync_dirty = !(same && value["syncDirty"] != true && w.sync_request_meta == w.sync_signature()); if let Some(items) = value["projects"].as_array() { - w.projects = items.clone(); + w.merge_project_receipt(app, items); } w.creation_progress = if w.sync_dirty { "云端已保存该版本,当前仍有新修改" } else { "已同步" }.into(); } @@ -351,7 +300,7 @@ impl Workspace { self.creation_submit(app, method, json!({})); } fn sync_signature(&self) -> String { - json!({"composer":self.composer,"messages":self.messages,"creation":self.creation,"draft":self.creation_draft()}).to_string() + json!({"composer":self.composer,"messages":self.messages,"creation":self.creation,"draft":self.creation_draft(),"templateWorkflow":self.template_workflow}).to_string() } pub(super) fn sync_action(&mut self, app: &PhotocraftApp, method: &str, mut input: Value) { if self.busy.is_some() || self.saving.is_some() { @@ -407,6 +356,19 @@ impl Workspace { } pub(super) fn creation_action(&mut self, app: &mut PhotocraftApp, method: &str, mut input: Value) -> Result { let mut method = method; + if matches!(method, "creation-catalog" | "creation-thumbnail") { + if input["doc"].as_u64().is_some_and(|id| Some(id) != app.session.active().map(|s| s.doc.id.0)) { + return Err("项目已切换".into()); + } + if method == "creation-catalog" { + let kind = input["kind"].as_str().ok_or("目录类型无效")?; + self.request_creation_catalog(app, kind, input["query"].as_str().unwrap_or("").into()); + } else { + let id = input["id"].as_str().filter(|id| !id.is_empty()).ok_or("材质编号无效")?; + self.queue_creation_thumbnail(app, id); + } + return Ok(json!({"submitted":true,"readOnly":true})); + } if method == "creation-import" && app.session.authorize.is_some() { use base64::Engine as _; let paths = input["paths"].as_array().filter(|a| !a.is_empty() && a.len() <= 8).ok_or("每次最多8张图片")?; @@ -435,10 +397,7 @@ impl Workspace { return Ok(json!({"created":true})); } if method == "creation-view" { - self.creation_open = true; - self.creation_step = input["step"].as_u64().unwrap_or(0).min(3) as usize; - self.library = false; - self.material = false; + self.show_creation_step(input["step"].as_u64().unwrap_or(0).min(4) as usize, app.session.active().is_some()); return Ok(Value::Null); } if self.busy.is_some() { @@ -493,23 +452,48 @@ impl Workspace { } } fn catalog(&mut self, app: &mut PhotocraftApp, kind: &str, query: String) { - self.creation_submit(app,"creation-catalog",json!({"kind":kind,"query":query,"identifierType":self.creation_brief["identifierType"],"identifier":if kind=="products" {self.creation_brief["identifier"].clone()}else{Value::Null}})); + self.request_creation_catalog(app, kind, query); + } + pub(super) fn creation_tabs(&mut self, app: &PhotocraftApp, ui: &mut Ui) { + self.creation_view_geometry = json!({"controls":{},"popups":{},"materialRows":[],"hoverPreview":null}); + let document_open = app.session.active().is_some(); + ui.horizontal(|ui| { + for (step, label) in [(0, "资料"), (1, "卖点"), (2, "方案"), (4, "图位"), (3, "画布")] { + let response = ui.selectable_label(self.creation_step == step, label); + self.creation_view_geometry[format!("step-{step}")] = rect_value(response.rect); + self.creation_view_geometry["controls"][format!("step-{step}")] = rect_value(response.rect); + if response.clicked() { + self.show_creation_step(step, document_open); + } + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.weak(format!("{} px", self.creation_width)); + }); + }); + } + fn control_geometry(&mut self, name: &str, rect: egui::Rect) { + self.creation_view_geometry[name] = rect_value(rect); + self.creation_view_geometry["controls"][name] = rect_value(rect); + } + fn picker_geometry(&mut self, ui: &Ui, name: &str, response: &egui::Response, loading: bool, ready: bool, rows: Vec) { + self.control_geometry(name, response.rect); + let open = egui::ComboBox::is_open(ui.ctx(), response.id); + let rect = open.then(|| ui.ctx().memory(|memory| memory.area_rect(response.id.with("popup")))).flatten(); + self.creation_view_geometry["popups"][name] = json!({"open":open,"loading":loading,"ready":ready,"rect":rect.map(rect_value),"rows":rows}); + } + fn material_preview(&mut self, response: egui::Response, id: &str, label: &str, image: &egui::TextureHandle) { + response.on_hover_ui(|ui| { + ui.set_max_width(240.0); + ui.label(label); + let size = preview_size(image.size_vec2(), vec2(240.0, 240.0)); + let image_response = ui.image((image.id(), size)); + self.creation_view_geometry["hoverPreview"] = json!({"id":id,"ready":true,"rect":rect_value(image_response.rect),"sourceSize":image.size()}); + }); } pub(super) fn creation_ui(&mut self, app: &mut PhotocraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let doc = app.session.active().map(|s| s.doc.id.0); egui::Frame::NONE.inner_margin(20).show(ui, |ui| { - ui.horizontal(|ui| { - for (step, label) in ["资料", "卖点", "方案", "画布"].iter().enumerate() { - if ui.selectable_label(self.creation_step == step, *label).clicked() { - self.creation_step = step; - } - } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.weak(format!("{} px", self.creation_width)); - }); - }); - ui.separator(); if self.creation_doc != doc { if self.creation_read_failed { ui.colored_label(t.danger, &self.error); @@ -550,6 +534,47 @@ impl Workspace { }); } let idle = self.busy.is_none() && !self.unknown; + if self.creation_step == 0 { + // Directory reads have their own identity-bound queue and do not + // acquire a model execution lease, even while an old request is pending. + for kind in ["brands", "materials"] { + if !self.creation_catalog.contains_key(kind) && !self.creation_catalog_loading(kind) && self.creation_catalog_error(kind).is_none() { + self.request_creation_catalog(app, kind, String::new()); + } + } + } + if self.creation_step == 4 { + let binding = format!("{}|{}|{:?}", self.account["id"], self.origin, doc); + let mut actions = Vec::new(); + egui::ScrollArea::vertical().id_salt("template-slots-body").show(ui, |ui| { + super::template_slots::show( + ui, + &mut self.template_ui, + &self.template_workflow, + &binding, + self.busy.is_some(), + &self.agent_images, + &mut |method, input| actions.push((method.to_owned(), input)), + ); + }); + let view = self.template_ui.inspect(); + if let Some(slot) = self.template_workflow["slots"].as_array().and_then(|a| a.iter().find(|s| s["id"] == view["selectedSlotId"])) { + for c in slot["candidates"].as_array().into_iter().flatten().filter(|c| c["status"] == "complete") { + if let Some(id) = c["id"].as_str() + && !self.agent_images.contains_key(id) + && self.requested_previews.insert(id.into()) + { + self.tx.send(Request::Action("agent-image-preview".into(), json!({"doc":doc,"taskId":id}))).ok(); + } + } + } + for (method, input) in actions { + if let Err(e) = self.slot_action(app, &method, input) { + self.error = e; + } + } + return; + } egui::ScrollArea::vertical().id_salt("creation-body").show(ui, |ui| { ui.add_enabled_ui(idle, |ui| match self.creation_step { 0 => self.brief_ui(app, ui), @@ -564,20 +589,26 @@ impl Workspace { let width = (ui.available_width() - 100.0).clamp(160.0, 560.0); egui::Grid::new("creation-brief").num_columns(2).spacing(vec2(16.0, 12.0)).show(ui, |ui| { ui.label("资料来源"); - egui::ComboBox::from_id_salt("creation-source") - .width(width) - .selected_text(if self.creation_brief["source"] == "library" { "联动素材库" } else { "上传产品资料" }) - .show_ui(ui, |ui| { - for (value, label) in [("upload", "上传产品资料"), ("library", "联动素材库")] { - if ui.selectable_label(self.creation_brief["source"] == value, label).clicked() { - self.creation_brief["source"] = json!(value); - self.creation_dirty = true; - } + let mut source_rows = Vec::new(); + let source_label = if self.creation_brief["source"] == "library" { "联动素材库" } else { "上传产品资料" }; + let source_height = PICKER_ROW_HEIGHT * 2.0 + ui.spacing().item_spacing.y; + let source = picker_height(ui, "creation-source", width, source_height, source_label, |ui| { + for (value, label) in [("upload", "上传产品资料"), ("library", "联动素材库")] { + let response = picker_row(ui, self.creation_brief["source"] == value, label, true); + source_rows.push(json!({"id":value,"label":label,"ready":true,"rect":rect_value(response.rect)})); + if response.clicked() { + self.creation_brief["source"] = json!(value); + self.creation_dirty = true; + ui.close(); } - }); + } + }); + self.picker_geometry(ui, "source", &source.response, false, true, source_rows); ui.end_row(); ui.label("产品名称 *"); - self.creation_dirty |= edit(ui, &mut self.creation_brief["name"], width, false); + let name = ui.scope(|ui| edit(ui, &mut self.creation_brief["name"], width, false)); + self.creation_dirty |= name.inner; + self.control_geometry("name", name.response.rect); ui.end_row(); ui.label("品牌 *"); self.brand_select(app, ui, width); @@ -694,124 +725,183 @@ impl Workspace { let ready = !self.creation_brief["name"].as_str().unwrap_or("").trim().is_empty() && !self.creation_brief["brandId"].as_str().unwrap_or("").is_empty() && self.creation_brief["images"].as_array().is_some_and(|a| a.iter().any(|v| v["purpose"] == "product")); - if ui.add_enabled(ready, egui::Button::new("分析资料").fill(Tokens::get(ui.ctx()).accent)).clicked() { + if ui.add_enabled(ready && self.busy.is_none() && !self.unknown, egui::Button::new("分析资料").fill(Tokens::get(ui.ctx()).accent)).clicked() { self.creation_submit(app, "creation-facts", json!({})); } }); } fn brand_select(&mut self, app: &mut PhotocraftApp, ui: &mut Ui, width: f32) { let rows = self.creation_catalog.get("brands").cloned().unwrap_or_default(); - egui::ComboBox::from_id_salt("creation-brand") - .width(width) - .selected_text(self.creation_brief["brandName"].as_str().filter(|s| !s.is_empty()).unwrap_or("选择品牌")) - .show_ui(ui, |ui| { - if rows.is_empty() { - if !self.creation_catalog.contains_key("brands") { - self.creation_catalog.insert("brands".into(), vec![]); - self.catalog(app, "brands", String::new()); - } else if ui.button("重新读取").clicked() { - self.catalog(app, "brands", String::new()); - } + let selected = self.creation_brief["brandName"].as_str().filter(|s| !s.is_empty()).unwrap_or("选择品牌").to_owned(); + let mut geometry = Vec::new(); + let result = picker(ui, "creation-brand", width, &selected, |ui| { + if !self.creation_catalog.contains_key("brands") && !self.creation_catalog_loading("brands") && self.creation_catalog_error("brands").is_none() { + self.catalog(app, "brands", String::new()); + } + let error = self.creation_catalog_error("brands").map(str::to_owned); + if picker_notice(ui, self.creation_catalog_loading("brands"), error.as_deref(), rows.is_empty(), "暂无品牌") { + self.catalog(app, "brands", String::new()); + } + for row in &rows { + let ready = row["ready"] == true; + let label = format!("{}{}", row["name"].as_str().unwrap_or(""), if ready { "" } else { " · 待开发" }); + let response = picker_row(ui, self.creation_brief["brandId"] == row["id"], &label, ready); + geometry.push(json!({"id":row["id"],"label":label,"ready":ready,"rect":rect_value(response.rect)})); + if response.clicked() { + self.creation_brief["brandId"] = row["id"].clone(); + self.creation_brief["brandName"] = row["name"].clone(); + self.creation_brief["categoryId"] = json!(""); + self.creation_brief["categoryName"] = json!(""); + self.agent_rule = Value::Null; + self.agent_rule_state = Value::Null; + self.creation_dirty = true; + ui.close(); } - for row in rows { - let ready = row["ready"] == true; - let label = format!("{}{}", row["name"].as_str().unwrap_or(""), if ready { "" } else { " · 待开发" }); - if ui.add_enabled(ready, egui::Button::selectable(self.creation_brief["brandId"] == row["id"], label)).clicked() { - self.creation_brief["brandId"] = row["id"].clone(); - self.creation_brief["brandName"] = row["name"].clone(); - self.creation_brief["categoryId"] = json!(""); - self.creation_brief["categoryName"] = json!(""); - self.agent_rule = Value::Null; - self.agent_rule_state = Value::Null; - self.creation_dirty = true; - ui.close(); - } - } - }); + } + }); + let loading = self.creation_catalog_loading("brands"); + self.picker_geometry(ui, "brand", &result.response, loading, !rows.is_empty() && !loading && self.creation_catalog_error("brands").is_none(), geometry); } fn category_select(&mut self, app: &PhotocraftApp, ui: &mut Ui, width: f32) { - let brand = self.creation_brief["brandId"].as_str().unwrap_or(""); - let rows = self.agent_categories.clone(); + let brand = self.creation_brief["brandId"].as_str().unwrap_or("").to_owned(); + let mut rows = self.agent_categories.clone(); let ready_categories: Vec = self.agent_rules.iter().filter(|r| r["shopId"] == brand).filter_map(|r| r["categoryId"].as_str().map(str::to_owned)).collect(); + rows.sort_by_key(|row| !row["id"].as_str().is_some_and(|id| ready_categories.iter().any(|ready| ready == id))); let mut refresh = false; - egui::ComboBox::from_id_salt("creation-category") - .width(width) - .selected_text(self.creation_brief["categoryName"].as_str().filter(|s| !s.is_empty()).unwrap_or(if brand.is_empty() { - "先选择品牌" - } else { - "选择品类" - })) - .show_ui(ui, |ui| { - if rows.is_empty() { + let selected = self.creation_brief["categoryName"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or(if brand.is_empty() { "先选择品牌" } else { "选择品类" }) + .to_owned(); + let mut geometry = Vec::new(); + let loading = self.busy.as_deref() == Some("pi-rules"); + let result = picker(ui, "creation-category", width, &selected, |ui| { + if rows.is_empty() { + if loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("正在读取…"); + }); + } else { + ui.weak(if brand.is_empty() { "先选择品牌" } else { "尚未读取到品类" }); if ui.button("读取品类").clicked() { refresh = true; } } - for row in rows { - let ready = row["id"].as_str().is_some_and(|id| ready_categories.iter().any(|s| s == id)); - let name = row["name"].as_str().unwrap_or(""); - let label = if ready { name.to_owned() } else { format!("{name} · 待配置") }; - if ui - .add_enabled(ready, egui::Button::selectable(self.creation_brief["categoryId"] == row["id"], label)) - .on_hover_text(if ready { "使用该品牌与品类的项目模板" } else { "此品牌尚未配置该品类模板" }) - .clicked() - { - self.creation_brief["categoryId"] = row["id"].clone(); - self.creation_brief["categoryName"] = row["name"].clone(); - self.agent_rule = Value::Null; - self.agent_rule_state = Value::Null; - self.creation_dirty = true; - ui.close(); - } + } + for row in &rows { + let ready = row["id"].as_str().is_some_and(|id| ready_categories.iter().any(|s| s == id)); + let name = row["name"].as_str().unwrap_or(""); + let label = if ready { name.to_owned() } else { format!("{name} · 待配置") }; + let response = picker_row(ui, self.creation_brief["categoryId"] == row["id"], &label, ready).on_hover_text(if ready { + "使用该品牌与品类的项目模板" + } else { + "此品牌尚未配置该品类模板" + }); + geometry.push(json!({"id":row["id"],"label":label,"ready":ready,"rect":rect_value(response.rect)})); + if response.clicked() { + self.creation_brief["categoryId"] = row["id"].clone(); + self.creation_brief["categoryName"] = row["name"].clone(); + self.agent_rule = Value::Null; + self.agent_rule_state = Value::Null; + self.creation_dirty = true; + ui.close(); } - }); + } + }); + self.picker_geometry(ui, "category", &result.response, loading, !rows.is_empty() && !loading, geometry); if refresh { - let _ = self.tx.send(Request::Action("pi-rules".into(), json!({"doc":app.session.active().map(|s| s.doc.id.0)}))); + self.send("pi-rules", json!({"doc":app.session.active().map(|s| s.doc.id.0)})); } } fn material_select(&mut self, app: &mut PhotocraftApp, ui: &mut Ui, width: f32) { let rows = self.creation_catalog.get("materials").cloned().unwrap_or_default(); - egui::ComboBox::from_id_salt("creation-material") - .width(width) - .selected_text(self.creation_brief["colorName"].as_str().filter(|s| !s.is_empty()).unwrap_or("选择材质颜色")) - .show_ui(ui, |ui| { - if !self.creation_catalog.contains_key("materials") { - self.creation_catalog.insert("materials".into(), vec![]); - self.catalog(app, "materials", String::new()); - } - ui.horizontal(|ui| { - text_edit(ui, egui::TextEdit::singleline(&mut self.creation_material_query).hint_text("搜索材质").desired_width(180.0)); - if ui.button("查找").clicked() { - self.catalog(app, "materials", self.creation_material_query.clone()); - } - }); - if ui.selectable_label(self.creation_brief["colorMaterialId"] == "", "未指定").clicked() { - self.creation_brief["colorMaterialId"] = json!(""); - self.creation_brief["colorName"] = json!(""); - self.creation_dirty = true; - } - for row in rows { - let id = row["id"].as_str().unwrap_or("").to_owned(); - ui.horizontal(|ui| { - if let Some(image) = self.creation_thumbs.get(&id) { - ui.image((image.id(), vec2(30.0, 30.0))); - } else if self.creation_thumb_loading.len() < 2 && !self.creation_thumb_loading.contains(&id) && !self.creation_thumb_failed.contains(&id) { - self.creation_thumb_loading.insert(id.clone()); - let _ = self.tx.send(Request::Action( - "creation-thumbnail".into(), - json!({"doc":app.session.active().map(|s|s.doc.id.0),"kind":"material","id":id}), - )); - } - if ui.selectable_label(self.creation_brief["colorMaterialId"] == row["id"], row["name"].as_str().unwrap_or("")).clicked() { - self.creation_brief["colorMaterialId"] = row["id"].clone(); - self.creation_brief["colorName"] = row["name"].clone(); - self.creation_dirty = true; - ui.close(); - } - }); + let selected = self.creation_brief["colorName"].as_str().filter(|s| !s.is_empty()).unwrap_or("选择材质颜色").to_owned(); + let mut geometry = Vec::new(); + let mut material_rows = Vec::new(); + let result = picker(ui, "creation-material", width, &selected, |ui| { + if !self.creation_catalog.contains_key("materials") + && !self.creation_catalog_loading("materials") + && self.creation_catalog_error("materials").is_none() + { + self.catalog(app, "materials", String::new()); + } + ui.horizontal(|ui| { + let search_width = (ui.available_width() - 56.0).max(80.0); + let search = text_edit(ui, egui::TextEdit::singleline(&mut self.creation_material_query).hint_text("搜索材质").desired_width(search_width)); + if ui.button("查找").clicked() || (search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) { + self.catalog(app, "materials", self.creation_material_query.clone()); } }); + let error = self.creation_catalog_error("materials").map(str::to_owned); + if picker_notice(ui, self.creation_catalog_loading("materials"), error.as_deref(), rows.is_empty(), "暂无匹配材质") { + self.catalog(app, "materials", self.creation_material_query.clone()); + } + if picker_row(ui, self.creation_brief["colorMaterialId"] == "", "未指定", true).clicked() { + self.creation_brief["colorMaterialId"] = json!(""); + self.creation_brief["colorName"] = json!(""); + self.creation_dirty = true; + ui.close(); + } + for row in &rows { + let Some(id) = row["id"].as_str().filter(|id| !id.is_empty()) else { continue }; + let label = row["name"].as_str().unwrap_or(""); + let image = self.creation_thumbs.get(id).cloned(); + let error = self.creation_thumbnail_error(id).map(str::to_owned); + let loading = self.creation_thumbnail_loading(id); + let mut image_rect = egui::Rect::NOTHING; + let mut visible = false; + let line = ui.horizontal(|ui| { + let thumbnail = material_thumbnail(ui, image.as_ref(), loading, error.is_some()); + image_rect = thumbnail.rect; + visible = ui.is_rect_visible(image_rect); + if visible && image.is_none() && !loading && error.is_none() { + self.queue_creation_thumbnail(app, id); + } + if let Some(error) = error.as_deref() { + if thumbnail.on_hover_text(format!("{error}\n点击重新读取")).clicked() { + self.retry_creation_thumbnail(app, id); + } + } else if let Some(image) = image.as_ref() { + self.material_preview(thumbnail, id, label, image); + } + let response = picker_row(ui, self.creation_brief["colorMaterialId"] == row["id"], label, true); + if response.clicked() { + self.creation_brief["colorMaterialId"] = row["id"].clone(); + self.creation_brief["colorName"] = row["name"].clone(); + self.creation_dirty = true; + ui.close(); + } + if let Some(image) = image.as_ref() { + self.material_preview(response, id, label, image); + } + }); + let status = if image.is_some() { + "ready" + } else if self.creation_thumbnail_error(id).is_some() { + "failed" + } else if self.creation_thumbnail_loading(id) { + "loading" + } else { + "queued" + }; + geometry.push(json!({"id":id,"label":label,"ready":true,"rect":rect_value(line.response.rect)})); + material_rows.push( + json!({"id":id,"label":label,"rect":rect_value(line.response.rect),"imageRect":rect_value(image_rect),"status":status,"visible":visible}), + ); + } + }); + self.creation_view_geometry["materialRows"] = json!(material_rows); + let loading = self.creation_catalog_loading("materials"); + self.picker_geometry( + ui, + "material", + &result.response, + loading, + !rows.is_empty() && !loading && self.creation_catalog_error("materials").is_none(), + geometry, + ); } fn product_select(&mut self, app: &mut PhotocraftApp, ui: &mut Ui) { ui.horizontal(|ui| { @@ -927,8 +1017,8 @@ impl Workspace { if ui.add_enabled(status == "needs-confirm" && !self.creation_dirty, egui::Button::new("确认方案")).clicked() { self.creation_submit(app, "creation-confirm", json!({})); } - if ui.add_enabled(status == "confirmed" && !self.creation_dirty, egui::Button::new("开始生成").fill(Tokens::get(ui.ctx()).accent)).clicked() { - self.creation_submit(app, "creation-generate", json!({})); + if ui.add_enabled(status == "confirmed" && !self.creation_dirty, egui::Button::new("制作图位").fill(Tokens::get(ui.ctx()).accent)).clicked() { + self.creation_step = 4; } if self.creation_pending.is_some() && ui.button("载入生成结果").clicked() { self.creation_submit(app, "creation-load", json!({})); @@ -936,3 +1026,287 @@ impl Workspace { }); } } + +#[cfg(test)] +mod tests { + use super::*; + fn picker_frame(ctx: &egui::Context, populated: bool) -> egui::Id { + let mut id = egui::Id::NULL; + let mut output = + ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0))), ..Default::default() }, |ui| { + id = picker(ui, "stable-picker", 320.0, "选择品牌", |ui| { + if populated { + for i in 0..16 { + picker_row(ui, false, &format!("品牌 {i} · 很长的目录名称应在固定宽度内截断"), true); + } + } else { + picker_notice(ui, true, None, false, ""); + } + }) + .response + .id; + }); + output.textures_delta.clear(); + id + } + #[test] + fn dynamic_picker_loading_loaded_and_reopened_keep_the_same_measured_bounds() { + let ctx = egui::Context::default(); + // The shipped native theme adds scroll content margins. Omitting those + // in a default-context test misses a first-frame width expansion. + ctx.all_styles_mut(|style| style.spacing.scroll.content_margin = egui::Margin::same(1)); + let id = picker_frame(&ctx, false); + let popup = id.with("popup"); + egui::Popup::open_id(&ctx, popup); + picker_frame(&ctx, false); + picker_frame(&ctx, false); + let loading = ctx.memory(|memory| memory.area_rect(popup)).unwrap(); + picker_frame(&ctx, true); + picker_frame(&ctx, true); + let populated = ctx.memory(|memory| memory.area_rect(popup)).unwrap(); + egui::Popup::close_id(&ctx, popup); + picker_frame(&ctx, true); + egui::Popup::open_id(&ctx, popup); + picker_frame(&ctx, true); + picker_frame(&ctx, true); + let reopened = ctx.memory(|memory| memory.area_rect(popup)).unwrap(); + assert!(loading.height() >= PICKER_HEIGHT, "loading retains the full list space"); + for rect in [populated, reopened] { + assert!((rect.width() - loading.width()).abs() <= 1.0, "menu width is independent of loaded labels: {loading:?} {rect:?}"); + assert!((rect.height() - loading.height()).abs() <= 1.0, "menu height is independent of loading/reopen: {loading:?} {rect:?}"); + } + } + #[test] + fn material_thumbnail_reserves_identical_space_in_all_read_states() { + let ctx = egui::Context::default(); + let texture = ctx.load_texture("material-test", egui::ColorImage::filled([48, 24], egui::Color32::WHITE), egui::TextureOptions::LINEAR); + let mut sizes = Vec::new(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + for (image, loading, failed) in [(None, false, false), (None, true, false), (None, false, true), (Some(&texture), false, false)] { + sizes.push(material_thumbnail(ui, image, loading, failed).rect.size()); + } + }); + output.textures_delta.clear(); + assert!(sizes.iter().all(|size| *size == vec2(MATERIAL_THUMB_SIDE, MATERIAL_THUMB_SIDE))); + // Scaling uses f32; accept sub-millipixel rounding while checking both + // the maximum size and the preserved aspect ratio. + for (source, expected) in [(vec2(1600.0, 800.0), vec2(240.0, 120.0)), (vec2(400.0, 1600.0), vec2(60.0, 240.0))] { + assert!((preview_size(source, vec2(240.0, 240.0)) - expected).abs().max_elem() < 0.001); + } + assert_eq!(preview_size(vec2(0.0, 10.0), vec2(240.0, 240.0)), egui::Vec2::ZERO); + } + #[test] + fn material_popup_exposes_each_cached_image_and_its_hover_target() { + let (mut w, mut app) = setup(); + let ctx = egui::Context::default(); + w.creation_brief = empty_brief(); + w.creation_view_geometry = json!({"controls":{},"popups":{},"materialRows":[],"hoverPreview":null}); + let rows: Vec<_> = (0..6).map(|i| json!({"id":format!("material-{i}"),"name":format!("材质 {i}")})).collect(); + for row in &rows { + let id = row["id"].as_str().unwrap(); + w.creation_thumbs.insert(id.into(), ctx.load_texture(id, egui::ColorImage::filled([40, 20], egui::Color32::WHITE), egui::TextureOptions::LINEAR)); + } + w.creation_catalog.insert("materials".into(), rows); + let popup = ui_rect_popup_id(&ctx, &w); + egui::Popup::open_id(&ctx, popup); + for _ in 0..2 { + let mut output = ctx + .run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0))), ..Default::default() }, |ui| { + w.material_select(&mut app, ui, 320.0) + }); + output.textures_delta.clear(); + } + let visible: Vec<_> = w.creation_view_geometry["materialRows"].as_array().unwrap().iter().filter(|r| r["visible"] == true).collect(); + assert!(visible.len() >= 4, "a full native popup exposes several material thumbnails"); + for row in visible { + assert_eq!(row["status"], "ready"); + assert_eq!(row["imageRect"][2].as_f64(), Some(f64::from(MATERIAL_THUMB_SIDE))); + assert_eq!(row["imageRect"][3].as_f64(), Some(f64::from(MATERIAL_THUMB_SIDE))); + } + } + fn ui_rect_popup_id(ctx: &egui::Context, w: &Workspace) -> egui::Id { + // Obtain the same native id without inventing an automation-only widget path. + let mut id = egui::Id::NULL; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + id = picker(ui, "creation-material", 320.0, w.creation_brief["colorName"].as_str().unwrap_or("选择材质颜色"), |_| {}).response.id; + }); + output.textures_delta.clear(); + id.with("popup") + } + fn setup() -> (Workspace, PhotocraftApp) { + let (tx, _) = mpsc::channel(); + let (_, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"name":"流程验收","width":1440,"height":1800})).unwrap(); + let app = PhotocraftApp::new(session, crate::Services::default()); + w.account = json!({"id":"qa"}); + w.origin = "http://localhost:3138".into(); + let st = app.session.active().unwrap(); + w.owner_doc = Some(st.doc.id.0); + w.expected_revision = st.revision; + w.creation_base_revision = st.revision; + w.allow_apply = true; + w.busy = Some("creation-generate".into()); + (w, app) + } + fn reference(w: &Workspace) -> Value { + json!({"doc":w.owner_doc,"accountId":w.account["id"],"origin":w.origin,"run":"00000000-0000-4000-8000-000000000001","baseRevision":w.creation_base_revision,"sequence":1,"creation":{}}) + } + #[test] + fn read_only_catalog_requests_do_not_take_or_clear_a_model_execution_lease() { + let (mut w, mut app) = setup(); + let (tx, rx) = mpsc::channel(); + w.tx = tx; + w.unknown = true; + let busy = w.busy.clone(); + let result = w.creation_action(&mut app, "creation-catalog", json!({"kind":"brands","query":""})).unwrap(); + assert_eq!(result["readOnly"], true); + assert_eq!(w.busy, busy); + assert!(w.unknown, "a read cannot resolve or replace the original pending model request"); + let Request::Action(method, input) = rx.try_recv().unwrap() else { panic!("expected independent catalog request") }; + assert_eq!(method, "creation-catalog"); + assert_eq!(input["doc"].as_u64(), app.session.active().map(|s| s.doc.id.0)); + assert_eq!(input["accountId"], "qa"); + assert!(w.creation_action(&mut app, "creation-catalog", json!({"kind":"brands","doc":u64::MAX})).is_err()); + } + #[test] + fn creation_generation_requires_current_identity_revision_and_human_permission() { + let (mut w, mut app) = setup(); + let v = reference(&w); + assert_eq!(allowed(&mut w, &app, &v).unwrap()["allowed"], true); + let mut changed = v.clone(); + changed["accountId"] = json!("other"); + assert_eq!(allowed(&mut w, &app, &changed).unwrap()["allowed"], false); + changed = v.clone(); + changed["run"] = json!("00000000-0000-4000-8000-000000000002"); + assert_eq!(allowed(&mut w, &app, &changed).unwrap()["allowed"], false); + app.session.execute("type.create", json!({"text":"用户已编辑","font":"Noto Sans SC","size":32,"box":[20,20,500,90]})).unwrap(); + assert_eq!(allowed(&mut w, &app, &v).unwrap()["allowed"], false); + w.expected_revision = app.session.active().unwrap().revision; + w.handover(); + assert_eq!(allowed(&mut w, &app, &v).unwrap()["allowed"], false); + } + #[test] + fn generated_canvas_uses_native_undo_and_preserves_late_human_edits() { + let (mut w, mut app) = setup(); + let v = reference(&w); + allowed(&mut w, &app, &v).unwrap(); + let original = app.session.active().unwrap().doc.id; + let before = app.session.active().unwrap().doc.clone(); + let mut generated = photocraft_engine::Session::new(); + generated.execute("file.new", json!({"name":"实际生成结果","width":1440,"height":3600})).unwrap(); + generated.execute("type.create", json!({"text":"真实可编辑标题","font":"Noto Sans SC","size":32,"box":[20,20,500,90]})).unwrap(); + let result = (*generated.active().unwrap().doc).clone(); + canvas_received(&mut w, &mut app, Box::new(result.clone()), v.clone()); + assert_eq!(app.session.active().unwrap().doc.id, original); + assert_eq!(app.session.active().unwrap().doc.size.height, 3600); + assert!(app.session.active().unwrap().doc.walk().iter().any(|(_, _, l)| matches!(&l.content, LayerContent::Text(_)))); + app.session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.size, before.size); + assert_eq!(app.session.active().unwrap().doc.walk().len(), before.walk().len()); + let revision = app.session.active().unwrap().revision; + canvas_received(&mut w, &mut app, Box::new(result), v); + assert!(w.creation_pending.is_some()); + assert_eq!(app.session.active().unwrap().revision, revision); + assert_eq!(app.session.active().unwrap().doc.size, before.size); + } + #[test] + fn sync_does_not_clear_dirty_when_backend_detects_a_newer_snapshot() { + let (mut w, app) = setup(); + w.sync_request_meta = w.sync_signature(); + let mut value = reference(&w); + value["revision"] = json!(app.session.active().unwrap().revision); + value["syncDirty"] = json!(true); + sync_received(&mut w, &app, &value); + assert!(w.sync_dirty); + value["syncDirty"] = json!(false); + sync_received(&mut w, &app, &value); + assert!(!w.sync_dirty); + } + + #[test] + fn changed_candidate_or_text_requires_sync_even_when_canvas_revision_is_identical() { + let (mut w, app) = setup(); + let canvas_revision = app.session.active().unwrap().revision; + let baseline = json!({"revision":1,"confirmed":true,"slots":[{"id":"image","selectedCandidateId":"a","fields":[{"id":"title","value":"原文"}]}]}); + w.sync_dirty = false; + w.draft_changed = false; + assert!(w.receive_template_snapshot(&baseline, false)); + assert!(!w.sync_dirty, "loading the existing project establishes a baseline"); + for changed in ["candidate", "text"] { + w.sync_dirty = false; + w.draft_changed = false; + w.sync_request_meta = w.sync_signature(); + let original_signature = w.sync_request_meta.clone(); + let mut next = w.template_workflow.clone(); + next["revision"] = json!(next["revision"].as_u64().unwrap() + 1); + if changed == "candidate" { + next["slots"][0]["selectedCandidateId"] = json!("b"); + } else { + next["slots"][0]["fields"][0]["value"] = json!("人工编辑的标题"); + } + assert!(w.receive_template_snapshot(&next, false), "a read may reconcile a newly completed or edited journal"); + assert!(w.sync_dirty && w.draft_changed, "{changed} changes need a fresh backup"); + assert_ne!(w.sync_signature(), original_signature); + assert_eq!(app.session.active().unwrap().revision, canvas_revision); + let mut receipt = reference(&w); + receipt["revision"] = json!(canvas_revision); + receipt["syncDirty"] = json!(false); + sync_received(&mut w, &app, &receipt); + assert!(w.sync_dirty, "the previous same-canvas sync ACK cannot claim the newer {changed} snapshot"); + } + } + + #[test] + fn first_and_identical_template_reads_do_not_create_an_endless_dirty_cycle() { + let (mut w, _) = setup(); + let snapshot = json!({"revision":7,"confirmed":true,"slots":[{"id":"owned","selectedCandidateId":"a"}]}); + w.sync_dirty = false; + w.draft_changed = false; + assert!(w.receive_template_snapshot(&snapshot, false)); + let loaded_signature = w.sync_signature(); + for _ in 0..3 { + assert!(w.receive_template_snapshot(&snapshot, false)); + assert!(!w.sync_dirty && !w.draft_changed); + assert_eq!(w.sync_signature(), loaded_signature); + } + w.template_workflow = Value::Null; + assert!(w.receive_template_snapshot(&json!({"revision":1,"confirmed":true,"slots":[]}), true)); + assert!(w.sync_dirty && w.draft_changed, "an explicit first template install is a mutation, not a passive baseline read"); + } + #[test] + fn delayed_rules_refresh_updates_canonical_revision_without_overwriting_the_users_draft() { + let (mut w, app) = setup(); + w.creation = json!({"revision":2,"brief":empty_brief(),"sellingPoints":{"selected":["old:0"],"direction":"原方向"},"plan":{"status":"confirmed","screens":[{"id":"one","title":"原方案"}]},"width":1440,"screenCount":3,"designSnapshot":{"rules":{"id":"old-rule"}}}); + w.creation_brief = w.creation["brief"].clone(); + w.creation_brief["name"] = json!("刚输入的产品名称"); + w.creation_brief["dimensions"]["length"] = json!("1800"); + w.creation_selected = vec!["new:1".into()]; + w.creation_direction = "刚输入的表达方向".into(); + w.creation_plan = json!([{"id":"one","title":"尚未保存的标题"}]); + w.creation_width = 750; + w.creation_count = 6; + w.creation_dirty = true; + let draft = w.creation_draft(); + let mut value = reference(&w); + value["creation"] = w.creation.clone(); + value["creation"]["revision"] = json!(3); + value["creation"]["designSnapshot"]["rules"] = json!({"id":"fixed-rule","contentHash":"a".repeat(64)}); + value["creation"]["plan"]["status"] = json!("stale"); + received(&mut w, &app, "creation-rules", &value); + assert_eq!(w.creation_draft()["brief"], draft["brief"]); + for field in ["screens", "direction", "selected", "width", "screenCount"] { + assert_eq!(w.creation_draft()[field], draft[field]); + } + assert!(w.creation_dirty); + assert_eq!(w.creation["revision"], 3); + assert_eq!(w.creation["designSnapshot"]["rules"]["id"], "fixed-rule"); + assert_eq!(w.creation["plan"]["status"], "stale"); + // With no local edits, the normal canonical plan state remains visible. + w.creation_dirty = false; + received(&mut w, &app, "creation-rules", &value); + assert_eq!(w.creation_plan, value["creation"]["plan"]["screens"]); + assert!(!w.creation_dirty); + } +} diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation_reads.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation_reads.rs new file mode 100644 index 00000000..b4522120 --- /dev/null +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation_reads.rs @@ -0,0 +1,492 @@ +//! Bounded, account/document-bound catalogue and thumbnail GETs. This lane never +//! owns global model busy/unknown state, changes product facts, or uploads data. +use super::*; +use std::collections::{HashMap, VecDeque}; + +const READ_WAIT: Duration = Duration::from_secs(45); +const CACHE_LIMIT: usize = 48; +const QUEUE_LIMIT: usize = 128; + +#[derive(Clone, PartialEq, Eq)] +struct Scope { + account: String, + origin: String, + doc: Option, +} +#[derive(Clone)] +struct Queued { + method: &'static str, + input: Value, + signature: String, + attempts: u8, + ready: Instant, +} +struct Pending { + request: Queued, + started: Instant, +} +#[derive(Default)] +pub(super) struct UiState { + scope: Option, + serial: u64, + queue: VecDeque, + pending: HashMap, + desired: HashMap, + catalog_errors: HashMap, + thumbnail_errors: HashMap, + cache_order: VecDeque, +} + +fn same(input: &Value, value: &Value) -> bool { + ["readId", "accountId", "origin", "doc", "kind", "id", "query"].iter().all(|key| input[*key] == value[*key]) +} +fn thumbnail(request: &Queued) -> bool { + request.method == "creation-thumbnail" +} +fn kind(request: &Queued) -> &str { + request.input["kind"].as_str().unwrap_or("") +} +fn id(request: &Queued) -> &str { + request.input["id"].as_str().unwrap_or("") +} + +impl Workspace { + fn creation_read_scope(&mut self, app: &PhotocraftApp) { + let scope = + Scope { account: self.account["id"].as_str().unwrap_or("").into(), origin: self.origin.clone(), doc: app.session.active().map(|s| s.doc.id.0) }; + if self.creation_reads.scope.as_ref() == Some(&scope) { + return; + } + let identity_changed = self.creation_reads.scope.as_ref().is_none_or(|old| old.account != scope.account || old.origin != scope.origin); + self.creation_reads.queue.clear(); + self.creation_reads.pending.clear(); + self.creation_reads.desired.clear(); + self.creation_reads.catalog_errors.clear(); + self.creation_reads.thumbnail_errors.clear(); + self.creation_catalog.clear(); + if identity_changed { + self.creation_thumbs.clear(); + self.creation_reads.cache_order.clear(); + } + self.creation_reads.scope = Some(scope); + } + fn creation_read_input(&self, app: &PhotocraftApp, kind: &str, id: Value, query: &str) -> Value { + json!({"doc":app.session.active().map(|s|s.doc.id.0),"accountId":self.account["id"],"origin":self.origin,"kind":kind,"id":id,"query":query}) + } + pub(super) fn request_creation_catalog(&mut self, app: &PhotocraftApp, kind: &str, query: String) { + self.creation_read_scope(app); + if !["brands", "materials", "products"].contains(&kind) || query.chars().count() > 200 { + self.creation_reads.catalog_errors.insert(kind.into(), "目录查询参数无效".into()); + return; + } + if self.account["id"].as_str().is_none() || app.session.active().is_none() { + self.creation_reads.catalog_errors.insert(kind.into(), "请先登录并打开项目".into()); + return; + } + let mut input = self.creation_read_input(app, kind, Value::Null, &query); + if kind == "products" { + input["identifierType"] = self.creation_brief["identifierType"].clone(); + input["identifier"] = self.creation_brief["identifier"].clone(); + } + let signature = input.to_string(); + self.creation_reads.desired.insert(kind.into(), signature.clone()); + self.creation_reads.catalog_errors.remove(kind); + self.creation_reads.queue.retain(|r| thumbnail(r) || self::kind(r) != kind); + if self.creation_reads.pending.values().any(|p| !thumbnail(&p.request) && p.request.signature == signature) { + return; + } + self.creation_reads.queue.push_back(Queued { method: "creation-catalog", input, signature, attempts: 0, ready: Instant::now() }); + self.pump_creation_reads(); + } + pub(super) fn queue_creation_thumbnail(&mut self, app: &PhotocraftApp, id: &str) { + self.creation_read_scope(app); + if self.creation_thumbs.contains_key(id) { + self.creation_reads.cache_order.retain(|old| old != id); + self.creation_reads.cache_order.push_back(id.into()); + return; + } + if self.creation_reads.thumbnail_errors.contains_key(id) || self.creation_thumbnail_loading(id) { + return; + } + if id.is_empty() || id.len() > 100 || self.creation_reads.queue.len() >= QUEUE_LIMIT { + self.creation_reads.thumbnail_errors.insert(id.into(), "缩略图队列已满或编号无效,请稍后重读".into()); + return; + } + if self.account["id"].as_str().is_none() || app.session.active().is_none() { + return; + } + let input = self.creation_read_input(app, "material", json!(id), ""); + self.creation_reads.queue.push_back(Queued { method: "creation-thumbnail", signature: input.to_string(), input, attempts: 0, ready: Instant::now() }); + self.pump_creation_reads(); + } + pub(super) fn retry_creation_thumbnail(&mut self, app: &PhotocraftApp, id: &str) { + self.creation_read_scope(app); + self.creation_reads.thumbnail_errors.remove(id); + self.queue_creation_thumbnail(app, id); + } + pub(super) fn creation_catalog_loading(&self, kind: &str) -> bool { + self.creation_reads.queue.iter().any(|r| !thumbnail(r) && self::kind(r) == kind) + || self.creation_reads.pending.values().any(|p| !thumbnail(&p.request) && self::kind(&p.request) == kind) + } + pub(super) fn creation_catalog_error(&self, kind: &str) -> Option<&str> { + self.creation_reads.catalog_errors.get(kind).map(String::as_str) + } + pub(super) fn creation_thumbnail_loading(&self, id: &str) -> bool { + self.creation_reads.queue.iter().any(|r| thumbnail(r) && self::id(r) == id) + || self.creation_reads.pending.values().any(|p| thumbnail(&p.request) && self::id(&p.request) == id) + } + pub(super) fn creation_thumbnail_error(&self, id: &str) -> Option<&str> { + self.creation_reads.thumbnail_errors.get(id).map(String::as_str) + } + pub(super) fn creation_reads_state(&self) -> Value { + json!({"pending":self.creation_reads.pending.len(),"queued":self.creation_reads.queue.len(),"catalogErrors":self.creation_reads.catalog_errors,"thumbnailErrors":self.creation_reads.thumbnail_errors,"cachedThumbnails":self.creation_thumbs.len()}) + } + fn creation_read_failed(&mut self, mut request: Queued, error: &str, queue_busy: bool) { + if !thumbnail(&request) && self.creation_reads.desired.get(kind(&request)) != Some(&request.signature) { + return; + } + if queue_busy && request.attempts < 3 { + request.ready = Instant::now() + Duration::from_millis(250 * u64::from(request.attempts.max(1))); + self.creation_reads.queue.push_back(request); + } else if thumbnail(&request) { + self.creation_reads.thumbnail_errors.insert(id(&request).into(), error.into()); + } else { + self.creation_reads.catalog_errors.insert(kind(&request).into(), error.into()); + } + } + fn pump_creation_reads(&mut self) { + if self.creation_reads.scope.as_ref().is_none_or(|scope| scope.account.is_empty() || scope.doc.is_none()) { + return; + } + loop { + let images = self.creation_reads.pending.values().filter(|p| thumbnail(&p.request)).count(); + let catalogs = self.creation_reads.pending.len() - images; + let Some(index) = self.creation_reads.queue.iter().position(|r| r.ready <= Instant::now() && if thumbnail(r) { images < 2 } else { catalogs < 1 }) + else { + break; + }; + let Some(mut request) = self.creation_reads.queue.remove(index) else { + break; + }; + let Some(serial) = self.creation_reads.serial.checked_add(1) else { + self.creation_read_failed(request, "本机只读请求编号已用尽,请保留项目后重启", false); + continue; + }; + self.creation_reads.serial = serial; + request.input["readId"] = json!(serial); + request.attempts += 1; + let method = request.method; + let input = request.input.clone(); + self.creation_reads.pending.insert(serial, Pending { request, started: Instant::now() }); + if self.tx.send(Request::Action(method.into(), input)).is_err() + && let Some(pending) = self.creation_reads.pending.remove(&serial) + { + self.creation_read_failed(pending.request, "只读加载服务已停止,请保留项目后重启", false); + } + } + } + pub(super) fn creation_reads_tick(&mut self, app: &PhotocraftApp) { + self.creation_read_scope(app); + let expired: Vec = self.creation_reads.pending.iter().filter_map(|(id, p)| (p.started.elapsed() >= READ_WAIT).then_some(*id)).collect(); + for serial in expired { + if let Some(pending) = self.creation_reads.pending.remove(&serial) { + self.creation_read_failed(pending.request, "只读加载超时,请重读;未影响模型原请求", false); + } + } + // Continues after closing the popup; no render loop must keep it open. + self.pump_creation_reads(); + } + pub(super) fn creation_read_received(&mut self, app: &PhotocraftApp, method: &str, value: &Value) -> bool { + if !matches!(method, "creation-catalog" | "creation-thumbnail") { + return false; + } + self.creation_read_scope(app); + let Some(serial) = value["readId"].as_u64() else { + return true; + }; + let Some(pending) = self.creation_reads.pending.get(&serial) else { + return false; + }; + if pending.request.method != method || !same(&pending.request.input, value) { + return true; + } + // Thumbnail pixels arrive via Preview. A JSON success alone must not + // release its slot before the matching image has actually been adopted. + if method == "creation-thumbnail" && !value["error"].is_string() { + return true; + } + let Some(pending) = self.creation_reads.pending.remove(&serial) else { + return false; + }; + if let Some(error) = value["error"].as_str() { + self.creation_read_failed(pending.request, error, value["readError"] == "queue_busy"); + } else if self.creation_reads.desired.get(kind(&pending.request)) == Some(&pending.request.signature) { + if let Some(items) = value["catalog"]["items"].as_array() { + self.creation_catalog.insert(kind(&pending.request).into(), items.clone()); + self.creation_reads.catalog_errors.remove(kind(&pending.request)); + } else { + self.creation_read_failed(pending.request, "目录返回格式无效,原结果保留", false); + } + } + self.pump_creation_reads(); + true + } + pub(super) fn creation_thumbnail_received(&mut self, app: &PhotocraftApp, ui: &Ui, reference: &Value, image: egui::ColorImage) -> bool { + if reference["purpose"] != "creation-material" { + return false; + } + self.creation_read_scope(app); + let Some(serial) = reference["readId"].as_u64() else { + return true; + }; + let Some(pending) = self.creation_reads.pending.get(&serial) else { + return false; + }; + if !thumbnail(&pending.request) || !same(&pending.request.input, reference) { + return true; + } + let Some(pending) = self.creation_reads.pending.remove(&serial) else { + return false; + }; + let id = id(&pending.request).to_owned(); + self.creation_reads.thumbnail_errors.remove(&id); + self.creation_reads.cache_order.retain(|old| old != &id); + self.creation_reads.cache_order.push_back(id.clone()); + self.creation_thumbs.insert(id.clone(), ui.ctx().load_texture(format!("creation-material-{}-{id}", serial), image, egui::TextureOptions::LINEAR)); + while self.creation_thumbs.len() > CACHE_LIMIT { + let Some(old) = self.creation_reads.cache_order.pop_front() else { + break; + }; + self.creation_thumbs.remove(&old); + } + self.pump_creation_reads(); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> (Workspace, PhotocraftApp, mpsc::Receiver) { + let (tx, requests) = mpsc::channel(); + let (_, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + let _ = requests.try_recv(); + w.account = json!({"id":"account-a"}); + w.busy = Some("creation-facts".into()); + w.unknown = true; + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); + let app = PhotocraftApp::new(session, crate::Services::default()); + (w, app, requests) + } + fn request(rx: &mpsc::Receiver) -> Value { + let Request::Action(_, value) = rx.try_recv().unwrap() else { panic!("read action expected") }; + value + } + fn fail(input: &Value) -> Value { + let mut value = input.clone(); + value["error"] = json!("one missing preview"); + value + } + #[test] + fn exhausted_read_ids_reject_new_reads_without_wrapping_or_touching_pending_and_model_state() { + let (mut w, app, rx) = fixture(); + w.queue_creation_thumbnail(&app, "still-pending"); + let pending = request(&rx); + w.creation_reads.serial = u64::MAX; + w.request_creation_catalog(&app, "materials", "new search".into()); + w.queue_creation_thumbnail(&app, "cannot-dispatch"); + assert!(rx.try_recv().is_err()); + assert_eq!(w.creation_reads.serial, u64::MAX); + assert!(w.creation_reads.queue.is_empty()); + assert_eq!(w.creation_reads.pending.len(), 1); + assert!(w.creation_reads.pending.contains_key(&pending["readId"].as_u64().unwrap())); + assert!(w.creation_thumbnail_loading("still-pending")); + assert!(w.creation_thumbnail_error("still-pending").is_none()); + assert!(w.creation_catalog_error("materials").is_some_and(|error| error.contains("编号已用尽"))); + assert!(w.creation_thumbnail_error("cannot-dispatch").is_some_and(|error| error.contains("编号已用尽"))); + assert_eq!(w.busy.as_deref(), Some("creation-facts")); + assert!(w.unknown); + } + #[test] + fn replies_without_pending_identity_are_rejected_without_publishing_pixels_or_settling_other_reads() { + let (mut w, app, rx) = fixture(); + let ctx = egui::Context::default(); + w.queue_creation_thumbnail(&app, "current"); + let mut late = fail(&request(&rx)); + late["readId"] = json!(9999); + assert!(!w.creation_read_received(&app, "creation-thumbnail", &late)); + assert!(!w.creation_read_received(&app, "creation-catalog", &late)); + late["purpose"] = json!("creation-material"); + let mut accepted = true; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + accepted = w.creation_thumbnail_received(&app, ui, &late, egui::ColorImage::filled([1, 1], egui::Color32::WHITE)); + }); + output.textures_delta.clear(); + assert!(!accepted); + assert_eq!(w.creation_reads.pending.len(), 1); + assert!(w.creation_thumbnail_loading("current")); + assert!(w.creation_thumbnail_error("current").is_none()); + assert!(w.creation_thumbs.is_empty()); + assert!(w.creation_catalog.is_empty()); + assert!(rx.try_recv().is_err()); + assert_eq!(w.busy.as_deref(), Some("creation-facts")); + assert!(w.unknown); + } + #[test] + fn twenty_four_thumbnails_are_bounded_and_one_failure_does_not_fail_the_other_item_or_stop_pumping() { + let (mut w, app, rx) = fixture(); + for i in 0..24 { + w.queue_creation_thumbnail(&app, &format!("material-{i}")); + } + let first = request(&rx); + let second = request(&rx); + assert!(rx.try_recv().is_err()); + assert_eq!(w.creation_reads.pending.len(), 2); + assert_eq!(w.creation_reads.queue.len(), 22); + w.creation_read_received(&app, "creation-thumbnail", &fail(&first)); + assert!(w.creation_thumbnail_error(first["id"].as_str().unwrap()).is_some()); + assert!(w.creation_thumbnail_error(second["id"].as_str().unwrap()).is_none()); + assert!(w.creation_thumbnail_loading(second["id"].as_str().unwrap())); + assert_eq!(request(&rx)["id"], "material-2"); + assert_eq!(w.busy.as_deref(), Some("creation-facts")); + assert!(w.unknown); + } + #[test] + fn catalog_reads_bypass_model_busy_and_only_the_latest_requested_search_can_replace_results() { + let (mut w, app, rx) = fixture(); + w.request_creation_catalog(&app, "materials", "first".into()); + let first = request(&rx); + w.request_creation_catalog(&app, "materials", "latest".into()); + assert!(rx.try_recv().is_err()); + let mut old = first; + old["catalog"] = json!({"items":[{"id":"old"}]}); + w.creation_read_received(&app, "creation-catalog", &old); + assert!(!w.creation_catalog.contains_key("materials")); + let mut latest = request(&rx); + assert_eq!(latest["query"], "latest"); + latest["catalog"] = json!({"items":[{"id":"new"}]}); + w.creation_read_received(&app, "creation-catalog", &latest); + assert_eq!(w.creation_catalog["materials"][0]["id"], "new"); + assert_eq!(w.busy.as_deref(), Some("creation-facts")); + assert!(w.unknown); + } + #[test] + fn old_document_failure_cannot_consume_new_document_capacity_or_mark_its_preview_failed() { + let (mut w, mut app, rx) = fixture(); + w.queue_creation_thumbnail(&app, "shared-material"); + let old = request(&rx); + app.session.execute("file.new", json!({"width":750,"height":1800})).unwrap(); + w.creation_reads_tick(&app); + w.queue_creation_thumbnail(&app, "shared-material"); + let current = request(&rx); + assert_ne!(old["readId"], current["readId"]); + w.creation_read_received(&app, "creation-thumbnail", &fail(&old)); + assert!(w.creation_thumbnail_loading("shared-material")); + assert!(w.creation_thumbnail_error("shared-material").is_none()); + } + #[test] + fn account_or_origin_change_clears_catalog_textures_and_rejects_late_pixels() { + let (mut w, app, rx) = fixture(); + let ctx = egui::Context::default(); + w.queue_creation_thumbnail(&app, "private-preview"); + let old = request(&rx); + w.creation_catalog.insert("materials".into(), vec![json!({"id":"private-preview"})]); + w.creation_thumbs + .insert("private-preview".into(), ctx.load_texture("old", egui::ColorImage::filled([1, 1], egui::Color32::WHITE), egui::TextureOptions::LINEAR)); + w.account = json!({"id":"account-b"}); + w.creation_reads_tick(&app); + assert!(w.creation_catalog.is_empty()); + assert!(w.creation_thumbs.is_empty()); + assert!(!w.creation_thumbnail_loading("private-preview")); + let mut reference = old; + reference["purpose"] = json!("creation-material"); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + w.creation_thumbnail_received(&app, ui, &reference, egui::ColorImage::filled([1, 1], egui::Color32::WHITE)); + }); + output.textures_delta.clear(); + assert!(w.creation_thumbs.is_empty()); + w.creation_catalog.insert("materials".into(), vec![json!({"id":"old-origin"})]); + w.origin = "http://127.0.0.1:3138".into(); + w.creation_reads_tick(&app); + assert!(w.creation_catalog.is_empty()); + } + #[test] + fn failures_wait_for_explicit_read_retry_while_transient_admission_is_bounded() { + let (mut w, app, rx) = fixture(); + w.queue_creation_thumbnail(&app, "one"); + let first = request(&rx); + w.creation_read_received(&app, "creation-thumbnail", &fail(&first)); + w.queue_creation_thumbnail(&app, "one"); + assert!(rx.try_recv().is_err()); + w.retry_creation_thumbnail(&app, "one"); + let second = request(&rx); + assert_ne!(first["readId"], second["readId"]); + let mut busy = fail(&second); + busy["readError"] = json!("queue_busy"); + w.creation_read_received(&app, "creation-thumbnail", &busy); + assert!(w.creation_thumbnail_error("one").is_none()); + assert!(w.creation_thumbnail_loading("one")); + for request in &mut w.creation_reads.queue { + request.ready = Instant::now(); + } + w.creation_reads_tick(&app); + let third = request(&rx); + assert_ne!(second["readId"], third["readId"]); + } + #[test] + fn matching_preview_settles_only_itself_and_pumps_after_the_popup_is_closed() { + let (mut w, app, rx) = fixture(); + let ctx = egui::Context::default(); + for i in 0..3 { + w.queue_creation_thumbnail(&app, &format!("image-{i}")); + } + let mut first = request(&rx); + let second = request(&rx); + first["purpose"] = json!("creation-material"); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + w.creation_thumbnail_received(&app, ui, &first, egui::ColorImage::filled([1, 1], egui::Color32::WHITE)); + }); + output.textures_delta.clear(); + assert!(w.creation_thumbs.contains_key("image-0")); + assert!(w.creation_thumbnail_loading(second["id"].as_str().unwrap())); + assert_eq!(request(&rx)["id"], "image-2"); + } + #[test] + fn three_queue_admission_failures_stop_without_repeating_a_failed_image_get_forever() { + let (mut w, app, rx) = fixture(); + w.queue_creation_thumbnail(&app, "one"); + for attempt in 1..=3 { + let mut value = fail(&request(&rx)); + value["readError"] = json!("queue_busy"); + w.creation_read_received(&app, "creation-thumbnail", &value); + if attempt < 3 { + assert!(w.creation_thumbnail_loading("one")); + assert!(w.creation_thumbnail_error("one").is_none()); + for request in &mut w.creation_reads.queue { + request.ready = Instant::now(); + } + w.creation_reads_tick(&app); + } + } + assert!(!w.creation_thumbnail_loading("one")); + assert!(w.creation_thumbnail_error("one").is_some()); + w.creation_reads_tick(&app); + w.queue_creation_thumbnail(&app, "one"); + assert!(rx.try_recv().is_err()); + } + #[test] + fn mismatched_item_and_read_identity_cannot_settle_another_thumbnail() { + let (mut w, app, rx) = fixture(); + w.queue_creation_thumbnail(&app, "one"); + let input = request(&rx); + for key in ["readId", "accountId", "origin", "doc", "kind", "id", "query"] { + let mut value = fail(&input); + value[key] = if key == "readId" || key == "doc" { json!(9999) } else { json!("wrong") }; + w.creation_read_received(&app, "creation-thumbnail", &value); + assert!(w.creation_thumbnail_loading("one"), "{key}"); + assert!(w.creation_thumbnail_error("one").is_none(), "{key}"); + } + } +} diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun/pi_bridge.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun/pi_bridge.rs index fa7acb99..78d184de 100644 --- a/apps/detail-photocraft/crates/ui-egui/src/jiansun/pi_bridge.rs +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun/pi_bridge.rs @@ -3,41 +3,78 @@ use super::*; fn identity(w: &Workspace, input: &Value) -> Result<(), String> { - if w.pi_run.as_deref().is_some_and(|s| input["run"].as_str() == Some(s)) && w.pi_binding == json!({"accountId":w.account["id"],"origin":w.origin}) { Ok(()) } else { Err("本地Agent会话或账户已改变".into()) } + if w.pi_run.as_deref().is_some_and(|s| input["run"].as_str() == Some(s)) && w.pi_binding == json!({"accountId":w.account["id"],"origin":w.origin}) { + Ok(()) + } else { + Err("本地Agent会话或账户已改变".into()) + } } fn status(w: &Workspace, app: &PhotocraftApp) -> Value { let same = app.session.active().is_some_and(|s| Some(s.doc.id.0) == w.owner_doc && s.revision == w.expected_revision); - json!({"run":w.pi_run,"allowed":w.allow_apply && same,"queued":w.queue.len(),"revision":app.session.active().map(|s|s.revision),"context":w.context(app),"error":w.error}) + let active = w.pi_run.is_some() && app.session.active().is_some_and(|s| Some(s.doc.id.0) == w.owner_doc); + let context = if active { w.context(app) } else { Value::Null }; + let revision = if active { app.session.active().map(|s| s.revision) } else { None }; + json!({"run":w.pi_run,"conversationActive":active,"allowed":w.allow_apply && same && !w.pi_write_blocked,"writeBlocked":w.pi_write_blocked || !same,"queued":if active {w.queue.len()} else {0},"revision":revision,"context":context,"error":if active {json!(w.error)} else {Value::Null}}) } pub(super) fn action(w: &mut Workspace, app: &mut PhotocraftApp, method: &str, input: &Value) -> Result { if method == "pi-begin" { - if (input["accountId"].is_string() && input["accountId"] != w.account["id"]) || (input["origin"].is_string() && input["origin"] != w.origin) { return Err("画布与模型账户不一致".into()); } + if (input["accountId"].is_string() && input["accountId"] != w.account["id"]) || (input["origin"].is_string() && input["origin"] != w.origin) { + return Err("画布与模型账户不一致".into()); + } let own_submit = w.busy.as_deref() == Some("agent") && w.pi_run.is_none() && input["documentId"].as_u64() == w.owner_doc; - if (w.busy.is_some() && !own_submit) || w.unknown || !w.queue.is_empty() || w.pending_image.is_some() { return Err("工作台仍有任务,请保留原请求".into()); } - let run = input["run"].as_str().filter(|s| (32..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-')).ok_or("会话编号无效")?; + if (w.busy.is_some() && !own_submit) || w.unknown || !w.queue.is_empty() || w.pending_image.is_some() { + return Err("工作台仍有任务,请保留原请求".into()); + } + let run = + input["run"].as_str().filter(|s| (32..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-')).ok_or("会话编号无效")?; let text = input["text"].as_str().filter(|s| !s.is_empty() && s.chars().count() <= 6000).ok_or("要求无效")?; let st = app.session.active().ok_or("请先打开项目")?; - if input["documentId"].as_u64().is_some_and(|id| id != st.doc.id.0) { return Err("项目已切换,原任务未应用到其他画布".into()); } - if own_submit && (!w.allow_apply || st.revision != w.expected_revision) { return Err("用户已接手或画布已修改,本轮自动编辑未继续".into()); } + if input["documentId"].as_u64().is_some_and(|id| id != st.doc.id.0) { + return Err("项目已切换,原任务未应用到其他画布".into()); + } + if !own_submit { + w.pi_write_blocked = false; + } + if own_submit && st.revision != w.expected_revision { + w.pi_write_blocked = true; + } w.owner_doc = Some(st.doc.id.0); w.expected_revision = st.revision; w.pi_run = Some(run.to_owned()); w.pi_native = own_submit; w.pi_touched = Instant::now(); w.pi_binding = json!({"accountId":w.account["id"],"origin":w.origin}); - w.allow_apply = true; + // A conversation starts without permission to mutate the canvas. The + // actual write tool must request permission against a fresh revision. + w.allow_apply = false; w.busy = Some("pi-agent".into()); w.stream_reply.clear(); w.error.clear(); - w.library = false; - w.material = false; - w.tab = 0; - if !own_submit { w.messages.push(json!({"role":"user","text":text})); } + // The discussion lease does not take over the user's current workspace + // destination. A late child startup must not undo manual navigation. + if !own_submit { + w.messages.push(json!({"role":"user","text":text})); + } w.draft_changed = true; return Ok(status(w, app)); } identity(w, input)?; w.pi_touched = Instant::now(); + if method != "pi-state" && !app.session.active().is_some_and(|s| Some(s.doc.id.0) == w.owner_doc) { + if method == "pi-end" { + // The SDK transcript/response stays in its original project's store. + // Release only the old run; never append its text to the new canvas. + w.allow_apply = false; + w.pi_write_blocked = true; + w.pi_run = None; + w.pi_native = false; + w.queue.clear(); + if matches!(w.busy.as_deref(), Some("agent" | "pi-agent")) { + w.busy = None; + } + } + return Err("原项目已切换,回复保留在原项目会话,未写入当前项目".into()); + } match method { "pi-state" => Ok(status(w, app)), "pi-delta" => { @@ -51,6 +88,21 @@ pub(super) fn action(w: &mut Workspace, app: &mut PhotocraftApp, method: &str, i w.draft_changed = true; Ok(Value::Null) } + "pi-write-request" => { + if !w.pi_write_blocked + && app.session.active().is_some_and(|s| Some(s.doc.id.0) == w.owner_doc && s.revision == w.expected_revision) + && input["revision"].as_u64() == Some(w.expected_revision) + && w.queue.is_empty() + && !w.unknown + { + w.allow_apply = true; + Ok(status(w, app)) + } else { + w.pi_write_blocked = true; + w.allow_apply = false; + Err("用户已接手、画布已修改或原请求待核查,本轮写入未获许可".into()) + } + } "pi-edit" => { if status(w, app)["allowed"] != true || !w.queue.is_empty() || input["revision"].as_u64() != Some(w.expected_revision) { return Err("用户已接手或画布已修改,操作未执行".into()); @@ -62,9 +114,11 @@ pub(super) fn action(w: &mut Workspace, app: &mut PhotocraftApp, method: &str, i Ok(status(w, app)) } "pi-end" => { - if !w.queue.is_empty() { return Err("画布操作尚未落定".into()); } + if !w.queue.is_empty() { + return Err("画布操作尚未落定".into()); + } let text = input["text"].as_str().filter(|s| s.len() <= 65000).ok_or("回复无效")?; - w.messages.push(json!({"role":"assistant","text":text})); + w.messages.push(json!({"role":"assistant","kind":"reply","text":text})); w.stream_reply.clear(); w.busy = if w.pi_native { Some("agent".into()) } else { None }; w.allow_apply = false; @@ -78,14 +132,29 @@ pub(super) fn action(w: &mut Workspace, app: &mut PhotocraftApp, method: &str, i impl Workspace { pub(super) fn pi_received(&mut self, app: &PhotocraftApp, value: &Value) { - if value["accountId"] != self.account["id"] || value["origin"] != self.origin || value["doc"].as_u64() != app.session.active().map(|s|s.doc.id.0) { return; } - self.busy = None; self.allow_apply = false; self.pi_run = None; self.pi_native = false; - self.unknown = value["unknown"] == true; self.pi_unknown = self.unknown; + if value["accountId"] != self.account["id"] || value["origin"] != self.origin || value["doc"].as_u64() != app.session.active().map(|s| s.doc.id.0) { + return; + } + self.busy = None; + self.allow_apply = false; + self.pi_run = None; + self.pi_native = false; + self.unknown = value["unknown"] == true; + self.pi_unknown = self.unknown; self.pi_review = value["review"] == true; self.pi_usage = value["usage"].clone(); - if let Some(error) = value["error"].as_str() { self.error = error.into(); self.queue.clear(); } - if !self.unknown { self.stream_reply.clear(); } - if value["mode"] != "prompt" && let Some(text) = value["text"].as_str() { self.messages.push(json!({"role":"assistant","kind":"reply","text":text})); } + if let Some(error) = value["error"].as_str() { + self.error = error.into(); + self.queue.clear(); + } + if !self.unknown { + self.stream_reply.clear(); + } + if value["mode"] != "prompt" + && let Some(text) = value["text"].as_str() + { + self.messages.push(json!({"role":"assistant","kind":"reply","text":text})); + } self.draft_changed = true; } } @@ -94,6 +163,45 @@ impl Workspace { mod tests { use super::*; #[test] + fn an_inactive_pi_run_never_reads_or_appends_the_other_documents_context_or_history() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"name":"项目A","width":1440,"height":1800})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let (tx, _) = mpsc::channel(); + let (_, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + w.busy = None; // The fixture represents a completed account restore. + let owner = app.session.active().unwrap().doc.id.0; + let run = "12345678-1234-1234-1234-123456789012"; + action(&mut w, &mut app, "pi-begin", &json!({"run":run,"documentId":owner,"text":"项目A的讨论"})).unwrap(); + app.session.execute("file.new", json!({"name":"项目B私有资料","width":750,"height":1000})).unwrap(); + app.session.execute("type.create", json!({"text":"B的保密标题","size":32})).unwrap(); + w.messages = vec![json!({"role":"user","text":"B自己的历史"})]; + w.stream_reply = "B自己的输入状态".into(); + w.draft_changed = false; + let before = w.messages.clone(); + let stream = w.stream_reply.clone(); + let state = action(&mut w, &mut app, "pi-state", &json!({"run":run})).unwrap(); + assert_eq!(state["conversationActive"], false); + assert!(state["context"].is_null()); + assert!(state["revision"].is_null()); + assert!(!state.to_string().contains("B的保密标题")); + assert!(!state.to_string().contains("项目B私有资料")); + let revision = w.expected_revision; + for method in ["pi-delta", "pi-activity", "pi-write-request", "pi-edit"] { + assert!(action(&mut w, &mut app, method, &json!({"run":run,"text":"A的迟到回复","revision":revision})).is_err()); + assert_eq!(w.messages, before); + assert_eq!(w.stream_reply, stream); + assert!(!w.draft_changed); + } + assert!(action(&mut w, &mut app, "pi-end", &json!({"run":run,"text":"A的最终回复"})).is_err()); + assert_eq!(w.messages, before); + assert_eq!(w.stream_reply, stream); + assert!(!w.draft_changed); + assert!(w.pi_run.is_none()); + assert!(w.busy.is_none()); + } + #[test] fn bridge_does_not_allow_stale_or_invented_runs() { let (tx, _) = mpsc::channel(); let (_, rx) = mpsc::channel(); @@ -115,16 +223,22 @@ mod tests { session.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); let mut app = PhotocraftApp::new(session, crate::Services::default()); let id = app.session.active().unwrap().doc.id.0; - let (tx, _) = mpsc::channel(); let (_, rx) = mpsc::channel(); - let mut w = Workspace::new(tx, rx); w.account = json!({"id":"qa"}); - w.busy = Some("agent".into()); w.owner_doc = Some(id); w.allow_apply = true; w.expected_revision = app.session.active().unwrap().revision; + let (tx, _) = mpsc::channel(); + let (_, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + w.account = json!({"id":"qa"}); + w.busy = Some("agent".into()); + w.owner_doc = Some(id); + w.allow_apply = true; + w.expected_revision = app.session.active().unwrap().revision; let run = "12345678-1234-1234-1234-123456789012"; action(&mut w, &mut app, "pi-begin", &json!({"run":run,"documentId":id,"text":"验收"})).unwrap(); action(&mut w, &mut app, "pi-end", &json!({"run":run,"text":"完成"})).unwrap(); assert_eq!(w.busy.as_deref(), Some("agent")); w.pi_received(&app, &json!({"accountId":"qa","origin":w.origin.clone(),"doc":id,"usage":[{"inputTokens":100,"cachedTokens":40}],"unknown":false})); - assert!(w.busy.is_none()); assert_eq!(w.pi_usage[0]["cachedTokens"],40); - assert_eq!(w.messages.iter().filter(|m|m["role"]=="user").count(),0); + assert!(w.busy.is_none()); + assert_eq!(w.pi_usage[0]["cachedTokens"], 40); + assert_eq!(w.messages.iter().filter(|m| m["role"] == "user").count(), 0); } #[test] fn takeover_before_child_is_ready_cannot_reenable_canvas_writes() { @@ -132,12 +246,127 @@ mod tests { session.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); let mut app = PhotocraftApp::new(session, crate::Services::default()); let id = app.session.active().unwrap().doc.id.0; - let (tx, _) = mpsc::channel(); let (_, rx) = mpsc::channel(); + let (tx, _) = mpsc::channel(); + let (_, rx) = mpsc::channel(); let mut w = Workspace::new(tx, rx); - w.owner_doc = Some(id); w.expected_revision = app.session.active().unwrap().revision; - w.busy = Some("agent".into()); w.allow_apply = true; + w.owner_doc = Some(id); + w.expected_revision = app.session.active().unwrap().revision; + w.busy = Some("agent".into()); + w.allow_apply = true; w.handover(); - assert!(action(&mut w, &mut app, "pi-begin", &json!({"run":"12345678-1234-1234-1234-123456789012","documentId":id,"text":"验收"})).is_err()); - assert!(!w.allow_apply); assert!(w.pi_run.is_none()); + let run = "12345678-1234-1234-1234-123456789012"; + let conversation = action(&mut w, &mut app, "pi-begin", &json!({"run":run,"documentId":id,"text":"继续讨论,但已接手画布"})).unwrap(); + assert_eq!(conversation["conversationActive"], true); + assert_eq!(conversation["writeBlocked"], true); + assert!(!w.allow_apply); + let revision = w.expected_revision; + assert!(action(&mut w, &mut app, "pi-write-request", &json!({"run":run,"revision":revision})).is_err()); + assert!(!w.allow_apply); + assert!(w.queue.is_empty()); + } + #[test] + fn conversation_starts_without_write_permission_and_only_a_current_write_request_grants_it() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let (tx, _) = mpsc::channel(); + let (_, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + w.busy = None; // The fixture represents a completed account restore. + let id = app.session.active().unwrap().doc.id.0; + let run = "12345678-1234-1234-1234-123456789012"; + let state = action(&mut w, &mut app, "pi-begin", &json!({"run":run,"documentId":id,"text":"先解释一下建议"})).unwrap(); + assert_eq!(state["conversationActive"], true); + assert_eq!(state["allowed"], false); + assert_eq!(state["writeBlocked"], false); + let revision = w.expected_revision; + let granted = action(&mut w, &mut app, "pi-write-request", &json!({"run":run,"revision":revision})).unwrap(); + assert_eq!(granted["allowed"], true); + w.handover(); + assert_eq!(status(&w, &app)["conversationActive"], true, "taking over the canvas does not close the discussion lease"); + assert!(action(&mut w, &mut app, "pi-write-request", &json!({"run":run,"revision":revision})).is_err()); + assert!(!w.allow_apply); + action(&mut w, &mut app, "pi-end", &json!({"run":run,"text":"已停止画布修改,讨论内容保留。"})).unwrap(); + let next = + action(&mut w, &mut app, "pi-begin", &json!({"run":"22345678-1234-1234-1234-123456789012","documentId":id,"text":"下一轮继续问答"})).unwrap(); + assert_eq!(next["conversationActive"], true); + assert_eq!(next["writeBlocked"], false); + assert_eq!(next["allowed"], false); + } + #[test] + fn manual_canvas_takeover_before_the_first_write_grant_still_blocks_this_turn() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.sync_views(); + app.last_canvas_rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(200.0, 200.0)); + let (tx, _requests) = mpsc::channel(); + let (_, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + let id = app.session.active().unwrap().doc.id.0; + w.busy = None; + w.observed_doc = Some(id); + w.loaded_draft = Some(id); + w.creation_doc = Some(id); + let run = "12345678-1234-1234-1234-123456789012"; + action(&mut w, &mut app, "pi-begin", &json!({"run":run,"documentId":id,"text":"先讨论再决定是否修改"})).unwrap(); + assert!(!w.allow_apply); + assert!(!w.pi_write_blocked); + let input = egui::RawInput { + events: vec![ + egui::Event::PointerMoved(egui::pos2(20.0, 20.0)), + egui::Event::PointerButton { + pos: egui::pos2(20.0, 20.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ], + ..Default::default() + }; + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(input, |ui| w.tick(&mut app, ui)); + output.textures_delta.clear(); + assert!(w.pi_write_blocked); + assert_eq!(status(&w, &app)["conversationActive"], true); + let revision = w.expected_revision; + assert!(action(&mut w, &mut app, "pi-write-request", &json!({"run":run,"revision":revision})).is_err()); + assert!(!w.allow_apply); + assert!(w.queue.is_empty()); + } + #[test] + fn approved_pi_write_uses_the_native_queue_and_the_same_undo_history() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); + let layer = session.execute("type.create", json!({"text":"原标题","size":32})).unwrap()["layer"].clone(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.sync_views(); + let (tx, _requests) = mpsc::channel(); + let (_, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + let id = app.session.active().unwrap().doc.id.0; + w.busy = None; + w.observed_doc = Some(id); + w.loaded_draft = Some(id); + w.creation_doc = Some(id); + let run = "12345678-1234-1234-1234-123456789012"; + action(&mut w, &mut app, "pi-begin", &json!({"run":run,"documentId":id,"text":"把当前标题改为新标题"})).unwrap(); + let revision = w.expected_revision; + let proposal = json!({"run":run,"revision":revision,"edit":{"command":"type.edit","params":{"layer":layer,"text":"新标题"},"label":"修改标题"}}); + assert!(action(&mut w, &mut app, "pi-edit", &proposal).is_err(), "an active discussion lease alone cannot write"); + action(&mut w, &mut app, "pi-write-request", &json!({"run":run,"revision":revision})).unwrap(); + action(&mut w, &mut app, "pi-edit", &proposal).unwrap(); + w.next_step = Instant::now() - Duration::from_secs(1); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| w.tick(&mut app, ui)); + output.textures_delta.clear(); + assert!(app.session.active().unwrap().doc.walk().iter().any(|(_, _, l)| matches!(&l.content,LayerContent::Text(t) if t.text=="新标题"))); + app.session.execute("edit.undo", json!({})).unwrap(); + assert!(app.session.active().unwrap().doc.walk().iter().any(|(_, _, l)| matches!(&l.content,LayerContent::Text(t) if t.text=="原标题"))); + assert!( + action(&mut w, &mut app, "pi-write-request", &json!({"run":run,"revision":revision})).is_err(), + "manual undo invalidates the old AI write revision" + ); + assert!(!w.allow_apply); } } diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun/project_browser.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun/project_browser.rs index 5cfd5900..7d18959a 100644 --- a/apps/detail-photocraft/crates/ui-egui/src/jiansun/project_browser.rs +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun/project_browser.rs @@ -26,6 +26,20 @@ fn delete_block_reason(item: &Value) -> Option<&'static str> { } } +fn cloud_update_block_reason(item: &Value, open: bool) -> Option<&'static str> { + if open { + Some("先保存并关闭此项目,再读取云端新版") + } else if item["localPending"] == true || item["cloudUpdateBlocked"] == "pending_request" || item["deleteBlocked"] == true { + Some("原任务尚待处理,本机工程已保留") + } else if item["syncDirty"] != false || item["cloudUpdateBlocked"] == "unsynced_draft" { + Some("本机还有未同步修改,暂不替换工程") + } else if item["cloudUpdateAvailable"] != true { + Some("请刷新项目后核对云端版本") + } else { + None + } +} + fn badge(item: &Value) -> (&'static str, egui::Color32, egui::Color32) { if item["status"] == "completed" && item["hasDraft"] != true { ("完结", egui::Color32::from_rgb(183, 232, 206), egui::Color32::from_rgb(21, 67, 43)) @@ -38,6 +52,26 @@ impl Workspace { pub(super) fn local_identity(&self, local: &str) -> String { format!("{}|{}|{local}", self.origin, self.account["id"].as_str().unwrap_or("guest")) } + fn request_cloud_update(&mut self, app: &PhotocraftApp, item: &Value) -> Result<(), String> { + let local = item["id"].as_str().ok_or("本机项目编号无效")?; + let open = self.open_projects.values().any(|identity| identity == &self.local_identity(local)); + if let Some(reason) = cloud_update_block_reason(item, open) { + return Err(reason.into()); + } + let target = &item["cloudUpdateRef"]; + if item["source"] != "native" + || target["source"] != "desktop" + || target["updateExisting"] != true + || target["localId"] != item["id"] + || target["localRevision"] != item["revision"] + || target["accountId"] != self.account["id"] + || target["origin"] != self.origin + { + return Err("项目或账户已变化,请刷新后读取新版".into()); + } + self.handover(); + self.request_project_open(app, target.clone()) + } pub(super) fn cover_failed(&mut self, value: &Value) { if value["accountId"] == self.account["id"] && value["origin"] == self.origin { let k = key(value); @@ -68,6 +102,23 @@ impl Workspace { if value["accountId"] != self.account["id"] || value["origin"] != self.origin || value["deleted"] != true { return; } + // An older read/save receipt must not revive a user-confirmed deletion. + self.project_read = None; + self.project_refresh_requested = true; + for row in value["deletedProjects"].as_array().into_iter().flatten() { + if !self.project_tombstones.iter().any(|d| same(d, row)) { + self.project_tombstones.push(row.clone()); + } + } + for id in value["localIds"].as_array().into_iter().flatten() { + let row = json!({"source":"native","id":id}); + if !self.project_tombstones.iter().any(|d| same(d, &row)) { + self.project_tombstones.push(row); + } + } + if !self.project_tombstones.iter().any(|d| same(d, value)) { + self.project_tombstones.push(json!({"source":value["source"],"id":value["id"]})); + } let pending = self.delete_confirm.take(); self.delete_error.clear(); let local_ids = value["localIds"].as_array().cloned().unwrap_or_else(|| if value["source"] == "native" { vec![value["id"].clone()] } else { vec![] }); @@ -98,32 +149,10 @@ impl Workspace { } pub(super) fn projects_received(&mut self, app: &mut PhotocraftApp, items: Vec) { - let removed: Vec = self - .projects - .iter() - .filter(|p| p["source"] == "native" && p["cloudLink"]["id"].is_string() && !items.iter().any(|n| same(p, n))) - .cloned() - .collect(); - for item in removed { - let identity = self.local_identity(item["id"].as_str().unwrap_or("")); - let mut close = Vec::new(); - for (index, st) in app.session.documents().iter().enumerate() { - if self.open_projects.get(&st.doc.id.0) == Some(&identity) { - if Some(st.revision) == item["revision"].as_u64() { - close.push(index); - } else { - self.delete_error = "项目已在云端删除;当前新修改仍保留,请另存为新项目".into(); - } - } - } - for index in close.into_iter().rev() { - if let Some(st) = app.session.close(index) { - self.open_projects.remove(&st.doc.id.0); - } - } - app.sync_views(); - } - self.projects = items; + // Legacy mixed receipts have no authoritative tombstone set. Omissions + // must never close a canvas or delete a local draft. + self.merge_project_receipt(app, &items); + self.project_refresh_requested = true; } pub(super) fn library_ui(&mut self, app: &mut PhotocraftApp, ui: &mut Ui) { @@ -145,7 +174,7 @@ impl Workspace { self.error = e; } } - if icons::button(ui, "rotate-cw", 30.0, false, "刷新项目").clicked() && self.busy.is_none() { + if icons::button(ui, "rotate-cw", 30.0, false, "刷新项目").clicked() { self.cover_errors.clear(); self.send("projects", json!({})); } @@ -155,11 +184,14 @@ impl Workspace { let query = text_edit(ui, egui::TextEdit::singleline(&mut self.project_query).hint_text("搜索项目").desired_width(240.0)); self.project_geometry["search"] = agent_panel::rect_value(query.rect); ui.add_space(16.0); - if matches!(self.busy.as_deref(), Some("restore" | "projects" | "open" | "startup-open" | "project-delete")) { + if self.project_read.is_some() + || matches!(self.busy.as_deref(), Some("restore" | "projects" | "open" | "project-open-save" | "startup-open" | "project-delete")) + { ui.horizontal(|ui| { ui.spinner(); ui.label(match self.busy.as_deref() { Some("open") => "正在打开项目…", + Some("project-open-save") => "正在保存当前项目…", Some("project-delete") => "正在删除…", _ => "正在加载项目…", }); @@ -170,6 +202,10 @@ impl Workspace { ui.colored_label(t.danger, &self.error); ui.add_space(8.0); } + if !self.project_list_error.is_empty() { + ui.colored_label(t.danger, &self.project_list_error); + ui.add_space(8.0); + } if self.delete_confirm.is_none() && !self.delete_error.is_empty() { ui.colored_label(t.danger, &self.delete_error); ui.add_space(8.0); @@ -198,7 +234,7 @@ impl Workspace { ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing.x = 16.0; for item in items.iter().skip(row * cols).take(cols) { - ui.push_id((item["source"].as_str(), item["id"].as_str()), |ui| self.project_tile(ui, item, tile_width)); + ui.push_id((item["source"].as_str(), item["id"].as_str()), |ui| self.project_tile(app, ui, item, tile_width)); } }); } @@ -210,7 +246,7 @@ impl Workspace { } } - fn project_tile(&mut self, ui: &mut Ui, item: &Value, width: f32) { + fn project_tile(&mut self, app: &PhotocraftApp, ui: &mut Ui, item: &Value, width: f32) { let t = Tokens::get(ui.ctx()); let k = key(item); let native = item["source"] == "native"; @@ -246,15 +282,29 @@ impl Workspace { ui.painter().rect_filled(badge_rect,3,fill); ui.painter().text(badge_rect.center(),egui::Align2::CENTER_CENTER,state,egui::FontId::proportional(11.5),text); if response.has_focus() { ui.painter().rect_stroke(rect.expand(2.0),t.radius_sm,Stroke::new(1.5,t.accent),egui::StrokeKind::Outside); } - if response.clicked() && self.busy.is_none() { + let update = if item["cloudUpdateAvailable"] == true || item["cloudUpdateBlocked"].is_string() { + let open = self.open_projects.values().any(|identity| identity == &self.local_identity(item["id"].as_str().unwrap_or(""))); + let blocked = cloud_update_block_reason(item, open); + let allowed = self.busy.is_none() && self.saving.is_none() && blocked.is_none(); + let update_rect = egui::Rect::from_min_size(pos2(rect.left()+6.0,rect.bottom()-34.0),vec2(84.0,28.0)); + let button = ui.add_enabled_ui(allowed,|ui|ui.put(update_rect,egui::Button::new(RichText::new("读取新版").size(11.5)).fill(t.field).corner_radius(14))).inner; + Some(if let Some(reason)=blocked {button.on_disabled_hover_text(reason)} else {button.on_hover_text("读取云端新版本;旧本机工程保留备份")}) + } else { None }; + if update.as_ref().is_some_and(|button|button.clicked()) { + if let Err(error) = self.request_cloud_update(app,item) { + self.error = error; + } + } else if response.clicked() && self.busy.is_none() { self.handover(); - self.send("open",item.clone()); + if let Err(error) = self.request_project_open(app,item.clone()) { + self.error = error; + } } ui.add_space(8.0); let name = item["name"].as_str().unwrap_or("未命名项目"); widgets::elided_label(ui,name,ui.available_width()).on_hover_text(name); ui.horizontal(|ui| { - ui.label(RichText::new(if item["cloudDeleted"]==true {"云端已删除 · 任务待核查"} else if native && cloud {"本机 · 云端"} else if native {"仅本机"} else {"云端"}).size(11.0).color(t.text_dim)) + ui.label(RichText::new(if item["cloudDeleted"]==true {if item["retainedReason"]=="pending_request" {"云端已删除 · 任务待核查"} else {"云端已删除 · 本机保留"}} else if native && cloud {if item["syncDirty"]==true {"本机 · 尚有未同步修改"} else {"本机 · 云端"}} else if native {"仅本机 · 未同步"} else {"云端"}).size(11.0).color(t.text_dim)) .on_hover_text(if native && cloud {"云端有原项目;当前本机修改尚不代表已备份"} else if cloud {"账户中的项目"} else {"尚未同步至云端"}); ui.with_layout(Layout::right_to_left(Align::Center),|ui| { let blocked = delete_block_reason(item); @@ -262,7 +312,7 @@ impl Workspace { let response = if let Some(reason) = blocked { response.on_disabled_hover_text(reason) } else { response }; if response.clicked() { self.handover(); self.delete_error.clear(); self.delete_confirm=Some(item.clone()); } if let Some(tiles)=self.project_geometry["tiles"].as_array_mut() { - tiles.push(json!({"id":item["id"],"source":item["source"],"open":agent_panel::rect_value(rect),"delete":agent_panel::rect_value(response.rect),"deleteEnabled":response.enabled(),"deleteBlockedReason":blocked,"hasCover":self.project_covers.contains_key(&key(item)),"cloud":cloud,"borderPhysicalPixels":if cloud {2.0} else {ui.ctx().pixels_per_point()},"state":state,"badge":agent_panel::rect_value(badge_rect)})); + tiles.push(json!({"id":item["id"],"source":item["source"],"open":agent_panel::rect_value(rect),"delete":agent_panel::rect_value(response.rect),"deleteEnabled":response.enabled(),"deleteBlockedReason":blocked,"hasCover":self.project_covers.contains_key(&key(item)),"cloud":cloud,"borderPhysicalPixels":if cloud {2.0} else {ui.ctx().pixels_per_point()},"state":state,"badge":agent_panel::rect_value(badge_rect),"cloudUpdate":update.as_ref().map(|b|agent_panel::rect_value(b.rect)),"cloudUpdateEnabled":update.as_ref().is_some_and(|b|b.enabled())})); } }); }); @@ -334,6 +384,51 @@ impl Workspace { #[cfg(test)] mod tests { use super::*; + fn cloud_update_item(origin: &str) -> Value { + json!({"source":"native","id":"11111111-1111-4111-8111-111111111111","revision":7,"syncDirty":false,"cloudPresent":true,"cloudUpdateAvailable":true, + "cloudUpdateRef":{"source":"desktop","id":"22222222-2222-4222-8222-222222222222","revision":4,"updateExisting":true, + "localId":"11111111-1111-4111-8111-111111111111","localRevision":7,"localCloudRevision":3,"accountId":"a","origin":origin}}) + } + #[test] + fn cloud_update_opens_the_exact_checked_remote_without_changing_normal_local_open() { + let (tx, requests) = mpsc::channel(); + let (_events, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + w.busy = None; + w.account = json!({"id":"a"}); + let _startup: Vec<_> = requests.try_iter().collect(); + let item = cloud_update_item(&w.origin); + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + w.request_cloud_update(&app, &item).unwrap(); + assert!(matches!(requests.try_recv().unwrap(), Request::Action(method, input) if method=="open" && input==item["cloudUpdateRef"])); + assert!(requests.try_recv().is_err()); + } + #[test] + fn cloud_update_preserves_open_unsynced_unknown_and_other_account_projects() { + for case in ["open", "dirty", "pending", "account", "origin", "revision"] { + let (tx, requests) = mpsc::channel(); + let (_events, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + w.busy = None; + w.account = json!({"id":"a"}); + let _startup: Vec<_> = requests.try_iter().collect(); + let mut item = cloud_update_item(&w.origin); + match case { + "open" => { + w.open_projects.insert(9, w.local_identity(item["id"].as_str().unwrap())); + } + "dirty" => item["syncDirty"] = json!(true), + "pending" => item["localPending"] = json!(true), + "account" => item["cloudUpdateRef"]["accountId"] = json!("b"), + "origin" => item["cloudUpdateRef"]["origin"] = json!("https://other.invalid"), + "revision" => item["cloudUpdateRef"]["localRevision"] = json!(6), + _ => panic!("unsupported test case: {case}"), + } + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + assert!(w.request_cloud_update(&app, &item).is_err(), "{case}"); + assert!(requests.try_recv().is_err(), "{case}: no open/save/model request may escape"); + } + } #[test] fn deletion_block_state_is_explicit_and_never_inferred_from_names_or_age() { assert_eq!(delete_block_reason(&json!({"name":"old test","updatedAt":"2020-01-01"})), None); diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun/template_slots.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun/template_slots.rs new file mode 100644 index 00000000..53122f4b --- /dev/null +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun/template_slots.rs @@ -0,0 +1,1156 @@ +//! Native template slots: choose an output, compare A/B, then fill the editable document. +//! The view never dispatches an inference on mount or retries an existing candidate. +use crate::theme::Tokens; +use egui::{Align, Layout, RichText, Stroke, Ui, vec2}; +use serde_json::{Value, json}; +use std::cmp::Ordering; +use std::collections::{BTreeMap, HashMap}; + +const OUTPUTS: [(&str, &str); 3] = [("detail", "详情"), ("main_images", "主图"), ("sku_images", "SKU")]; +const MAX_VISIBLE_SLOTS: usize = 64; +const MAX_FIELDS: usize = 32; + +#[derive(Default)] +struct TextDraft { + original: Vec, + fields: Vec, +} + +impl TextDraft { + fn reconcile(&mut self, canonical: &[Value]) { + if self.fields == self.original || self.fields == canonical { + self.original = canonical.to_vec(); + self.fields = canonical.to_vec(); + } + } + + fn conflicts(&self, canonical: &[Value]) -> bool { + self.fields != self.original && self.fields != canonical && self.original != canonical + } +} + +#[derive(Default)] +pub(super) struct UiState { + binding: String, + output: String, + selected_slot: String, + drafts: BTreeMap<(String, String), TextDraft>, + instructions: BTreeMap, + geometry: BTreeMap, +} + +impl UiState { + fn bind(&mut self, binding: &str) { + if self.binding != binding { + // Keep typed, unsaved fields under their original account/project. They + // must neither appear in the new project nor disappear on a view switch. + self.drafts.retain(|_, draft| draft.fields != draft.original); + self.binding = binding.to_owned(); + self.output = "detail".into(); + self.selected_slot.clear(); + // These are notes for a future request, not canonical product facts. + // Never carry them across an account/project binding. + self.instructions.clear(); + self.geometry.clear(); + } + } + + fn dirty(&self, slot_id: &str) -> bool { + self.drafts.get(&(self.binding.clone(), slot_id.to_owned())).is_some_and(|draft| draft.fields != draft.original) + } + + fn has_dirty_text(&self) -> bool { + self.drafts.iter().any(|((binding, _), draft)| binding == &self.binding && draft.fields != draft.original) + } + + fn remember(&mut self, name: impl Into, rect: egui::Rect) { + self.geometry.insert(name.into(), [rect.min.x, rect.min.y, rect.width(), rect.height()]); + } + + /// Safe automation metadata: no local paths, credentials, or image bytes. + pub(super) fn inspect(&self) -> Value { + json!({ + "outputId":self.output,"selectedSlotId":self.selected_slot, + "dirtySlots":self.drafts.iter().filter_map(|((binding,id),draft)| (binding == &self.binding && draft.fields != draft.original).then_some(id)).collect::>(), + "geometry":self.geometry + }) + } +} + +fn text(value: &Value, key: &str) -> String { + value[key].as_str().unwrap_or("").to_owned() +} + +fn text_document(slot: &Value) -> String { + // Several image slots in S02 edit the same output's text fields. One shared + // buffer prevents a later image-slot save from restoring a stale field value. + slot["documentId"].as_str().filter(|id| !id.is_empty()).unwrap_or_else(|| slot["id"].as_str().unwrap_or("")).to_owned() +} + +fn label(output: &str) -> &'static str { + OUTPUTS.iter().find_map(|(id, label)| (*id == output).then_some(*label)).unwrap_or("详情") +} + +fn slots(workflow: &Value, output: &str) -> Vec { + let mut visible: Vec = + workflow["slots"].as_array().map(|slots| slots.iter().filter(|slot| slot["outputId"] == output).cloned().collect()).unwrap_or_default(); + // Sort only this display copy. Reordering the installed pack would change + // its content hash and therefore the identities of existing requests. + visible.sort_by(|left, right| { + natural_cmp(left["documentId"].as_str().unwrap_or(""), right["documentId"].as_str().unwrap_or("")) + .then_with(|| natural_cmp(left["fieldId"].as_str().unwrap_or(""), right["fieldId"].as_str().unwrap_or(""))) + }); + visible.truncate(MAX_VISIBLE_SLOTS); + visible +} + +fn natural_cmp(left: &str, right: &str) -> Ordering { + let (left, right) = (left.as_bytes(), right.as_bytes()); + let (mut a, mut b) = (0, 0); + while a < left.len() && b < right.len() { + if left[a].is_ascii_digit() && right[b].is_ascii_digit() { + let (a_start, b_start) = (a, b); + while a < left.len() && left[a].is_ascii_digit() { + a += 1; + } + while b < right.len() && right[b].is_ascii_digit() { + b += 1; + } + let mut a_number = a_start; + let mut b_number = b_start; + while a_number + 1 < a && left[a_number] == b'0' { + a_number += 1; + } + while b_number + 1 < b && right[b_number] == b'0' { + b_number += 1; + } + let order = (a - a_number).cmp(&(b - b_number)).then_with(|| left[a_number..a].cmp(&right[b_number..b])); + if order != Ordering::Equal { + return order; + } + } else { + let order = left[a].cmp(&right[b]); + if order != Ordering::Equal { + return order; + } + a += 1; + b += 1; + } + } + (left.len() - a).cmp(&(right.len() - b)) +} + +pub(super) fn capability(workflow: &Value, name: &str) -> bool { + workflow["capabilities"][name] == true +} + +fn candidates(slot: &Value) -> &[Value] { + slot["candidates"].as_array().map(Vec::as_slice).unwrap_or(&[]) +} + +fn candidate(slot: &Value, variant: &str) -> Option { + candidates(slot).iter().find(|candidate| candidate["variant"] == variant).cloned() +} + +fn generation_input(state: &UiState, slot_id: &str, variant: &str) -> Value { + let mut input = json!({"slotId":slot_id,"candidate":variant}); + if let Some(instructions) = state.instructions.get(slot_id).filter(|text| !text.trim().is_empty()) { + input["instructions"] = json!(instructions); + } + input +} + +fn instructions_ui(ui: &mut Ui, state: &mut UiState, slot_id: &str) { + let header = egui::CollapsingHeader::new("调整要求").id_salt(("slot-instructions", &state.binding, slot_id)).default_open(false).show(ui, |ui| { + let instructions = state.instructions.entry(slot_id.to_owned()).or_default(); + let input = super::text_edit( + ui, + egui::TextEdit::multiline(instructions) + .hint_text("这次需要调整的外观或构图,最多2000字") + .desired_rows(3) + .desired_width(f32::INFINITY) + .char_limit(2000), + ); + state.remember(format!("instructions-{slot_id}"), input.rect); + }); + state.remember(format!("instructions-toggle-{slot_id}"), header.header_response.rect); +} + +fn unresolved(slot: &Value) -> bool { + candidates(slot).iter().any(|candidate| matches!(candidate["status"].as_str(), Some("prepared" | "dispatched" | "running" | "unknown"))) +} + +fn selected_complete(slot: &Value) -> bool { + let selected = slot["selectedCandidateId"].as_str().filter(|id| !id.is_empty()); + selected.is_some_and(|id| candidates(slot).iter().any(|candidate| candidate["id"] == id && candidate["status"] == "complete")) +} + +fn can_fill_empty_candidate(workflow: &Value, slot: &Value, variant: &str, busy: bool) -> bool { + !busy && matches!(variant, "A" | "B") && workflow["confirmed"] == true && candidate(slot, variant).is_none() && candidates(slot).len() < 2 +} + +fn can_generate(workflow: &Value, slot: &Value, variant: &str, busy: bool) -> bool { + capability(workflow, "imageDispatchAllowed") && can_fill_empty_candidate(workflow, slot, variant, busy) && !unresolved(slot) +} + +fn reuse_sources(workflow: &Value, target: &Value, variant: &str, busy: bool) -> Vec { + if !can_fill_empty_candidate(workflow, target, variant, busy) { + return Vec::new(); + } + let Some(group) = target["reuseGroup"].as_str().filter(|group| !group.is_empty()) else { return Vec::new() }; + let mut out = Vec::new(); + for source in workflow["slots"].as_array().into_iter().flatten() { + if source["id"] == target["id"] || source["reuseGroup"] != group { + continue; + } + for candidate in candidates(source) { + if candidate["status"] != "complete" + || !candidate["id"].as_str().is_some_and(|id| !id.is_empty()) + || !matches!(candidate["variant"].as_str(), Some("A" | "B")) + || candidates(target).iter().any(|existing| existing["id"] == candidate["id"]) + { + continue; + } + out.push(json!({"label":format!("{} · {}",slot_label(source),candidate["variant"].as_str().unwrap_or("")),"input":{"slotId":target["id"],"sourceSlotId":source["id"],"sourceCandidateId":candidate["id"],"candidate":variant}})); + if out.len() == 128 { + return out; + } + } + } + out +} + +fn can_apply(state: &UiState, workflow: &Value, output: &str, busy: bool) -> bool { + let slots = slots(workflow, output); + !busy + && capability(workflow, "localApplyAllowed") + && workflow["confirmed"] == true + && !slots.is_empty() + && slots.iter().all(|slot| selected_complete(slot) && !unresolved(slot) && !state.dirty(&text_document(slot))) +} + +fn current_document<'a>(state: &UiState, workflow: &'a Value) -> Option<&'a Value> { + let rows = workflow["slots"].as_array()?; + let selected = rows.iter().find(|slot| slot["outputId"] == state.output && slot["id"] == state.selected_slot)?; + let id = selected["documentId"].as_str().filter(|id| !id.is_empty())?; + workflow["outputs"].as_array()?.iter().find(|output| output["outputId"] == state.output && output["documentId"] == id) +} + +fn can_apply_document(state: &UiState, workflow: &Value, busy: bool) -> bool { + let Some(document) = current_document(state, workflow) else { return false }; + let slots: Vec<_> = workflow["slots"] + .as_array() + .into_iter() + .flatten() + .filter(|slot| slot["outputId"] == document["outputId"] && slot["documentId"] == document["documentId"]) + .collect(); + !busy + && capability(workflow, "localApplyAllowed") + && workflow["confirmed"] == true + && document["ready"] == true + && !slots.is_empty() + && slots.iter().all(|slot| selected_complete(slot) && !unresolved(slot) && !state.dirty(&text_document(slot))) +} + +fn current_apply_label(output: &str, edited: bool) -> &'static str { + match (output, edited) { + ("detail", false) => "回填当前屏", + ("detail", true) => "打开当前屏", + (_, false) => "回填当前图", + (_, true) => "打开当前图", + } +} + +fn can_adopt_copy(state: &UiState, workflow: &Value, busy: bool) -> bool { + !busy && workflow["confirmed"] == true && workflow["copyReady"] == true && !state.has_dirty_text() +} + +fn preview_texture<'a>(candidate: &Value, previews: &'a HashMap) -> Option<&'a egui::TextureHandle> { + // The account/document-scoped transport owns decoding and texture publication. + // Candidate content cannot introduce a URI loader, local path or network read. + candidate["id"].as_str().and_then(|id| previews.get(id)) +} + +fn status(candidate: &Value, busy: bool) -> &'static str { + match candidate["status"].as_str() { + Some("prepared" | "dispatched" | "running") if !busy => "待核查", + Some("prepared") => "已保存请求", + Some("dispatched" | "running") => "生成中", + Some("unknown") => "待核查", + Some("complete") if candidate["reusedFrom"].is_object() => "已复用", + Some("complete") => "已生成", + Some("failed") => "失败", + _ => "未生成", + } +} + +/// `binding` must include account, origin and document identity. Unsaved field buffers +/// remain scoped to their original binding when switching projects. +pub(super) fn show( + ui: &mut Ui, + state: &mut UiState, + workflow: &Value, + binding: &str, + busy: bool, + previews: &HashMap, + dispatch: &mut impl FnMut(&str, Value), +) { + state.bind(binding); + state.geometry.clear(); + let t = Tokens::get(ui.ctx()); + if state.output.is_empty() { + state.output = "detail".into(); + } + let all_slots = workflow["slots"].as_array(); + if all_slots.is_none_or(Vec::is_empty) { + ui.horizontal(|ui| { + ui.label("项目模板"); + let install = ui.add_enabled(!busy, egui::Button::new("载入模板图位")); + state.remember("install", install.rect); + if install.clicked() { + dispatch("slot-install", json!({})); + } + if busy { + ui.spinner(); + } + }); + return; + } + let visible = slots(workflow, &state.output); + if !visible.iter().any(|slot| slot["id"] == state.selected_slot) { + state.selected_slot = visible.first().map(|slot| text(slot, "id")).unwrap_or_default(); + } + ui.horizontal(|ui| { + for (output, name) in OUTPUTS { + let count = slots(workflow, output).len(); + let response = ui.add_enabled(count > 0, egui::Button::selectable(state.output == output, format!("{name} {count}"))); + state.remember(format!("output-{output}"), response.rect); + if response.clicked() { + state.output = output.into(); + state.selected_slot = slots(workflow, output).first().map(|slot| text(slot, "id")).unwrap_or_default(); + } + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let ready = can_apply_document(state, workflow, busy); + let document = current_document(state, workflow); + let edited = document.is_some_and(|document| document["hasEditedDraft"] == true); + let response = ui.add_enabled(ready, egui::Button::new(current_apply_label(&state.output, edited))); + state.remember("apply-current", response.rect); + let response = response.on_hover_text(if ready { + if edited { "打开已保存的当前输出,保留人工修改" } else { "只回填当前输出的所选图片与已保存文字" } + } else { + "先选齐当前输出的图片并保存文字;其他屏可稍后制作" + }); + if response.clicked() + && let Some(document) = document + { + dispatch("slot-apply", json!({"outputId":state.output,"documentId":document["documentId"]})); + } + let menu = ui.menu_button("整组", |ui| { + let ready = can_apply(state, workflow, &state.output, busy); + let response = ui.add_enabled(ready, egui::Button::new(format!("回填全部{}", label(&state.output)))); + state.remember("apply", response.rect); + if response.on_hover_text("需要本组全部图片已选定,并已保存文字修改").clicked() { + dispatch("slot-apply", json!({"outputId":state.output})); + ui.close(); + } + }); + state.remember("apply-group-menu", menu.response.rect); + }); + }); + ui.horizontal(|ui| { + let generate = ui.add_enabled(!busy && capability(workflow, "copyDispatchAllowed") && workflow["confirmed"] == true, egui::Button::new("生成文案")); + state.remember("copy-generate", generate.rect); + if generate.on_hover_text("按本产品已核对事实生成文字候选,不会自动替换草稿").clicked() { + dispatch("slot-copy", json!({})); + } + if workflow["copyReady"] == true { + let adopt = ui.add_enabled(can_adopt_copy(state, workflow, busy), egui::Button::new("采用文案")); + state.remember("copy-apply", adopt.rect); + if adopt + .on_hover_text(if state.has_dirty_text() { + "先保存或处理未保存的文字草稿" + } else { + "用候选替换全部模板文字,不调用模型" + }) + .clicked() + { + dispatch("slot-copy-apply", json!({})); + } + } + }); + if workflow["copyReady"] == true { + egui::CollapsingHeader::new("文案候选").id_salt("template-copy-candidate").show(ui, |ui| { + for field in workflow["copyFields"].as_array().into_iter().flatten().take(80) { + ui.horizontal_wrapped(|ui| { + ui.strong(field["fieldId"].as_str().or_else(|| field["label"].as_str()).unwrap_or("文字")); + let copy = field["text"].as_str().unwrap_or(""); + if copy.is_empty() { + ui.weak("留空"); + } else { + ui.label(copy); + } + }); + } + for warning in workflow["copyWarnings"].as_array().into_iter().flatten().filter_map(Value::as_str).take(20) { + ui.colored_label(t.warning, warning); + } + }); + } + ui.separator(); + let visible = slots(workflow, &state.output); + if !visible.iter().any(|slot| slot["id"] == state.selected_slot) { + state.selected_slot = visible.first().map(|slot| text(slot, "id")).unwrap_or_default(); + } + if workflow["confirmed"] != true { + ui.colored_label(t.warning, "模板图位待确认"); + } + if visible.is_empty() { + ui.label("此模板暂无图位"); + return; + } + if ui.available_width() >= 640.0 { + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(184.0); + egui::ScrollArea::vertical().id_salt("template-slot-list").max_height(ui.available_height().max(240.0)).show(ui, |ui| { + slot_list(ui, state, &visible); + }); + }); + ui.separator(); + ui.vertical(|ui| { + ui.set_width(ui.available_width()); + slot_body(ui, state, workflow, &visible, busy, previews, dispatch); + }); + }); + } else { + let current_label = visible.iter().find(|slot| slot["id"] == state.selected_slot).map(slot_label).unwrap_or_default(); + egui::ComboBox::from_id_salt("template-slot-picker").width(ui.available_width()).selected_text(current_label).show_ui(ui, |ui| { + slot_list(ui, state, &visible); + }); + ui.add_space(8.0); + slot_body(ui, state, workflow, &visible, busy, previews, dispatch); + } +} + +fn slot_label(slot: &Value) -> String { + let field = slot["fieldId"].as_str().filter(|name| !name.is_empty()).unwrap_or(""); + let name = slot["label"].as_str().filter(|name| !name.is_empty() && Some(*name) != slot["id"].as_str()).unwrap_or(""); + match (field.is_empty(), name.is_empty()) { + (true, true) => "图位".into(), + (true, false) => name.into(), + (false, true) => field.into(), + (false, false) if name.contains(field) => name.into(), + (false, false) => format!("{field} {name}"), + } +} + +fn candidate_title(slot: &Value, variant: &str) -> String { + format!("{} · 候选 {variant}", slot_label(slot)) +} + +fn slot_list(ui: &mut Ui, state: &mut UiState, slots: &[Value]) { + let t = Tokens::get(ui.ctx()); + for slot in slots { + let id = text(slot, "id"); + let label = slot_label(slot); + let suffix = if unresolved(slot) { + "待核查" + } else if selected_complete(slot) { + "已选" + } else { + "待选" + }; + let full_label = format!("{label}\n{suffix}"); + let response = ui.add( + egui::Button::selectable(state.selected_slot == id, RichText::new(&full_label).color(t.text)) + .min_size(vec2(ui.available_width(), 44.0)) + .wrap_mode(egui::TextWrapMode::Truncate), + ); + state.remember(format!("slot-{id}"), response.rect); + if response.on_hover_text(&full_label).clicked() { + state.selected_slot = id; + } + } +} + +fn slot_body( + ui: &mut Ui, + state: &mut UiState, + workflow: &Value, + visible: &[Value], + busy: bool, + previews: &HashMap, + dispatch: &mut impl FnMut(&str, Value), +) { + let Some(slot) = visible.iter().find(|slot| slot["id"] == state.selected_slot) else { return }; + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + let width = (ui.available_width() - 110.0).max(100.0); + crate::widgets::elided_label(ui, &slot_label(slot), width); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label(RichText::new(format!("{} × {} px", slot["width"].as_u64().unwrap_or(0), slot["height"].as_u64().unwrap_or(0))).color(t.text_dim)); + }); + }); + ui.add_space(8.0); + instructions_ui(ui, state, slot["id"].as_str().unwrap_or("")); + ui.add_space(4.0); + let context = CandidateContext { workflow, slot, busy, previews }; + if ui.available_width() >= 360.0 { + ui.columns(2, |columns| { + if let Some(column) = columns.get_mut(0) { + candidate_ui(column, state, context, "A", dispatch); + } + if let Some(column) = columns.get_mut(1) { + candidate_ui(column, state, context, "B", dispatch); + } + }); + } else { + for variant in ["A", "B"] { + candidate_ui(ui, state, context, variant, dispatch); + ui.add_space(8.0); + } + } + fields_ui(ui, state, slot, busy, dispatch); +} + +#[derive(Clone, Copy)] +struct CandidateContext<'a> { + workflow: &'a Value, + slot: &'a Value, + busy: bool, + previews: &'a HashMap, +} + +fn candidate_ui(ui: &mut Ui, state: &mut UiState, context: CandidateContext<'_>, variant: &str, dispatch: &mut impl FnMut(&str, Value)) { + let CandidateContext { workflow, slot, busy, previews } = context; + let t = Tokens::get(ui.ctx()); + let current = candidate(slot, variant); + let slot_id = text(slot, "id"); + let selected = current.as_ref().is_some_and(|candidate| candidate["id"] == slot["selectedCandidateId"] && candidate["status"] == "complete"); + let stroke = Stroke::new(1.0, if selected { t.accent } else { t.field_border }); + let frame = egui::Frame::NONE.stroke(stroke).inner_margin(8.0).corner_radius(t.radius_sm).show(ui, |ui| { + ui.set_width((ui.available_width() - 2.0).max(80.0)); + ui.horizontal(|ui| { + ui.strong(variant); + let empty = Value::Null; + let value = current.as_ref().unwrap_or(&empty); + let status_text = if selected { "已采用" } else { status(value, busy) }; + let color = match value["status"].as_str() { + Some("prepared" | "dispatched" | "running") if !busy => t.warning, + Some("unknown") => t.warning, + Some("failed") => t.danger, + _ => t.text_dim, + }; + ui.label(RichText::new(status_text).color(color)); + if busy && matches!(value["status"].as_str(), Some("prepared" | "dispatched" | "running")) { + ui.spinner(); + } + }); + let preview_size = vec2(ui.available_width(), 150.0); + if let Some(texture) = current.as_ref().and_then(|candidate| preview_texture(candidate, previews)) { + let size = texture.size_vec2(); + let factor = (preview_size.x / size.x.max(1.0)).min(preview_size.y / size.y.max(1.0)); + let fitted_size = size * factor; + ui.allocate_ui_with_layout(preview_size, Layout::centered_and_justified(egui::Direction::TopDown), |ui| { + ui.image((texture.id(), fitted_size)).on_hover_text(candidate_title(slot, variant)); + }); + } else { + let (rect, _) = ui.allocate_exact_size(preview_size, egui::Sense::hover()); + ui.painter().rect_filled(rect, t.radius_sm, t.field); + let empty = if current.as_ref().is_some_and(|candidate| candidate["status"] == "complete") { "预览待载入" } else { "候选图片" }; + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, empty, egui::FontId::proportional(12.0), t.text_dim); + } + ui.add_space(8.0); + match current { + Some(ref candidate) if matches!(candidate["status"].as_str(), Some("prepared" | "dispatched" | "running" | "unknown")) => { + let response = ui.add_enabled(!busy, egui::Button::new("核查原结果")); + state.remember(format!("recover-{slot_id}-{variant}"), response.rect); + if response.on_hover_text("只读核查原请求,不会重新生成").clicked() { + dispatch("slot-recover", json!({"slotId":slot_id,"candidateId":candidate["id"]})); + } + } + Some(ref candidate) if candidate["status"] == "complete" => { + let response = ui.add_enabled(!busy && !selected, egui::Button::new(if selected { "已采用" } else { "采用此图" })); + state.remember(format!("select-{slot_id}-{variant}"), response.rect); + if response.clicked() { + dispatch("slot-select", json!({"slotId":slot_id,"candidateId":candidate["id"]})); + } + } + Some(ref candidate) if candidate["status"] == "failed" => { + ui.colored_label(t.danger, "本次生成失败").on_hover_text(candidate["error"].as_str().unwrap_or("请查看原请求记录")); + } + Some(_) => { + ui.add_enabled(false, egui::Button::new("等待原请求")); + } + None => { + ui.horizontal(|ui| { + let response = ui.add_enabled(can_generate(workflow, slot, variant, busy), egui::Button::new(format!("生成 {variant}"))); + state.remember(format!("generate-{slot_id}-{variant}"), response.rect); + if response.on_hover_text("调用所选生图模型,新增一张候选").clicked() { + dispatch("slot-generate", generation_input(state, &slot_id, variant)); + } + let sources = reuse_sources(workflow, slot, variant, busy); + if !sources.is_empty() { + let menu = ui.menu_button("复用", |ui| { + ui.set_max_width(320.0); + egui::ScrollArea::vertical().id_salt(("template-reuse", &slot_id, variant)).max_height(240.0).show(ui, |ui| { + for (index, source) in sources.iter().enumerate() { + let response = ui.button(source["label"].as_str().unwrap_or("成功候选")); + state.remember(format!("reuse-source-{slot_id}-{variant}-{index}"), response.rect); + if response.on_hover_text("复用此成功图片,不调用模型;仍需单独采用").clicked() { + dispatch("slot-reuse", source["input"].clone()); + ui.close(); + } + } + }); + }); + state.remember(format!("reuse-{slot_id}-{variant}"), menu.response.rect); + menu.response.on_hover_text("使用模板允许共用的成功图片,不会发起生成"); + } + }); + } + } + }); + state.remember(format!("candidate-{slot_id}-{variant}"), frame.response.rect); +} + +fn fields_ui(ui: &mut Ui, state: &mut UiState, slot: &Value, busy: bool, dispatch: &mut impl FnMut(&str, Value)) { + let id = text(slot, "id"); + let canonical = slot["fields"].as_array().map(|fields| fields.iter().take(MAX_FIELDS).cloned().collect::>()).unwrap_or_default(); + if canonical.is_empty() { + return; + } + ui.add_space(16.0); + ui.separator(); + let draft = state.drafts.entry((state.binding.clone(), text_document(slot))).or_default(); + // A candidate response must not overwrite text the person is typing. A saved + // acknowledgement may update it once it equals the canonical field values. + draft.reconcile(&canonical); + if draft.conflicts(&canonical) { + ui.horizontal_wrapped(|ui| { + ui.colored_label(Tokens::get(ui.ctx()).warning, "文字已有新版本"); + if ui.add_enabled(!busy, egui::Button::new("载入最新")).on_hover_text("放弃本地未保存文字,载入当前版本").clicked() { + draft.original = canonical.clone(); + draft.fields = canonical.clone(); + } + }); + } + ui.add_enabled_ui(!busy, |ui| { + egui::Grid::new(("template-text", &id)).num_columns(2).spacing(vec2(12.0, 8.0)).show(ui, |ui| { + for field in &mut draft.fields { + let name = field["label"].as_str().or_else(|| field["fieldId"].as_str()).unwrap_or("文字"); + crate::widgets::elided_label(ui, name, 100.0); + let mut value = field["value"].as_str().unwrap_or("").to_owned(); + let response = super::text_edit(ui, egui::TextEdit::singleline(&mut value).desired_width((ui.available_width() - 12.0).max(100.0))); + if response.changed() { + field["value"] = json!(value); + } + ui.end_row(); + } + }); + }); + let dirty = draft.fields != draft.original; + let conflict = draft.conflicts(&canonical); + let fields = draft.fields.clone(); + let base_fields = draft.original.clone(); + let response = ui.add_enabled(!busy && dirty && !conflict, egui::Button::new("保存文字")); + state.remember("save-text", response.rect); + if response.on_hover_text("只保存文字,不调用生图模型").clicked() { + dispatch("slot-text-save", json!({"slotId":id,"baseFields":base_fields,"fields":fields})); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> Value { + json!({"revision":1,"confirmed":true,"capabilities":{"imageDispatchAllowed":true,"copyDispatchAllowed":true,"localApplyAllowed":true},"slots":[ + {"id":"D01","outputId":"detail","label":"首屏","width":1440,"height":1800,"fields":[{"id":"title","label":"标题","value":"产品名称"}],"candidates":[]}, + {"id":"M01","outputId":"main_images","label":"主图","width":1440,"height":1440,"fields":[],"candidates":[]}, + {"id":"S01","outputId":"sku_images","label":"SKU","width":1440,"height":1920,"fields":[],"candidates":[]} + ]}) + } + + fn unknown_frame(ctx: &egui::Context, state: &mut UiState, workflow: &Value, events: Vec) -> Vec<(String, Value)> { + let mut actions = Vec::new(); + let mut output = ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 900.0))), events, ..Default::default() }, + |ui| { + show(ui, state, workflow, "owner|origin|doc", false, &HashMap::new(), &mut |method, input| actions.push((method.to_owned(), input))); + }, + ); + output.textures_delta.clear(); + actions + } + + fn click_while_unknown(ctx: &egui::Context, state: &mut UiState, workflow: &Value, name: &str) -> Vec<(String, Value)> { + let rect = state.geometry.get(name).unwrap(); + let pos = egui::pos2(rect[0] + rect[2] / 2.0, rect[1] + rect[3] / 2.0); + let mut actions = unknown_frame( + ctx, + state, + workflow, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + ], + ); + actions.extend(unknown_frame( + ctx, + state, + workflow, + vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }], + )); + actions + } + + #[test] + fn an_unknown_request_disables_new_model_buttons_instead_of_dispatching_to_a_rejecting_guard() { + let ctx = egui::Context::default(); + let mut workflow = fixture(); + let mut state = UiState::default(); + workflow["capabilities"] = json!({"imageDispatchAllowed":false,"copyDispatchAllowed":false,"localApplyAllowed":false}); + for _ in 0..2 { + assert!(unknown_frame(&ctx, &mut state, &workflow, vec![]).is_empty()); + } + assert!(!can_generate(&workflow, &workflow["slots"][0], "A", false)); + for action in ["copy-generate", "generate-D01-A", "generate-D01-B"] { + assert!(click_while_unknown(&ctx, &mut state, &workflow, action).is_empty(), "{action} must be actually disabled"); + } + } + + #[test] + fn an_unknown_request_preserves_readonly_recovery_successful_selection_and_local_text_save() { + let ctx = egui::Context::default(); + let mut workflow = fixture(); + let mut state = UiState::default(); + workflow["capabilities"] = json!({"imageDispatchAllowed":false,"copyDispatchAllowed":false,"localApplyAllowed":false}); + workflow["slots"][0]["candidates"] = + json!([{"id":"original-unknown","variant":"A","status":"unknown"},{"id":"known-success","variant":"B","status":"complete"}]); + for _ in 0..2 { + assert!(unknown_frame(&ctx, &mut state, &workflow, vec![]).is_empty()); + } + let recovered = click_while_unknown(&ctx, &mut state, &workflow, "recover-D01-A"); + assert_eq!(recovered, vec![("slot-recover".to_owned(), json!({"slotId":"D01","candidateId":"original-unknown"}))]); + let selected = click_while_unknown(&ctx, &mut state, &workflow, "select-D01-B"); + assert_eq!(selected, vec![("slot-select".to_owned(), json!({"slotId":"D01","candidateId":"known-success"}))]); + let original = workflow["slots"][0]["fields"].as_array().unwrap().clone(); + let mut edited = original.clone(); + edited[0]["value"] = json!("人工修改文字"); + state.drafts.insert((state.binding.clone(), "D01".into()), TextDraft { original: original.clone(), fields: edited.clone() }); + assert!(unknown_frame(&ctx, &mut state, &workflow, vec![]).is_empty()); + let saved = click_while_unknown(&ctx, &mut state, &workflow, "save-text"); + assert_eq!(saved, vec![("slot-text-save".to_owned(), json!({"slotId":"D01","fields":edited,"baseFields":original}))]); + } + + #[test] + fn stage_capabilities_allow_a_first_image_without_enabling_another_copy_request() { + let ctx = egui::Context::default(); + let mut workflow = fixture(); + let mut state = UiState::default(); + workflow["capabilities"] = json!({"imageDispatchAllowed":true,"copyDispatchAllowed":false,"localApplyAllowed":true}); + for _ in 0..2 { + assert!(unknown_frame(&ctx, &mut state, &workflow, vec![]).is_empty()); + } + assert!(click_while_unknown(&ctx, &mut state, &workflow, "copy-generate").is_empty()); + assert_eq!( + click_while_unknown(&ctx, &mut state, &workflow, "generate-D01-A"), + vec![("slot-generate".to_owned(), json!({"slotId":"D01","candidate":"A"}))] + ); + workflow["slots"][0]["candidates"] = json!([{"id":"original-image","variant":"A","status":"unknown"}]); + assert!(!can_generate(&workflow, &workflow["slots"][0], "B", false), "an unresolved A still blocks a second request in the same slot"); + assert!(can_generate(&workflow, &workflow["slots"][1], "A", false), "another empty slot remains an independent first request"); + workflow["capabilities"] = Value::Null; + assert!(!can_generate(&workflow, &workflow["slots"][1], "A", false), "missing backend permission must not be guessed from copy state"); + } + + #[test] + fn adjustment_drafts_are_isolated_per_slot_and_reset_on_a_different_project() { + let mut state = UiState::default(); + state.bind("owner|origin|project-one"); + assert_eq!(generation_input(&state, "D01", "A"), json!({"slotId":"D01","candidate":"A"})); + state.instructions.insert("D01".into(), "保留桌面后沿走线缺口,不增绘圆孔".into()); + state.instructions.insert("M01".into(), "纯产品,不增加道具".into()); + assert_eq!(generation_input(&state, "D01", "B")["instructions"], "保留桌面后沿走线缺口,不增绘圆孔"); + assert_eq!(generation_input(&state, "M01", "A")["instructions"], "纯产品,不增加道具"); + assert!(generation_input(&state, "S01", "A").get("instructions").is_none()); + state.bind("owner|origin|project-one"); + assert_eq!(state.instructions.len(), 2, "a refresh or output switch preserves this project's drafts"); + state.bind("owner|origin|project-two"); + assert!(state.instructions.is_empty()); + assert!(generation_input(&state, "D01", "A").get("instructions").is_none()); + } + + #[test] + fn collapsed_adjustment_input_can_be_typed_and_only_maps_to_the_new_candidate() { + let ctx = egui::Context::default(); + for theme in [egui::Theme::Dark, egui::Theme::Light] { + ctx.style_mut_of(theme, |style| style.animation_time = 0.0); + } + let mut state = UiState::default(); + let mut workflow = fixture(); + workflow["slots"][0]["candidates"] = json!([{"id":"original-success","variant":"A","status":"complete"}]); + let original = workflow.clone(); + for _ in 0..2 { + assert!(unknown_frame(&ctx, &mut state, &workflow, vec![]).is_empty()); + } + assert!(state.geometry.contains_key("instructions-toggle-D01")); + assert!(!state.geometry.contains_key("instructions-D01"), "adjustments are collapsed by default"); + assert!(!state.geometry.contains_key("generate-D01-A"), "the existing A has no dispatch button"); + assert!(click_while_unknown(&ctx, &mut state, &workflow, "instructions-toggle-D01").is_empty()); + assert!(unknown_frame(&ctx, &mut state, &workflow, vec![]).is_empty()); + assert!(click_while_unknown(&ctx, &mut state, &workflow, "instructions-D01").is_empty()); + let notes = "桌面后沿是走线缺口,不是圆孔"; + assert!(unknown_frame(&ctx, &mut state, &workflow, vec![egui::Event::Text(notes.into())]).is_empty()); + assert_eq!(state.instructions.get("D01").map(String::as_str), Some(notes)); + let generated = click_while_unknown(&ctx, &mut state, &workflow, "generate-D01-B"); + assert_eq!(generated, vec![("slot-generate".to_owned(), json!({"slotId":"D01","candidate":"B","instructions":notes}))]); + assert_eq!(workflow, original, "typing and dispatch mapping cannot alter A or the installed template"); + } + + #[test] + fn readable_slot_sorting_is_natural_per_output_and_never_changes_operation_identity() { + let mut workflow = fixture(); + let mut rows = Vec::new(); + for (output, prefix) in [("detail", "S"), ("main_images", "M"), ("sku_images", "K")] { + for (identity, document, field) in [("ten", 10, 1), ("second-ten", 2, 10), ("second-two", 2, 2), ("second-one", 2, 1)] { + rows.push(json!({"id":format!("{output}-{identity}"),"outputId":output,"documentId":format!("{prefix}{document}"),"fieldId":format!("{prefix}{document}-I{field}"),"label":"图位","width":1440,"height":1800,"fields":[],"candidates":[{"id":format!("task-{output}-{identity}"),"variant":"A","status":"complete"}],"selectedCandidateId":format!("task-{output}-{identity}")})); + } + } + workflow["slots"] = json!(rows); + let original = workflow.clone(); + for (output, _) in OUTPUTS { + let visible = slots(&workflow, output); + let ids: Vec<_> = visible.iter().map(|slot| text(slot, "id")).collect(); + assert_eq!(ids, vec![format!("{output}-second-one"), format!("{output}-second-two"), format!("{output}-second-ten"), format!("{output}-ten")]); + for row in visible { + assert_eq!(row["selectedCandidateId"], format!("task-{}", row["id"].as_str().unwrap())); + } + } + assert_eq!(workflow, original, "the installed pack, candidate task IDs and selections are untouched"); + let ctx = egui::Context::default(); + let mut state = UiState::default(); + state.bind("owner|origin|doc"); + state.selected_slot = "detail-ten".into(); + assert!(unknown_frame(&ctx, &mut state, &workflow, vec![]).is_empty()); + assert_eq!(state.selected_slot, "detail-ten", "selection follows stable identity rather than the sorted list index"); + } + + #[test] + fn labels_and_preview_titles_use_field_names_and_never_expose_the_internal_slot_hash() { + let internal = "a".repeat(64); + let slot = json!({"id":internal,"fieldId":"S01-I01","label":"首屏 · 产品场景"}); + let label = slot_label(&slot); + assert!(label.contains("S01-I01")); + assert!(label.contains("产品场景")); + assert!(!label.contains(&internal)); + assert!(!candidate_title(&slot, "A").contains(&internal)); + assert_eq!(slot_label(&json!({"id":internal})), "图位"); + assert_eq!(slot_label(&json!({"id":internal,"label":internal,"fieldId":"S01-I01"})), "S01-I01"); + } + + #[test] + fn unknown_or_dispatched_candidates_have_no_generate_path() { + for status in ["prepared", "dispatched", "running", "unknown"] { + let workflow = fixture(); + let mut slot = workflow["slots"][0].clone(); + slot["candidates"] = json!([{"id":"original-request","variant":"A","status":status}]); + assert!(!can_generate(&workflow, &slot, "A", false)); + assert!(!can_generate(&workflow, &slot, "B", false)); + } + } + + #[test] + fn existing_variants_and_two_request_cap_cannot_be_regenerated() { + let workflow = fixture(); + let mut slot = workflow["slots"][0].clone(); + slot["candidates"] = json!([{"id":"a","variant":"A","status":"failed"}]); + assert!(!can_generate(&workflow, &slot, "A", false)); + assert!(can_generate(&workflow, &slot, "B", false)); + slot["candidates"].as_array_mut().unwrap().push(json!({"id":"b","variant":"B","status":"complete"})); + assert!(!can_generate(&workflow, &slot, "B", false)); + assert!(!can_generate(&workflow, &slot, "C", false)); + } + + #[test] + fn reuse_lists_only_successful_other_slots_in_the_explicit_same_group() { + let mut workflow = fixture(); + workflow["slots"][0]["reuseGroup"] = json!("shared-product"); + workflow["slots"][1]["reuseGroup"] = json!("shared-product"); + workflow["slots"][1]["candidates"] = + json!([{"id":"original-success","variant":"A","status":"complete"},{"id":"original-unknown","variant":"B","status":"unknown"}]); + workflow["slots"][2]["reuseGroup"] = json!("different-product"); + workflow["slots"][2]["candidates"] = json!([{"id":"unrelated-success","variant":"A","status":"complete"}]); + let target = workflow["slots"][0].clone(); + let sources = reuse_sources(&workflow, &target, "B", false); + assert_eq!(sources.len(), 1); + assert_eq!(sources[0]["input"], json!({"slotId":"D01","sourceSlotId":"M01","sourceCandidateId":"original-success","candidate":"B"})); + assert!(reuse_sources(&workflow, &target, "B", true).is_empty()); + let mut missing = target.clone(); + missing["reuseGroup"] = Value::Null; + assert!(reuse_sources(&workflow, &missing, "B", false).is_empty()); + let mut occupied = target.clone(); + occupied["candidates"] = json!([{"id":"own-original","variant":"B","status":"unknown"}]); + assert!(reuse_sources(&workflow, &occupied, "B", false).is_empty(), "reuse cannot replace an original unknown request"); + workflow["slots"][0]["candidates"] = json!([{"id":"own-success","variant":"A","status":"complete"}]); + let sources = reuse_sources(&workflow, &workflow["slots"][0], "B", false); + assert!(sources.iter().all(|source| source["input"]["sourceSlotId"] != "D01"), "A is not copied into B in the same slot"); + let mut duplicated = target.clone(); + duplicated["candidates"] = json!([{"id":"original-success","variant":"A","status":"complete","reusedFrom":{"slotId":"M01"}}]); + assert!(reuse_sources(&workflow, &duplicated, "B", false).is_empty(), "the same original task cannot masquerade as two candidates of one slot"); + } + + #[test] + fn showing_a_reusable_source_is_not_a_new_model_call_or_automatic_adoption() { + let ctx = egui::Context::default(); + let mut workflow = fixture(); + for index in [0, 1] { + workflow["slots"][index]["reuseGroup"] = json!("shared-product"); + } + workflow["slots"][1]["candidates"] = json!([{"id":"original-success","variant":"A","status":"complete"}]); + let mut state = UiState::default(); + let mut actions = Vec::new(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + show(ui, &mut state, &workflow, "owner|origin|doc", false, &HashMap::new(), &mut |method, input| actions.push((method.to_owned(), input))) + }); + output.textures_delta.clear(); + assert!(state.geometry.contains_key("reuse-D01-A")); + assert!(actions.is_empty(), "the menu never dispatches on mount"); + assert!(workflow["slots"][0]["selectedCandidateId"].is_null(), "reuse is separate from selecting and filling"); + assert_eq!(status(&json!({"status":"complete","reusedFrom":{"slotId":"M01"}}), false), "已复用"); + } + + #[test] + fn apply_requires_all_selected_results_and_saved_text_in_the_current_output() { + let mut workflow = fixture(); + let mut state = UiState::default(); + assert!(!can_apply(&state, &workflow, "detail", false)); + workflow["slots"][0]["candidates"] = json!([{"id":"a","variant":"A","status":"complete"}]); + workflow["slots"][0]["selectedCandidateId"] = json!("a"); + assert!(can_apply(&state, &workflow, "detail", false)); + assert!(!can_apply(&state, &workflow, "main_images", false)); + assert!(!can_apply(&state, &workflow, "detail", true)); + state + .drafts + .insert((state.binding.clone(), "D01".into()), TextDraft { original: vec![json!({"value":"原文"})], fields: vec![json!({"value":"新文"})] }); + assert!(!can_apply(&state, &workflow, "detail", false)); + } + + fn ready_first_screen() -> Value { + let mut workflow = fixture(); + let mut first = workflow["slots"][0].clone(); + first["documentId"] = json!("S01"); + first["candidates"] = json!([{"id":"first-product","variant":"A","status":"complete"}]); + first["selectedCandidateId"] = json!("first-product"); + let mut other = first.clone(); + other["id"] = json!("D02"); + other["documentId"] = json!("S02"); + other["candidates"] = json!([]); + other["selectedCandidateId"] = Value::Null; + let mut second = first.clone(); + second["id"] = json!("D03"); + second["candidates"] = json!([{"id":"first-detail","variant":"B","status":"complete"}]); + second["selectedCandidateId"] = json!("first-detail"); + workflow["slots"] = json!([first, other, second]); + workflow["outputs"] = json!([ + {"documentId":"S01","outputId":"detail","name":"首屏","requiredImages":2,"selectedImages":2,"ready":true,"hasEditedDraft":false}, + {"documentId":"S02","outputId":"detail","name":"细节屏","requiredImages":1,"selectedImages":0,"ready":false,"hasEditedDraft":false} + ]); + workflow + } + + #[test] + fn a_ready_screen_can_be_filled_before_other_screens_without_dispatching_generation() { + let ctx = egui::Context::default(); + let workflow = ready_first_screen(); + let mut state = UiState::default(); + for _ in 0..2 { + assert!(unknown_frame(&ctx, &mut state, &workflow, vec![]).is_empty()); + } + assert_eq!(state.selected_slot, "D01"); + assert!(can_apply_document(&state, &workflow, false)); + assert!(!can_apply(&state, &workflow, "detail", false), "the unfinished second screen still blocks a whole-group fill"); + assert_eq!( + click_while_unknown(&ctx, &mut state, &workflow, "apply-current"), + vec![("slot-apply".to_owned(), json!({"outputId":"detail","documentId":"S01"}))] + ); + state.selected_slot = "D02".into(); + assert!(unknown_frame(&ctx, &mut state, &workflow, vec![]).is_empty()); + assert!( + click_while_unknown(&ctx, &mut state, &workflow, "apply-current").is_empty(), + "the unselected second screen cannot borrow first-screen readiness" + ); + } + + #[test] + fn single_screen_fill_keeps_text_and_backend_permission_guards_scoped_to_its_document() { + let mut workflow = ready_first_screen(); + let mut state = UiState::default(); + state.bind("owner|origin|doc"); + state.selected_slot = "D01".into(); + let draft = || TextDraft { original: vec![json!({"value":"原文"})], fields: vec![json!({"value":"人工修改"})] }; + state.drafts.insert((state.binding.clone(), "S02".into()), draft()); + assert!(can_apply_document(&state, &workflow, false), "another screen's draft is preserved without blocking this screen"); + state.drafts.insert((state.binding.clone(), "S01".into()), draft()); + assert!(!can_apply_document(&state, &workflow, false)); + state.drafts.remove(&(state.binding.clone(), "S01".into())); + assert!(!can_apply_document(&state, &workflow, true)); + workflow["slots"][2]["candidates"][0]["status"] = json!("unknown"); + assert!(!can_apply_document(&state, &workflow, false), "every image of the current screen remains guarded"); + workflow["slots"][2]["candidates"][0]["status"] = json!("complete"); + workflow["capabilities"]["localApplyAllowed"] = json!(false); + assert!(!can_apply_document(&state, &workflow, false)); + workflow["capabilities"]["localApplyAllowed"] = json!(true); + workflow["outputs"][0]["ready"] = json!(false); + assert!(!can_apply_document(&state, &workflow, false), "frontend selection cannot override the backend output check"); + workflow["outputs"] = Value::Null; + assert!(!can_apply_document(&state, &workflow, false), "an older snapshot must be refreshed instead of guessing a document binding"); + } + + #[test] + fn edited_single_screen_reopens_explicitly_and_group_fill_keeps_its_original_contract() { + let ctx = egui::Context::default(); + let mut workflow = ready_first_screen(); + workflow["outputs"][0]["hasEditedDraft"] = json!(true); + let mut state = UiState::default(); + for _ in 0..2 { + assert!(unknown_frame(&ctx, &mut state, &workflow, vec![]).is_empty()); + } + assert_eq!(current_apply_label(&state.output, true), "打开当前屏"); + assert_eq!(current_apply_label("main_images", false), "回填当前图"); + assert_eq!(current_apply_label("sku_images", true), "打开当前图"); + assert_eq!( + click_while_unknown(&ctx, &mut state, &workflow, "apply-current"), + vec![("slot-apply".to_owned(), json!({"outputId":"detail","documentId":"S01"}))] + ); + workflow["slots"][1]["candidates"] = json!([{"id":"second-product","variant":"A","status":"complete"}]); + workflow["slots"][1]["selectedCandidateId"] = json!("second-product"); + assert!(unknown_frame(&ctx, &mut state, &workflow, vec![]).is_empty()); + assert!(click_while_unknown(&ctx, &mut state, &workflow, "apply-group-menu").is_empty()); + assert!(unknown_frame(&ctx, &mut state, &workflow, vec![]).is_empty()); + assert_eq!(click_while_unknown(&ctx, &mut state, &workflow, "apply"), vec![("slot-apply".to_owned(), json!({"outputId":"detail"}))]); + } + + #[test] + fn project_switch_preserves_original_drafts_without_reusing_other_accounts_edits() { + let mut state = UiState::default(); + state.bind("account-a|origin|doc-1"); + state.drafts.insert((state.binding.clone(), "D01".into()), TextDraft { original: vec![], fields: vec![json!({"value":"A用户草稿"})] }); + state.bind("account-a|origin|doc-1"); + assert!(state.dirty("D01")); + state.bind("account-b|origin|doc-1"); + assert!(!state.dirty("D01")); + state.bind("account-a|origin|doc-1"); + assert!(state.dirty("D01")); + } + + #[test] + fn image_slots_of_one_document_share_text_and_preserve_a_conflicting_draft() { + let mut first = fixture()["slots"][0].clone(); + first["documentId"] = json!("S02"); + let mut second = first.clone(); + second["id"] = json!("D02"); + assert_eq!(text_document(&first), text_document(&second)); + let mut state = UiState::default(); + state.bind("account|origin|doc"); + let original = vec![json!({"id":"title","value":"原文"}), json!({"id":"body","value":"原说明"})]; + let mut fields = original.clone(); + fields[0]["value"] = json!("手动改名"); + state.drafts.insert((state.binding.clone(), text_document(&first)), TextDraft { original: original.clone(), fields: fields.clone() }); + assert!(state.dirty(&text_document(&second)), "moving to the second image slot retains the shared edits"); + let shared = state.drafts.get_mut(&(state.binding.clone(), text_document(&second))).unwrap(); + shared.reconcile(&original); + assert_eq!(shared.fields, fields); + let mut remote = original.clone(); + remote[1]["value"] = json!("其他设备的新说明"); + shared.reconcile(&remote); + assert!(shared.conflicts(&remote)); + assert_eq!(shared.original, original, "save must retain the actual baseFields for the backend check"); + assert_eq!(shared.fields, fields, "a refresh never destroys unsaved text"); + shared.reconcile(&fields); + assert!(!shared.conflicts(&fields), "the matching save acknowledgement clears the dirty baseline"); + assert_eq!(shared.original, fields); + } + + #[test] + fn copy_adoption_is_explicit_and_never_overwrites_another_outputs_unsaved_text() { + let mut workflow = fixture(); + workflow["copyReady"] = json!(true); + let mut state = UiState::default(); + state.bind("account|origin|doc"); + assert!(can_adopt_copy(&state, &workflow, false)); + state.drafts.insert( + (state.binding.clone(), "S02".into()), + TextDraft { original: vec![json!({"value":"原文"})], fields: vec![json!({"value":"未保存的详情文案"})] }, + ); + state.output = "main_images".into(); + assert!(!can_adopt_copy(&state, &workflow, false), "adopting from main images cannot overwrite a dirty detail draft"); + state.bind("other|origin|doc"); + assert!(can_adopt_copy(&state, &workflow, false), "another account's buffers are never applied to this project"); + assert!(!can_adopt_copy(&state, &workflow, true)); + workflow["confirmed"] = json!(false); + assert!(!can_adopt_copy(&state, &workflow, false)); + } + + #[test] + fn previews_resolve_published_candidate_identity_without_reading_any_uri() { + let ctx = egui::Context::default(); + let texture = ctx.load_texture("owned-candidate", egui::ColorImage::filled([2, 2], egui::Color32::WHITE), egui::TextureOptions::LINEAR); + let previews = HashMap::from([("owned".to_owned(), texture.clone())]); + assert_eq!(preview_texture(&json!({"id":"owned","previewUri":"file:///private.png"}), &previews).map(|value| value.id()), Some(texture.id())); + for candidate in [json!({"id":"different","previewUri":"https://example.com/a.png"}), json!({"previewUri":"owned"}), Value::Null] { + assert!(preview_texture(&candidate, &previews).is_none()); + } + } + + #[test] + fn showing_slots_never_dispatches_generation_or_recovery() { + for (width, height) in [(420.0, 700.0), (800.0, 720.0), (1100.0, 800.0)] { + let ctx = egui::Context::default(); + let mut workflow = fixture(); + workflow["slots"][0]["candidates"] = json!([{"id":"original","variant":"A","status":"unknown"}]); + let mut state = UiState::default(); + let mut actions = Vec::new(); + for _ in 0..2 { + let mut output = ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(width, height))), ..Default::default() }, + |ui| { + show(ui, &mut state, &workflow, "account|origin|doc", false, &HashMap::new(), &mut |method, input| { + actions.push((method.to_owned(), input)) + }); + }, + ); + output.textures_delta.clear(); + } + assert!(actions.is_empty()); + assert!(state.geometry.contains_key("recover-D01-A")); + assert!(!state.geometry.contains_key("generate-D01-A")); + for (name, rect) in &state.geometry { + assert!(rect.iter().all(|value| value.is_finite()), "{name} has valid bounds at {width}x{height}"); + assert!(rect[0] >= 0.0 && rect[0] + rect[2] <= width + 1.0, "{name} stays within {width}px: {rect:?}"); + } + } + } + + #[test] + fn interrupted_or_async_candidates_expose_only_original_recovery_when_idle() { + for phase in ["prepared", "dispatched", "running", "unknown"] { + let ctx = egui::Context::default(); + let mut workflow = fixture(); + workflow["slots"][0]["candidates"] = json!([{"id":"original","variant":"A","status":phase}]); + assert_eq!(status(&workflow["slots"][0]["candidates"][0], false), "待核查"); + let mut state = UiState::default(); + let mut actions = Vec::new(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + show(ui, &mut state, &workflow, "account|origin|doc", false, &HashMap::new(), &mut |method, input| actions.push((method.to_owned(), input))); + }); + output.textures_delta.clear(); + assert!(actions.is_empty()); + assert!(state.geometry.contains_key("recover-D01-A"), "{phase} can query its original request"); + assert!(!state.geometry.contains_key("generate-D01-A"), "{phase} cannot resend candidate A"); + assert!(!can_generate(&workflow, &workflow["slots"][0], "B", false), "{phase} blocks another generation of the same slot until resolved"); + } + } +} diff --git a/docs/photocraft-client-design.md b/docs/photocraft-client-design.md index f8bec9bb..ae3903ff 100644 --- a/docs/photocraft-client-design.md +++ b/docs/photocraft-client-design.md @@ -36,6 +36,8 @@ - 没有文档时直接进入“我的项目”,不另设欢迎页或草稿入口。项目以真实首屏缩略图、名称、草稿角标与本机/账户来源呈现,按更新时间排序;没有页面时显示待生成。可见行才请求封面,后台渲染并限制并发与缓存。 - 项目垃圾桶入口保持可见,确认时明确删除本机还是账户项目。本机移入所属账户回收区,账户删除复用现有鉴权、版本和任务保护;网络、版本冲突、正在执行和结果待核查分别显示原因。列表已知受保护时禁用删除并给出悬停原因;最终提交仍实时核对,不乐观移除。存在待核查任务时保留项目和请求记录。删除错误只出现在项目和确认区,不混入 Agent 对话。 - “我的项目/添加素材/画布”是互斥的中央目的地,切换不重建或清空原文档。返回画布时关闭浏览层;没有文档时回到我的项目。 +- 云端已有更高版本时,项目卡片按需显示“读取新版”。普通打开继续使用本机草稿;读取新版必须显式操作,先保存并关闭原画布。未同步修改和待核查任务阻止覆盖,旧工程及原请求归档保留;迟到的旧打开与旧保存不能重新覆盖新版。 +- 图位页优先“回填当前屏/图”,整组操作放入收起菜单。当前屏自己的必需图片就绪即可操作,不要求其他屏完成;回填前等待保存确认,已有编辑稿优先打开,候选变化不能静默覆盖它。 - “我的项目/添加素材”等入口放在原生工具选项行右侧,不额外占用一整行;左侧工具参数可横向滚动。隐藏工具选项时保留最小可达入口。“添加素材”支持 SKU / SPU / 条码从素材库查询,图片预览后置入当前画布。目录与图片采用独立有界只读队列;缩略图最多两张在途。私有图片每次 GET 保持实时鉴权,预览不生成临时图片,置入及画布修改仍串行。 - 默认窗口 1440×900,验收 1280×800、1440×900、1920×1080 与 125% / 150% 缩放。最低 1100×720;窄窗口收起辅助面板,不把桌面客户端伪装成移动网页。 diff --git a/docs/photocraft-client-display-scaling.md b/docs/photocraft-client-display-scaling.md new file mode 100644 index 00000000..dd5ea779 --- /dev/null +++ b/docs/photocraft-client-display-scaling.md @@ -0,0 +1,54 @@ +# 客户端自适应缩放 + +界面缩放属于原生客户端功能。安装、启动或使用客户端时,不修改 Windows 的分辨率、显示缩放、注册表和兼容性设置。 + +## 行为 + +- 新用户默认使用「自动」,跟随当前显示器的系统 DPI;移动到不同 DPI 的屏幕后,由 winit/egui 更新界面缩放。 +- 使用现有 winit 原生 `PerMonitorV2` 链路,在窗口创建前启用 DPI 感知;保留普通用户权限和长路径支持,不注入第二份冲突的 manifest。 +- 尚未初始化缩放策略的内测配置,首次启动只将旧 `interface.uiScale` 固定档迁移为 `auto`。先完整备份原偏好,再原子写入;其他设置保持不变。 +- 每个客户端配置目录记录一次策略版本。迁移后用户主动选择 100%、125% 等档位,后续启动保留该选择,不反复重置。 +- 偏好损坏、备份冲突或写入失败时,保留原文件并明确提示。编辑器不会继续用默认配置覆盖损坏文件。 + +## 与画布的边界 + +菜单、面板和输入框跟随界面缩放。画布尺寸、文字图层的文档字号、导出尺寸不跟随界面放大;1440px 的作品仍导出 1440px。画布 100% 缩放时,一个文档像素保持对应一个实际显示像素。 + +## 配置文件 + +均位于客户端自己的配置目录,不属于系统设置: + +- `preferences.json`:偏好。 +- `preferences.before-ui-scale-policy-v1.json`:一次性迁移前的完整原始备份,不覆盖。 +- `ui-scale-policy-v1.json`:策略版本,避免覆盖用户后续选择。 + +## 验证入口 + +- 原生应用单测 `ui_scale_policy::`:迁移、备份、幂等和异常保护。 +- `prefs_ui::tests::`:系统 DPI、屏幕变化、手动档位和字体设置。 +- 集成测试 `ui_scale_canvas`:CPU/GPU 实际渲染像素,检查界面缩放不改变作品的显示像素尺寸。 +- 本机原生验收使用隔离测试配置,复制旧版 100% 偏好,检查客户端自行迁移、实际窗口 DPI、手动档位重启保留,以及 1440px 导出。过程中不改变系统显示设置。 + +## 本地实测(2026-10-23) + +验收构建 `portable-v6`(SHA256 `69502ed125cfe027ee252a08146d792fefa2e3c996ee5b513b1f582e148ab9ff`)通过: + +- 10 项偏好迁移单测、56 项原生偏好回归、CPU/GPU 两项渲染像素测试。 +- 2880×1800 笔记本上,Windows 窗口实际 DPI 为 192,原生上下文确认为 PerMonitorV2。 +- 隔离配置由旧固定 100% 自行迁移到自动,界面为 2 pixels/point;原偏好备份逐字节一致,除缩放值外其余偏好一致。 +- 手选 125% 后重启,界面仍为 1.25 pixels/point,备份和策略版本不被重写。 +- 两次导出的 PNG 均为 1440×900,文件 SHA256 完全相同;两个测试窗口均正常退出,未同时打开。 + +证据位于本任务的 `output/template-slots-20261023/native-dpi-policy-acceptance.json`、`native-pe-resources-v6.json` 和两张 `dpi-policy-*-native.png`。这是缩放专项验收,不代表其他尚在开发的功能已通过最终发布验收。实际跨物理显示器拖动未测试;不同 DPI 切换由已有原生偏好回归覆盖。 + +## 启动窗口位置修复(2026-10-23) + +旧版恢复窗口时,eframe 先限制保存的位置,随后应用扩大不足的窗口宽度并保持旧中心,可能把窗口重新移到屏幕左侧之外。现于窗口创建后按真实 Windows 窗口外框、各显示器工作区的联合覆盖检查一次:越界窗口居中,超过工作区的窗口最大化;合法位置(含相邻跨屏和多屏负坐标)、最大化、最小化和全屏状态保持。显示器之间的空隙不当作可见桌面,枚举不完整时不猜测搬动。不会持续干预拖动,也不修改系统显示设置或清空用户偏好。 + +本地 `portable-v14`(SHA256 `8dac3fcbb319a3ee8e0fc502e3d62437ec7c66d209eb75e290e870cceacc1222`)通过 13 项窗口回归及两次顺序启动实测。当时显示器为 2560×1080、DPI 96,工作区为 `(0,0)-(2560,1032)`: + +- 保存宽度 80px 的恢复场景复现最小宽度扩张后的越界路径,启动外框正确居中于 `(730,156)-(1830,876)`。 +- 正常保存位置 `(24,24)` 重启后精确保留,未被强制居中。 +- 两个测试窗口均正常退出,无同时运行;未调用模型、写入云端或修改 Windows 设置。 + +证据:`output/template-slots-20261023/window-placement-acceptance-v14.json`。本次实机验证覆盖当前单屏;相邻跨屏、多屏负坐标、显示器空隙、超大窗口及原生窗口状态由回归测试覆盖,不宣称已完成跨物理屏幕实测。 diff --git a/docs/photocraft-colleague-workflow-2026-10-23.md b/docs/photocraft-colleague-workflow-2026-10-23.md index c6164a78..71cf6620 100644 --- a/docs/photocraft-colleague-workflow-2026-10-23.md +++ b/docs/photocraft-colleague-workflow-2026-10-23.md @@ -61,18 +61,18 @@ ## 与当前客户端的差距 -对照本轮源码中的 `jiansun_creation_model.rs`、`jiansun_creation.rs`、`jiansun_rule_cache.rs` 和服务端 `agent-rule-catalog.json`: +对照本轮源码中的 `jiansun_creation_model.rs`、`jiansun_creation.rs`、`jiansun_template_slots.rs`、`jiansun_template_workflow.rs`、`jiansun_slot_canvas.rs`、模板缓存及服务端模板包。下表已更新本轮实现进度;**代码完成不等于真实完整验收通过**。实际调用和未完成项见[模板图位阶段验收记录](photocraft-template-slots-acceptance-2026-10-23.md):前三笔事实/卖点/方案已complete,第四笔逐字段文案unknown且原GET410,本轮生图POST0,未提交本轮Git/部署。 | 环节 | 当前能力与仍需补齐的部分 | |---|---| -| 产品输入 | 已有上传/素材库双来源、品牌、颜色材质、长宽高、图片和备注,本轮补品牌/品类绑定。尚缺上述多测量部位、每项事实来源与确认状态的结构化表达。 | -| 产品参考 | 已分产品图与场景图并保存来源。尚未形成表格中 P01 → 颜色 → SKU → 多个输出字段的完整关系。 | -| 云端模板与本机缓存 | 本轮缓存的是版本化规则和屏幕清单;旧项目固定快照不随公共更新改写。现有 ready 绑定只有顾家电脑桌 `GJ-DESK-DNZ0385`,不是本表索菲亚椅子 `SFY-CHAIR-001`,也不是完整 PSD/图片资源包。 | -| 文案与页面方案 | 已有事实分析、卖点批次、人工挑选和方案确认。当前计划结构仍以标题、正文、图片提示词为主;需增加稳定字段、字段排版约束及模板图层映射。 | -| 画布生成 | 当前合成按标题、正文和产品图组织图层,不能据此称为复现该表的六图总览、章节页及各图框排版。需要按真实模板布局回填。 | -| 候选与确认 | 尚无覆盖每个字段的 A/B 版本、人工采用和回填的完整工作流。要保留“生成候选”和“替换画布”的区别。 | -| 多类输出 | 详情生成与原生编辑/导出已有基础,不能据此宣称主图、SKU 双版、视频、买家秀和裁切任务全部实现。视频、买家秀本次未实现、未生成验收。 | -| 屏数上限 | 现客户端最多 12 屏、总高 30000px,本表是 14 屏详情。不能静默截掉 S13 尺寸和 S14 参数,也不能把 14 屏写成已完整导入。须先明确保留 14 个逻辑模块但合并部分屏幕,或单独评估扩容;变更前继续遵守现上限。 | -| 字体与 PSD | 当前画布基线 Noto Sans SC,与表中“保留原模板字体”不是同一口径。原模板字体、许可/缺字、换行、图层字段及 PSD 回开需单独验收,不能沿用通用导出测试冒充通过。 | +| 产品输入 | 已有上传/素材库双来源、品牌、颜色材质、长宽高、图片和备注,本轮实现品牌/品类精确绑定。真实测试固定亿家达DNZ0385同一SKU/颜色规格;未证实尺寸保持空白。上述多测量部位、逐项来源与人工确认的完整结构仍需补齐。 | +| 产品参考 | 本轮代码已让每个图位复用已选同一规格的完整嵌入产品参考,按98k契约校验数量,归档各参考hash;不以画布截图替代。真实生图尚未发生,不能称产品外观一致性已验收;本表P编号关系也未完整导入。 | +| 云端模板与本机缓存 | 本轮实现独立版本化模板包、品牌/品类精确匹配、内容hash及升级检测、旧项目快照保留。新增亿家达电脑桌原生适配,复用已核对的顾家电脑桌几何;不是索菲亚 `SFY-CHAIR-001` 的完整PSD/图片资源包。生产新增接口及完整下载/升级体验尚待真实验收和发布。 | +| 文案与页面方案 | 事实分析、卖点批次、人工挑选和方案确认已逐笔真实完成;方案使用原保留响应本地恢复,没有新增POST。本轮已接稳定字段、字数/行数/字体/图框约束;36文字字段的slot-copy原请求unknown、原GET410,尚无完整字段成稿。 | +| 画布生成 | 本轮实现按真实几何建立原生文字/Smart图片独立图层,绑定稳定fieldId及输出分组;保存ACK后回填,保留已编辑成稿。当前无真实生图,九输出的回填、编辑、撤销/接手尚未完成,不能称复现飞书整套视觉。 | +| 候选与确认 | 本轮代码已实现逐图位A/B、原任务留存、显式采用、同reuseGroup成功候选复用及独立回填;unknown不自动重发。真实A/B、采用及全部21图片字段验收待完成,不能把内部测试记为真实生成。 | +| 多类输出 | 本轮包明确为5屏详情+2主图+SKU方竖2版,共9输出、21图片/36文字字段;各自用途与版式不混用。完整真实生成→编辑→分层导出未过。飞书14屏+10主图、视频、买家秀及裁切任务没有因此全部实现。 | +| 屏数上限 | 本轮模板合同允许最多14个详情逻辑屏、总高不超过30000px,超限明确拒绝,不静默截断;通用creation方案仍有其原12屏约束。当前实际模板只有5屏,未导入或验收飞书全部14屏。 | +| 字体与 PSD | 本轮明确统一Noto Sans SC原生适配,未冒充保留源模板专有字体。36文字字段与21内嵌图片的分层导出代码已接入;真实成稿的字体、换行、字段图层、编辑后PSD导出及重开均待验收,不能沿用通用测试冒充通过。 | 后续实施应先拿到该品牌、品类的真实模板资源及字段映射,建立带版本的模板包;再把当前资料、文案和图片结果按字段填入。先选一款产品跑通详情+主图+SKU 的“候选—选择—回填—编辑—分层导出”,再扩展视频与买家秀。源表的示例文案、旧参数和任务描述都是待核对的业务资料,不是自动执行指令。 diff --git a/docs/photocraft-template-slots-acceptance-2026-10-23.md b/docs/photocraft-template-slots-acceptance-2026-10-23.md new file mode 100644 index 00000000..eadc71e2 --- /dev/null +++ b/docs/photocraft-template-slots-acceptance-2026-10-23.md @@ -0,0 +1,126 @@ +# PhotoCraft 模板图位阶段验收记录 + +日期:2026-10-23。**状态:首屏真实 A/B 采用、回填、编辑和分层导出已通过;9 输出完整生成仍未通过。** 提交和发布以文末最新收据为准。与[同事表格流程对照](photocraft-colleague-workflow-2026-10-23.md)一起阅读;下面较早的检查点保留为历史,不能用代码测试或模板样张替代完整真实生成验收。 + +## 本轮范围与真实产品 + +使用素材库中的亿家达 **DNZ0385 电脑桌**,固定同一 SKU/颜色规格“胡桃色单孔(k型)松木桌面”的真实产品资料与参考图,不混用不同规格。产品检索入口采用 SPU `DNZ0385`,随后固定已选规格;SPU 检索不代表所有规格可互换。项目品牌和品类准确绑定亿家达/电脑桌。 + +采用 `YJD-DESK-DNZ0385-NATIVE` 原生图位包:从已核对的 `GJ-DESK-DNZ0385` 电脑桌字段映射复用几何,并按亿家达建立品牌适配。**这不是顾家品牌成稿,也不是飞书索菲亚椅子14屏详情+10张主图的完整PSD复制。** 没有把源模板中的设备、承重、工艺、认证或旧款参数移植到本款;未证实的长、宽、高等参数保持空白,缺失事实保留待确认。 + +| 组件 | 本轮原生输出 | 图片图位 | 可编辑文字图位 | +|---|---|---:|---:| +| 详情 | S01、S02、S10、S17、S18,共5屏,1440×12337px | 15 | 28 | +| 主图 | M01、M02,共2张,各1440×1440px | 2 | 4 | +| SKU | K01方版1440×1440px、K02竖版1440×1920px | 4 | 4 | +| 合计 | 9个输出 | 21 | 36 | + +所有文字采用明确声明的 Noto Sans SC 原生适配,不宣称保留源PSD的专有字体或完整装饰视觉。当前包的默认产品文字为空,必须由当前产品事实与人工确认填入。几何与原资源核对证据位于 `output/template-slots-20261023/template-asset-readonly-audit.json`。 + +## 真实调用记录 + +本轮仅通过现有98k公开适配器,用用户个人Key调用。**累计4次真实模型POST,自动重发0次,实际生图POST为0。** 前三笔已完成;第四笔结果未知,流程在此保留原任务,未通过重新生成绕过屏障。 + +| 原请求用途 | 实际结果 | 本轮验收边界 | +|---|---|---| +| 产品资料/事实分析 | complete,事实进入项目 | 仅证明本次分析链路完成,不证明未提供的参数属实。 | +| 卖点批次 | complete,人工选择进入方案 | 仅证明本次批次生成和采用链路完成。 | +| 页面方案 | complete;原保留响应经本地解析恢复并应用、确认 | 保持原task、请求号及原文本,派发审计从3条仍为3条,恢复新增POST为0。不能把本地解析修复计为重发成功。 | +| 模板逐字段文案 `slot-copy` | unknown;原结果只读GET返回410 | 未留存可用完整结果,不作成功或模型最终失败判断;未再次派发。 | + +方案恢复证据:`output/template-slots-20261023/plan-original-recovered.json`。原任务 `4ec2e952-2118-48dc-937d-c3e51166046c`,客户请求号 `fa6b7817-e599-4ec0-b527-410be0250dc1`,恢复前后身份及保留文本均一致。 + +第四笔需要继续保留的原身份: + +```text +用途:slot-copy(36个模板文字字段) +模型:grok-4.7 +幂等键/原task:a771ab28-f214-476d-8a13-4ed7037176f9 +客户请求号:7151b8c3-768e-472b-98f7-5614c6b553f3 +原结果只读查询:HTTP 410 +当前本地状态:unknown;自动重发0次 +``` + +原流仅保留1019个字符,不能作为36字段完整成稿回填。安全元数据证据为 `output/template-slots-20261023/copy-interruption-safe-metadata.json`,只读恢复执行记录为 `phase-copy-recovery-receipt.json`;完整原任务和部分文本仍留在本轮隔离项目归档,不在本文复制Key或产品内容。 + +## 连接中断的已知事实与修复范围 + +该原请求在 UTC `05:08:04` 发起,本地于 `05:11:07.982` 结束,耗时 **183.982秒**。当时客户端请求总预算为 **180秒**,两者吻合,支持优先检查客户端总等待时限。旧日志已经将底层传输错误归并成“模型流连接中断”,没有保留下层错误分类,因此**不能据此断言已确认是上游故障,也不能断言已证明此次必定由客户端超时引起**。 + +本轮新增修复将后续 creation 请求的有限总预算调整为600秒,并保留真实 reqwest/I/O timeout 分类,区分“客户端等待达到时限”和普通断连;两类都继续保留原任务为unknown,不自动重发。原始请求的历史日志不会被改写成新分类。新增本地loopback测试用例覆盖真实send/body超时、部分文本保留和只有一次POST;当前构建及最终测试收据待root补录,不能将新代码测试写成第四笔原请求已恢复。 + +后续交叉核对:负责人明确授权向98k开发对话回传安全路径/时间/状态后,已直接对接。98k侧只读反馈:约179.958秒收到下游取消并向执行端传播,非其自身deadline;中转流式预算15分钟,首普通文本约在171秒才开始。该反馈与本机180秒总期限及少量片段相互印证,当前主要原因判断为客户端总等待时限过短。此判断新增了对侧只读证据,并非修改旧日志补造timeout原因;本项目未读取98k源码、数据库或服务器。对侧确认无完整响应缓存、客户扣费0,执行端成本尚待核,不将其写为0成本或安全重发许可。 + +## 代码完成与尚缺真实验收 + +| 功能 | 本轮代码状态 | 真实验收状态 | +|---|---|---| +| 云端模板与本机缓存 | 品牌/品类精确匹配、版本及内容hash校验、ETag/升级检测、旧项目快照保留。 | 本地隔离适配器复用正式账号只读校验;新增生产接口未因本轮验收而发布。完整首次下载与后续升级的生产体验待验收。 | +| 稳定字段与原生回填 | 36文字+21图片独立字段,绑定模板版本、slotId、输出分组及应用hash;文字可编辑、图片嵌入为Smart内容。 | 真实图片尚未生成,9输出完整回填未验收。 | +| A/B候选与采用 | 按图位保留A/B原task、结果和显式选择;生成候选与实际回填分开,未选择不得自动应用。 | 真实A/B生成、人工选用及逐图位用途核对待完成。 | +| 模板声明的同组复用 | 仅相同非空reuseGroup可复用已成功候选;保持原UUID及像素hash,仍需显式采用;不派发新请求。 | 真实素材复用待完成。不能把同一候选铺满全部21位当作全图位验收。 | +| 产品参考一致性 | 每图位读取同一项目的完整嵌入产品参考,检查所选98k契约参考数量;超量拒绝而非静默截断。 | 本轮生图POST0,未证明任何生成图实际保持了产品外观或多屏风格。 | +| 保存ACK及编辑保留 | 回填前保存并等待确认;保存失败不替换画布。组件已编辑稿保留,候选/文字变化不能覆盖后续编辑。 | 完整真实图位回填、接手、撤销、关闭重启及编辑后再回填待完成。 | +| unknown与双端屏障 | 原任务归档纳入同步;unknown阻止后续生成、完结及删除,保留请求身份;只读恢复不变成新POST。 | 本地第四笔屏障已实际触发,原GET410仍unknown;真实双端恢复/同步闭环待完成。 | +| 分层导出 | 组件5+2+2分组、独立文字/图片图层及现有原生PSD导出链路已接入。 | 9输出真实生成→编辑→PSD导出→重开未通过;没有本轮真实成稿导出结果。 | + +复用范围当前仅 `S17-I01/K01-I01/K02-I01` 同属 `SKU01-product`,`K01-I02/K02-I02` 同属 `SKU01-color`;其它图位不具备任意候选复用权限。主图、详情的不同视觉任务仍须独立制作并核对。飞书的完整14屏/10主图、视频、买家秀不是本轮已完成范围。 + +## 余下验收与交付门槛 + +1. 保留第四笔原task、幂等键和请求号,等待可确认的原结果;410不能当作模型失败后重发的许可。 +2. 完成当前恢复修复构建与相关测试;最终测试数量、产物SHA、窗口/临时文件/隔离凭证清理由root按真实收据补录。 +3. 在事实及模板文案可确认后,再完成逐图位真实候选、显式采用、三组件回填、中文编辑/撤销/接手,以及9输出的分层PSD和原生重开检查。 +4. 逐图核对品牌调性、中文排版、参考产品外观和事实依据。独立PSD解析与原生重开不等于Adobe Photoshop实际打开验收,二者不得混称。 +5. 完整真实验收通过后才执行限定文件Git提交/推送及部署;当前不生成“passed”收据,不以本记录替代该门槛。 + +余下操作准备见 `output/template-slots-20261023/export-acceptance-plan.md` 与纯本地解析脚本 `verify-layered-exports.mjs`。这些是未执行的验收准备,不是产出证据。 + +## 本轮本地修复的最终检查点 + +- 原生后端 **148通过、0失败、5项专用环境测试跳过**;原生界面 **60通过**。真实loopback的send/body超时两项定向回归均通过,仍断言只接收一次POST、原UUID/partial落盘及unknown屏障。最初测试peer错误地提前关闭连接,已修正为显式释放连接后重跑,未通过放宽超时断言掩盖。 +- JavaScript合同/连接恢复 **19通过**,模板包/鉴权路由/规则目录 **24通过**。第一次Jest按名称匹配还扫描了历史output并遇到无关旧snapshot告警;改用精确测试路径及排除output后,三套当前测试24项通过,没有删除或更新历史snapshot。 +- 已实际运行最新版单窗口,在1280/1440/1920宽度检查图位页;显示业务字段编号,未知请求期间的新文案及A/B生成按钮禁用。用真实鼠标输入保存本地中文“电脑桌”,重启后仍在同一图位;原确认方案、unknown和原请求身份保留。此项仅证明本地编辑及重启保留,**没有证明生成候选或PSD输出**。 +- 最终本地候选:`output/template-slots-20261023/portable-v4/photocraft.exe`,SHA256 `e341e1db29b7e6f409d4221e911bab23a982ba8edc6052c66892477e77edfe0f`。原生源码与构建前后manifest一致;本机验收包采用应用/界面opt-level=0、渲染依赖release优化的明确构建配置,不冒称正式优化安装包。 +- 最终检查点为 `output/template-slots-20261023/final-checkpoint-not-release-acceptance.json`,状态是 **blocked_real_generation_acceptance**,不是发布通过。模型POST仍4笔,生图POST0,未知请求重发0。原copy任务日志SHA与恢复后保存值一致,先前受保护的Nano未知任务日志也未变。 +- 测试窗口全部正常退出,原生窗口数0,3149适配器监听数0,控制token已清理。删除仅本任务的3份过期候选二进制,约467MiB;先验证精确目录、exe hash及junction目标,再移除链接及本机副本,共享runtime未删。保留最终候选、恢复所需项目/原日志/原图与本任务隔离凭据,未触碰用户正式账号连接、原启动器或此前清理被拒的目录。 +- 本轮尚未提交、推送或部署;工作区其他WIP与当前Git HEAD保持。 + +## 后续本地验收进展(尚非发布通过) + +截至首屏A/B检查,本轮已派发6笔模型POST:原4笔创作请求,以及2笔独立生图;没有重发原未知文案请求。首屏 `S01-I01` 的A候选原任务为 `97357e4a-98d9-44bb-b8c8-2b3d44c45e38`,客户请求号 `43846c7d-f1ab-4e95-952a-933367271e06`,传输结果complete。实际像素为800×1328px;1440px是排版画布口径,不能将其说成模型已经原生返回1440px图片。 + +人工查看原产品图片和生成像素后,A的外观检查不通过:参考图的走线细节是桌面后沿缺口,生成图却增绘了两个桌面圆孔。A已保留、未采用,后续批量生成暂停;这是“调用完成但质量未通过”,不能混称模型请求失败或完整验收通过。证据为 `output/template-slots-20261023/image-s01-i01-a-visual-review.json`。 + +针对此类偏差,图位面板增加默认收起的“调整要求”。备注只用于该项目该图位的新候选,最多2000字;完整实际提示词与哈希沿原候选日志保存。已有A和未知请求不能由备注覆盖或重新派发。已在真实客户端输入备注并点击独立B候选一次:任务 `1bc52a28-425f-4dc9-be98-16ce254f7b2b`、客户请求号 `9dae80e9-690b-4485-a750-6f05eeea5064`,结果complete,像素SHA256为 `a5f5efb9f919d8141935dab2de9c186b6823e7fe86eb08ecc5432fd13aa4e5c9`。人工查看确认新增圆孔已消失,产品外观接近参考;笔记本屏幕仍留有源图时间字样,需要精修,不将其写成完整无字成稿通过。A/B均保留,尚未选用和回填。证据为 `image-s01-i01-b-visual-review.json` 及 `b-ui-v12-first-b-01-dispatch-receipt.json`。 + +同一轮还补齐项目刷新与对话隔离:Web在重新聚焦、可见和定时刷新时更新列表;客户端用独立只读队列刷新项目,合并时保留未同步资料并拒绝云端版本回退。已登录Web刷新后为4个云项目,同账号客户端也读取到相同4项;本机未同步草稿单独保留,不自动上传。用户已确认验收时曾切换账号或连接,待登录的连接选择现与当前生效连接分开。 + +Pi对话改为可以连续讨论、解释方案,再按明确要求操作画布。项目资料按身份及长度边界注入,历史会话跨重启保留,旧规则域用户原话不能直接写为新店铺记忆。流式中途切换项目时,只停止向新画布投递,继续接收并留存原项目回复;不会因此新增模型请求。当前离线真实Pi SDK模拟流回归为53/53通过,仍需在实际98k模型和新版单窗口中验收;不能将模拟流测试写成真实模型验收完成。 + +后续原生回归中,158项后端测试通过、5项专用环境测试跳过,82项界面、10项缩放策略、56项偏好及2项CPU/GPU像素测试通过。Windows本机loopback测试修正了接受socket继承非阻塞模式的问题,没有修改线上传输或降低unknown保护。旧文案和历史Nano未知任务仍按原身份和原哈希保留。完整生成、编辑、导出和双端验收尚未完成,当前不签发发布passed收据,也不据此提交或部署。 + +## 2026-10-23 客户端继续推进:当前屏与安全云版本 + +本阶段停止追查用户已确认永久丢失的旧材质,不删除其记录、原素材和下载文件。继续原生客户端,保留当前 PhotoCraft 界面。 + +- 图位可以单独“回填当前屏/图”,不再要求整个组件全部图位完成。整组操作收起;回填仍等待真实保存 ACK。已经编辑的当前屏会打开原稿,改候选不能静默覆盖它。 +- 本机已有云项目副本时,显式“读取新版”只接受同账号、源站和项目的更高版本。未同步、未保存和 pending/unknown 均拒绝覆盖。下载校验、实时授权、版本 CAS、失败回退和旧版本归档均已覆盖;画布打开采用实际文件指纹校验,迟到旧打开不能重新绑定新版目录。 +- 修复旧界面状态缺失时的生产 unwrap/expect;请求编号耗尽不回绕,迟到事件不能污染新项目。 +- 实际 PSD 往返发现两条半透明装饰带因 f32→PSD 8bit 透明度量化而产生通道差 1。仅在原生模板创建时规范装饰透明度,原包及应用 hash 不变;未改通用 PSD 导出器、字体或图像内容,也没有放宽像素比较。 + +最终本地验证:后端 **172通过/5专用环境项跳过**;原生界面 **110通过**;窗口位置13、缩放策略10、偏好56、CPU/GPU像素2通过。Pi SDK 53项、模板/规则/鉴权路由24项、原生云备份警告合同6项通过。严格 Clippy、31 crate 依赖层级及25项 wasm检查均通过;wasm初次缺少锁定依赖,补齐相同锁定版本后重新全量检查通过,未改锁文件。最后仅 L7 装饰规范改动后重跑后端及严格 Clippy。 + +### 真实 S01 验收 + +在来源 QA 项目的独立副本中,使用已完成 B 任务 `1bc52a28-425f-4dc9-be98-16ce254f7b2b`,实际点击“采用此图”和“回填当前屏”,改中文为已知事实“胡桃色电脑桌”,执行撤销/重做,保存 PCraft 并重开,导出 PNG、分层 PSD,并用原生客户端重开 PSD。 + +- 实际画布 **1440×2351**;独立 ag-psd 解析确认12层、7个可编辑 Noto Sans SC 中文文字层、嵌入B原图 SHA `a5f5efb9f919d8141935dab2de9c186b6823e7fe86eb08ecc5432fd13aa4e5c9`;XMP保留8个图位映射。 +- PSD重开后可编辑字段保持,导出PNG的RGBA像素逐字节一致。这里没有 Adobe Photoshop 实机验收。 +- PNG的合并图层提示属于平面输出预期;PSD提示丢弃重复的自由文本元数据,已独立确认同一映射完整保留在其支持的XMP中。初版验收脚本将这两项预期提示误判失败,原失败收据保留,未删日志或放过意外警告。 +- 最终通过收据:`output/template-slots-20261023/client-progress/s01-native-02/s01-acceptance.json`;最终构建:同目录上层 `build-manifest-v4.json`。源码构建前后hash一致,16个Pi源模块随包复制,复用已有运行时与构建目录,不新增独立大target。 +- 来源项目仍选A、全部来源文件hash未变。未知文案原日志前后 SHA均为 `f091ce1699d97284ef68ad68bf2612efb5b7ef37987e345f0ad1f542c08a6bea`;**本阶段模型POST0,未知重发0**。本机完整回填只包含S01,其余20个图片图位保持空白,没有伪造候选或成稿。 +- 原生有界 Impeccable audit覆盖1137×901真实窗口;本阶段新增控件未发现阻断。未新增多宽度、系统读屏或整个客户端视觉通过的主张。 +- 本轮测试窗口均串行,最终主窗口及两个重开标签正常退出,控制token清除;最终退出收据 `s01-native-02/host-exit.json` 的剩余客户端数为0。 + +云端安全读取新版目前由真实本机loopback下载/授权/冲突及文件回退测试证明,尚未将其写成新的正式跨设备全流程验收。全套详情、主图、SKU九输出的真实生成与导出仍待继续;不能将本节首屏通过扩大为全部完成。 diff --git a/lib/__tests__/desktop-agent-rules.test.ts b/lib/__tests__/desktop-agent-rules.test.ts index aca9a516..fdb9bd50 100644 --- a/lib/__tests__/desktop-agent-rules.test.ts +++ b/lib/__tests__/desktop-agent-rules.test.ts @@ -5,7 +5,7 @@ import { emptyBrief, validateBrief } from '../desktop-client/creation.mjs' describe('desktop Agent versioned rule catalog', () => { test('lists only real ready combinations and returns an exact scoped package', () => { const index = desktopAgentRules(null) as any - expect(index.items).toHaveLength(1) + expect(index.items).toHaveLength(2) expect(index.items[0].label).toBe('顾家 · 电脑桌') const selected = desktopAgentRules(index.items[0].id) as any expect(selected.catalog.brands).toHaveLength(1) @@ -22,6 +22,25 @@ describe('desktop Agent versioned rule catalog', () => { expect(() => desktopAgentRules('unknown')).toThrow('尚未配置') expect(() => desktopAgentRules('../other')).toThrow('编号无效') }) + test('Yijiada rules use their own official brand and cannot inherit Gujia branding', () => { + const index = desktopAgentRules(null) as any + const gujia = index.items.find((item: any) => item.id === 'GJ-DESK-DNZ0385') + const yijiada = index.items.find((item: any) => item.id === 'YJD-DESK-DNZ0385-NATIVE') + expect(yijiada.label).toBe('亿家达 · 电脑桌') + expect(yijiada.projectBrandId).toBe('2c6d91fd-5a7f-4e95-adcc-7f4437c6772b') + expect(yijiada.projectBrandId).not.toBe(gujia.projectBrandId) + const selected = desktopAgentRules(yijiada.id) as any + expect(selected.catalog.brands).toHaveLength(1) + expect(selected.catalog.brands[0]).toMatchObject({ id: yijiada.brandId, name: '亿家达' }) + expect(selected.catalog.shops).toHaveLength(1) + expect(selected.catalog.shops[0]).toMatchObject({ id: yijiada.projectBrandId, name: '亿家达' }) + expect(selected.catalog.bindings).toHaveLength(1) + expect(selected.catalog.bindings[0].id).toBe(yijiada.id) + expect(JSON.stringify(selected.catalog)).not.toContain(gujia.brandId) + expect(JSON.stringify(selected.catalog)).not.toContain(gujia.projectBrandId) + expect(selected.contentHash).toBe(yijiada.contentHash) + expect(selected.contentHash).not.toBe(gujia.contentHash) + }) test('old briefs keep an empty category, while an explicit category survives cloud validation', () => { const old = emptyBrief() as any delete old.categoryId; delete old.categoryName diff --git a/lib/__tests__/desktop-native-contract.test.ts b/lib/__tests__/desktop-native-contract.test.ts index 1a5fd432..2df4b16c 100644 --- a/lib/__tests__/desktop-native-contract.test.ts +++ b/lib/__tests__/desktop-native-contract.test.ts @@ -1,5 +1,6 @@ /** @jest-environment node */ import { desktopProjectBody } from '@/lib/desktop-client/contract.mjs' +import { emptyCreation } from '@/lib/desktop-client/creation.mjs' const hash = 'a'.repeat(64) function fixture() { return { revision: 0, status: 'draft', document: { version: 3, id: 'project', name: '顾家五屏', screens: [{ id: 'one', width: 1440, height: 2200, previewSource: null }], native: { format: 'photocraft', schemaVersion: 1, documentSource: null as string | null, files: [] as any[] } } } } test('accepts an explicit incomplete draft but never labels it completed', () => { @@ -21,3 +22,41 @@ test('rejects unhashed native sources, missing session backup, duplicate screens value.document.native.files[0].source = 'native:uuid'; expect(() => desktopProjectBody(value)).toThrow('工程文件无效') const other = fixture(); other.document.screens[0].height = 30001; expect(() => desktopProjectBody(other)).toThrow('画布超出限制') }) + +function nativeCreationFixture() { + const value: any = fixture() + value.status = 'completed' + value.document.native.files = ['canvas.pcraft', 'context.json', 'session-backup.json'].map(path => ({ path, source: 'native:' + hash, sha256: hash, size: 100 })) + value.document.native.documentSource = 'native:' + hash + value.document.screens[0].previewSource = 'local:first-screen' + const inherited = Array.from({ length: 20 }, (_, index) => `事实待确认${index + 1}`) + const own = Array.from({ length: 20 }, (_, index) => `模型待确认${index + 1}`) + const creation: any = emptyCreation() + creation.facts.missing = inherited + creation.sellingPoints.batches = [{ id: 'batch-1', direction: '', items: ['可见桌面'], warnings: [...inherited, ...own] }] + creation.plan.warnings = [...inherited, ...own] + value.document.creation = creation + return value +} + +test('native cloud upload preserves all forty inherited and model warnings in selling points and plan', () => { + const value = nativeCreationFixture() + const original = JSON.stringify(value) + const saved = desktopProjectBody(value) + expect(saved.document.version).toBe(3) + expect(saved.document.native.documentSource).toBe('native:' + hash) + expect(saved.document.creation.sellingPoints.batches[0].warnings).toEqual(value.document.creation.sellingPoints.batches[0].warnings) + expect(saved.document.creation.plan.warnings).toEqual(value.document.creation.plan.warnings) + expect(saved.document.creation.sellingPoints.batches[0].warnings).toHaveLength(40) + expect(saved.document.creation.plan.warnings).toHaveLength(40) + expect(saved.document.creation.facts.missing).toHaveLength(20) + expect(JSON.stringify(value)).toBe(original) +}) + +test.each(['sellingPoints', 'plan'])('native cloud upload rejects forty-one %s warnings instead of truncating evidence', field => { + const value = nativeCreationFixture() + const warnings = field === 'sellingPoints' ? value.document.creation.sellingPoints.batches[0].warnings : value.document.creation.plan.warnings + warnings.push('第41条不得静默丢弃') + expect(() => desktopProjectBody(value)).toThrow(field === 'sellingPoints' ? '卖点待确认项超出限制' : '方案待确认项超出限制') + expect(warnings).toHaveLength(41) +}) diff --git a/lib/__tests__/desktop-template-packs-route.test.ts b/lib/__tests__/desktop-template-packs-route.test.ts new file mode 100644 index 00000000..72f30984 --- /dev/null +++ b/lib/__tests__/desktop-template-packs-route.test.ts @@ -0,0 +1,59 @@ +/** @jest-environment node */ +import { GET } from '@/app/api/desktop/template-packs/route' +import { requireDesktop } from '@/lib/desktop-client/service' +import { DesktopClientError } from '@/lib/desktop-client/contract.mjs' +import gujia from '@/lib/desktop-client/template-packs/gj-desk-dnz0385-native-v1.json' + +jest.mock('@/lib/desktop-client/service', () => ({ requireDesktop: jest.fn() })) +const url = `https://platform.test/api/desktop/template-packs?brandId=${gujia.brandId}&categoryId=${gujia.categoryId}` +describe('native template GET route always reauthorizes', () => { + beforeEach(() => jest.clearAllMocks()) + test('missing, expired and revoked authority cannot receive package data or cache hits', async () => { + for (const status of [401, 403]) { + ;(requireDesktop as jest.Mock).mockRejectedValueOnce(new DesktopClientError('无权访问', status, 'denied')) + const response = await GET(new Request(url, { headers: { 'If-None-Match': '"some-existing-value"' } })) + expect(response.status).toBe(status) + expect(await response.json()).not.toHaveProperty('items') + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(response.headers.get('etag')).toBeNull() + } + expect(requireDesktop).toHaveBeenCalledTimes(2) + }) + test('valid package, empty pairing, malformed or duplicate parameters all keep private responses', async () => { + ;(requireDesktop as jest.Mock).mockResolvedValue({ account: { id: 'owner' } }) + const response = await GET(new Request(url)) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.items).toHaveLength(1); expect(body.items[0].pack.brandId).toBe(gujia.brandId) + const missing = await GET(new Request(url.replace(gujia.categoryId, '00000000-0000-4000-8000-000000000000'))) + expect(missing.status).toBe(200); expect((await missing.json()).items).toEqual([]) + for (const query of ['', `?brandId=${gujia.brandId}`, `?brandId=invalid&categoryId=${gujia.categoryId}`, `?brandId=${gujia.brandId}&brandId=${gujia.brandId}&categoryId=${gujia.categoryId}`]) { + const response = await GET(new Request('https://platform.test/api/desktop/template-packs' + query)) + expect(response.status).toBe(400) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(await response.json()).not.toHaveProperty('items') + } + expect(requireDesktop).toHaveBeenCalledTimes(6) + }) + test('a matching ETag is checked only after fresh permission and parameter validation', async () => { + ;(requireDesktop as jest.Mock).mockResolvedValue({ account: { id: 'owner' } }) + const first = await GET(new Request(url)), etag = first.headers.get('etag')! + expect(etag).toMatch(/^"[a-f0-9]{64}"$/) + const hit = await GET(new Request(url, { headers: { 'If-None-Match': etag } })) + expect(hit.status).toBe(304); expect(await hit.text()).toBe('') + expect(hit.headers.get('cache-control')).toBe('private, no-store') + ;(requireDesktop as jest.Mock).mockRejectedValueOnce(new DesktopClientError('授权已撤销', 403, 'denied')) + const denied = await GET(new Request(url, { headers: { 'If-None-Match': etag } })) + expect(denied.status).toBe(403); expect(await denied.json()).not.toHaveProperty('items') + const malformed = await GET(new Request('https://platform.test/api/desktop/template-packs', { headers: { 'If-None-Match': etag } })) + expect(malformed.status).toBe(400); expect(requireDesktop).toHaveBeenCalledTimes(4) + }) + test('unexpected failures do not expose internal source paths or server diagnostics', async () => { + ;(requireDesktop as jest.Mock).mockRejectedValueOnce(new Error('D:\\private\\connection-password.txt')) + const response = await GET(new Request(url)) + expect(response.status).toBe(500) + const body = await response.json() + expect(body).toEqual({ error: '模板暂时无法读取', code: 'internal_error' }) + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + }) +}) diff --git a/lib/__tests__/desktop-template-packs.test.ts b/lib/__tests__/desktop-template-packs.test.ts new file mode 100644 index 00000000..d454704e --- /dev/null +++ b/lib/__tests__/desktop-template-packs.test.ts @@ -0,0 +1,95 @@ +/** @jest-environment node */ +import { createHash } from 'node:crypto' +import gujia from '../desktop-client/template-packs/gj-desk-dnz0385-native-v1.json' +import yijiada from '../desktop-client/template-packs/yjd-desk-dnz0385-native-v1.json' +import { desktopTemplatePacks, selectDesktopTemplatePacks, templatePackHash, validateDesktopTemplatePack } from '../desktop-client/template-packs' + +const copy = () => JSON.parse(JSON.stringify(gujia)) +const seal = (pack: ReturnType) => { pack.contentHash = templatePackHash(pack); return pack } + +describe('read-only native template package registry', () => { + test('ships one fixed, hash-verified combination with detail, main and SKU outputs', () => { + const value = desktopTemplatePacks(gujia.brandId, gujia.categoryId) + expect(value.items).toHaveLength(1) + const { pack } = value.items[0] + expect(value.items[0]).toMatchObject({ id: gujia.id, version: gujia.version, contentHash: gujia.contentHash }) + expect(pack.outputs.some(output => output.component === 'detail')).toBe(true) + expect(pack.outputs.some(output => output.component === 'main_images')).toBe(true) + expect(pack.outputs.some(output => output.component === 'sku_images')).toBe(true) + const content = copy(); delete content.contentHash + const canonical = JSON.stringify(content, (_key, item) => item && typeof item === 'object' && !Array.isArray(item) + ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item) + expect(pack.contentHash).toBe(createHash('sha256').update(canonical).digest('hex')) + expect(pack.source).toMatchObject({ templateId: 'GJ-DESK-DNZ0385' }) + }) + test('never substitutes a different brand or category and rejects missing or ambiguous query inputs', () => { + const other = '00000000-0000-4000-8000-000000000000' + expect(desktopTemplatePacks(other, gujia.categoryId).items).toEqual([]) + expect(desktopTemplatePacks(gujia.brandId, other).items).toEqual([]) + for (const [brand, category] of [[null, gujia.categoryId], [gujia.brandId, null], ['', gujia.categoryId], ['../private', gujia.categoryId]]) { + expect(() => desktopTemplatePacks(brand, category)).toThrow('品牌和品类编号') + } + expect(() => selectDesktopTemplatePacks(gujia.brandId, gujia.categoryId, [gujia, gujia])).toThrow('模板包暂时不可用') + }) + test('the real Yijiada product receives its own brand package, preserving declared geometry provenance', () => { + expect(yijiada.brandId).not.toBe(gujia.brandId) + const selected = desktopTemplatePacks(yijiada.brandId, yijiada.categoryId) + expect(selected.items).toHaveLength(1) + expect(selected.items[0]).toMatchObject({ id: yijiada.id, contentHash: yijiada.contentHash }) + expect(selected.items[0].pack.brandId).toBe(yijiada.brandId) + expect(selected.items[0].id).not.toBe(gujia.id) + expect(desktopTemplatePacks(gujia.brandId, gujia.categoryId).items[0].id).toBe(gujia.id) + }) + test('Yijiada field labels describe visible appearances without inherited performance or material claims', () => { + const slots = yijiada.outputs.flatMap(output => output.slots) + const labels = yijiada.outputs.flatMap(output => [output.name, ...output.slots.flatMap(slot => [slot.name, (slot as { label?: string }).label || ''])]) + expect(labels.join('\n')).not.toMatch(/承重|涂层|加厚|斜切|稳固|升级|木材|实木|松木|橡胶木|FAS|认证|环保|耐磨|防水|宽大|桌面厚度/i) + const byField = (id: string) => slots.find(slot => slot.fieldId === id)! + expect(byField('S02-I05').name).toBe('外观总览-日常使用场景图') + expect(byField('S02-I03').name).toBe('外观总览-桌面表面图') + expect(byField('S02-I06').name).toBe('外观总览-桌边外观图') + expect(byField('S10-I01').name).toBe('桌边细节图') + const geometry = (outputs: typeof yijiada.outputs) => outputs.map(output => ({ id: output.id, component: output.component, width: output.width, height: output.height, + slots: output.slots.map(slot => ({ fieldId: slot.fieldId, frame: slot.frame, crop: (slot as { crop?: string }).crop, reuseGroup: (slot as { reuseGroup?: string }).reuseGroup })) })) + expect(geometry(yijiada.outputs)).toEqual(geometry(gujia.outputs)) + for (const slot of slots.filter(slot => slot.kind === 'text')) expect((slot as { defaultText?: string }).defaultText || '').toBe('') + }) + test('returns a detached snapshot and cannot mutate the next caller or package revision', () => { + const first = desktopTemplatePacks(gujia.brandId, gujia.categoryId) + first.items[0].pack.name = 'changed caller copy' + first.items[0].pack.outputs[0].name = 'changed output' + const second = desktopTemplatePacks(gujia.brandId, gujia.categoryId) + expect(second.items[0].pack.name).toBe(gujia.name) + expect(second.items[0].pack.outputs[0].name).toBe(gujia.outputs[0].name) + }) + test('changed geometry, text constraints and undeclared package bytes fail hash validation', () => { + const changed = copy(); changed.outputs[0].slots[0].frame.width++ + expect(() => validateDesktopTemplatePack(changed)).toThrow('模板包暂时不可用') + const invalid = seal(changed) + expect(() => validateDesktopTemplatePack(invalid)).toThrow('模板包暂时不可用') + const duplicate = copy(); duplicate.outputs[1].slots[0].fieldId = duplicate.outputs[0].slots[0].fieldId + expect(() => validateDesktopTemplatePack(seal(duplicate))).toThrow('模板包暂时不可用') + }) + test.each([ + 'C:\\Users\\54972\\private\\source.psd', 'S:/品牌源文件/模板.psd', '\\\\nas\\private\\source.psd', + '/mnt/private/template.psd', '/home/designer/template.psd', 'file:///D:/private/source.psd', + '记录位置 D:\\source\\secret.psd', '来源:D:\\source\\secret.psd', 'http://192.168.1.5/private', '.private/source.psd', + ])('rejects private source metadata even when its content hash was recomputed: %s', location => { + const pack = copy(); pack.source.location = location + try { validateDesktopTemplatePack(seal(pack)); throw new Error('must reject private path') } catch (error) { + expect((error as Error).message).toBe('模板包暂时不可用,请联系管理员核对版本') + expect((error as Error).message).not.toContain(location) + } + }) + test('refuses credential fields, external decoration sources and invalid layer placement', () => { + for (const input of [{ authorization: 'Bearer secret' }, { apiKey: 'sk-secret' }, { accessToken: 'secret' }, { sourcePath: 'relative/internal.psd' }]) { + const pack = copy(); Object.assign(pack.source, input) + expect(() => validateDesktopTemplatePack(seal(pack))).toThrow('模板包暂时不可用') + } + const pack = copy() + pack.outputs[0].decorations = [{ id: 'external', kind: 'rect', frame: { x: 0, y: 0, width: 100, height: 100 }, color: '#ffffff', opacity: 1, path: 'private.png' }] + expect(() => validateDesktopTemplatePack(seal(pack))).toThrow('模板包暂时不可用') + pack.outputs[0].decorations[0] = { ...pack.outputs[0].decorations[0], path: undefined, aboveImages: 'false' } + expect(() => validateDesktopTemplatePack(seal(pack))).toThrow('模板包暂时不可用') + }) +}) diff --git a/lib/desktop-client/agent-rule-catalog.json b/lib/desktop-client/agent-rule-catalog.json index 0367639c..e1ac80a9 100644 --- a/lib/desktop-client/agent-rule-catalog.json +++ b/lib/desktop-client/agent-rule-catalog.json @@ -552,6 +552,59 @@ } ] } + }, + { + "id": "YJD-DESK-DNZ0385-NATIVE", + "shopId": "2c6d91fd-5a7f-4e95-adcc-7f4437c6772b", + "categoryId": "78f8044c-62d3-49fa-9b25-3e011bc0a0cf", + "source": "native-template-pack:YJD-DESK-DNZ0385-NATIVE:20261023.1", + "state": "ready", + "rules": [ + "亿家达电脑桌原生图位模板;只使用当前产品已确认事实与参考图。", + "图框几何源于已核对的电脑桌映射,品牌风格使用亿家达知识规范;不继承顾家品牌页面、原示例参数、认证、销量或售后。", + "详情五屏、两张主图和SKU双版属于本原生模板的明确制作范围,不是飞书索菲亚十四屏模板的完整复刻。" + ], + "template": { + "id": "YJD-DESK-DNZ0385-NATIVE", + "version": "20261023.1", + "sourceCanvas": [ + 1440, + 12337 + ], + "screens": [ + { + "id": "S01", + "title": "产品首屏", + "purpose": "按当前产品事实和本图位要求制作;文字、图片、参数独立核对", + "role": "content" + }, + { + "id": "S02", + "title": "六图卖点总览", + "purpose": "按当前产品事实和本图位要求制作;文字、图片、参数独立核对", + "role": "content" + }, + { + "id": "S10", + "title": "人物接触细节", + "purpose": "按当前产品事实和本图位要求制作;文字、图片、参数独立核对", + "role": "content" + }, + { + "id": "S17", + "title": "产品参数", + "purpose": "按当前产品事实和本图位要求制作;文字、图片、参数独立核对", + "role": "content" + }, + { + "id": "S18", + "title": "光源与设备图", + "purpose": "按当前产品事实和本图位要求制作;文字、图片、参数独立核对", + "role": "content" + } + ] + }, + "revision": "fa19c86de01328350bb400872027b1a28a63fe8d06e6b908881de0c2021d0379" } ], "sourceFiles": [ @@ -576,5 +629,5 @@ "sha256": "84476b388be25d99e7ab9de61f717244846985302ea187c1be8f3ee595a51f00" } ], - "revision": "dba072d2aa994bc9eab38bee9ffa5d6d4b67ac5c59e5e0f93c1b989a981961ad" + "revision": "425c06e6198cc999b4541ce6c41c14d8ac9dc609de7eea82f05b481348d05576" } diff --git a/lib/desktop-client/creation.mjs b/lib/desktop-client/creation.mjs index 9cefff77..2222d15e 100644 --- a/lib/desktop-client/creation.mjs +++ b/lib/desktop-client/creation.mjs @@ -89,7 +89,7 @@ export function validateCreation(value) { const batches = list(points.batches, 12, '卖点批次').map(batch => ({ id: identifier(batch.id), direction: string(batch.direction, 500, '表达方向'), items: list(batch.items, 6, '每批卖点').map(item => string(item, 500, '卖点')), ...(batch.factsHash ? { factsHash: string(batch.factsHash, 64, '事实版本') } : {}), - ...(batch.warnings ? { warnings: list(batch.warnings, 20, '卖点待确认项').map(item => string(item, 500, '待确认项')) } : {}) })) + ...(batch.warnings ? { warnings: list(batch.warnings, 40, '卖点待确认项').map(item => string(item, 500, '待确认项')) } : {}) })) if (new Set(batches.map(batch => batch.id)).size !== batches.length) fail('卖点批次编号重复') const possible = new Set(batches.flatMap(batch => batch.items.map((_, index) => `${batch.id}:${index}`))) const selected = list(points.selected, 20, '已选卖点').map(identifier) @@ -106,7 +106,7 @@ export function validateCreation(value) { sellingPoints: { batches, selected, direction: string(points.direction, 500, '表达方向'), stale: Boolean(points.stale) }, plan: { status: states(plan.status, ['idle', 'needs-confirm', 'confirmed', 'stale']), screens, invalidReason: string(plan.invalidReason, 500, '失效原因'), confirmedAt: date(plan.confirmedAt), confirmedRevision, - ...(plan.warnings ? { warnings: list(plan.warnings, 20, '方案待确认项').map(item => string(item, 500, '待确认项')) } : {}) }, + ...(plan.warnings ? { warnings: list(plan.warnings, 40, '方案待确认项').map(item => string(item, 500, '待确认项')) } : {}) }, designSnapshot: snapshot(value.designSnapshot), ...(value.generation ? {generation:validateGeneration(value.generation)} : {}) } } diff --git a/lib/desktop-client/template-packs.ts b/lib/desktop-client/template-packs.ts new file mode 100644 index 00000000..80c1ddb8 --- /dev/null +++ b/lib/desktop-client/template-packs.ts @@ -0,0 +1,136 @@ +import { createHash } from 'node:crypto' +import gujia from './template-packs/gj-desk-dnz0385-native-v1.json' +import yijiada from './template-packs/yjd-desk-dnz0385-native-v1.json' +import { DesktopClientError, desktopAssert } from './contract.mjs' + +type ObjectValue = Record +export type DesktopTemplatePack = ObjectValue & { + schemaVersion: 1 + id: string + version: string + contentHash: string + brandId: string + categoryId: string + name: string + outputs: ObjectValue[] +} +const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i +const digest = /^[a-f0-9]{64}$/ +const identifier = /^[a-zA-Z0-9_.-]{1,120}$/ +const color = /^#[a-f0-9]{6}$/i +const deniedKeys = new Set(['authorization', 'apikey', 'api_key', 'access_token', 'accesstoken', 'refresh_token', 'refreshtoken', 'password', 'secretkey', 'credentials', 'privatekey']) + +function invalid(): never { + // Internal pack diagnostics and private source locations must never become a + // client error payload. Invalid packages fail closed instead of being edited. + throw new DesktopClientError('模板包暂时不可用,请联系管理员核对版本', 500, 'template_pack_invalid') +} +function object(value: unknown): ObjectValue { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid() + return value as ObjectValue +} +function text(value: unknown, max: number): value is string { + return typeof value === 'string' && value.trim().length > 0 && Array.from(value).length <= max && !/[\u0000-\u001f]/.test(value) +} +function finite(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) } +function integer(value: unknown, min: number, max: number): value is number { + return finite(value) && Number.isInteger(value) && value >= min && value <= max +} +function canonical(value: unknown): string { + return JSON.stringify(value, (_key: string, item: unknown) => item && typeof item === 'object' && !Array.isArray(item) + ? Object.fromEntries(Object.keys(item).sort().map(key => [key, (item as ObjectValue)[key]])) : item) +} +/** Matches the native compact serde_json object serialization. */ +export function templatePackHash(pack: unknown): string { + const content = { ...object(pack) } + delete content.contentHash + return createHash('sha256').update(canonical(content)).digest('hex') +} + +/** Published packs contain geometry and provenance, never local source files. */ +function publicMetadata(value: unknown, depth = 0): void { + if (depth > 32) invalid() + if (typeof value === 'string') { + if (/(?:^|[^a-z0-9])(?:[a-z]:[\\/]|\\\\|file:\/\/|\/(?:Users|home|root|mnt|tmp|var|srv|opt|data|volume\d*|etc|private|workspace)(?:\/|\b))/i.test(value) + || /(?:^|[\\/])\.private(?:[\\/]|$)/i.test(value) + || /^\//.test(value) + || /https?:\/\/(?:localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(?:1[6-9]|2\d|3[01])\.\d+\.\d+)(?:[:/]|$)/i.test(value)) invalid() + } else if (Array.isArray(value)) { + value.forEach(item => publicMetadata(item, depth + 1)) + } else if (value && typeof value === 'object') { + for (const [key, item] of Object.entries(value)) { + if (deniedKeys.has(key.toLowerCase()) || /^(?:filePath|localPath|sourcePath|rootPath)$/i.test(key)) invalid() + publicMetadata(item, depth + 1) + } + } +} +function frame(value: unknown, width: number, height: number, line = false): void { + const box = object(value) + if (!finite(box.x) || !finite(box.y) || !finite(box.width) || !finite(box.height) + || box.x < 0 || box.y < 0 || box.width <= 0 || box.height < 0 || (!line && box.height === 0) + || box.x + box.width > width + 0.01 || box.y + box.height > height + 0.01) invalid() +} + +/** Full immutable package validation occurs before any part is returned. */ +export function validateDesktopTemplatePack(value: unknown): DesktopTemplatePack { + const pack = object(value) + if (JSON.stringify(pack).length > 600_000 || pack.schemaVersion !== 1 || !text(pack.name, 160) + || typeof pack.id !== 'string' || !identifier.test(pack.id) || typeof pack.version !== 'string' || !identifier.test(pack.version) + || typeof pack.brandId !== 'string' || !uuid.test(pack.brandId) || typeof pack.categoryId !== 'string' || !uuid.test(pack.categoryId) + || typeof pack.contentHash !== 'string' || !digest.test(pack.contentHash) || pack.contentHash !== templatePackHash(pack) + || !Array.isArray(pack.outputs) || pack.outputs.length === 0 || pack.outputs.length > 40) invalid() + publicMetadata(pack) + const outputIds = new Set(), fields = new Set() + let detailCount = 0, detailHeight = 0 + for (const item of pack.outputs) { + const output = object(item) + if (typeof output.id !== 'string' || !identifier.test(output.id) || outputIds.has(output.id) || !text(output.name, 160) + || !['detail', 'main_images', 'sku_images'].includes(String(output.component)) + || !integer(output.width, 750, 1440) || ![750, 1440].includes(output.width) + || !integer(output.height, 1, 30000) || !Array.isArray(output.slots) || output.slots.length === 0 || output.slots.length > 120) invalid() + outputIds.add(output.id) + if (output.background !== undefined && (typeof output.background !== 'string' || !color.test(output.background))) invalid() + if (output.component === 'detail') { detailCount++; detailHeight += output.height } + for (const item of output.slots) { + const slot = object(item) + if (typeof slot.fieldId !== 'string' || !identifier.test(slot.fieldId) || fields.has(slot.fieldId)) invalid() + fields.add(slot.fieldId) + frame(slot.frame, output.width, output.height) + if (slot.reuseGroup !== undefined && (typeof slot.reuseGroup !== 'string' || !identifier.test(slot.reuseGroup))) invalid() + if (slot.kind === 'image') { + if (!['cover', 'contain'].includes(String(slot.crop))) invalid() + } else if (slot.kind === 'text') { + const font = object(slot.font) + if (font.family !== 'Noto Sans SC' || !finite(font.size) || font.size < 8 || font.size > 600 + || !integer(font.weight, 100, 900) || typeof font.color !== 'string' || !color.test(font.color) + || !integer(slot.maxLines, 1, 100) || !integer(slot.maxChars, 1, 6000)) invalid() + } else invalid() + } + if (output.decorations !== undefined) { + if (!Array.isArray(output.decorations) || output.decorations.length > 120) invalid() + const ids = new Set() + for (const item of output.decorations) { + const decoration = object(item) + if (typeof decoration.id !== 'string' || !identifier.test(decoration.id) || ids.has(decoration.id) + || !['rect', 'line'].includes(String(decoration.kind)) || typeof decoration.color !== 'string' || !color.test(decoration.color) + || !finite(decoration.opacity) || decoration.opacity < 0 || decoration.opacity > 1 + || (decoration.aboveImages !== undefined && typeof decoration.aboveImages !== 'boolean') || decoration.path !== undefined || decoration.url !== undefined) invalid() + ids.add(decoration.id) + frame(decoration.frame, output.width, output.height, decoration.kind === 'line') + } + } + } + if (detailCount > 14 || detailHeight > 30000) invalid() + return pack as DesktopTemplatePack +} + +/** Registry selection has no database writes and never substitutes another pair. */ +export function selectDesktopTemplatePacks(brandId: string | null, categoryId: string | null, registry: readonly unknown[]) { + desktopAssert(typeof brandId === 'string' && uuid.test(brandId) && typeof categoryId === 'string' && uuid.test(categoryId), '请提供有效的品牌和品类编号', 400, 'invalid_request') + const packs = registry.map(validateDesktopTemplatePack).filter(pack => pack.brandId.toLowerCase() === brandId!.toLowerCase() && pack.categoryId.toLowerCase() === categoryId!.toLowerCase()) + if (packs.length > 1) invalid() + return { items: packs.map(pack => ({ id: pack.id, version: pack.version, contentHash: pack.contentHash, pack: JSON.parse(JSON.stringify(pack)) as DesktopTemplatePack })) } +} +export function desktopTemplatePacks(brandId: string | null, categoryId: string | null) { + return selectDesktopTemplatePacks(brandId, categoryId, [gujia, yijiada]) +} diff --git a/lib/desktop-client/template-packs/gj-desk-dnz0385-native-v1.json b/lib/desktop-client/template-packs/gj-desk-dnz0385-native-v1.json new file mode 100644 index 00000000..f266bdf5 --- /dev/null +++ b/lib/desktop-client/template-packs/gj-desk-dnz0385-native-v1.json @@ -0,0 +1,1713 @@ +{ + "schemaVersion": 1, + "id": "GJ-DESK-DNZ0385-NATIVE", + "version": "20261023.1", + "brandId": "42bb46b2-89cd-47cf-9ad8-f2e95b8c5a3a", + "categoryId": "78f8044c-62d3-49fa-9b25-3e011bc0a0cf", + "name": "顾家电脑桌 · 原生图位映射", + "source": { + "templateId": "GJ-DESK-DNZ0385", + "mappingVersion": "20260924.1", + "mappingSha256": "84476b388be25d99e7ab9de61f717244846985302ea187c1be8f3ee595a51f00", + "scope": "五屏现有几何适配,新增两张主图及SKU方竖两版;不是索菲亚飞书模板或原PSD完整复刻。" + }, + "adaptation": { + "fonts": "统一Noto Sans SC;字号取源字号与本原生框每行可容纳高度/1.2的较小值,作为明确的原生换字体适配;sourceFont仅作原映射溯源,不宣称保留方正/Gotham原字体。", + "facts": "无产品文案或参数默认值。每图位需要当前产品事实核对;承重、工艺、涂层、设备色差默认名称不是证明。", + "fixedLayers": "原固定装饰、旧参数文字和原示例图片未复制,详见遗漏清单。", + "clippedFrames": [ + { + "fieldId": "S01-I01", + "sourceFrame": { + "x": -1, + "y": 0, + "width": 1442, + "height": 2353 + }, + "nativeFrame": { + "x": 0, + "y": 0, + "width": 1440, + "height": 2351 + }, + "reason": "原出血框明确裁至本输出边界,不缩放源图位" + }, + { + "fieldId": "S10-I01", + "sourceFrame": { + "x": 40, + "y": 774, + "width": 1350, + "height": 1727 + }, + "nativeFrame": { + "x": 40, + "y": 774, + "width": 1350, + "height": 1714 + }, + "reason": "原出血框明确裁至本输出边界,不缩放源图位" + } + ], + "omittedSourceLayers": [ + { + "outputId": "S01", + "sourceLayerId": 2102, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S01", + "sourceLayerId": 77, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S01", + "sourceLayerId": 78, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S01", + "sourceLayerId": 83, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S01", + "sourceLayerId": 80, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S01", + "sourceLayerId": 4713, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S01", + "sourceLayerId": 3825, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S01", + "sourceLayerId": 4714, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S02", + "sourceLayerId": 173, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 962, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 2110, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 831, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 3776, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 2112, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 844, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 848, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 849, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 850, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 852, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 774, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 775, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 776, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 777, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 785, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 790, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 802, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 803, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 804, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 805, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 806, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 807, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 810, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 811, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 813, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 814, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 816, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 851, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + } + ] + }, + "outputs": [ + { + "id": "S01", + "component": "detail", + "name": "产品首屏", + "width": 1440, + "height": 2351, + "background": "#ffffff", + "source": "adapted-source-geometry", + "slots": [ + { + "fieldId": "S01-I01", + "kind": "image", + "name": "首屏产品场景主图", + "frame": { + "x": 0, + "y": 0, + "width": 1440, + "height": 2351 + }, + "sourceLayerId": 3731, + "sourceFieldId": "S01-I01", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S01-T02", + "kind": "text", + "name": "产品品类名", + "frame": { + "x": 711, + "y": 162, + "width": 551, + "height": 105 + }, + "sourceLayerId": 76, + "sourceFieldId": "S01-T02", + "font": { + "family": "Noto Sans SC", + "size": 87.5, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 6, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S01-T01", + "kind": "text", + "name": "产品系列名", + "frame": { + "x": 212, + "y": 156, + "width": 441, + "height": 104 + }, + "sourceLayerId": 79, + "sourceFieldId": "S01-T01", + "font": { + "family": "Noto Sans SC", + "size": 86.67, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 5, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S01-T03", + "kind": "text", + "name": "主张说明", + "frame": { + "x": 157, + "y": 346, + "width": 714, + "height": 54 + }, + "sourceLayerId": 81, + "sourceFieldId": "S01-T03", + "font": { + "family": "Noto Sans SC", + "size": 45, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 15, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S01-T04", + "kind": "text", + "name": "代际升级标签", + "frame": { + "x": 949, + "y": 351, + "width": 310, + "height": 44 + }, + "sourceLayerId": 82, + "sourceFieldId": "S01-T04", + "font": { + "family": "Noto Sans SC", + "size": 36.67, + "weight": 400, + "color": "#080808" + }, + "maxLines": 1, + "maxChars": 8, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S01-T05", + "kind": "text", + "name": "卖点标签-木材", + "frame": { + "x": 181, + "y": 2071, + "width": 286, + "height": 49 + }, + "sourceLayerId": 87, + "sourceFieldId": "S01-T05", + "font": { + "family": "Noto Sans SC", + "size": 40.83, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 7, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_509R--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S01-T06", + "kind": "text", + "name": "卖点标签-桌面厚度", + "frame": { + "x": 701, + "y": 2071, + "width": 206, + "height": 49 + }, + "sourceLayerId": 91, + "sourceFieldId": "S01-T06", + "font": { + "family": "Noto Sans SC", + "size": 40.83, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 5, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_509R--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S01-T07", + "kind": "text", + "name": "卖点标签-稳固", + "frame": { + "x": 1141, + "y": 2072, + "width": 202, + "height": 49 + }, + "sourceLayerId": 95, + "sourceFieldId": "S01-T07", + "font": { + "family": "Noto Sans SC", + "size": 40.83, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 4, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_509R--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + } + ], + "decorations": [ + { + "id": "S01-D01", + "kind": "rect", + "frame": { + "x": 0, + "y": 100, + "width": 1440, + "height": 350 + }, + "color": "#000000", + "opacity": 0.18, + "aboveImages": true + }, + { + "id": "S01-D02", + "kind": "rect", + "frame": { + "x": 0, + "y": 2010, + "width": 1440, + "height": 180 + }, + "color": "#000000", + "opacity": 0.25, + "aboveImages": true + } + ] + }, + { + "id": "S02", + "component": "detail", + "name": "六图卖点总览", + "width": 1440, + "height": 3608, + "background": "#050505", + "source": "adapted-source-geometry", + "slots": [ + { + "fieldId": "S02-I04", + "kind": "image", + "name": "卖点总览-桌腿结构图", + "frame": { + "x": 40, + "y": 2171, + "width": 670, + "height": 690 + }, + "sourceLayerId": 234, + "sourceFieldId": "S02-I04", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S02-T10", + "kind": "text", + "name": "桌腿结构说明", + "frame": { + "x": 89, + "y": 2296, + "width": 338, + "height": 84 + }, + "sourceLayerId": 127, + "sourceFieldId": "S02-T10", + "font": { + "family": "Noto Sans SC", + "size": 35, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 2, + "maxChars": 18, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T09", + "kind": "text", + "name": "桌腿结构标题", + "frame": { + "x": 87, + "y": 2222, + "width": 348, + "height": 49 + }, + "sourceLayerId": 128, + "sourceFieldId": "S02-T09", + "font": { + "family": "Noto Sans SC", + "size": 40.83, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 8, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-I02", + "kind": "image", + "name": "卖点总览-木材图", + "frame": { + "x": 40, + "y": 1469, + "width": 670, + "height": 690 + }, + "sourceLayerId": 4032, + "sourceFieldId": "S02-I02", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S02-T07", + "kind": "text", + "name": "木材说明", + "frame": { + "x": 89, + "y": 1593, + "width": 485, + "height": 85 + }, + "sourceLayerId": 226, + "sourceFieldId": "S02-T07", + "font": { + "family": "Noto Sans SC", + "size": 35.42, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 2, + "maxChars": 26, + "lineHeight": 1.12, + "align": "left", + "sourceFont": "Gotham-Book", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T05", + "kind": "text", + "name": "木材标题", + "frame": { + "x": 86, + "y": 1519, + "width": 371, + "height": 50 + }, + "sourceLayerId": 227, + "sourceFieldId": "S02-T05", + "font": { + "family": "Noto Sans SC", + "size": 41.67, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 8, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-I03", + "kind": "image", + "name": "卖点总览-涂层图", + "frame": { + "x": 723, + "y": 1469, + "width": 682, + "height": 1398 + }, + "sourceLayerId": 4465, + "sourceFieldId": "S02-I03", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S02-T08", + "kind": "text", + "name": "涂层说明", + "frame": { + "x": 770, + "y": 1628, + "width": 526, + "height": 38 + }, + "sourceLayerId": 139, + "sourceFieldId": "S02-T08", + "font": { + "family": "Noto Sans SC", + "size": 31.67, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 16, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T06", + "kind": "text", + "name": "涂层标题", + "frame": { + "x": 772, + "y": 1554, + "width": 468, + "height": 52 + }, + "sourceLayerId": 140, + "sourceFieldId": "S02-T06", + "font": { + "family": "Noto Sans SC", + "size": 43.33, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 10, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T01", + "kind": "text", + "name": "卖点总览主标题", + "frame": { + "x": 308, + "y": 225, + "width": 829, + "height": 90 + }, + "sourceLayerId": 143, + "sourceFieldId": "S02-T01", + "font": { + "family": "Noto Sans SC", + "size": 75, + "weight": 400, + "color": "#f7c580" + }, + "maxLines": 1, + "maxChars": 11, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_509R--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T02", + "kind": "text", + "name": "卖点总览引导文案", + "frame": { + "x": 212, + "y": 386, + "width": 1016, + "height": 255 + }, + "sourceLayerId": 145, + "sourceFieldId": "S02-T02", + "font": { + "family": "Noto Sans SC", + "size": 60.01, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 3, + "maxChars": 48, + "lineHeight": 1.49, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-I01", + "kind": "image", + "name": "卖点总览-大桌面图", + "frame": { + "x": 41, + "y": 808, + "width": 1364, + "height": 648 + }, + "sourceLayerId": 4264, + "sourceFieldId": "S02-I01", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S02-T03", + "kind": "text", + "name": "宽大桌面标题", + "frame": { + "x": 87, + "y": 856, + "width": 257, + "height": 49 + }, + "sourceLayerId": 151, + "sourceFieldId": "S02-T03", + "font": { + "family": "Noto Sans SC", + "size": 40.83, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 6, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T04", + "kind": "text", + "name": "宽大桌面说明", + "frame": { + "x": 89, + "y": 931, + "width": 464, + "height": 41 + }, + "sourceLayerId": 152, + "sourceFieldId": "S02-T04", + "font": { + "family": "Noto Sans SC", + "size": 34.17, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 13, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-I05", + "kind": "image", + "name": "卖点总览-承重图", + "frame": { + "x": 40, + "y": 2881, + "width": 670, + "height": 691 + }, + "sourceLayerId": 4045, + "sourceFieldId": "S02-I05", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S02-T13", + "kind": "text", + "name": "承重说明", + "frame": { + "x": 90, + "y": 3002, + "width": 275, + "height": 38 + }, + "sourceLayerId": 158, + "sourceFieldId": "S02-T13", + "font": { + "family": "Noto Sans SC", + "size": 31.67, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 8, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T11", + "kind": "text", + "name": "承重标题", + "frame": { + "x": 90, + "y": 2927, + "width": 211, + "height": 49 + }, + "sourceLayerId": 159, + "sourceFieldId": "S02-T11", + "font": { + "family": "Noto Sans SC", + "size": 40.83, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 5, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-I06", + "kind": "image", + "name": "卖点总览-加厚桌面图", + "frame": { + "x": 726, + "y": 2881, + "width": 681, + "height": 691 + }, + "sourceLayerId": 3815, + "sourceFieldId": "S02-I06", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S02-T14", + "kind": "text", + "name": "桌面厚度说明", + "frame": { + "x": 770, + "y": 3002, + "width": 277, + "height": 37 + }, + "sourceLayerId": 166, + "sourceFieldId": "S02-T14", + "font": { + "family": "Noto Sans SC", + "size": 30.83, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 8, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T12", + "kind": "text", + "name": "桌面厚度标题", + "frame": { + "x": 770, + "y": 2927, + "width": 356, + "height": 49 + }, + "sourceLayerId": 167, + "sourceFieldId": "S02-T12", + "font": { + "family": "Noto Sans SC", + "size": 40.83, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 8, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + } + ] + }, + { + "id": "S10", + "component": "detail", + "name": "人物接触细节", + "width": 1440, + "height": 2488, + "background": "#050505", + "source": "adapted-source-geometry", + "slots": [ + { + "fieldId": "S10-T01", + "kind": "text", + "name": "斜切桌边主标题", + "frame": { + "x": 65, + "y": 309, + "width": 701, + "height": 93 + }, + "sourceLayerId": 665, + "sourceFieldId": "S10-T01", + "font": { + "family": "Noto Sans SC", + "size": 77.5, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 9, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S10-T02", + "kind": "text", + "name": "斜切桌边说明", + "frame": { + "x": 66, + "y": 483, + "width": 274, + "height": 139 + }, + "sourceLayerId": 666, + "sourceFieldId": "S10-T02", + "font": { + "family": "Noto Sans SC", + "size": 56.01, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 2, + "maxChars": 8, + "lineHeight": 1.3, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S10-I01", + "kind": "image", + "name": "斜切桌边细节图", + "frame": { + "x": 40, + "y": 774, + "width": 1350, + "height": 1714 + }, + "sourceLayerId": 4497, + "sourceFieldId": "S10-I01", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + } + ] + }, + { + "id": "S17", + "component": "detail", + "name": "产品参数", + "width": 1440, + "height": 1984, + "background": "#ffffff", + "source": "adapted-source-geometry", + "slots": [ + { + "fieldId": "S17-I01", + "kind": "image", + "name": "尺寸标注产品图", + "frame": { + "x": 74, + "y": 229, + "width": 1292, + "height": 1731 + }, + "sourceLayerId": 4486, + "sourceFieldId": "S17-I01", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。", + "reuseGroup": "SKU01-product" + }, + { + "fieldId": "S17-T01", + "kind": "text", + "name": "参数页标题", + "frame": { + "x": 88, + "y": 40, + "width": 1264, + "height": 72 + }, + "font": { + "family": "Noto Sans SC", + "size": 52, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 20, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。", + "source": "native-extension" + }, + { + "fieldId": "S17-T02", + "kind": "text", + "name": "已核对尺寸参数", + "frame": { + "x": 88, + "y": 132, + "width": 1264, + "height": 72 + }, + "font": { + "family": "Noto Sans SC", + "size": 32, + "weight": 500, + "color": "#242424" + }, + "maxLines": 2, + "maxChars": 36, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。", + "source": "native-extension" + } + ], + "decorations": [ + { + "id": "S17-D01", + "kind": "line", + "frame": { + "x": 88, + "y": 220, + "width": 1264, + "height": 2 + }, + "color": "#d7d2ca", + "opacity": 1 + } + ] + }, + { + "id": "S18", + "component": "detail", + "name": "光源与设备图", + "width": 1440, + "height": 1906, + "background": "#ffffff", + "source": "adapted-source-geometry", + "slots": [ + { + "fieldId": "S18-I04", + "kind": "image", + "name": "电脑屏幕内产品示意图", + "frame": { + "x": 101, + "y": 1220, + "width": 372, + "height": 209 + }, + "sourceLayerId": 3802, + "sourceFieldId": "S18-I04", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S18-I05", + "kind": "image", + "name": "手机屏幕内产品示意图", + "frame": { + "x": 658, + "y": 1168, + "width": 175, + "height": 353 + }, + "sourceLayerId": 3803, + "sourceFieldId": "S18-I05", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S18-I06", + "kind": "image", + "name": "平板屏幕内产品示意图", + "frame": { + "x": 1037, + "y": 1157, + "width": 263, + "height": 343 + }, + "sourceLayerId": 789, + "sourceFieldId": "S18-I06", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S18-I01", + "kind": "image", + "name": "强光线下产品示意图", + "frame": { + "x": 93, + "y": 569, + "width": 351, + "height": 215 + }, + "sourceLayerId": 4726, + "sourceFieldId": "S18-I01", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S18-I03", + "kind": "image", + "name": "自然光下产品示意图", + "frame": { + "x": 957, + "y": 569, + "width": 351, + "height": 215 + }, + "sourceLayerId": 2120, + "sourceFieldId": "S18-I03", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S18-I02", + "kind": "image", + "name": "暖光源下产品示意图", + "frame": { + "x": 524, + "y": 569, + "width": 351, + "height": 215 + }, + "sourceLayerId": 798, + "sourceFieldId": "S18-I02", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S18-T01", + "kind": "text", + "name": "外观展示标题", + "frame": { + "x": 88, + "y": 88, + "width": 1264, + "height": 88 + }, + "font": { + "family": "Noto Sans SC", + "size": 56, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 20, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。", + "source": "native-extension" + }, + { + "fieldId": "S18-T02", + "kind": "text", + "name": "光线图组说明", + "frame": { + "x": 88, + "y": 456, + "width": 1264, + "height": 64 + }, + "font": { + "family": "Noto Sans SC", + "size": 36, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 32, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。", + "source": "native-extension" + }, + { + "fieldId": "S18-T03", + "kind": "text", + "name": "画幅图组说明", + "frame": { + "x": 88, + "y": 1040, + "width": 1264, + "height": 64 + }, + "font": { + "family": "Noto Sans SC", + "size": 36, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 32, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。", + "source": "native-extension" + } + ], + "decorations": [ + { + "id": "S18-D01", + "kind": "rect", + "frame": { + "x": 91, + "y": 1210, + "width": 392, + "height": 229 + }, + "color": "#e6e4e0", + "opacity": 1 + }, + { + "id": "S18-D02", + "kind": "rect", + "frame": { + "x": 648, + "y": 1158, + "width": 195, + "height": 373 + }, + "color": "#e6e4e0", + "opacity": 1 + }, + { + "id": "S18-D03", + "kind": "rect", + "frame": { + "x": 1027, + "y": 1147, + "width": 283, + "height": 363 + }, + "color": "#e6e4e0", + "opacity": 1 + }, + { + "id": "S18-D04", + "kind": "rect", + "frame": { + "x": 83, + "y": 559, + "width": 371, + "height": 235 + }, + "color": "#e6e4e0", + "opacity": 1 + }, + { + "id": "S18-D05", + "kind": "rect", + "frame": { + "x": 947, + "y": 559, + "width": 371, + "height": 235 + }, + "color": "#e6e4e0", + "opacity": 1 + }, + { + "id": "S18-D06", + "kind": "rect", + "frame": { + "x": 514, + "y": 559, + "width": 371, + "height": 235 + }, + "color": "#e6e4e0", + "opacity": 1 + } + ] + }, + { + "id": "M01", + "component": "main_images", + "name": "方版主图 · 产品全貌", + "width": 1440, + "height": 1440, + "background": "#f3f0ea", + "source": "native-extension", + "slots": [ + { + "fieldId": "M01-I01", + "kind": "image", + "name": "主图独立产品场景", + "frame": { + "x": 0, + "y": 0, + "width": 1440, + "height": 1440 + }, + "crop": "cover", + "role": "product", + "requirements": "本款真实外观参考,完整轮廓、颜色及部件一致;无文字、标注或虚构结构。" + }, + { + "fieldId": "M01-T01", + "kind": "text", + "name": "产品名称", + "frame": { + "x": 88, + "y": 88, + "width": 1264, + "height": 96 + }, + "font": { + "family": "Noto Sans SC", + "size": 64, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 18, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + }, + { + "fieldId": "M01-T02", + "kind": "text", + "name": "已确认卖点", + "frame": { + "x": 88, + "y": 200, + "width": 1050, + "height": 96 + }, + "font": { + "family": "Noto Sans SC", + "size": 36, + "weight": 500, + "color": "#242424" + }, + "maxLines": 2, + "maxChars": 24, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + } + ] + }, + { + "id": "M02", + "component": "main_images", + "name": "方版主图 · 局部特写", + "width": 1440, + "height": 1440, + "background": "#f3f0ea", + "source": "native-extension", + "slots": [ + { + "fieldId": "M02-I01", + "kind": "image", + "name": "独立局部特写", + "frame": { + "x": 0, + "y": 0, + "width": 1440, + "height": 1440 + }, + "crop": "cover", + "role": "product", + "requirements": "本款真实外观参考,完整轮廓、颜色及部件一致;无文字、标注或虚构结构。" + }, + { + "fieldId": "M02-T01", + "kind": "text", + "name": "可见细节", + "frame": { + "x": 88, + "y": 88, + "width": 1264, + "height": 90 + }, + "font": { + "family": "Noto Sans SC", + "size": 56, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 20, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + }, + { + "fieldId": "M02-T02", + "kind": "text", + "name": "细节说明", + "frame": { + "x": 88, + "y": 196, + "width": 1050, + "height": 100 + }, + "font": { + "family": "Noto Sans SC", + "size": 34, + "weight": 500, + "color": "#242424" + }, + "maxLines": 2, + "maxChars": 30, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + } + ] + }, + { + "id": "K01", + "component": "sku_images", + "name": "SKU · 方版", + "width": 1440, + "height": 1440, + "background": "#ffffff", + "source": "native-extension", + "slots": [ + { + "fieldId": "K01-I01", + "kind": "image", + "name": "完整商品图", + "frame": { + "x": 88, + "y": 256, + "width": 1264, + "height": 900 + }, + "crop": "contain", + "role": "product", + "requirements": "本款真实外观参考,完整轮廓、颜色及部件一致;无文字、标注或虚构结构。", + "reuseGroup": "SKU01-product" + }, + { + "fieldId": "K01-I02", + "kind": "image", + "name": "实际颜色纹理", + "frame": { + "x": 1136, + "y": 1192, + "width": 216, + "height": 122 + }, + "crop": "cover", + "role": "color", + "requirements": "本款真实外观参考,完整轮廓、颜色及部件一致;无文字、标注或虚构结构。", + "reuseGroup": "SKU01-color" + }, + { + "fieldId": "K01-T01", + "kind": "text", + "name": "规格名称", + "frame": { + "x": 88, + "y": 88, + "width": 1264, + "height": 88 + }, + "font": { + "family": "Noto Sans SC", + "size": 56, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 20, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + }, + { + "fieldId": "K01-T02", + "kind": "text", + "name": "已核对规格尺寸", + "frame": { + "x": 88, + "y": 1200, + "width": 1000, + "height": 90 + }, + "font": { + "family": "Noto Sans SC", + "size": 32, + "weight": 500, + "color": "#242424" + }, + "maxLines": 2, + "maxChars": 40, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + } + ] + }, + { + "id": "K02", + "component": "sku_images", + "name": "SKU · 竖版", + "width": 1440, + "height": 1920, + "background": "#ffffff", + "source": "native-extension", + "slots": [ + { + "fieldId": "K02-I01", + "kind": "image", + "name": "完整商品图", + "frame": { + "x": 88, + "y": 340, + "width": 1264, + "height": 1200 + }, + "crop": "contain", + "role": "product", + "requirements": "本款真实外观参考,完整轮廓、颜色及部件一致;无文字、标注或虚构结构。", + "reuseGroup": "SKU01-product" + }, + { + "fieldId": "K02-I02", + "kind": "image", + "name": "实际颜色纹理", + "frame": { + "x": 1136, + "y": 1620, + "width": 216, + "height": 122 + }, + "crop": "cover", + "role": "color", + "requirements": "本款真实外观参考,完整轮廓、颜色及部件一致;无文字、标注或虚构结构。", + "reuseGroup": "SKU01-color" + }, + { + "fieldId": "K02-T01", + "kind": "text", + "name": "规格名称", + "frame": { + "x": 88, + "y": 120, + "width": 1264, + "height": 88 + }, + "font": { + "family": "Noto Sans SC", + "size": 56, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 20, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + }, + { + "fieldId": "K02-T02", + "kind": "text", + "name": "已核对规格尺寸", + "frame": { + "x": 88, + "y": 1628, + "width": 1000, + "height": 100 + }, + "font": { + "family": "Noto Sans SC", + "size": 32, + "weight": 500, + "color": "#242424" + }, + "maxLines": 2, + "maxChars": 40, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + } + ] + } + ], + "contentHash": "0c5834443081c865c72b66e162f6b03c3c53a8c0f7652707569df88fa933649e" +} diff --git a/lib/desktop-client/template-packs/yjd-desk-dnz0385-native-v1.json b/lib/desktop-client/template-packs/yjd-desk-dnz0385-native-v1.json new file mode 100644 index 00000000..3e7ff603 --- /dev/null +++ b/lib/desktop-client/template-packs/yjd-desk-dnz0385-native-v1.json @@ -0,0 +1,1715 @@ +{ + "schemaVersion": 1, + "id": "YJD-DESK-DNZ0385-NATIVE", + "version": "20261023.1", + "brandId": "2c6d91fd-5a7f-4e95-adcc-7f4437c6772b", + "categoryId": "78f8044c-62d3-49fa-9b25-3e011bc0a0cf", + "name": "亿家达电脑桌 · 原生图位映射", + "source": { + "templateId": "GJ-DESK-DNZ0385", + "mappingVersion": "20260924.1", + "mappingSha256": "84476b388be25d99e7ab9de61f717244846985302ea187c1be8f3ee595a51f00", + "scope": "复用已核对的电脑桌图框几何,按亿家达品牌规范建立原生适配;五屏详情、两张主图及SKU方竖两版;不是顾家品牌成稿或索菲亚PSD复刻。", + "geometryTemplateId": "GJ-DESK-DNZ0385" + }, + "adaptation": { + "fonts": "统一Noto Sans SC;字号取源字号与本原生框每行可容纳高度/1.2的较小值,作为明确的原生换字体适配;sourceFont仅作原映射溯源,不宣称保留方正/Gotham原字体。", + "facts": "无产品文案或参数默认值。每图位需要当前产品事实核对;承重、工艺、涂层、设备色差默认名称不是证明。", + "fixedLayers": "原固定装饰、旧参数文字和原示例图片未复制,详见遗漏清单。", + "clippedFrames": [ + { + "fieldId": "S01-I01", + "sourceFrame": { + "x": -1, + "y": 0, + "width": 1442, + "height": 2353 + }, + "nativeFrame": { + "x": 0, + "y": 0, + "width": 1440, + "height": 2351 + }, + "reason": "原出血框明确裁至本输出边界,不缩放源图位" + }, + { + "fieldId": "S10-I01", + "sourceFrame": { + "x": 40, + "y": 774, + "width": 1350, + "height": 1727 + }, + "nativeFrame": { + "x": 40, + "y": 774, + "width": 1350, + "height": 1714 + }, + "reason": "原出血框明确裁至本输出边界,不缩放源图位" + } + ], + "omittedSourceLayers": [ + { + "outputId": "S01", + "sourceLayerId": 2102, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S01", + "sourceLayerId": 77, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S01", + "sourceLayerId": 78, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S01", + "sourceLayerId": 83, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S01", + "sourceLayerId": 80, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S01", + "sourceLayerId": 4713, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S01", + "sourceLayerId": 3825, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S01", + "sourceLayerId": 4714, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S02", + "sourceLayerId": 173, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 962, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 2110, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 831, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 3776, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 2112, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 844, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 848, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 849, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 850, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S17", + "sourceLayerId": 852, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 774, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 775, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 776, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 777, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 785, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 790, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 802, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 803, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 804, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 805, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 806, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 807, + "kind": "image", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 810, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 811, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 813, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 814, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 816, + "kind": "text", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + }, + { + "outputId": "S18", + "sourceLayerId": 851, + "kind": "shape", + "reason": "源固定装饰及示例参数未作为当前产品内容复制" + } + ], + "brand": "只使用当前项目绑定的亿家达品牌规范。源几何不提供本款材料、认证、涂层、尺寸、销量或售后事实。" + }, + "outputs": [ + { + "id": "S01", + "component": "detail", + "name": "产品首屏", + "width": 1440, + "height": 2351, + "background": "#ffffff", + "source": "adapted-source-geometry", + "slots": [ + { + "fieldId": "S01-I01", + "kind": "image", + "name": "首屏产品场景主图", + "frame": { + "x": 0, + "y": 0, + "width": 1440, + "height": 2351 + }, + "sourceLayerId": 3731, + "sourceFieldId": "S01-I01", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S01-T02", + "kind": "text", + "name": "产品品类名", + "frame": { + "x": 711, + "y": 162, + "width": 551, + "height": 105 + }, + "sourceLayerId": 76, + "sourceFieldId": "S01-T02", + "font": { + "family": "Noto Sans SC", + "size": 87.5, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 6, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S01-T01", + "kind": "text", + "name": "产品系列名", + "frame": { + "x": 212, + "y": 156, + "width": 441, + "height": 104 + }, + "sourceLayerId": 79, + "sourceFieldId": "S01-T01", + "font": { + "family": "Noto Sans SC", + "size": 86.67, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 5, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S01-T03", + "kind": "text", + "name": "产品概述", + "frame": { + "x": 157, + "y": 346, + "width": 714, + "height": 54 + }, + "sourceLayerId": 81, + "sourceFieldId": "S01-T03", + "font": { + "family": "Noto Sans SC", + "size": 45, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 15, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S01-T04", + "kind": "text", + "name": "产品特点标签", + "frame": { + "x": 949, + "y": 351, + "width": 310, + "height": 44 + }, + "sourceLayerId": 82, + "sourceFieldId": "S01-T04", + "font": { + "family": "Noto Sans SC", + "size": 36.67, + "weight": 400, + "color": "#080808" + }, + "maxLines": 1, + "maxChars": 8, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S01-T05", + "kind": "text", + "name": "外观标签-桌面", + "frame": { + "x": 181, + "y": 2071, + "width": 286, + "height": 49 + }, + "sourceLayerId": 87, + "sourceFieldId": "S01-T05", + "font": { + "family": "Noto Sans SC", + "size": 40.83, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 7, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_509R--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S01-T06", + "kind": "text", + "name": "外观标签-桌边", + "frame": { + "x": 701, + "y": 2071, + "width": 206, + "height": 49 + }, + "sourceLayerId": 91, + "sourceFieldId": "S01-T06", + "font": { + "family": "Noto Sans SC", + "size": 40.83, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 5, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_509R--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S01-T07", + "kind": "text", + "name": "外观标签-桌腿", + "frame": { + "x": 1141, + "y": 2072, + "width": 202, + "height": 49 + }, + "sourceLayerId": 95, + "sourceFieldId": "S01-T07", + "font": { + "family": "Noto Sans SC", + "size": 40.83, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 4, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_509R--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + } + ], + "decorations": [ + { + "id": "S01-D01", + "kind": "rect", + "frame": { + "x": 0, + "y": 100, + "width": 1440, + "height": 350 + }, + "color": "#000000", + "opacity": 0.18, + "aboveImages": true + }, + { + "id": "S01-D02", + "kind": "rect", + "frame": { + "x": 0, + "y": 2010, + "width": 1440, + "height": 180 + }, + "color": "#000000", + "opacity": 0.25, + "aboveImages": true + } + ] + }, + { + "id": "S02", + "component": "detail", + "name": "六图外观总览", + "width": 1440, + "height": 3608, + "background": "#050505", + "source": "adapted-source-geometry", + "slots": [ + { + "fieldId": "S02-I04", + "kind": "image", + "name": "外观总览-桌腿外观图", + "frame": { + "x": 40, + "y": 2171, + "width": 670, + "height": 690 + }, + "sourceLayerId": 234, + "sourceFieldId": "S02-I04", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S02-T10", + "kind": "text", + "name": "桌腿外观说明", + "frame": { + "x": 89, + "y": 2296, + "width": 338, + "height": 84 + }, + "sourceLayerId": 127, + "sourceFieldId": "S02-T10", + "font": { + "family": "Noto Sans SC", + "size": 35, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 2, + "maxChars": 18, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T09", + "kind": "text", + "name": "桌腿外观标题", + "frame": { + "x": 87, + "y": 2222, + "width": 348, + "height": 49 + }, + "sourceLayerId": 128, + "sourceFieldId": "S02-T09", + "font": { + "family": "Noto Sans SC", + "size": 40.83, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 8, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-I02", + "kind": "image", + "name": "外观总览-桌面纹理图", + "frame": { + "x": 40, + "y": 1469, + "width": 670, + "height": 690 + }, + "sourceLayerId": 4032, + "sourceFieldId": "S02-I02", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S02-T07", + "kind": "text", + "name": "桌面纹理说明", + "frame": { + "x": 89, + "y": 1593, + "width": 485, + "height": 85 + }, + "sourceLayerId": 226, + "sourceFieldId": "S02-T07", + "font": { + "family": "Noto Sans SC", + "size": 35.42, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 2, + "maxChars": 26, + "lineHeight": 1.12, + "align": "left", + "sourceFont": "Gotham-Book", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T05", + "kind": "text", + "name": "桌面纹理标题", + "frame": { + "x": 86, + "y": 1519, + "width": 371, + "height": 50 + }, + "sourceLayerId": 227, + "sourceFieldId": "S02-T05", + "font": { + "family": "Noto Sans SC", + "size": 41.67, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 8, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-I03", + "kind": "image", + "name": "外观总览-桌面表面图", + "frame": { + "x": 723, + "y": 1469, + "width": 682, + "height": 1398 + }, + "sourceLayerId": 4465, + "sourceFieldId": "S02-I03", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S02-T08", + "kind": "text", + "name": "桌面表面说明", + "frame": { + "x": 770, + "y": 1628, + "width": 526, + "height": 38 + }, + "sourceLayerId": 139, + "sourceFieldId": "S02-T08", + "font": { + "family": "Noto Sans SC", + "size": 31.67, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 16, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T06", + "kind": "text", + "name": "桌面表面标题", + "frame": { + "x": 772, + "y": 1554, + "width": 468, + "height": 52 + }, + "sourceLayerId": 140, + "sourceFieldId": "S02-T06", + "font": { + "family": "Noto Sans SC", + "size": 43.33, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 10, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T01", + "kind": "text", + "name": "外观总览主标题", + "frame": { + "x": 308, + "y": 225, + "width": 829, + "height": 90 + }, + "sourceLayerId": 143, + "sourceFieldId": "S02-T01", + "font": { + "family": "Noto Sans SC", + "size": 75, + "weight": 400, + "color": "#f7c580" + }, + "maxLines": 1, + "maxChars": 11, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_509R--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T02", + "kind": "text", + "name": "外观总览引导文案", + "frame": { + "x": 212, + "y": 386, + "width": 1016, + "height": 255 + }, + "sourceLayerId": 145, + "sourceFieldId": "S02-T02", + "font": { + "family": "Noto Sans SC", + "size": 60.01, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 3, + "maxChars": 48, + "lineHeight": 1.49, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-I01", + "kind": "image", + "name": "外观总览-桌面使用场景图", + "frame": { + "x": 41, + "y": 808, + "width": 1364, + "height": 648 + }, + "sourceLayerId": 4264, + "sourceFieldId": "S02-I01", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S02-T03", + "kind": "text", + "name": "桌面展示标题", + "frame": { + "x": 87, + "y": 856, + "width": 257, + "height": 49 + }, + "sourceLayerId": 151, + "sourceFieldId": "S02-T03", + "font": { + "family": "Noto Sans SC", + "size": 40.83, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 6, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T04", + "kind": "text", + "name": "桌面展示说明", + "frame": { + "x": 89, + "y": 931, + "width": 464, + "height": 41 + }, + "sourceLayerId": 152, + "sourceFieldId": "S02-T04", + "font": { + "family": "Noto Sans SC", + "size": 34.17, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 13, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-I05", + "kind": "image", + "name": "外观总览-日常使用场景图", + "frame": { + "x": 40, + "y": 2881, + "width": 670, + "height": 691 + }, + "sourceLayerId": 4045, + "sourceFieldId": "S02-I05", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S02-T13", + "kind": "text", + "name": "日常使用说明", + "frame": { + "x": 90, + "y": 3002, + "width": 275, + "height": 38 + }, + "sourceLayerId": 158, + "sourceFieldId": "S02-T13", + "font": { + "family": "Noto Sans SC", + "size": 31.67, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 8, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T11", + "kind": "text", + "name": "日常使用标题", + "frame": { + "x": 90, + "y": 2927, + "width": 211, + "height": 49 + }, + "sourceLayerId": 159, + "sourceFieldId": "S02-T11", + "font": { + "family": "Noto Sans SC", + "size": 40.83, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 5, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-I06", + "kind": "image", + "name": "外观总览-桌边外观图", + "frame": { + "x": 726, + "y": 2881, + "width": 681, + "height": 691 + }, + "sourceLayerId": 3815, + "sourceFieldId": "S02-I06", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S02-T14", + "kind": "text", + "name": "桌边外观说明", + "frame": { + "x": 770, + "y": 3002, + "width": 277, + "height": 37 + }, + "sourceLayerId": 166, + "sourceFieldId": "S02-T14", + "font": { + "family": "Noto Sans SC", + "size": 30.83, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 8, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S02-T12", + "kind": "text", + "name": "桌边外观标题", + "frame": { + "x": 770, + "y": 2927, + "width": 356, + "height": 49 + }, + "sourceLayerId": 167, + "sourceFieldId": "S02-T12", + "font": { + "family": "Noto Sans SC", + "size": 40.83, + "weight": 500, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 8, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_510M--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + } + ] + }, + { + "id": "S10", + "component": "detail", + "name": "桌边细节", + "width": 1440, + "height": 2488, + "background": "#050505", + "source": "adapted-source-geometry", + "slots": [ + { + "fieldId": "S10-T01", + "kind": "text", + "name": "桌边主标题", + "frame": { + "x": 65, + "y": 309, + "width": 701, + "height": 93 + }, + "sourceLayerId": 665, + "sourceFieldId": "S10-T01", + "font": { + "family": "Noto Sans SC", + "size": 77.5, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 1, + "maxChars": 9, + "lineHeight": 1.1, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S10-T02", + "kind": "text", + "name": "桌边说明", + "frame": { + "x": 66, + "y": 483, + "width": 274, + "height": 139 + }, + "sourceLayerId": 666, + "sourceFieldId": "S10-T02", + "font": { + "family": "Noto Sans SC", + "size": 56.01, + "weight": 400, + "color": "#ffffff" + }, + "maxLines": 2, + "maxChars": 8, + "lineHeight": 1.3, + "align": "left", + "sourceFont": "FZYOUHK_506L--GBK1-0", + "defaultText": "", + "requirements": "默认留空;仅填当前产品人工核对的事实,原模板卖点名称不是本款证明。资料不足时明确排除此图位并保留原因,不编参数/等级/认证。" + }, + { + "fieldId": "S10-I01", + "kind": "image", + "name": "桌边细节图", + "frame": { + "x": 40, + "y": 774, + "width": 1350, + "height": 1714 + }, + "sourceLayerId": 4497, + "sourceFieldId": "S10-I01", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + } + ] + }, + { + "id": "S17", + "component": "detail", + "name": "产品信息", + "width": 1440, + "height": 1984, + "background": "#ffffff", + "source": "adapted-source-geometry", + "slots": [ + { + "fieldId": "S17-I01", + "kind": "image", + "name": "完整产品展示图", + "frame": { + "x": 74, + "y": 229, + "width": 1292, + "height": 1731 + }, + "sourceLayerId": 4486, + "sourceFieldId": "S17-I01", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。", + "reuseGroup": "SKU01-product" + }, + { + "fieldId": "S17-T01", + "kind": "text", + "name": "产品信息标题", + "frame": { + "x": 88, + "y": 40, + "width": 1264, + "height": 72 + }, + "font": { + "family": "Noto Sans SC", + "size": 52, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 20, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。", + "source": "native-extension" + }, + { + "fieldId": "S17-T02", + "kind": "text", + "name": "规格信息(选填)", + "frame": { + "x": 88, + "y": 132, + "width": 1264, + "height": 72 + }, + "font": { + "family": "Noto Sans SC", + "size": 32, + "weight": 500, + "color": "#242424" + }, + "maxLines": 2, + "maxChars": 36, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。", + "source": "native-extension" + } + ], + "decorations": [ + { + "id": "S17-D01", + "kind": "line", + "frame": { + "x": 88, + "y": 220, + "width": 1264, + "height": 2 + }, + "color": "#d7d2ca", + "opacity": 1 + } + ] + }, + { + "id": "S18", + "component": "detail", + "name": "多环境外观展示", + "width": 1440, + "height": 1906, + "background": "#ffffff", + "source": "adapted-source-geometry", + "slots": [ + { + "fieldId": "S18-I04", + "kind": "image", + "name": "电脑屏幕内产品示意图", + "frame": { + "x": 101, + "y": 1220, + "width": 372, + "height": 209 + }, + "sourceLayerId": 3802, + "sourceFieldId": "S18-I04", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S18-I05", + "kind": "image", + "name": "手机屏幕内产品示意图", + "frame": { + "x": 658, + "y": 1168, + "width": 175, + "height": 353 + }, + "sourceLayerId": 3803, + "sourceFieldId": "S18-I05", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S18-I06", + "kind": "image", + "name": "平板屏幕内产品示意图", + "frame": { + "x": 1037, + "y": 1157, + "width": 263, + "height": 343 + }, + "sourceLayerId": 789, + "sourceFieldId": "S18-I06", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S18-I01", + "kind": "image", + "name": "强光线下产品示意图", + "frame": { + "x": 93, + "y": 569, + "width": 351, + "height": 215 + }, + "sourceLayerId": 4726, + "sourceFieldId": "S18-I01", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S18-I03", + "kind": "image", + "name": "自然光下产品示意图", + "frame": { + "x": 957, + "y": 569, + "width": 351, + "height": 215 + }, + "sourceLayerId": 2120, + "sourceFieldId": "S18-I03", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S18-I02", + "kind": "image", + "name": "暖光源下产品示意图", + "frame": { + "x": 524, + "y": 569, + "width": 351, + "height": 215 + }, + "sourceLayerId": 798, + "sourceFieldId": "S18-I02", + "crop": "cover", + "role": "product", + "requirements": "使用本款产品参考图,输出不含文字、参数、认证或旧款型号;人物/场景依据当前图位策划。仅作外观画面,不得生成未证实的内部结构或性能对比。" + }, + { + "fieldId": "S18-T01", + "kind": "text", + "name": "外观展示标题", + "frame": { + "x": 88, + "y": 88, + "width": 1264, + "height": 88 + }, + "font": { + "family": "Noto Sans SC", + "size": 56, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 20, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。", + "source": "native-extension" + }, + { + "fieldId": "S18-T02", + "kind": "text", + "name": "光线图组说明", + "frame": { + "x": 88, + "y": 456, + "width": 1264, + "height": 64 + }, + "font": { + "family": "Noto Sans SC", + "size": 36, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 32, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。", + "source": "native-extension" + }, + { + "fieldId": "S18-T03", + "kind": "text", + "name": "画幅图组说明", + "frame": { + "x": 88, + "y": 1040, + "width": 1264, + "height": 64 + }, + "font": { + "family": "Noto Sans SC", + "size": 36, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 32, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。", + "source": "native-extension" + } + ], + "decorations": [ + { + "id": "S18-D01", + "kind": "rect", + "frame": { + "x": 91, + "y": 1210, + "width": 392, + "height": 229 + }, + "color": "#e6e4e0", + "opacity": 1 + }, + { + "id": "S18-D02", + "kind": "rect", + "frame": { + "x": 648, + "y": 1158, + "width": 195, + "height": 373 + }, + "color": "#e6e4e0", + "opacity": 1 + }, + { + "id": "S18-D03", + "kind": "rect", + "frame": { + "x": 1027, + "y": 1147, + "width": 283, + "height": 363 + }, + "color": "#e6e4e0", + "opacity": 1 + }, + { + "id": "S18-D04", + "kind": "rect", + "frame": { + "x": 83, + "y": 559, + "width": 371, + "height": 235 + }, + "color": "#e6e4e0", + "opacity": 1 + }, + { + "id": "S18-D05", + "kind": "rect", + "frame": { + "x": 947, + "y": 559, + "width": 371, + "height": 235 + }, + "color": "#e6e4e0", + "opacity": 1 + }, + { + "id": "S18-D06", + "kind": "rect", + "frame": { + "x": 514, + "y": 559, + "width": 371, + "height": 235 + }, + "color": "#e6e4e0", + "opacity": 1 + } + ] + }, + { + "id": "M01", + "component": "main_images", + "name": "方版主图 · 产品全貌", + "width": 1440, + "height": 1440, + "background": "#f3f0ea", + "source": "native-extension", + "slots": [ + { + "fieldId": "M01-I01", + "kind": "image", + "name": "主图独立产品场景", + "frame": { + "x": 0, + "y": 0, + "width": 1440, + "height": 1440 + }, + "crop": "cover", + "role": "product", + "requirements": "本款真实外观参考,完整轮廓、颜色及部件一致;无文字、标注或虚构结构。" + }, + { + "fieldId": "M01-T01", + "kind": "text", + "name": "产品名称", + "frame": { + "x": 88, + "y": 88, + "width": 1264, + "height": 96 + }, + "font": { + "family": "Noto Sans SC", + "size": 64, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 18, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + }, + { + "fieldId": "M01-T02", + "kind": "text", + "name": "产品特点(选填)", + "frame": { + "x": 88, + "y": 200, + "width": 1050, + "height": 96 + }, + "font": { + "family": "Noto Sans SC", + "size": 36, + "weight": 500, + "color": "#242424" + }, + "maxLines": 2, + "maxChars": 24, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + } + ] + }, + { + "id": "M02", + "component": "main_images", + "name": "方版主图 · 局部特写", + "width": 1440, + "height": 1440, + "background": "#f3f0ea", + "source": "native-extension", + "slots": [ + { + "fieldId": "M02-I01", + "kind": "image", + "name": "独立局部特写", + "frame": { + "x": 0, + "y": 0, + "width": 1440, + "height": 1440 + }, + "crop": "cover", + "role": "product", + "requirements": "本款真实外观参考,完整轮廓、颜色及部件一致;无文字、标注或虚构结构。" + }, + { + "fieldId": "M02-T01", + "kind": "text", + "name": "可见细节", + "frame": { + "x": 88, + "y": 88, + "width": 1264, + "height": 90 + }, + "font": { + "family": "Noto Sans SC", + "size": 56, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 20, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + }, + { + "fieldId": "M02-T02", + "kind": "text", + "name": "细节说明", + "frame": { + "x": 88, + "y": 196, + "width": 1050, + "height": 100 + }, + "font": { + "family": "Noto Sans SC", + "size": 34, + "weight": 500, + "color": "#242424" + }, + "maxLines": 2, + "maxChars": 30, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + } + ] + }, + { + "id": "K01", + "component": "sku_images", + "name": "SKU · 方版", + "width": 1440, + "height": 1440, + "background": "#ffffff", + "source": "native-extension", + "slots": [ + { + "fieldId": "K01-I01", + "kind": "image", + "name": "完整商品图", + "frame": { + "x": 88, + "y": 256, + "width": 1264, + "height": 900 + }, + "crop": "contain", + "role": "product", + "requirements": "本款真实外观参考,完整轮廓、颜色及部件一致;无文字、标注或虚构结构。", + "reuseGroup": "SKU01-product" + }, + { + "fieldId": "K01-I02", + "kind": "image", + "name": "颜色纹理展示", + "frame": { + "x": 1136, + "y": 1192, + "width": 216, + "height": 122 + }, + "crop": "cover", + "role": "color", + "requirements": "本款真实外观参考,完整轮廓、颜色及部件一致;无文字、标注或虚构结构。", + "reuseGroup": "SKU01-color" + }, + { + "fieldId": "K01-T01", + "kind": "text", + "name": "规格名称", + "frame": { + "x": 88, + "y": 88, + "width": 1264, + "height": 88 + }, + "font": { + "family": "Noto Sans SC", + "size": 56, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 20, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + }, + { + "fieldId": "K01-T02", + "kind": "text", + "name": "规格信息(选填)", + "frame": { + "x": 88, + "y": 1200, + "width": 1000, + "height": 90 + }, + "font": { + "family": "Noto Sans SC", + "size": 32, + "weight": 500, + "color": "#242424" + }, + "maxLines": 2, + "maxChars": 40, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + } + ] + }, + { + "id": "K02", + "component": "sku_images", + "name": "SKU · 竖版", + "width": 1440, + "height": 1920, + "background": "#ffffff", + "source": "native-extension", + "slots": [ + { + "fieldId": "K02-I01", + "kind": "image", + "name": "完整商品图", + "frame": { + "x": 88, + "y": 340, + "width": 1264, + "height": 1200 + }, + "crop": "contain", + "role": "product", + "requirements": "本款真实外观参考,完整轮廓、颜色及部件一致;无文字、标注或虚构结构。", + "reuseGroup": "SKU01-product" + }, + { + "fieldId": "K02-I02", + "kind": "image", + "name": "颜色纹理展示", + "frame": { + "x": 1136, + "y": 1620, + "width": 216, + "height": 122 + }, + "crop": "cover", + "role": "color", + "requirements": "本款真实外观参考,完整轮廓、颜色及部件一致;无文字、标注或虚构结构。", + "reuseGroup": "SKU01-color" + }, + { + "fieldId": "K02-T01", + "kind": "text", + "name": "规格名称", + "frame": { + "x": 88, + "y": 120, + "width": 1264, + "height": 88 + }, + "font": { + "family": "Noto Sans SC", + "size": 56, + "weight": 500, + "color": "#242424" + }, + "maxLines": 1, + "maxChars": 20, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + }, + { + "fieldId": "K02-T02", + "kind": "text", + "name": "规格信息(选填)", + "frame": { + "x": 88, + "y": 1628, + "width": 1000, + "height": 100 + }, + "font": { + "family": "Noto Sans SC", + "size": 32, + "weight": 500, + "color": "#242424" + }, + "maxLines": 2, + "maxChars": 40, + "lineHeight": 1.2, + "defaultText": "", + "requirements": "仅使用当前产品已核对内容;不继承模板旧参数。" + } + ] + } + ], + "contentHash": "a6b7a91dbacc5d9e3b76a158c706e275cad494083b3c9d794ab928e27fe70367" +} diff --git a/scripts/desktop-template-pack-context.test.mjs b/scripts/desktop-template-pack-context.test.mjs new file mode 100644 index 00000000..03c2b0ff --- /dev/null +++ b/scripts/desktop-template-pack-context.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { createRequire } from 'node:module' +import test from 'node:test' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const require = createRequire(path.join(root, 'package.json')) +// Reuse the existing TypeScript parser and ignore matcher. This is a source-input gate for +// the repository's current literal/glob rules, not a replacement for a real Docker build. +const ts = require('typescript') +const ignore = require('ignore') +const expected = [ + 'lib/desktop-client/template-packs/gj-desk-dnz0385-native-v1.json', + 'lib/desktop-client/template-packs/yjd-desk-dnz0385-native-v1.json', +] +const policy = await fs.readFile(path.join(root, '.dockerignore'), 'utf8') +const sourcePath = 'lib/desktop-client/template-packs.ts' +const source = ts.createSourceFile(sourcePath, await fs.readFile(path.join(root, sourcePath), 'utf8'), ts.ScriptTarget.Latest, true) +const imports = source.statements + .filter(item => ts.isImportDeclaration(item) && ts.isStringLiteral(item.moduleSpecifier) && item.moduleSpecifier.text.endsWith('.json')) + .map(item => path.posix.normalize(path.posix.join(path.posix.dirname(sourcePath), item.moduleSpecifier.text))) + .sort() +const matcher = ignore().add(policy) + +test('each statically imported template package exists and survives the build-context rules', async () => { + assert.deepEqual(imports, expected, 'Review any new template import and add only its precise build input') + for (const file of imports) { + assert.equal(matcher.ignores(file), false, `Template import excluded from build context: ${file}`) + const info = await fs.stat(path.join(root, file)) + assert.ok(info.isFile() && info.size > 0 && info.size <= 600_000, `Invalid template input: ${file}`) + assert.equal(JSON.parse(await fs.readFile(path.join(root, file), 'utf8')).schemaVersion, 1) + } +}) + +test('the package exceptions name exact files instead of opening the template directory', () => { + const exceptions = policy.split(/\r?\n/).map(line => line.trim()) + .filter(line => line.startsWith('!') && line.includes('template-packs')) + assert.deepEqual(exceptions, expected.map(file => '!' + file)) +}) + +test('other JSON, local authorization, uploads and private task data stay excluded', () => { + for (const file of [ + 'lib/desktop-client/template-packs/unreviewed-template.json', + 'lib/desktop-client/template-packs/customer-session.json', + 'lib/desktop-client/private-credentials.json', + 'output/template-slots-20261023/profile-local-qa/jiansun/settings.json', + '.private/connection.json', + 'detail-generator-private/project/request.json', + 'uploads/product/source.json', + '.env.production', + 'apps/detail-photocraft/local-settings.json', + ]) assert.equal(matcher.ignores(file), true, `Sensitive or unrelated input reopened: ${file}`) +}) + +test('removing these exact exceptions reproduces the missing-package build input failure', () => { + const before = ignore().add(policy.split(/\r?\n/).filter(line => !expected.some(file => line.trim() === '!' + file)).join('\n')) + for (const file of imports) assert.equal(before.ignores(file), true) +}) From fb4212eb856c487430f1b68c6c6256698f13d95a Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Fri, 23 Oct 2026 23:21:27 +0800 Subject: [PATCH 5/9] feat(client): connect Pi Agent to the shared creation workflow Add live workflow reads, native workspace navigation and reviewed action cards. Reuse existing creation, template-slot and export actions with account/project/version checks, busy handoff, manual takeover and original unknown-request protections. Refresh product evidence without duplicating rule context and invalidate pending actions after manual changes. Validated: Pi 63/63, native UI 122/122, backend 172 passed with 5 environment tests ignored; strict Clippy, layering and wasm checks. One native window and real selected Grok 4.7 completed live reads/navigation with 3 complete model calls, 0 retries and 0 images. Full paid generation and Agent-driven PSD export are outside this acceptance. --- apps/detail-agent-pi/src/agent.mjs | 59 +- apps/detail-agent-pi/src/native-bridge.mjs | 3 + apps/detail-agent-pi/src/stdio-canvas.mjs | 3 + apps/detail-agent-pi/src/workflow-context.mjs | 96 +++ apps/detail-agent-pi/test/workflow.test.mjs | 283 +++++++ apps/detail-photocraft/DESIGN.md | 4 + .../apps/photocraft/src/jiansun_pi.rs | 23 +- .../apps/photocraft/src/jiansun_pi_runtime.rs | 25 +- .../crates/ui-egui/src/jiansun.rs | 74 ++ .../crates/ui-egui/src/jiansun/agent_panel.rs | 131 +++- .../crates/ui-egui/src/jiansun/creation.rs | 7 +- .../crates/ui-egui/src/jiansun/pi_bridge.rs | 3 + .../ui-egui/src/jiansun/template_slots.rs | 9 + .../ui-egui/src/jiansun/workflow_bridge.rs | 736 ++++++++++++++++++ docs/photocraft-agent-workflow-2026-10-23.md | 69 ++ 15 files changed, 1509 insertions(+), 16 deletions(-) create mode 100644 apps/detail-agent-pi/src/workflow-context.mjs create mode 100644 apps/detail-agent-pi/test/workflow.test.mjs create mode 100644 apps/detail-photocraft/crates/ui-egui/src/jiansun/workflow_bridge.rs create mode 100644 docs/photocraft-agent-workflow-2026-10-23.md diff --git a/apps/detail-agent-pi/src/agent.mjs b/apps/detail-agent-pi/src/agent.mjs index 807ba49b..0043f052 100644 --- a/apps/detail-agent-pi/src/agent.mjs +++ b/apps/detail-agent-pi/src/agent.mjs @@ -13,6 +13,7 @@ import { auditResponse } from './response-audit.mjs'; import { projectContext, promptChars } from './context-budget.mjs'; import { taskFor } from './rule-context.mjs'; import { productContext } from './project-context.mjs'; +import { workflowContext, workflowHistory, workflowResult } from './workflow-context.mjs'; import { responseUsage } from './usage.mjs'; import { assertPiNodeVersion } from './runtime-policy.mjs'; @@ -22,6 +23,10 @@ const SYSTEM = `你是由壹点详情页的本地 Agent。使用中文,能够 用户新确认的信息覆盖旧信息,缺失事实写待确认,不编造认证、材质、性能。 当前产品资料与当前规则注入是权威。历史对话中的旧产品、旧店铺或旧规则只作讨论历史,不能覆盖当前绑定;未配置规则时可以讨论通用方案,明确不冒充已有品牌规范。 重要产品事实和用户决定用 memory_set 保存,必须引用当前规则域用户明确确认的原话。旧店铺历史只能讨论,需用户在当前域重新确认才能记为当前事实。需要历史用 memory_search。 +存在工作流工具时,每轮先依据实时工作流判断进度;需要详情时用 workflow_read,按当前版本提出下一步。workflow_navigate只切换视图,workflow_propose只创建本机待审核卡,不会分析、生成、确认、回填或导出;实际执行仍由用户在原有按钮完成,不得声称建议已执行。 +workflow_propose的instruction仅用于selling_points的补充表达方向;其他意图使用当前已保存配置,不通过建议修改资料、屏数或宽度。品牌或品类发生变化时必须停止当前回合,重新开始一轮对话加载对应规则,不能将新产品与旧规则混用。 +实时读取界面草稿不代表已经保存或完成分析。草稿或依据变更后,旧事实分析、卖点和方案的stale状态必须保留,不能当作新资料的有效依据。工作流version只用于上下文一致性,不代表方案已确认,也不授予模型调用或付费许可。 +memory_set只记录对话记忆,不修改产品资料。用户希望改资料时说明尚待审核,不把记忆成功说成产品资料已经变更。 工具不够时自己写 main(input) JavaScript,用 tool_develop 提交至少3个正常/边界测试,通过后 tool_run 使用。 自建工具在无网络、无文件、无宿主API的QuickJS沙箱运行,仅输入输出JSON;输出编辑建议后仍须canvas_edit执行。 画布每步必须先canvas_read读取revision。只改用户要求的现有未锁定图层。回合最初allowed=false表示尚未申请写入,canvas_edit会先向宿主申请;writeBlocked=true表示本轮已接手或版本已变化,必须停止写入。历史接手只影响当时那一轮,新用户回合需以实时状态为准。讨论不需要画布写许可。 @@ -34,6 +39,7 @@ const inactiveDocument = error => /^(?:原项目已切换|当前项目已切换) function toolsFor(host) { const make = (name, description, parameters, execute) => ({ name, label: name, description, parameters, executionMode: 'sequential', execute: async (_id, args, signal) => { if (host.compromised) throw Error('本地会话锁失效,已停止工具执行'); + if (name !== 'workflow_read' && typeof host.canvas?.workflowRead === 'function') await host.readWorkflow({ section: 'overview' }); if (name === 'memory_search') { const key = digest(args), count = (host.recallCounts.get(key) || 0) + 1; host.recallCounts.set(key, count); @@ -75,7 +81,29 @@ function toolsFor(host) { })(); }), ]; - return host.canvas ? tools : tools.filter(t => !t.name.startsWith('canvas_')); + const active = host.canvas ? tools : tools.filter(t => !t.name.startsWith('canvas_')); + const outputId = Type.Optional(Type.Union(['detail', 'main_images', 'sku_images'].map(value => Type.Literal(value)))); + if (typeof host.canvas?.workflowRead === 'function') active.push(make('workflow_read', '读取当前创作工作流、实时版本及资料摘要;section按需读取,原图片和长提示词不会进入摘要。', Type.Object({ + section: Type.Optional(Type.Union(['overview', 'materials', 'points', 'plan', 'slots'].map(value => Type.Literal(value)))), + outputId, offset: Type.Optional(Type.Integer({ minimum: 0, maximum: 300 })), + }, { additionalProperties: false }), args => host.readWorkflow(args))); + if (typeof host.canvas?.workflowNavigate === 'function') active.push(make('workflow_navigate', '切换到当前项目的资料、卖点、方案、图位或画布视图;不修改资料、不生成或导出。', Type.Object({ + stage: Type.Union(['materials', 'points', 'plan', 'slots', 'canvas'].map(value => Type.Literal(value))), outputId, + }, { additionalProperties: false }), async args => { + if (host.canvasNotificationsDetached) throw Error('当前项目已切换,未切换其他项目视图'); + return workflowResult(await host.canvas.workflowNavigate(args)); + })); + if (typeof host.canvas?.workflowPropose === 'function') active.push(make('workflow_propose', '按刚读取的实时version创建本机待审核建议卡。instruction仅限selling_points补充表达方向;其他意图使用现配置,不修改资料、屏数或宽度。不会执行分析、生图、确认、回填或导出;实际任务由用户审核后使用原按钮。', Type.Object({ + version: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), + intent: Type.Union(['analyze', 'selling_points', 'plan', 'review_plan', 'review_candidates', 'fill_output', 'export'].map(value => Type.Literal(value))), + instruction: Type.Optional(string(500)), outputId, slotId: Type.Optional(string(120)), variant: Type.Optional(Type.Union([Type.Literal('A'), Type.Literal('B')])), + }, { additionalProperties: false }), async args => { + if (host.canvasNotificationsDetached) throw Error('当前项目已切换,未向其他项目添加建议'); + const result = await host.canvas.workflowPropose(args); + if (result?.proposed !== true || result?.awaitingReview !== true || result?.executed !== false) throw Error('工作流建议未按待审核状态返回,未确认任何执行'); + return workflowResult(result); + })); + return active; } /** Coding Agent SDK owns the transcript/compaction; our host owns all I/O boundaries. */ @@ -92,7 +120,11 @@ export class DetailPiAgent { const info = transport?.info ?? await broker.info(); if (info.accountScope !== store.identity.scope) throw Error('模型账户与项目隔离域不一致'); host.info = info; + host.productIdentity = { ...store.identity, documentId }; host.productEvidence = productContext(projectEvidence,{...store.identity,documentId}); + host.productRuleIdentity = projectEvidence ? { + brandId: host.productEvidence.product.brandId, categoryId: host.productEvidence.product.categoryId, + } : null; if (canvas) canvas.identity = { accountId: info.accountId, origin: info.origin }; const runtime = await ModelRuntime.create({ authPath: path.join(store.dir, 'pi-runtime-auth.json'), modelsPath: null, modelsStorePath: path.join(store.dir, 'pi-model-cache'), refreshOnCreate: false, allowModelNetwork: false }); const api = info.model.protocol === 'anthropic-messages' ? 'anthropic-messages' : 'openai-completions'; @@ -118,13 +150,15 @@ export class DetailPiAgent { return; } if (host.recallRepeats >= 2) throw Error('模型重复检索同一证据,已停止继续调用;历史和结果已保留'); + if (liveCanvas && typeof host.canvas.workflowRead === 'function') await host.readWorkflow({ section: 'overview' }); if (++host.rounds > host.callBudget) throw Error(`本轮已达${host.callBudget}次调用预算,停止继续调用`); const injected = await store.modelContext({ task: host.task || 'chat' }); injected.currentProduct = host.productEvidence; + if (!host.compacting && host.currentWorkflow) injected.currentWorkflow = host.currentWorkflow; if (liveCanvas?.context?.width) injected.constraints.width = liveCanvas.context.width; if (liveCanvas) injected.currentCanvasAuthority = { run: liveCanvas.run, conversationActive: liveCanvas.conversationActive, allowed: liveCanvas.allowed, writeBlocked: liveCanvas.writeBlocked, revision: liveCanvas.revision, documentId: liveCanvas.context?.id, appliesTo: 'current-user-turn' }; await store.appendEvent({ type: 'context_injected', bindingHash: store.hash, messageCount: context.messages.length, memoryHash: digest(injected.confirmedMemory) }); - const contextual = projectContext(normalizeContext({ messages: [...context.messages, { role: 'system', content: '当前项目规则快照与已确认事实(每次调用重新注入,以此为准):\n' + JSON.stringify(injected), timestamp: Date.now() }] }),{bindingHash:store.hash}); + const contextual = projectContext(workflowHistory(normalizeContext({ messages: [...context.messages, { role: 'system', content: '当前项目规则快照与已确认事实(每次调用重新注入,以此为准):\n' + JSON.stringify(injected), timestamp: Date.now() }] }), { version: host.currentWorkflow?.version }),{bindingHash:store.hash}); const inputChars = JSON.stringify(contextual).length; if (inputChars > 120000) throw Error('本轮上下文超过本地输入预算,请先压缩会话;未发起模型请求'); await store.appendEvent({ type: 'context_budget', inputChars, originalChars: JSON.stringify(context).length }); @@ -249,7 +283,7 @@ export class DetailPiAgent { if (event.type === 'tool_execution_start' || event.type === 'tool_execution_end') { const stopped = event.isError && event.toolName === 'canvas_edit' && event.result?.content?.some(c => c.type === 'text' && /接手|画布已修改/.test(c.text)); const status = event.type.endsWith('start') ? '开始' : stopped ? '已停止' : event.isError ? '失败' : '完成'; - const label = { context_read: '读取项目规则', memory_set: '记录事实', memory_search: '检索历史', tool_develop: '编写并测试工具', tool_run: '运行工具', canvas_read: '读取画布', canvas_edit: '更新图层' }[event.toolName] || event.toolName; + const label = { context_read: '读取项目规则', memory_set: '记录事实', memory_search: '检索历史', tool_develop: '编写并测试工具', tool_run: '运行工具', canvas_read: '读取画布', canvas_edit: '更新图层', workflow_read: '读取创作进度', workflow_navigate: '切换创作视图', workflow_propose: '添加待审核建议' }[event.toolName] || event.toolName; if (canvas) await host.notifyCanvas('activity',`${status}:${label}`); await store.appendEvent({ type: event.type, toolName: event.toolName, toolCallId: event.toolCallId, isError: event.isError ?? false }); await onEvent({ type: event.type, toolName: event.toolName, isError: event.isError ?? false }); @@ -263,6 +297,22 @@ export class DetailPiAgent { this.operationActive = true; try { return await operation(); } finally { this.operationActive = false; } } + async readWorkflow(args) { + if (this.canvasNotificationsDetached) throw Error('当前项目已切换,未读取其他项目工作流'); + if (this.workflowBindingChanged) throw Error('品牌或品类已变化,请重新开始一轮对话以加载对应规则;未继续执行当前回合'); + const latest = workflowContext(await this.canvas.workflowRead(args), this.productIdentity); + if (latest.workflow.available) { + const identity = { brandId: latest.product.product.brandId, categoryId: latest.product.product.categoryId }; + if (this.productRuleIdentity && (identity.brandId !== this.productRuleIdentity.brandId || identity.categoryId !== this.productRuleIdentity.categoryId)) { + this.workflowBindingChanged = true; + throw Error('品牌或品类已变化,请重新开始一轮对话以加载对应规则;未记录事实或修改画布'); + } + this.productRuleIdentity ??= identity; + } + this.currentWorkflow = latest.workflow; + this.productEvidence = latest.product; + return latest.workflow; + } async notifyCanvas(method,...args) { if(!this.canvas)return; if(method==='end'){ @@ -304,6 +354,7 @@ export class DetailPiAgent { this.heartbeat.unref(); } try { + if (typeof this.canvas?.workflowRead === 'function') await this.readWorkflow({ section: 'overview' }); await this.store.recordUserEvidence(text); const running = this.session.prompt(text); // SDK flushes the user entry before dispatch. Persist the authoritative file location. @@ -355,7 +406,7 @@ export class DetailPiAgent { const recorded = message.responseId && entries.some(e => e.type === 'message' && e.message.role === 'assistant' && e.message.responseId === message.responseId); const completed = calls.every(call => entries.some(e => e.type === 'message' && e.message.role === 'toolResult' && e.message.toolCallId === call.id)); if (recorded && completed) return { phase: 'complete', requestId: record.requestId, alreadyApplied: true, reposted: false }; - if (calls.length && executeReadOnly && calls.every(call => ['context_read', 'memory_search', 'canvas_read'].includes(call.name))) { + if (calls.length && executeReadOnly && calls.every(call => ['context_read', 'memory_search', 'canvas_read', 'workflow_read'].includes(call.name))) { const last = this.manager.getEntries().filter(e => e.type === 'message').at(-1); if (!recorded && last?.message.role === 'assistant' && ['error', 'aborted'].includes(last.message.stopReason)) this.manager.appendContextEdit(last.id, null); if (!recorded) this.manager.appendMessage(message); diff --git a/apps/detail-agent-pi/src/native-bridge.mjs b/apps/detail-agent-pi/src/native-bridge.mjs index 03778dc4..0a4e5b66 100644 --- a/apps/detail-agent-pi/src/native-bridge.mjs +++ b/apps/detail-agent-pi/src/native-bridge.mjs @@ -199,6 +199,9 @@ export class NativeCanvas { delta(text) { return this.action('pi-delta', { text }); } activity(text) { return this.action('pi-activity', { text }); } requestWrite(revision) { return this.action('pi-write-request', { revision }); } + workflowRead(input = {}) { return this.action('pi-workflow-read', input); } + workflowNavigate(input) { return this.action('pi-workflow-navigate', input); } + workflowPropose(input) { return this.action('pi-workflow-propose', input); } async edit(edit, revision, signal) { if (++this.edits > 20) throw Error('本轮画布操作已达20步'); signal?.throwIfAborted(); diff --git a/apps/detail-agent-pi/src/stdio-canvas.mjs b/apps/detail-agent-pi/src/stdio-canvas.mjs index 0cfc3eb8..17ab4d75 100644 --- a/apps/detail-agent-pi/src/stdio-canvas.mjs +++ b/apps/detail-agent-pi/src/stdio-canvas.mjs @@ -9,6 +9,9 @@ export class StdioCanvas { delta(text) { return this.action('pi-delta', { text }); } activity(text) { return this.action('pi-activity', { text }); } requestWrite(revision) { return this.action('pi-write-request', { revision }); } + workflowRead(input = {}) { return this.action('pi-workflow-read', input); } + workflowNavigate(input) { return this.action('pi-workflow-navigate', input); } + workflowPropose(input) { return this.action('pi-workflow-propose', input); } async edit(edit, revision, signal) { if (++this.edits > 20) throw Error('本轮画布操作已达20步'); signal?.throwIfAborted(); diff --git a/apps/detail-agent-pi/src/workflow-context.mjs b/apps/detail-agent-pi/src/workflow-context.mjs new file mode 100644 index 00000000..4dbfea63 --- /dev/null +++ b/apps/detail-agent-pi/src/workflow-context.mjs @@ -0,0 +1,96 @@ +import { productContext } from './project-context.mjs'; + +const fields = new Set(`available version stage section outputId revisions canvas creation slots id name label title text source status phase ready stale dirty uiDirty draftDirty writeBlocked factsStatus planConfirmed savedRevision invalidReason confirmed confirmedAt confirmedRevision templateId templateHash bindingId bindingHash contentHash planHash revision count total offset hasMore selected selectedCount batchCount factsCount missingCount imagesCount outputsCount candidatesCount materials points plan outputs items batches facts missing candidates selectedCandidateId selectedCandidate variant slotId fieldId kind component width height outputName slotName productName brandName categoryName brief brandId categoryId identifierType identifier colorName dimensions length unit note proposed awaitingReview executed navigated proposal intent instruction proposalId cardId message blocked pending capabilities imageDispatchAllowed copyDispatchAllowed localApplyAllowed canAnalyze canGenerate canConfirm canApply canExport needsConfirmation reason documentId localId outputIds complete failed unknown selectedSlots totalSlots applied appliedCount imageSlots textSlots imageCount textCount requiredImages selectedImages hasEditedDraft reused productImages sceneImages template statusText warnings nextSteps permittedActions currentOutput currentStage readyCount pendingCount unknownCount selectedIds direction screens module body omitted`.split(' ')); +const blockedValue = /(?:data:image\/|;base64,|[a-z]:[\\/]|\\\\[^\s]+|file:\/\/|https?:\/\/|\/(?:Users|home|root|mnt|tmp|var|srv|opt|data|volume\d*|etc|private|workspace)\/)/i; +fields.add('screenCount'); +const clip = (value, max) => Array.from(value).slice(0, max).join(''); + +/** A small data-only view. Pixels, paths, model prompts and private task bodies + * never enter the transcript, even if a future native reply includes them. */ +export function workflowResult(value) { + let omitted = 0; + const project = (item, depth = 0, key = '') => { + if (depth > 8) { omitted++; return null; } + if (item === null || typeof item === 'boolean') return item; + if (typeof item === 'number') return Number.isFinite(item) ? item : null; + if (typeof item === 'string') { + if (blockedValue.test(item)) { omitted++; return '[已省略私有资源]'; } + const limit = key === 'instruction' ? 500 : ['text', 'body', 'message', 'invalidReason'].includes(key) ? 260 : 160; + if (Array.from(item).length > limit) omitted++; + return clip(item, limit); + } + if (Array.isArray(item)) { + omitted += Math.max(0, item.length - 20); + return item.slice(0, 20).map(child => project(child, depth + 1, key)); + } + if (!item || typeof item !== 'object') return null; + return Object.fromEntries(Object.entries(item).filter(([name]) => { + if (fields.has(name)) return true; + // productEvidence has its own identity-checked projection and is injected + // once as currentProduct, rather than duplicated into currentWorkflow. + if (name !== 'productEvidence') omitted++; + return false; + }).map(([name, child]) => [name, project(child, depth + 1, name)])); + }; + if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error('工作流结果无效,未继续处理'); + const result = project(value); + const arrays = []; + const strings = []; + const visit = item => { + if (Array.isArray(item)) { arrays.push(item); item.forEach(visit); } + else if (item && typeof item === 'object') for (const [key, child] of Object.entries(item)) { + if (typeof child === 'string' && child.length > 80) strings.push([item, key]); + else visit(child); + } + }; + visit(result); + // Keep identity/version fields intact. Omission is explicit and never changes + // the immutable raw SDK transcript or the native project itself. + while (JSON.stringify(result).length > 7600 && arrays.some(items => items.length)) { + arrays.sort((a, b) => b.length - a.length); + arrays[0].pop(); omitted++; + } + for (const [item, key] of strings) { + if (JSON.stringify(result).length <= 7600) break; + item[key] = clip(item[key], 80); omitted++; + } + if (omitted) result.omitted = { entries: omitted }; + if (JSON.stringify(result).length > 8000) throw Error('工作流摘要超过本地预算,未继续派发模型'); + return result; +} + +export function workflowContext(value, identity) { + if (!value || typeof value.available !== 'boolean' || !Number.isSafeInteger(value.version) || value.version < 1) throw Error('工作流版本或状态无效,未继续派发模型'); + const workflow = workflowResult(value); + if (value.available && (!value.productEvidence || typeof value.productEvidence !== 'object')) throw Error('工作流产品资料缺失,未使用旧快照'); + return { workflow, product: productContext(value.available ? value.productEvidence : null, identity) }; +} + +/** Original tool results stay in the session tree. The model sees only the + * latest current read per section, plus compact receipts for other operations. */ +export function workflowHistory(context, { version } = {}) { + const parsed = new Map(), latest = new Map(); + context.messages.forEach((message, index) => { + if (message.role !== 'toolResult' || !['workflow_read', 'workflow_navigate', 'workflow_propose'].includes(message.toolName) || message.isError) return; + try { + const value = JSON.parse(message.content.filter(c => c.type === 'text').map(c => c.text).join('')); + parsed.set(index, value); + if (message.toolName === 'workflow_read') latest.set(`${value.section || 'overview'}:${value.outputId || ''}`, index); + } catch { /* An unparseable original result is retained for review. */ } + }); + return { ...context, messages: context.messages.map((message, index) => { + const value = parsed.get(index); + if (!value) return message; + const current = message.toolName === 'workflow_read' && latest.get(`${value.section || 'overview'}:${value.outputId || ''}`) === index && (!version || value.version === version); + if (current) return message; + const proposal = value.proposal && typeof value.proposal === 'object' && !Array.isArray(value.proposal) ? value.proposal : value; + const receipt = { archivedWorkflowResult: message.toolCallId, tool: message.toolName, version: value.version, section: value.section, + stage: value.stage, outputId: proposal.outputId || value.outputId, slotId: proposal.slotId, variant: proposal.variant, + proposed: value.proposed, awaitingReview: value.awaitingReview, executed: value.executed, + intent: proposal.intent, proposalId: value.proposalId || value.cardId, + note: message.toolName === 'workflow_propose' ? '这里只保留待审核建议,未自动执行;原结果在会话树。' : '历史工作流结果保留于会话树;以本轮实时版本为准。' }; + const next = { ...message, content: [{ type: 'text', text: JSON.stringify(receipt) }] }; + delete next.details; + return next; + }) }; +} diff --git a/apps/detail-agent-pi/test/workflow.test.mjs b/apps/detail-agent-pi/test/workflow.test.mjs new file mode 100644 index 00000000..e6d0b8ee --- /dev/null +++ b/apps/detail-agent-pi/test/workflow.test.mjs @@ -0,0 +1,283 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { createAssistantMessageEventStream, getCurrentSystemPrompt } from '@earendil-works/pi-ai/compat'; +import { SessionManager } from '@earendil-works/pi-coding-agent'; +import { DetailPiAgent } from '../src/agent.mjs'; +import { ProjectStore, atomic } from '../src/store.mjs'; +import { StdioCanvas } from '../src/stdio-canvas.mjs'; +import { NativeCanvas } from '../src/native-bridge.mjs'; +import { workflowContext, workflowHistory, workflowResult } from '../src/workflow-context.mjs'; +import { cleanup } from './cleanup.mjs'; + +const scope = '8'.repeat(64); +const binding = { brandId:'qa',shopId:'qa',categoryId:'desk',platform:'native',version:'1',source:'explicit-local-test',rules:[] }; +const info = { accountScope:scope,accountId:'local-qa',origin:'https://platform.test',model:{id:'qa-workflow',protocol:'openai-chat'},pending:[] }; +const prefix = '当前项目规则快照与已确认事实(每次调用重新注入,以此为准):\n'; +function injection(context) { + const text = getCurrentSystemPrompt(context.messages); + return JSON.parse(text.slice(text.lastIndexOf(prefix) + prefix.length).split('\n')[0]); +} +function stream(content) { + const out = createAssistantMessageEventStream(); + const message = { role:'assistant',api:'openai-completions',provider:'jiansun-native-98k',model:info.model.id,content, + stopReason:content.some(c=>c.type==='toolCall')?'toolUse':'stop',timestamp:Date.now(), + usage:{input:1,output:1,cacheRead:0,cacheWrite:0,totalTokens:2,cost:{input:0,output:0,cacheRead:0,cacheWrite:0,total:0}} }; + queueMicrotask(()=>{out.push({type:'start',partial:message});out.push({type:'done',reason:message.stopReason,message});out.end(message);}); + return out; +} +const tool = (name,args,id='workflow-call') => [{type:'toolCall',id,name,arguments:args}]; +const answer = text => [{type:'text',text}]; +function evidence(project,color='胡桃色',brandId='native-brand',categoryId='native-category',length='') { + return {accountScope:scope,project,documentId:7,revision:1,brief:{name:'电脑桌',brandId,categoryId,colorName:color,dimensions:{length,width:'',height:'',unit:'mm'}}, + facts:{status:'ready',items:[{text:'长方形桌面',source:'产品实拍'}],missing:['木种和承重未确认']},sellingPoints:{selected:[],batches:[]},plan:{status:'idle',screens:[]}}; +} +function baseCanvas() { + return {activities:[],writes:0,grants:0,async begin(){},async read(){return {conversationActive:true,allowed:false,writeBlocked:false,revision:1,context:{id:7,name:'本机画布',width:1440,height:1800,layers:[]}};}, + async requestWrite(){this.grants++;throw Error('not used');},async edit(){this.writes++;throw Error('not used');},async activity(text){this.activities.push(text);},async delta(){},async end(){}}; +} +async function fixture(reply,{adapter='full',broker,history=false,nativeEvidence=true,nativeBrandId='native-brand',nativeCategoryId='native-category'}={}) { + const root=await fs.mkdtemp(path.join(os.tmpdir(),'jiansun-pi-')),project=randomUUID(),identity={scope,project}; + const store=await ProjectStore.open(root,identity,binding,{continuous:true}),canvas=baseCanvas(); + if(history){ + const manager=SessionManager.create(store.dir,path.join(store.dir,'sessions')); + for(let i=0;i<12;i++){ + manager.appendMessage({role:'user',content:`本地旧讨论${i}:`+'资料'.repeat(500),timestamp:Date.now()}); + manager.appendMessage({role:'assistant',api:'openai-completions',provider:'jiansun-native-98k',model:info.model.id,content:answer('本地mock历史说明。'.repeat(100)),stopReason:'stop',timestamp:Date.now(),usage:{input:5000,output:500,cacheRead:0,cacheWrite:0,totalTokens:5500,cost:{input:0,output:0,cacheRead:0,cacheWrite:0,total:0}}}); + } + await atomic(path.join(store.dir,'session.json'),{file:manager.getSessionFile()}); + } + const native={version:1,stage:'materials',color:'胡桃色',brandId:nativeBrandId,categoryId:nativeCategoryId,length:'',reads:[],navigations:[],proposals:[],generated:0}; + if(adapter!=='legacy')canvas.workflowRead=async args=>{ + native.reads.push(structuredClone(args)); + return {available:adapter!=='unavailable',version:native.version,stage:native.stage,section:args.section||'overview',revisions:{canvas:1,creation:native.version,slots:0}, + productEvidence:adapter==='unavailable'?null:evidence(project,native.color,native.brandId,native.categoryId,native.length),outputs:[{id:'detail',name:'详情',count:5}]}; + }; + if(adapter==='full'){ + canvas.workflowNavigate=async args=>{native.navigations.push(args);native.stage=args.stage;return {available:true,version:native.version,stage:native.stage,outputId:args.outputId};}; + canvas.workflowPropose=async args=>{native.proposals.push(args);if(args.version!==native.version)throw Error('工作流版本已变化,请重新读取;未添加建议');if(args.instruction?.trim()&&args.intent!=='selling_points')throw Error('补充要求仅用于卖点表达方向,其他提案使用当前配置');return {proposed:true,awaitingReview:true,executed:false,version:native.version,proposal:{...args,title:'拟定页面方案',awaitingReview:true,executed:false}};}; + } + const calls=[]; + const transport={info,stream(_model,context){calls.push({context,injected:injection(context)});return stream(reply(context,calls.length,native,canvas));}}; + const host=await DetailPiAgent.open({store,canvas,transport,broker,projectEvidence:nativeEvidence?evidence(project,'胡桃色',native.brandId,native.categoryId):undefined,documentId:7}); + return {root,project,identity,store,canvas,native,calls,host,async close(){await host.dispose();await cleanup(root);}}; +} + +test('each SDK step reads live workflow and product state while suggestions stay review-only',async()=>{ + const x=await fixture((_context,n,native)=>{ + if(n===1)return tool('workflow_read',{section:'materials'},'read-materials'); + if(n===2)return tool('workflow_navigate',{stage:'plan',outputId:'detail'},'show-plan'); + if(n===3)return tool('workflow_propose',{version:native.version,intent:'plan',outputId:'detail'},'propose-plan'); + return answer('已添加待审核建议,尚未生成页面。'); + }); + const read=x.canvas.workflowRead; + x.canvas.workflowRead=async args=>{ + if(args.section==='materials'){x.native.version=2;x.native.color='米白色';x.native.length='1400';} + return read(args); + }; + try{ + const result=await x.host.prompt('先看当前资料,再展示方案步骤并列出建议,暂不执行。'); + assert.match(result.text,/待审核/); + assert.equal(x.calls.length,4); + assert.equal(x.calls[0].injected.currentProduct.product.colorName,'胡桃色'); + for(const call of x.calls.slice(1)){ + assert.equal(call.injected.currentWorkflow.version,2); + assert.equal(call.injected.currentProduct.product.colorName,'米白色'); + assert.equal(call.injected.currentProduct.dimensions.length,'1400'); + const system=getCurrentSystemPrompt(call.context.messages); + assert.equal(system.split('"currentProduct":').length-1,1); + assert.equal(system.split('"currentWorkflow":').length-1,1); + assert.ok(!JSON.stringify(call.injected.currentWorkflow).includes('productEvidence')); + } + assert.equal(x.native.reads.filter(v=>v.section==='overview').length,7); + assert.equal(x.native.navigations.length,1);assert.equal(x.native.proposals.length,1);assert.equal(x.native.generated,0); + assert.equal(x.canvas.writes,0);assert.equal(x.canvas.grants,0); + for(const label of ['读取创作进度','切换创作视图','添加待审核建议'])assert.ok(x.canvas.activities.some(a=>a.includes(label))); + const proposal=x.host.session.messages.find(m=>m.role==='toolResult'&&m.toolName==='workflow_propose'); + const card=JSON.parse(proposal.content[0].text); + assert.equal(card.executed,false);assert.equal(card.proposal.intent,'plan');assert.equal(card.proposal.outputId,'detail'); + assert.equal(card.proposal.title,'拟定页面方案');assert.equal(card.proposal.awaitingReview,true); + }finally{await x.close();} +}); + +test('missing workflow adapters keep old tools compatible; unavailable live data never revives an old product',async()=>{ + for(const adapter of ['legacy','unavailable']){ + const x=await fixture(()=>answer('仅讨论当前可确认的资料。'),{adapter}); + try{ + await x.host.prompt('先聊一下当前资料。'); + const names=x.host.session.getActiveToolNames(); + assert.equal(names.includes('workflow_read'),adapter!=='legacy'); + assert.ok(!names.includes('workflow_navigate'));assert.ok(!names.includes('workflow_propose')); + if(adapter==='legacy')assert.equal(x.calls[0].injected.currentProduct.product.colorName,'胡桃色'); + else{assert.equal(x.calls[0].injected.currentWorkflow.available,false);assert.equal(x.calls[0].injected.currentProduct.state,'unavailable');assert.ok(!JSON.stringify(x.calls[0].injected.currentProduct).includes('胡桃色'));} + }finally{await x.close();} + } +}); + +test('a stale proposal stays an SDK tool error and is never retried or executed',async()=>{ + const x=await fixture((context,n)=>{ + if(n===1)return tool('workflow_propose',{version:1,intent:'analyze'},'stale-proposal'); + const failure=context.messages.find(m=>m.role==='toolResult'&&m.toolName==='workflow_propose'); + assert.equal(failure.isError,true);assert.match(failure.content[0].text,/工作流版本已变化/); + return answer('资料已更新,原建议未添加,也未执行分析。'); + }); + x.native.version=2; + try{await x.host.prompt('先提出分析建议。');assert.equal(x.native.proposals.length,1);assert.equal(x.native.generated,0);assert.equal(x.calls.length,2);} + finally{await x.close();} +}); + +test('foreign live evidence rejects before inference and memory_set cannot mutate native product data',async()=>{ + const foreign=await fixture(()=>{throw Error('must not infer with foreign evidence');}); + foreign.canvas.workflowRead=async()=>({available:true,version:1,section:'overview',productEvidence:{...evidence(foreign.project),accountScope:'9'.repeat(64)}}); + try{await assert.rejects(foreign.host.prompt('读取资料。'),/不属于当前账户和项目/);assert.equal(foreign.calls.length,0);} + finally{await foreign.close();} + const x=await fixture((_context,n)=>n===1?tool('memory_set',{key:'color',value:'米白色',evidence:'颜色是米白色'},'remember-only'):answer('已记录讨论内容,产品资料尚未修改。')); + try{ + await x.host.prompt('我在讨论中确认颜色是米白色,请先记住。'); + assert.equal((await x.store.context()).confirmedMemory.color.value,'米白色'); + assert.equal(x.calls[1].injected.currentProduct.product.colorName,'胡桃色'); + assert.equal(x.native.color,'胡桃色');assert.equal(x.native.proposals.length,0); + }finally{await x.close();} +}); + +test('brand or category changes stop before inference or tools and require a new rule-bound turn',async()=>{ + for(const key of ['brandId','categoryId'])for(const phase of ['before-provider','before-tool']){ + const x=await fixture((_context,n,native)=>{ + if(n===1){native[key]='changed-native-id';return tool('memory_set',{key:'color',value:'米白色',evidence:'颜色是米白色'},'must-not-remember');} + throw Error('must not infer with a new product and old rules'); + },{nativeBrandId:'',nativeCategoryId:''}); + let reopened,disposed=false; + try{ + if(phase==='before-provider')x.native[key]='changed-native-id'; + const ledger=path.join(x.store.memoryDir,'user-evidence.jsonl'); + await assert.rejects(x.host.prompt('我确认颜色是米白色,请记录事实。'),/品牌或品类已变化/); + assert.equal(x.calls.length,phase==='before-provider'?0:1); + assert.deepEqual((await x.store.context()).confirmedMemory,{}); + if(phase==='before-provider')await assert.rejects(fs.stat(ledger),{code:'ENOENT'}); + assert.equal(x.canvas.writes,0);assert.equal(x.canvas.grants,0);assert.equal(x.native.proposals.length,0); + const calls=x.calls.length; + await assert.rejects(x.host.prompt('继续刚才的讨论。'),/品牌或品类已变化/); + assert.equal(x.calls.length,calls); + await x.host.dispose();disposed=true; + const updated=await ProjectStore.open(x.root,x.identity,{...binding,brandId:'new-rule-brand',categoryId:'new-rule-category',version:'2',rules:['新回合已加载新规则']},{continuous:true}); + const next=[]; + reopened=await DetailPiAgent.open({store:updated,canvas:x.canvas,documentId:7,projectEvidence:evidence(x.project,x.native.color,x.native.brandId,x.native.categoryId), + transport:{info,stream(_model,context){next.push(injection(context));return stream(answer('已按新回合的规则继续讨论。'));}}}); + await reopened.prompt('按当前产品重新讨论,不修改画布。'); + assert.equal(next.length,1);assert.equal(next[0].snapshot.brandId,'new-rule-brand');assert.equal(next[0].snapshot.categoryId,'new-rule-category'); + assert.equal(next[0].currentProduct.product[key],'changed-native-id'); + assert.deepEqual(next[0].confirmedMemory,{});assert.equal(x.canvas.writes,0); + }finally{await reopened?.dispose();if(!disposed)await x.host.dispose();await cleanup(x.root);} + } + // An adapter without initial native evidence may establish its first live + // binding, while a native empty ID above remains a real immutable binding. + const initial=await fixture(()=>answer('首次读取的本机资料已可用于讨论。'),{nativeEvidence:false}); + try{await initial.host.prompt('读取当前资料。');assert.equal(initial.calls.length,1);assert.equal(initial.calls[0].injected.currentProduct.product.brandId,'native-brand');} + finally{await initial.close();} +}); + +test('proposal instructions describe only selling-point direction and other intents reject them without executing',async()=>{ + const x=await fixture((context,n,native)=>{ + if(n===1){assert.match(getCurrentSystemPrompt(context.messages),/instruction仅用于selling_points/);return tool('workflow_propose',{version:native.version,intent:'plan',instruction:'改为12屏、750px'},'invalid-plan-instruction');} + if(n===2){const result=context.messages.find(m=>m.role==='toolResult'&&m.toolCallId==='invalid-plan-instruction');assert.equal(result.isError,true);assert.match(result.content[0].text,/补充要求仅用于卖点/);return tool('workflow_propose',{version:native.version,intent:'selling_points',instruction:'面向小户型,保持事实依据。'},'points-direction');} + return answer('已列出待审核的卖点方向,尚未生成或修改页面配置。'); + }); + try{ + await x.host.prompt('先讨论卖点方向,不改当前页面尺寸。'); + assert.equal(x.native.proposals.length,2);assert.equal(x.native.generated,0);assert.equal(x.canvas.writes,0); + const result=x.host.session.messages.find(m=>m.role==='toolResult'&&m.toolCallId==='points-direction'); + const proposal=JSON.parse(result.content[0].text).proposal; + assert.equal(proposal.intent,'selling_points');assert.equal(proposal.instruction,'面向小户型,保持事实依据。');assert.equal(proposal.executed,false); + }finally{await x.close();} +}); + +test('unsaved native drafts keep prior analysis stale and compaction does not refresh or navigate the UI',async()=>{ + const x=await fixture(()=>answer('草稿尚未保存,旧分析不能作为新依据;先等待用户核对。'),{history:true}); + const read=x.canvas.workflowRead; + x.canvas.workflowRead=async args=>{ + const value=await read(args); + value.uiDirty=true;value.dirty=true;value.draftDirty=true;value.writeBlocked=false;value.factsStatus='stale';value.planConfirmed=false; + value.selected=['batch:0'];value.batchCount=1; + value.capabilities={imageDispatchAllowed:false,copyDispatchAllowed:false,localApplyAllowed:false}; + value.productEvidence.facts.status='stale'; + value.productEvidence.sellingPoints={selected:['batch:0'],batches:[{id:'batch',items:['旧卖点']}],stale:true}; + value.productEvidence.plan.status='stale'; + return value; + }; + try{ + await x.host.prompt('讨论当前还没保存的资料草稿。'); + assert.equal(x.calls[0].injected.currentWorkflow.uiDirty,true); + assert.equal(x.calls[0].injected.currentWorkflow.draftDirty,true); + assert.equal(x.calls[0].injected.currentWorkflow.writeBlocked,false); + assert.equal(x.calls[0].injected.currentWorkflow.factsStatus,'stale'); + assert.equal(x.calls[0].injected.currentWorkflow.planConfirmed,false); + assert.equal(x.calls[0].injected.currentWorkflow.batchCount,1); + assert.deepEqual(x.calls[0].injected.currentWorkflow.selected,['batch:0']); + assert.deepEqual(x.calls[0].injected.currentWorkflow.capabilities,{imageDispatchAllowed:false,copyDispatchAllowed:false,localApplyAllowed:false}); + assert.equal(x.calls[0].injected.currentProduct.factsStatus,'stale'); + assert.deepEqual(x.calls[0].injected.currentProduct.selectedSellingPoints,[]); + assert.equal(x.calls[0].injected.currentProduct.plan.status,'stale'); + assert.match(getCurrentSystemPrompt(x.calls[0].context.messages),/version只用于上下文一致性,不代表方案已确认/); + const reads=x.native.reads.length; + await x.host.compact(); + assert.equal(x.native.reads.length,reads); + assert.equal(x.native.navigations.length,0);assert.equal(x.native.proposals.length,0);assert.equal(x.native.generated,0); + }finally{await x.close();} +}); + +test('workflow projection is bounded and data-only while history compaction preserves original receipts',()=>{ + const value={available:true,version:9,section:'slots',slots:Array.from({length:100},(_,i)=>({id:'slot-'+i,name:'槽位'+i,text:'可确认摘要'.repeat(100),source:'C:\\private\\original.png'})), + imagePrompt:'PRIVATE_LONG_PROMPT'.repeat(1000),path:'D:\\private\\project',images:['data:image/png;base64,PRIVATE_PIXELS'],previewUrl:'https://private.example/image'}; + const safe=workflowResult(value),text=JSON.stringify(safe); + assert.ok(text.length<=8000);assert.ok(safe.omitted.entries>0); + for(const secret of ['PRIVATE_LONG_PROMPT','PRIVATE_PIXELS','D:\\private','C:\\private','https://private'])assert.ok(!text.includes(secret)); + const nativeShape={available:true,version:9,section:'materials',brief:{name:'电脑桌',brandId:'brand',categoryId:'desk',identifierType:'SKU',identifier:'TABLE-1',colorName:'胡桃色',dimensions:{length:'1400',width:'600',height:'750',unit:'mm'},note:'实拍核对'}, + draftDirty:true,factsStatus:'stale',planConfirmed:false,writeBlocked:true,width:1440,screenCount:5,selected:['batch:0'],batchCount:1, + capabilities:{imageDispatchAllowed:false,copyDispatchAllowed:false,localApplyAllowed:true}, + slots:[{id:'slot',candidates:[{id:'candidate',variant:'B',reused:true}]}], + outputs:[{documentId:'M01',selectedImages:1,hasEditedDraft:true}]}; + assert.deepEqual(workflowResult(nativeShape),nativeShape); + assert.deepEqual(workflowResult({navigated:true,stage:'plan',outputId:'detail',version:9,executed:false}),{navigated:true,stage:'plan',outputId:'detail',version:9,executed:false}); + const raw={messages:[1,9].map((version,i)=>({role:'toolResult',toolName:'workflow_read',toolCallId:'read-'+i,content:[{type:'text',text:JSON.stringify({available:true,version,section:'slots',items:[{text:i?'latest':'OLD_LARGE_WORKFLOW'.repeat(500)}]})}]}))}; + raw.messages.push({role:'toolResult',toolName:'workflow_propose',toolCallId:'proposal',content:[{type:'text',text:JSON.stringify({version:9,proposed:true,awaitingReview:true,executed:false,proposal:{version:9,intent:'export',instruction:'长建议'.repeat(100),outputId:'main_images',slotId:'main-slot',variant:'B',awaitingReview:true,executed:false}})}]}); + const original=JSON.stringify(raw),projected=workflowHistory(raw,{version:9}); + assert.ok(!JSON.stringify(projected).includes('OLD_LARGE_WORKFLOW'));assert.ok(JSON.stringify(projected).includes('latest')); + const receipt=JSON.parse(projected.messages[2].content[0].text); + assert.equal(receipt.executed,false);assert.equal(receipt.intent,'export');assert.equal(receipt.outputId,'main_images');assert.equal(receipt.slotId,'main-slot');assert.equal(receipt.variant,'B'); + assert.equal(JSON.stringify(raw),original); + assert.throws(()=>workflowContext({...value,version:Number.MAX_SAFE_INTEGER+1},{scope,project:'p',documentId:7}),/工作流版本/); +}); + +test('stdio and socket adapters route only the three typed workflow actions without network',async()=>{ + const sent=[]; + const stdio=new StdioCanvas(async(action,input)=>{sent.push({action,input});return {ok:true};},7);stdio.run='same-run'; + await stdio.workflowRead({section:'plan'});await stdio.workflowNavigate({stage:'slots',outputId:'sku_images'});await stdio.workflowPropose({version:3,intent:'export'}); + assert.deepEqual(sent.map(q=>q.action),['pi-workflow-read','pi-workflow-navigate','pi-workflow-propose']); + assert.ok(sent.every(q=>q.input.run==='same-run'&&q.input.documentId===7)); + const socket=Object.create(NativeCanvas.prototype),calls=[];socket.run='socket-run';socket.call=async(method,params)=>{calls.push({method,params});return {};}; + await socket.workflowRead({section:'overview'});await socket.workflowNavigate({stage:'canvas'});await socket.workflowPropose({version:3,intent:'review_plan'}); + assert.ok(calls.every(q=>q.method==='jiansun.action'&&q.params.input.run==='socket-run')); + assert.deepEqual(calls.map(q=>q.params.action),['pi-workflow-read','pi-workflow-navigate','pi-workflow-propose']); +}); + +test('recovery can restore workflow_read but never replays navigation or review proposals',async()=>{ + for(const name of ['workflow_read','workflow_navigate','workflow_propose']){ + const x=await fixture(()=>{throw Error('recovery must never infer');}); + await x.host.dispose(); + const id=randomUUID(),args=name==='workflow_read'?{section:'overview'}:name==='workflow_navigate'?{stage:'plan'}:{version:1,intent:'plan'}; + await x.store.appendEvent({type:'provider_prepared',id,bindingHash:x.store.hash}); + let phase='received',posts=0; + const original={id:'original-'+name,model:info.model.id,choices:[{message:{role:'assistant',content:'',tool_calls:[{id:'original-tool',type:'function',function:{name,arguments:JSON.stringify(args)}}]},finish_reason:'tool_calls'}]}; + const broker={info:async()=>({...info,pending:[]}),read:async()=>({phase,terminal:true,model:info.model,raw:Buffer.from(JSON.stringify(original)).toString('base64'),bodyHash:'original-body',requestId:'original-request'}),confirm:async()=>{phase='complete';},round(){posts++;throw Error('must never post');}}; + const host=await DetailPiAgent.open({store:x.store,broker,canvas:x.canvas,projectEvidence:evidence(x.project),documentId:7}); + try{ + const result=await host.recover(id,{executeReadOnly:true});assert.equal(result.reposted,false);assert.equal(posts,0); + if(name==='workflow_read'){assert.equal(result.readOnlyToolsRecovered,1);assert.equal(x.native.reads.length,1);} + else{assert.equal(result.needsToolReview,true);assert.equal(result.toolsExecuted,0);} + assert.equal(x.native.navigations.length,0);assert.equal(x.native.proposals.length,0);assert.equal(x.native.generated,0); + }finally{await host.dispose();await cleanup(x.root);} + } +}); diff --git a/apps/detail-photocraft/DESIGN.md b/apps/detail-photocraft/DESIGN.md index 8a1a8e15..995cb607 100644 --- a/apps/detail-photocraft/DESIGN.md +++ b/apps/detail-photocraft/DESIGN.md @@ -73,6 +73,10 @@ description: 以本机工程和可编辑画布为中心的原生详情页创作 ## Do's and Don'ts +### Agent 与创作流程(2026-10-23) + +Agent 与资料、卖点、方案、图位及画布共用当前项目,逐步骤读取最新草稿和版本。右侧不增加另一套品牌、品类或阶段表单;空态建议随当前工作面变化,点击只填入对话草稿。操作以一张简短提案卡承接,现有 Pi 回合和保存结束后才可执行;执行复用原功能,实际结果写成简短记录。资料、项目或画布变化、用户接手后旧建议失效。资料分析、卖点批次、方案生成、查看候选、回填和导出仍分清建议、提交、完成和待核查,不自动确认方案、采用候选或重发未知请求。具体约束见 [协作契约](../../docs/photocraft-agent-workflow-2026-10-23.md)。 + ### Do: - Do 复用主题 Tokens、原生布局、控件与共享编辑命令。 diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_pi.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_pi.rs index 3486e329..684afdbb 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_pi.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_pi.rs @@ -71,7 +71,19 @@ fn validate_payload(value: &Value, model: &Value) -> R<()> { } for t in tools { let name = t["name"].as_str().or(t["function"]["name"].as_str()).ok_or("工具名称无效")?; - if !matches!(name, "context_read" | "memory_set" | "memory_search" | "tool_develop" | "tool_run" | "canvas_read" | "canvas_edit") { + if !matches!( + name, + "context_read" + | "memory_set" + | "memory_search" + | "tool_develop" + | "tool_run" + | "canvas_read" + | "canvas_edit" + | "workflow_read" + | "workflow_navigate" + | "workflow_propose" + ) { return Err("工具未授权".into()); } } @@ -306,6 +318,15 @@ mod tests { let mut payload = json!({"model":"test-model","stream":true,"max_tokens":100,"messages":[{"role":"user","content":"x"}],"tools":[{"name":"canvas_read"}]}); assert!(validate_payload(&payload, &model).is_ok()); + for name in ["workflow_read", "workflow_navigate", "workflow_propose"] { + payload["tools"][0]["name"] = json!(name); + assert!(validate_payload(&payload, &model).is_ok()); + } + // Workflow integration never grants a generic native command or dispatch tool. + for name in ["native_action", "workflow_execute", "workflow_confirm", "workflow_retry"] { + payload["tools"][0]["name"] = json!(name); + assert!(validate_payload(&payload, &model).is_err()); + } payload["tools"][0]["name"] = json!("bash"); assert!(validate_payload(&payload, &model).is_err()); payload["model"] = json!("other"); diff --git a/apps/detail-photocraft/apps/photocraft/src/jiansun_pi_runtime.rs b/apps/detail-photocraft/apps/photocraft/src/jiansun_pi_runtime.rs index 049b71d8..9011b0af 100644 --- a/apps/detail-photocraft/apps/photocraft/src/jiansun_pi_runtime.rs +++ b/apps/detail-photocraft/apps/photocraft/src/jiansun_pi_runtime.rs @@ -284,10 +284,31 @@ fn run_inner(b: &mut Backend, input: &Value, method: &str, events: &mpsc::Sender if value["type"] == "canvas" { let action = value["action"] .as_str() - .filter(|s| matches!(*s, "pi-begin" | "pi-state" | "pi-delta" | "pi-activity" | "pi-write-request" | "pi-edit" | "pi-end")) + .filter(|s| { + matches!( + *s, + "pi-begin" + | "pi-state" + | "pi-delta" + | "pi-activity" + | "pi-write-request" + | "pi-edit" + | "pi-end" + | "pi-workflow-read" + | "pi-workflow-navigate" + | "pi-workflow-propose" + ) + }) .ok_or("Agent本机动作未授权")?; let (tx, rx) = mpsc::channel(); - events.send(Event::PiAction(action.into(), value["input"].clone(), tx)).map_err(|_| "工作台已关闭,原请求需核查")?; + let mut native_input = value["input"].clone(); + if action == "pi-begin" { + // Only the trusted native owner establishes product evidence scope. + let fields = native_input.as_object_mut().ok_or("Agent启动参数无效")?; + fields.insert("accountScope".into(), json!(b.scope())); + fields.insert("project".into(), json!(project)); + } + events.send(Event::PiAction(action.into(), native_input, tx)).map_err(|_| "工作台已关闭,原请求需核查")?; ctx.request_repaint(); let reply = match rx.recv_timeout(Duration::from_secs(12)) { Ok(Ok(v)) => json!({"id":value["id"],"value":v}), diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun.rs index 59d8db49..1157efdb 100644 --- a/apps/detail-photocraft/crates/ui-egui/src/jiansun.rs +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun.rs @@ -16,6 +16,7 @@ mod creation_reads; mod pi_bridge; mod project_browser; mod template_slots; +mod workflow_bridge; /// egui 0.36 recolors hint atoms with weak_text_color, even for explicitly colored RichText. /// Scope the native token to the input so hints remain readable without changing other chrome. @@ -843,6 +844,8 @@ pub struct Workspace { pi_review: bool, pi_usage: Value, pi_native: bool, + workflow: workflow_bridge::State, + workflow_waiting: Option, creation_open: bool, creation_step: usize, creation: Value, @@ -880,6 +883,61 @@ pub struct Workspace { sync_request_meta: String, } impl Workspace { + fn accept_workflow(&mut self, app: &mut PhotocraftApp) -> Result { + let intent = self.workflow.proposal().and_then(|value| value["intent"].as_str()).unwrap_or("").to_owned(); + let result = self.workflow_accept(app)?; + let operation = match intent.as_str() { + "analyze" => Some(("creation-facts", "正在分析产品资料…")), + "selling_points" => Some(("creation-points", "正在生成一批卖点…")), + "plan" => Some(("creation-plan", "正在生成页面方案…")), + "fill_output" => Some(("slot-applied", "正在保存并回填画布…")), + _ => None, + }; + if result["submitted"] == true + && let Some((method, label)) = operation + { + self.workflow_waiting = Some(json!({"method":method,"label":label,"accountId":self.account["id"],"origin":self.origin, + "doc":app.session.active().map(|state|state.doc.id.0)})); + self.note(label); + } else if result["navigated"] == true { + self.note(if result["stage"] == "plan" { "已打开页面方案" } else { "已打开图位候选" }); + } else if result["dialog"].is_number() { + self.note("已打开导出设置"); + } + Ok(result) + } + + fn workflow_feedback(&mut self, app: &PhotocraftApp, method: &str, result: &Result) { + let Some(waiting) = self.workflow_waiting.as_ref() else { return }; + let expected = waiting["method"].as_str().unwrap_or("").to_owned(); + let matches = expected == method || (expected == "slot-applied" && result.is_err() && ["save", "slot-apply"].contains(&method)); + if !matches { + return; + } + let same = waiting["accountId"] == self.account["id"] + && waiting["origin"] == self.origin + && waiting["doc"].as_u64() == app.session.active().map(|state| state.doc.id.0); + self.workflow_waiting = None; + if !same { + return; + } + match result { + Err(_) => self.note("操作未完成,请查看当前错误;已有内容保留"), + Ok(value) + if value["accountId"] != self.account["id"] + || value["origin"] != self.origin + || value["doc"].as_u64() != app.session.active().map(|state| state.doc.id.0) => {} + Ok(value) if expected != "slot-applied" && value["pending"] == true => self.note("结果待核查,原请求已保留,不会重发"), + Ok(_) => self.note(match expected.as_str() { + "creation-facts" => "产品分析已写入资料", + "creation-points" => "新卖点已加入批次", + "creation-plan" => "页面方案已生成,等待你确认", + "slot-applied" => "已回填画布,可继续编辑或撤销", + _ => return, + }), + } + } + fn receive_template_snapshot(&mut self, incoming: &Value, mutation: bool) -> bool { if !template_snapshot_not_older(&self.template_workflow, incoming) { return false; @@ -1045,6 +1103,8 @@ impl Workspace { pi_review: false, pi_usage: Value::Null, pi_native: false, + workflow: workflow_bridge::State::default(), + workflow_waiting: None, creation_open: false, creation_step: 0, creation: Value::Null, @@ -1211,6 +1271,7 @@ impl Workspace { pub fn state(&self) -> Value { let mut state = self.base_state(); state["pi"] = json!({"run":self.pi_run,"allowed":self.allow_apply,"revision":self.expected_revision}); + state["agentWorkflow"] = self.workflow.proposal().cloned().unwrap_or(Value::Null); state["agentEngine"] = json!("pi"); state["agentRule"] = json!({"id":self.agent_rule["id"],"revision":self.agent_rule["revision"]}); state["agentRuleState"] = self.agent_rule_state.clone(); @@ -1248,6 +1309,7 @@ impl Workspace { self.draft_changed = true; } fn handover(&mut self) { + self.workflow.clear(); if self.pi_run.is_some() || self.busy.as_deref() == Some("agent") { self.pi_write_blocked = true; } @@ -1292,6 +1354,11 @@ impl Workspace { if self.library || self.material || !self.creation_open || self.creation_step != step { self.handover(); } + self.set_creation_destination(step); + } + /// A trusted Agent view change is not a human takeover. Callers must still + /// validate the active lease; this helper never grants or restores write access. + fn set_creation_destination(&mut self, step: usize) { self.library = false; self.material = false; self.creation_open = true; @@ -1540,6 +1607,7 @@ impl Workspace { return; } self.owner_doc = context["id"].as_u64(); + self.workflow.clear(); self.stream_reply.clear(); self.allow_apply = false; self.pi_write_blocked = false; @@ -1607,6 +1675,7 @@ impl Workspace { } } Event::Json(method, result) => { + self.workflow_feedback(app, &method, &result); if matches!(method.as_str(), "creation-catalog" | "creation-thumbnail") { if let Ok(value) = result { self.creation_read_received(app, &method, &value); @@ -2463,6 +2532,11 @@ pub fn action(app: &mut PhotocraftApp, method: &str, input: Value) -> Result pi_bridge::action(&mut w, app, name, &input), + "workflow-accept" => w.accept_workflow(app), + "workflow-dismiss" => { + w.workflow_dismiss(); + Ok(Value::Null) + } "state" => Ok(w.state()), name if name.starts_with("creation-") => w.creation_action(app, name, input), name if name.starts_with("slot-") => w.slot_action(app, name, input), diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun/agent_panel.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun/agent_panel.rs index d8f77dc4..6f2eaf4d 100644 --- a/apps/detail-photocraft/crates/ui-egui/src/jiansun/agent_panel.rs +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun/agent_panel.rs @@ -59,6 +59,32 @@ fn icon(ui: &mut Ui, name: &str, tint: Color32) { ui.add(icons::image(name, 14.0, tint)); } +fn workflow_scope(proposal: &Value, workflow: &Value) -> String { + if proposal["intent"] == "export" { + return "当前画布".into(); + } + if !matches!(proposal["intent"].as_str(), Some("fill_output" | "review_candidates")) { + return String::new(); + } + let output = match proposal["outputId"].as_str() { + Some("main_images") => "主图", + Some("sku_images") => "SKU", + _ => "详情", + }; + let target = if let Some(slot_id) = proposal["slotId"].as_str() { + workflow["slots"] + .as_array() + .into_iter() + .flatten() + .find(|slot| slot["id"] == slot_id) + .and_then(|slot| slot["documentId"].as_str()) + .unwrap_or("图位已变化") + } else { + "全组" + }; + format!("{output} · {target}") +} + impl Workspace { pub(super) fn register_image(&mut self, task: Value) { let Some(id) = task["taskId"].as_str() else { @@ -139,6 +165,7 @@ impl Workspace { ui.label(RichText::new("输出未完成").small().color(t.warning)); } } + self.workflow_card_ui(app, ui); }); }); }, @@ -155,6 +182,63 @@ impl Workspace { ui.add_space(6.0); } + fn workflow_card_ui(&mut self, app: &mut PhotocraftApp, ui: &mut Ui) { + let Some(proposal) = self.workflow.proposal().cloned() else { return }; + let t = Tokens::get(ui.ctx()); + let intent = proposal["intent"].as_str().unwrap_or(""); + let label = match intent { + "analyze" => "开始分析", + "selling_points" => "生成一批", + "plan" => "生成方案", + "review_plan" => "查看方案", + "review_candidates" => "查看候选", + "fill_output" => "回填画布", + "export" => "导出…", + _ => return, + }; + let running = self.busy.is_some() || self.pi_run.is_some(); + let current = workflow_bridge::proposal_current(self, app); + let unknown = self.unknown && matches!(intent, "analyze" | "selling_points" | "plan"); + let ready = !running && current && !unknown; + let scope = workflow_scope(&proposal, &self.template_workflow); + ui.add_space(10.0); + let card = Frame::NONE.fill(t.card).stroke(Stroke::new(1.0, t.field_border)).corner_radius(t.radius_sm).inner_margin(10).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + icon(ui, "sparkles", t.text_dim); + ui.label(RichText::new(proposal["title"].as_str().unwrap_or(label)).size(13.0).strong().color(t.text)); + }); + if !scope.is_empty() { + ui.label(RichText::new(scope).size(12.0).color(t.text_dim)); + } + if let Some(instruction) = proposal["instruction"].as_str().filter(|s| !s.trim().is_empty()) { + ui.add_space(4.0); + ui.add(egui::Label::new(RichText::new(instruction).size(12.0).color(t.text_dim)).wrap()); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + let response = ui.add_enabled(ready, egui::Button::new(label).min_size(vec2(78.0, 28.0)).corner_radius(14)); + self.panel_geometry["workflowAccept"] = rect_value(response.rect); + if response.clicked() + && let Err(error) = self.accept_workflow(app) + { + self.error = error; + } + let dismiss = ui.add(egui::Button::new("暂不处理").frame(false).min_size(vec2(70.0, 28.0))); + self.panel_geometry["workflowDismiss"] = rect_value(dismiss.rect); + if dismiss.clicked() { + self.workflow_dismiss(); + } + if running { + ui.spinner(); + } else if !current || unknown { + ui.label(RichText::new(if unknown { "结果待核查" } else { "内容已更新" }).size(12.0).color(t.warning)); + } + }); + }); + self.panel_geometry["workflowCard"] = rect_value(card.response.rect); + } + fn turn_ui(&self, ui: &mut Ui, turn: Turn<'_>) { let t = Tokens::get(ui.ctx()); if let Some(text) = turn.user { @@ -272,10 +356,10 @@ impl Workspace { icon(ui, "sparkles", t.icon); ui.add_space(10.0); let has_doc = app.session.active().is_some(); - ui.label(RichText::new(if has_doc { "一起完善这张详情页" } else { "打开项目,继续创作" }).size(14.0).strong().color(t.text)); + ui.label(RichText::new(if has_doc { "接着当前项目继续" } else { "打开项目,继续创作" }).size(14.0).strong().color(t.text)); ui.add_space(6.0); if has_doc { - ui.label(RichText::new("选中内容,告诉我想怎么改。").size(12.0).color(t.text_dim)); + ui.label(RichText::new("告诉我接下来想做什么。").size(12.0).color(t.text_dim)); } ui.add_space(16.0); if !has_doc { @@ -289,12 +373,31 @@ impl Workspace { .session .active() .is_some_and(|s| s.selected_layers().iter().any(|id| matches!(s.doc.layer(*id).map(|l| &l.content), Some(LayerContent::Text(_))))); - let suggestions = [ - ("精简文案", "type", "请精简当前选中的文字,保留原有事实和语气。", "chat"), - (if has_text { "调整字号" } else { "检查排版" }, "layers", "请检查当前画布的文字排版,给出层级和间距的调整建议。", "chat"), - ("生成场景", "image", "保持产品结构、颜色和材质,生成与当前品牌风格一致的产品场景。", "generate"), - ("精修图片", "wand-sparkles", "保持产品外观与构图,精修当前图片的光线和背景细节。", "regenerate"), - ]; + let mut suggestions = match self.creation_step { + 0 => vec![ + ("梳理资料", "layers", "请读取当前项目资料,梳理已有事实和需要我补充的内容。", "chat"), + ("分析产品", "sparkles", "请根据当前项目资料准备产品分析,缺少的事实列为待确认。", "chat"), + ], + 1 => vec![ + ("生成卖点", "sparkles", "请根据当前产品事实准备一批卖点,最多6条。", "chat"), + ("比较卖点", "layers", "请读取当前卖点批次,帮我比较和挑选,先说明推荐理由。", "chat"), + ], + 2 => vec![ + ("检查方案", "layers", "请读取当前方案,检查它是否覆盖已选卖点,并列出需要我确认的地方。", "chat"), + ("生成方案", "sparkles", "请根据当前项目资料、已选卖点和品牌规则准备页面方案。", "chat"), + ], + 4 => vec![ + ("查看图位", "layers", "请读取当前模板图位,说明已完成内容和下一步。", "chat"), + ("比较候选", "image", "请读取当前图位的A/B候选状态,打开候选让我比较,不要自动采用。", "chat"), + ], + _ => vec![ + ("精简文案", "type", "请精简当前选中的文字,保留原有事实和语气。", "chat"), + (if has_text { "调整字号" } else { "检查排版" }, "layers", "请检查当前画布的文字排版,给出层级和间距的调整建议。", "chat"), + ], + }; + if self.creation_step == 3 { + suggestions.push(("精修图片", "wand-sparkles", "保持产品外观与构图,精修当前图片的光线和背景细节。", "regenerate")); + } ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); for (label, glyph, prompt, mode) in suggestions { @@ -322,6 +425,7 @@ impl Workspace { matches!(self.busy.as_deref(), Some("agent" | "pi-agent" | "recover" | "image" | "image-recover" | "image-use")) || !self.queue.is_empty() || self.pending_image.is_some() + || (self.workflow_waiting.is_some() && self.busy.is_some()) } fn composer_min_height(&self, app: &PhotocraftApp, ui: &Ui) -> f32 { @@ -413,6 +517,8 @@ impl Workspace { } let text = if !self.queue.is_empty() || self.pending_image.is_some() { "正在更新画布…" + } else if let Some(label) = self.workflow_waiting.as_ref().and_then(|value| value["label"].as_str()) { + label } else if !self.image_progress.is_empty() { &self.image_progress } else { @@ -891,4 +997,13 @@ mod tests { assert!(w.panel_geometry.get("rules").is_none(), "project rules do not become a second composer selector"); } } + + #[test] + fn workflow_card_distinguishes_a_screen_from_the_whole_output_and_current_canvas_export() { + let workflow = json!({"slots":[{"id":"detail-slot","documentId":"S01"},{"id":"sku-slot","documentId":"K02"}]}); + assert_eq!(workflow_scope(&json!({"intent":"fill_output","outputId":"detail","slotId":"detail-slot"}), &workflow), "详情 · S01"); + assert_eq!(workflow_scope(&json!({"intent":"fill_output","outputId":"main_images"}), &workflow), "主图 · 全组"); + assert_eq!(workflow_scope(&json!({"intent":"review_candidates","outputId":"sku_images","slotId":"sku-slot"}), &workflow), "SKU · K02"); + assert_eq!(workflow_scope(&json!({"intent":"export","outputId":"main_images"}), &workflow), "当前画布"); + } } diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation.rs index e4f819d6..189012ad 100644 --- a/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation.rs +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun/creation.rs @@ -185,6 +185,8 @@ pub(super) fn received(w: &mut Workspace, app: &PhotocraftApp, method: &str, val let edited_plan = (w.creation_plan != w.creation["plan"]["screens"]).then(|| w.creation_plan.clone()); w.creation = value["creation"].clone(); w.creation_doc = value["doc"].as_u64(); + let template_binding = w.template_binding(app.session.active().map(|state| state.doc.id.0)); + w.template_ui.bind_project(&template_binding); w.creation_read_failed = false; if !w.creation_dirty || !matches!(method, "creation-read" | "creation-import" | "creation-rules") { w.creation_brief = w.creation["brief"].clone(); @@ -263,6 +265,9 @@ pub(super) fn sync_received(w: &mut Workspace, app: &PhotocraftApp, value: &Valu w.creation_progress = if w.sync_dirty { "云端已保存该版本,当前仍有新修改" } else { "已同步" }.into(); } impl Workspace { + fn template_binding(&self, doc: Option) -> String { + format!("{}|{}|{:?}", self.account["id"], self.origin, doc) + } pub(super) fn creation_draft(&self) -> Value { json!({"revision":self.creation["revision"],"brief":self.creation_brief,"width":self.creation_width,"screens":self.creation_plan,"direction":self.creation_direction,"selected":self.creation_selected,"screenCount":self.creation_count}) } @@ -544,7 +549,7 @@ impl Workspace { } } if self.creation_step == 4 { - let binding = format!("{}|{}|{:?}", self.account["id"], self.origin, doc); + let binding = self.template_binding(doc); let mut actions = Vec::new(); egui::ScrollArea::vertical().id_salt("template-slots-body").show(ui, |ui| { super::template_slots::show( diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun/pi_bridge.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun/pi_bridge.rs index 78d184de..1a286265 100644 --- a/apps/detail-photocraft/crates/ui-egui/src/jiansun/pi_bridge.rs +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun/pi_bridge.rs @@ -41,6 +41,8 @@ pub(super) fn action(w: &mut Workspace, app: &mut PhotocraftApp, method: &str, i w.owner_doc = Some(st.doc.id.0); w.expected_revision = st.revision; w.pi_run = Some(run.to_owned()); + w.workflow.clear(); + w.workflow.bind_identity(input["accountScope"].as_str().unwrap_or(""), input["project"].as_str().unwrap_or(""), st.doc.id.0); w.pi_native = own_submit; w.pi_touched = Instant::now(); w.pi_binding = json!({"accountId":w.account["id"],"origin":w.origin}); @@ -76,6 +78,7 @@ pub(super) fn action(w: &mut Workspace, app: &mut PhotocraftApp, method: &str, i return Err("原项目已切换,回复保留在原项目会话,未写入当前项目".into()); } match method { + "pi-workflow-read" | "pi-workflow-navigate" | "pi-workflow-propose" => workflow_bridge::action(w, app, method, input), "pi-state" => Ok(status(w, app)), "pi-delta" => { let text = input["text"].as_str().filter(|s| s.len() <= 65000).ok_or("回复过长")?; diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun/template_slots.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun/template_slots.rs index 53122f4b..8d7991e9 100644 --- a/apps/detail-photocraft/crates/ui-egui/src/jiansun/template_slots.rs +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun/template_slots.rs @@ -40,6 +40,15 @@ pub(super) struct UiState { } impl UiState { + pub(super) fn bind_project(&mut self, binding: &str) { + self.bind(binding); + } + + pub(super) fn navigate(&mut self, output: &str, slot: Option<&str>) { + self.output = output.to_owned(); + self.selected_slot = slot.unwrap_or("").to_owned(); + } + fn bind(&mut self, binding: &str) { if self.binding != binding { // Keep typed, unsaved fields under their original account/project. They diff --git a/apps/detail-photocraft/crates/ui-egui/src/jiansun/workflow_bridge.rs b/apps/detail-photocraft/crates/ui-egui/src/jiansun/workflow_bridge.rs new file mode 100644 index 00000000..90f7fd18 --- /dev/null +++ b/apps/detail-photocraft/crates/ui-egui/src/jiansun/workflow_bridge.rs @@ -0,0 +1,736 @@ +//! Typed, data-only workflow context and one reviewed proposal per Pi turn. +//! Proposing never dispatches a model or changes the canvas. Human acceptance +//! re-enters the existing creation/slot actions and their save acknowledgements. +use super::*; + +const MAX_VERSION: u64 = 9_007_199_254_740_991; +const OUTPUTS: [&str; 3] = ["detail", "main_images", "sku_images"]; + +#[derive(Clone)] +struct Owner { + scope: String, + project: String, + doc: u64, +} + +#[derive(Clone)] +struct Pending { + public: Value, + guard: Value, +} + +#[derive(Default)] +pub(super) struct State { + owner: Option, + version: u64, + guard: Option, + last_read_version: Option, + pending: Option, + proposed_run: Option, +} + +impl State { + pub(super) fn proposal(&self) -> Option<&Value> { + self.pending.as_ref().map(|pending| &pending.public) + } + + pub(super) fn clear(&mut self) { + self.pending = None; + } + + /// These identities are injected by the native parent, never inferred from + /// product text or accepted as tool arguments from the model. + pub(super) fn bind_identity(&mut self, scope: &str, project: &str, doc: u64) { + let valid_scope = scope.len() == 64 && scope.bytes().all(|byte| byte.is_ascii_hexdigit()); + let valid_project = project.len() == 36 + && project.bytes().enumerate().all(|(index, byte)| if [8, 13, 18, 23].contains(&index) { byte == b'-' } else { byte.is_ascii_hexdigit() }); + self.owner = (valid_scope && valid_project).then(|| Owner { scope: scope.into(), project: project.into(), doc }); + } + + fn observe(&mut self, guard: Value) -> Result { + if self.guard.as_ref() != Some(&guard) { + self.version = self.version.checked_add(1).filter(|version| *version <= MAX_VERSION).ok_or("工作流版本已达上限,请保留项目后重启")?; + self.guard = Some(guard); + } + Ok(self.version) + } +} + +fn clip(value: &Value, max: usize) -> String { + value.as_str().unwrap_or("").chars().take(max).collect() +} + +fn strings(value: &Value, count: usize, length: usize) -> Vec { + value.as_array().into_iter().flatten().filter(|item| item.is_string()).take(count).map(|item| json!(clip(item, length))).collect() +} + +fn source_label(value: &Value) -> String { + let source = clip(value, 100); + if source.contains(['/', '\\']) || source.contains("://") { "来源地址已省略".into() } else { source } +} + +fn stage(w: &Workspace) -> &'static str { + if w.creation_open { + match w.creation_step { + 0 => "materials", + 1 => "points", + 2 => "plan", + 4 => "slots", + _ => "canvas", + } + } else { + "canvas" + } +} + +fn stage_number(value: &str) -> Result { + match value { + "materials" => Ok(0), + "points" => Ok(1), + "plan" => Ok(2), + "slots" => Ok(4), + "canvas" => Ok(3), + _ => Err("工作流阶段无效".into()), + } +} + +fn guard(w: &Workspace, app: &PhotocraftApp) -> Value { + let current = app.session.active().map(|state| json!({"doc":state.doc.id.0,"revision":state.revision})); + let owner = w.workflow.owner.as_ref().map(|owner| json!({"scope":owner.scope,"project":owner.project,"doc":owner.doc})); + let template_ui = w.template_ui.inspect(); + json!({"account":w.account,"origin":w.origin,"owner":owner,"canvas":current, + "localBinding":app.session.active().and_then(|state|w.open_projects.get(&state.doc.id.0)), + "creation":w.creation,"creationDoc":w.creation_doc,"creationReadFailed":w.creation_read_failed,"loadedDraft":w.loaded_draft, + "draft":w.creation_draft(),"creationDirty":w.creation_dirty, + "templateWorkflow":w.template_workflow,"templateUi":{"outputId":template_ui["outputId"],"selectedSlotId":template_ui["selectedSlotId"],"dirtySlots":template_ui["dirtySlots"]}, + "model":w.model,"imageModel":w.image_model,"modelReady":w.model_ready,"imageReady":w.image_ready}) +} + +fn owned(w: &Workspace, app: &PhotocraftApp) -> bool { + let Some(owner) = &w.workflow.owner else { return false }; + let Some(current) = app.session.active() else { return false }; + current.doc.id.0 == owner.doc + && Some(owner.doc) == w.owner_doc + && w.loaded_draft == Some(owner.doc) + && w.creation_doc == Some(owner.doc) + && !w.creation_read_failed + && w.account["id"].as_str().is_some_and(|id| !id.is_empty()) + && w.open_projects.get(&owner.doc).is_none_or(|identity| identity == &w.local_identity(&owner.project)) +} + +fn active_run(w: &Workspace, app: &PhotocraftApp, input: &Value) -> Result<(), String> { + if w.pi_run.is_none() + || w.pi_run.as_deref() != input["run"].as_str() + || w.pi_binding != json!({"accountId":w.account["id"],"origin":w.origin}) + || app.session.active().is_none_or(|current| Some(current.doc.id.0) != w.owner_doc) + || input["documentId"].as_u64().is_some_and(|doc| Some(doc) != w.owner_doc) + { + Err("本地Agent会话、账户或项目已变化".into()) + } else { + Ok(()) + } +} + +fn arguments(input: &Value, allowed: &[&str]) -> Result<(), String> { + let object = input.as_object().ok_or("工作流参数无效")?; + if object.keys().any(|key| !["run", "documentId"].contains(&key.as_str()) && !allowed.contains(&key.as_str())) { + return Err("工作流包含未开放参数".into()); + } + Ok(()) +} + +fn output(w: &Workspace, input: &Value) -> Result { + if let Some(value) = input.get("outputId") { + return value.as_str().filter(|value| OUTPUTS.contains(value)).map(str::to_owned).ok_or("输出类型无效".into()); + } + Ok(w.template_ui.inspect()["outputId"].as_str().filter(|value| OUTPUTS.contains(value)).unwrap_or("detail").into()) +} + +fn selected_slot(w: &Workspace, input: &Value, output: &str) -> Result, String> { + let selected = w.template_ui.inspect(); + let value = input.get("slotId").or_else(|| selected.get("selectedSlotId")); + let Some(value) = value else { return Ok(None) }; + let id = value.as_str().ok_or("图位编号无效")?; + if id.is_empty() { + return if input.get("slotId").is_some() { Err("图位编号无效".into()) } else { Ok(None) }; + } + if w.template_workflow["slots"].as_array().into_iter().flatten().any(|slot| slot["id"] == id && slot["outputId"] == output) { + Ok(Some(id.into())) + } else if input.get("slotId").is_some() { + Err("图位不属于当前项目的输出".into()) + } else { + Ok(None) + } +} + +fn brief_dirty(w: &Workspace) -> bool { + w.creation_brief != w.creation["brief"] + || w.creation["width"].as_u64().is_some_and(|width| width != u64::from(w.creation_width)) + || w.creation["screenCount"].as_u64().is_some_and(|count| count != w.creation_count as u64) +} + +fn plan_dirty(w: &Workspace) -> bool { + brief_dirty(w) + || w.creation_dirty + || w.creation_plan != w.creation["plan"]["screens"] + || json!(w.creation_selected) != w.creation["sellingPoints"]["selected"] + || w.creation_direction != w.creation["sellingPoints"]["direction"].as_str().unwrap_or("") +} + +fn projected_facts(w: &Workspace) -> Value { + let changed = brief_dirty(w); + let items = if changed { + Vec::new() + } else { + w.creation["facts"]["items"] + .as_array() + .into_iter() + .flatten() + .take(24) + .map(|item| json!({"text":clip(&item["text"],260),"source":source_label(&item["source"])})) + .collect() + }; + let mut missing = strings(&w.creation["facts"]["missing"], 20, 220); + if changed { + missing.push(json!("产品资料尚有未保存修改,旧分析不作为新产品事实")); + } + json!({"status":if changed {json!("stale")} else {json!(clip(&w.creation["facts"]["status"],24))},"items":items,"missing":missing}) +} + +fn projected_brief(w: &Workspace) -> Value { + let mut brief = serde_json::Map::new(); + for key in ["name", "brandId", "brandName", "categoryId", "categoryName", "identifierType", "identifier", "variant", "colorName"] { + brief.insert(key.into(), json!(clip(&w.creation_brief[key], 160))); + } + brief.insert("note".into(), json!(clip(&w.creation_brief["note"], 1000))); + let dimensions = ["length", "width", "height", "unit"] + .into_iter() + .map(|key| (key.into(), json!(clip(&w.creation_brief["dimensions"][key], 24)))) + .collect::>(); + brief.insert("dimensions".into(), Value::Object(dimensions)); + Value::Object(brief) +} + +fn projected_batches(w: &Workspace) -> Vec { + w.creation["sellingPoints"]["batches"] + .as_array() + .into_iter() + .flatten() + .take(12) + .map(|batch| json!({"id":clip(&batch["id"],100),"items":strings(&batch["items"],6,200)})) + .collect() +} + +fn projected_plan(w: &Workspace) -> Value { + let changed = plan_dirty(w); + let screens = w + .creation_plan + .as_array() + .into_iter() + .flatten() + .take(12) + .map( + |screen| json!({"id":clip(&screen["id"],100),"name":clip(&screen["name"],100),"title":clip(&screen["title"],160),"body":clip(&screen["body"],300)}), + ) + .collect::>(); + json!({"status":if changed {json!("stale")} else {json!(clip(&w.creation["plan"]["status"],24))}, + "confirmed":!changed && w.creation["plan"]["status"]=="confirmed","screens":screens}) +} + +fn product_evidence(w: &Workspace) -> Value { + let Some(owner) = &w.workflow.owner else { return Value::Null }; + json!({"accountScope":owner.scope,"project":owner.project,"documentId":owner.doc,"revision":w.creation["revision"], + "brief":projected_brief(w),"facts":projected_facts(w), + "sellingPoints":{"selected":w.creation_selected.iter().take(20).collect::>(),"batches":projected_batches(w), + "stale":brief_dirty(w) || w.creation["sellingPoints"]["stale"]==true},"plan":projected_plan(w)}) +} + +fn read(w: &Workspace, version: u64, section: &str, output: &str, offset: usize, available: bool, canvas_revision: &Value) -> Value { + if !available { + return json!({"available":false,"version":version,"section":section,"productEvidence":null,"reason":"当前工作流尚未绑定本账户项目"}); + } + let facts = projected_facts(w); + let plan = projected_plan(w); + let batches = projected_batches(w); + let mut result = json!({"available":true,"version":version,"section":section,"stage":stage(w),"outputId":output, + "revisions":{"canvas":canvas_revision,"creation":w.creation["revision"],"slots":w.template_workflow["revision"]}, + "dirty":w.creation_dirty,"draftDirty":brief_dirty(w) || plan_dirty(w),"width":w.creation_width,"screenCount":w.creation_count, + "pending":w.unknown || w.template_workflow["pending"]==true,"writeBlocked":w.pi_write_blocked, + "factsStatus":facts["status"],"missing":facts["missing"],"selected":w.creation_selected.iter().take(20).collect::>(), + "batchCount":batches.len(),"planConfirmed":plan["confirmed"],"capabilities":w.template_workflow["capabilities"],"productEvidence":product_evidence(w)}); + match section { + "materials" => { + result["brief"] = projected_brief(w); + result["imageCount"] = json!(w.creation_brief["images"].as_array().map_or(0, Vec::len)); + result["facts"] = facts; + } + "points" => { + result["offset"] = json!(offset); + result["batches"] = json!(batches.into_iter().skip(offset).take(3).collect::>()); + result["direction"] = json!(clip(&json!(w.creation_direction), 500)); + } + "plan" => result["plan"] = plan, + "slots" => { + let slots = w.template_workflow["slots"].as_array().into_iter().flatten().filter(|slot| slot["outputId"] == output).collect::>(); + result["offset"] = json!(offset); + result["totalSlots"] = json!(slots.len()); + result["slots"] = json!(slots.into_iter().skip(offset).take(12).map(|slot| { + let candidates=slot["candidates"].as_array().into_iter().flatten().take(2).map(|candidate| json!({"id":clip(&candidate["id"],100),"variant":clip(&candidate["variant"],1),"status":clip(&candidate["status"],24),"reused":candidate["reusedFrom"].is_object()})).collect::>(); + json!({"id":clip(&slot["id"],160),"fieldId":clip(&slot["fieldId"],100),"documentId":clip(&slot["documentId"],100),"outputId":output, + "label":clip(&slot["label"],160),"selectedCandidateId":slot["selectedCandidateId"],"candidates":candidates}) + }).collect::>()); + result["outputs"] = json!(w.template_workflow["outputs"].as_array().into_iter().flatten().filter(|row| row["outputId"]==output).take(32) + .map(|row|json!({"documentId":clip(&row["documentId"],100),"name":clip(&row["name"],160),"ready":row["ready"]==true,"requiredImages":row["requiredImages"],"selectedImages":row["selectedImages"],"hasEditedDraft":row["hasEditedDraft"]==true})).collect::>()); + } + _ => {} + } + result +} + +fn title(intent: &str) -> Result<&'static str, String> { + match intent { + "analyze" => Ok("分析产品资料"), + "selling_points" => Ok("生成一批卖点"), + "plan" => Ok("生成页面方案"), + "review_plan" => Ok("查看页面方案"), + "review_candidates" => Ok("查看图位候选"), + "fill_output" => Ok("回填所选图位"), + "export" => Ok("打开导出"), + _ => Err("工作流提案类型未开放".into()), + } +} + +pub(super) fn proposal_current(w: &Workspace, app: &PhotocraftApp) -> bool { + w.workflow.pending.as_ref().is_some_and(|pending| owned(w, app) && !w.pi_write_blocked && guard(w, app) == pending.guard) +} + +pub(super) fn action(w: &mut Workspace, app: &mut PhotocraftApp, method: &str, input: &Value) -> Result { + active_run(w, app, input)?; + let current_guard = guard(w, app); + let version = w.workflow.observe(current_guard.clone())?; + let available = owned(w, app); + match method { + "pi-workflow-read" => { + arguments(input, &["section", "outputId", "offset"])?; + let section = input.get("section").map_or(Ok("overview"), |section| section.as_str().ok_or("读取分区无效"))?; + if !["overview", "materials", "points", "plan", "slots"].contains(§ion) { + return Err("读取分区未开放".into()); + } + let output = output(w, input)?; + let offset = input.get("offset").map_or(Ok(0), |offset| offset.as_u64().filter(|offset| *offset <= 300).ok_or("读取位置无效"))? as usize; + w.workflow.last_read_version = available.then_some(version); + Ok(read(w, version, section, &output, offset, available, ¤t_guard["canvas"]["revision"])) + } + "pi-workflow-navigate" => { + arguments(input, &["stage", "outputId"])?; + if !available || w.pi_write_blocked || w.pending_project_open.is_some() || !w.queue.is_empty() { + return Err("项目已切换、用户已接手或画布尚未落定,未切换阶段".into()); + } + let stage = input["stage"].as_str().ok_or("工作流阶段无效")?; + let step = stage_number(stage)?; + let output = output(w, input)?; + let selected = selected_slot(w, input, &output)?; + w.template_ui.navigate(&output, selected.as_deref()); + w.set_creation_destination(step); + let next_guard = guard(w, app); + let version = w.workflow.observe(next_guard)?; + Ok(json!({"navigated":true,"stage":stage,"outputId":output,"version":version,"executed":false})) + } + "pi-workflow-propose" => { + arguments(input, &["version", "intent", "instruction", "outputId", "slotId", "variant"])?; + if !available || w.pi_write_blocked || w.pending_project_open.is_some() || !w.queue.is_empty() { + return Err("用户已接手或项目已切换,提案未创建".into()); + } + if input["version"].as_u64().filter(|version| *version >= 1) != Some(version) || w.workflow.last_read_version != Some(version) { + return Err("工作流已变化,请重新读取后提出建议".into()); + } + let run = w.pi_run.as_ref().ok_or("本地Agent会话已结束")?; + if w.workflow.pending.is_some() || w.workflow.proposed_run.as_ref() == Some(run) { + return Err("本轮已有待审核建议,不会重复创建".into()); + } + let intent = input["intent"].as_str().ok_or("工作流提案类型无效")?; + let title = title(intent)?; + let instruction = input.get("instruction").map_or(Ok(""), |instruction| instruction.as_str().ok_or("调整要求须为文本"))?; + if instruction.chars().count() > 500 || instruction.chars().any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t')) { + return Err("调整要求最多500字,不能包含控制字符".into()); + } + if !instruction.trim().is_empty() && intent != "selling_points" { + return Err("补充方向目前仅用于卖点;此动作使用当前项目配置,不会修改资料、尺寸或屏数".into()); + } + if w.unknown && ["analyze", "selling_points", "plan"].contains(&intent) { + return Err("原请求待核查,未提出新的模型调用".into()); + } + let output = output(w, input)?; + let slot = selected_slot(w, input, &output)?; + let variant = + input.get("variant").map(|value| value.as_str().filter(|variant| ["A", "B"].contains(variant)).ok_or("候选仅支持A或B")).transpose()?; + let proposal = json!({"version":version,"intent":intent,"title":title,"instruction":instruction,"outputId":output, + "slotId":slot,"variant":variant,"awaitingReview":true,"executed":false}); + w.workflow.proposed_run = Some(run.clone()); + w.workflow.pending = Some(Pending { public: proposal.clone(), guard: current_guard }); + Ok(json!({"proposed":true,"awaitingReview":true,"executed":false,"version":version,"proposal":proposal})) + } + _ => Err("工作流工具未开放".into()), + } +} + +fn direction(w: &Workspace, proposal: &Value) -> Result { + let note = proposal["instruction"].as_str().unwrap_or("").trim(); + let current = w.creation_direction.trim(); + let merged = if note.is_empty() { + w.creation_direction.clone() + } else if current.is_empty() { + note.into() + } else { + format!("{current}\n{note}") + }; + if merged.chars().count() > 500 { Err("当前表达方向与补充要求合计超过500字,请先精简".into()) } else { Ok(merged) } +} + +fn fill_input(w: &Workspace, proposal: &Value) -> Result { + if plan_dirty(w) { + return Err("产品资料或方案有未保存修改,请先保存并核对后再回填".into()); + } + let output = output(w, proposal)?; + let slot_id = proposal["slotId"].as_str(); + let document = slot_id + .and_then(|id| w.template_workflow["slots"].as_array()?.iter().find(|slot| slot["id"] == id && slot["outputId"] == output)) + .and_then(|slot| slot["documentId"].as_str()); + if slot_id.is_some() && document.is_none() { + return Err("当前图位已变化,未回填其他输出".into()); + } + if w.template_ui.inspect()["dirtySlots"].as_array().is_some_and(|dirty| !dirty.is_empty()) { + return Err("请先保存模板文字修改,再回填画布".into()); + } + let outputs = w.template_workflow["outputs"] + .as_array() + .into_iter() + .flatten() + .filter(|row| row["outputId"] == output && document.is_none_or(|document| row["documentId"] == document)) + .collect::>(); + if outputs.is_empty() || outputs.iter().any(|row| row["ready"] != true) { + return Err("当前输出尚未选齐成功候选,未回填占位内容".into()); + } + Ok(json!({"outputId":output,"documentId":document})) +} + +impl Workspace { + pub(super) fn workflow_dismiss(&mut self) { + self.workflow.clear(); + } + + pub(super) fn workflow_accept(&mut self, app: &mut PhotocraftApp) -> Result { + if self.pi_run.is_some() || self.busy.is_some() || self.saving.is_some() || !self.queue.is_empty() || self.pending_image.is_some() { + return Err("请等待当前回合和本机保存完成,提案尚未执行".into()); + } + let pending = self.workflow.pending.as_ref().cloned().ok_or("没有待审核的工作流建议")?; + if !owned(self, app) || self.pi_write_blocked || guard(self, app) != pending.guard { + self.workflow.clear(); + return Err("项目、资料或画布已有变化,旧建议已失效;请重新讨论".into()); + } + let intent = pending.public["intent"].as_str().ok_or("工作流建议无效")?; + if self.unknown && ["analyze", "selling_points", "plan"].contains(&intent) { + return Err("原请求结果待核查,未执行新的模型调用".into()); + } + let input = match intent { + "selling_points" => json!({"direction":direction(self,&pending.public)?}), + "fill_output" => fill_input(self, &pending.public)?, + _ => json!({}), + }; + // Consume before invoking any old action. A dispatched, timed-out or + // unknown operation must never be recreated by clicking the card twice. + self.workflow.clear(); + match intent { + "analyze" => self.creation_action(app, "creation-facts", input), + "selling_points" => self.creation_action(app, "creation-points", input), + "plan" => self.creation_action(app, "creation-plan", input), + "review_plan" => { + self.show_creation_step(2, true); + Ok(json!({"navigated":true,"stage":"plan","confirmed":false})) + } + "review_candidates" => { + let output = output(self, &pending.public)?; + let slot = pending.public["slotId"].as_str(); + self.template_ui.navigate(&output, slot); + self.show_creation_step(4, true); + Ok(json!({"navigated":true,"stage":"slots","adopted":false})) + } + "fill_output" => self.slot_action(app, "slot-apply", input), + "export" => crate::export_dialog::open(app).map(|dialog| json!({"dialog":dialog,"exported":false})), + _ => Err("工作流提案类型未开放".into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> (Workspace, PhotocraftApp, mpsc::Receiver) { + let (tx, requests) = mpsc::channel(); + let (_, rx) = mpsc::channel(); + let mut w = Workspace::new(tx, rx); + let _ = requests.try_recv(); + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width":1440,"height":1800})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.sync_views(); + let current = app.session.active().unwrap(); + let doc = current.doc.id.0; + w.account = json!({"id":"owner"}); + w.owner_doc = Some(doc); + w.loaded_draft = Some(doc); + w.expected_revision = current.revision; + w.pi_run = Some("12345678-1234-1234-1234-123456789012".into()); + w.pi_binding = json!({"accountId":w.account["id"],"origin":w.origin}); + w.busy = Some("pi-agent".into()); + w.model = "chat".into(); + w.image_model = "image".into(); + w.creation = json!({"revision":2,"width":1440,"screenCount":3,"brief":creation::empty_brief(),"facts":{"status":"ready","items":[{"text":"黑色桌架","source":"实拍核对"}],"missing":["尺寸待确认"]}, + "sellingPoints":{"selected":["batch:0"],"batches":[{"id":"batch","items":["黑色桌架"]}]},"plan":{"status":"confirmed","screens":[{"id":"screen","name":"首屏","title":"电脑桌","body":"产品介绍","imagePrompt":"PRIVATE_IMAGE_PROMPT"}]}}); + w.creation["brief"]["name"] = json!("电脑桌"); + w.creation["brief"]["images"] = json!([{"source":"PRIVATE_LOCAL_IMAGE","path":"C:\\PRIVATE_RAW_IMAGE.png"}]); + w.creation["api_key"] = json!("PRIVATE_KEY"); + w.creation_brief = w.creation["brief"].clone(); + w.creation_plan = w.creation["plan"]["screens"].clone(); + w.creation_selected = vec!["batch:0".into()]; + w.creation_doc = Some(doc); + w.template_workflow = json!({"revision":4,"confirmed":true,"capabilities":{"localApplyAllowed":true,"imageDispatchAllowed":true,"copyDispatchAllowed":true}, + "outputs":[{"outputId":"main_images","documentId":"M01","name":"主图","ready":true,"requiredImages":1,"selectedImages":1}], + "slots":[{"id":"main-slot","fieldId":"M01-I01","documentId":"M01","outputId":"main_images","label":"产品主图","selectedCandidateId":"candidate-b", + "candidates":[{"id":"candidate-b","variant":"B","status":"complete","path":"C:\\PRIVATE_CANDIDATE.png"}]}]}); + w.template_ui.navigate("main_images", Some("main-slot")); + let project = "12345678-1234-4234-8234-123456789012"; + w.open_projects.insert(doc, w.local_identity(project)); + w.workflow.bind_identity(&"a".repeat(64), project, doc); + (w, app, requests) + } + + fn call(w: &mut Workspace, app: &mut PhotocraftApp, method: &str, mut input: Value) -> Result { + input["run"] = json!(w.pi_run); + input["documentId"] = json!(w.owner_doc); + action(w, app, method, &input) + } + + fn propose(w: &mut Workspace, app: &mut PhotocraftApp, intent: &str) -> Value { + let read = call(w, app, "pi-workflow-read", json!({})).unwrap(); + call(w, app, "pi-workflow-propose", json!({"version":read["version"],"intent":intent})).unwrap() + } + + fn ended(w: &mut Workspace) { + w.pi_run = None; + w.busy = None; + w.allow_apply = false; + } + + #[test] + fn workflow_reads_are_live_scoped_and_never_export_assets_prompts_or_keys() { + let (mut w, mut app, requests) = fixture(); + for section in ["overview", "materials", "points", "plan", "slots"] { + let value = call(&mut w, &mut app, "pi-workflow-read", json!({"section":section})).unwrap(); + assert_eq!(value["available"], true); + assert_eq!(value["productEvidence"]["accountScope"], "a".repeat(64)); + assert_eq!(value["productEvidence"]["documentId"].as_u64(), w.owner_doc); + let text = value.to_string(); + for hidden in ["PRIVATE_LOCAL_IMAGE", "PRIVATE_RAW_IMAGE", "PRIVATE_CANDIDATE", "PRIVATE_IMAGE_PROMPT", "PRIVATE_KEY"] { + assert!(!text.contains(hidden), "{section} leaked {hidden}"); + } + } + assert!(requests.try_recv().is_err()); + w.workflow.owner = None; + assert_eq!(call(&mut w, &mut app, "pi-workflow-read", json!({})).unwrap()["available"], false); + } + + #[test] + fn navigation_does_not_grant_writes_or_prevent_one_proposal_but_handover_does() { + let (mut w, mut app, requests) = fixture(); + let first = call(&mut w, &mut app, "pi-workflow-read", json!({})).unwrap(); + let navigation = call(&mut w, &mut app, "pi-workflow-navigate", json!({"stage":"plan"})).unwrap(); + assert_eq!(first["version"], navigation["version"]); + assert!(!w.allow_apply); + assert!(!w.pi_write_blocked); + let suggestion = call(&mut w, &mut app, "pi-workflow-propose", json!({"version":navigation["version"],"intent":"review_candidates"})).unwrap(); + assert_eq!(suggestion["executed"], false); + assert_eq!(w.workflow.proposal().unwrap()["outputId"], "main_images"); + assert_eq!(w.workflow.proposal().unwrap()["slotId"], "main-slot"); + assert!(requests.try_recv().is_err()); + w.handover(); + assert!(call(&mut w, &mut app, "pi-workflow-navigate", json!({"stage":"slots"})).is_err()); + let version = w.workflow.version; + assert!(call(&mut w, &mut app, "pi-workflow-propose", json!({"version":version,"intent":"plan"})).is_err()); + assert!(!w.allow_apply); + assert!(w.pi_write_blocked); + } + + #[test] + fn versions_follow_unsaved_input_changes_and_a_stale_proposal_is_never_refreshed() { + let (mut w, mut app, requests) = fixture(); + let original = call(&mut w, &mut app, "pi-workflow-read", json!({})).unwrap(); + assert_eq!(call(&mut w, &mut app, "pi-workflow-read", json!({})).unwrap()["version"], original["version"]); + w.creation_brief["name"] = json!("未保存的新产品名"); + let changed = call(&mut w, &mut app, "pi-workflow-read", json!({})).unwrap(); + assert!(changed["version"].as_u64() > original["version"].as_u64()); + assert_eq!(changed["productEvidence"]["facts"]["status"], "stale"); + assert!(call(&mut w, &mut app, "pi-workflow-propose", json!({"version":original["version"],"intent":"analyze"})).is_err()); + propose(&mut w, &mut app, "analyze"); + ended(&mut w); + w.creation_brief["dimensions"]["length"] = json!("1200"); + assert!(w.workflow_accept(&mut app).is_err()); + assert!(w.workflow.proposal().is_none()); + assert!(requests.try_recv().is_err()); + } + + #[test] + fn paid_accept_waits_for_durable_pi_end_and_double_accept_dispatches_only_once() { + let (mut w, mut app, requests) = fixture(); + propose(&mut w, &mut app, "selling_points"); + assert!(w.workflow_accept(&mut app).is_err()); + assert!(requests.try_recv().is_err()); + w.pi_run = None; + w.busy = Some("agent".into()); + assert!(w.workflow_accept(&mut app).is_err(), "pi-end still awaits the durable round receipt"); + assert!(requests.try_recv().is_err()); + ended(&mut w); + w.workflow_accept(&mut app).unwrap(); + assert!(matches!(requests.try_recv().unwrap(),Request::Action(method,_) if method=="creation-points")); + assert!(w.workflow_accept(&mut app).is_err()); + assert!(requests.try_recv().is_err()); + } + + #[test] + fn local_fill_keeps_unknown_and_the_exact_existing_save_ack_path() { + let (mut w, mut app, requests) = fixture(); + propose(&mut w, &mut app, "fill_output"); + ended(&mut w); + w.unknown = true; + let submitted = w.workflow_accept(&mut app).unwrap(); + assert_eq!(submitted["saving"], true); + let (doc, revision) = match requests.try_recv().unwrap() { + Request::Save(doc, revision, _) => (doc.id.0, revision), + _ => panic!("save must precede slot application"), + }; + assert!(requests.try_recv().is_err()); + w.slot_apply_saved(&app, doc, revision); + assert!( + matches!(requests.try_recv().unwrap(),Request::Action(method,input) if method=="slot-apply" && input["outputId"]=="main_images" && input["documentId"]=="M01") + ); + assert!(w.unknown); + assert!(w.workflow_accept(&mut app).is_err()); + } + + #[test] + fn review_is_not_confirmation_or_adoption_and_invalid_arguments_have_no_effect() { + let (mut w, mut app, requests) = fixture(); + let before = w.template_workflow.clone(); + let read = call(&mut w, &mut app, "pi-workflow-read", json!({})).unwrap(); + assert!(call(&mut w, &mut app, "pi-workflow-propose", json!({"version":read["version"],"intent":"review_plan","confirmed":true})).is_err()); + propose(&mut w, &mut app, "review_candidates"); + ended(&mut w); + w.unknown = true; + assert_eq!(w.workflow_accept(&mut app).unwrap()["adopted"], false); + assert_eq!(w.template_workflow, before); + assert_eq!(w.creation_step, 4); + assert!(requests.try_recv().is_err()); + } + + #[test] + fn another_account_or_document_cannot_read_or_accept_the_original_proposal() { + for change in ["account", "origin", "document"] { + let (mut w, mut app, requests) = fixture(); + propose(&mut w, &mut app, "analyze"); + match change { + "account" => w.account = json!({"id":"other"}), + "origin" => w.origin = "http://127.0.0.1:3138".into(), + _ => { + app.session.execute("file.new", json!({"width":750,"height":500})).unwrap(); + } + } + assert!(call(&mut w, &mut app, "pi-workflow-read", json!({})).is_err(), "{change}"); + ended(&mut w); + assert!(w.workflow_accept(&mut app).is_err(), "{change}"); + assert!(w.workflow.proposal().is_none()); + assert!(requests.try_recv().is_err()); + } + } + + #[test] + fn workflow_receipts_use_actual_results_and_never_append_to_another_project() { + for outcome in ["complete", "unknown", "switched", "failed"] { + let (mut w, mut app, _) = fixture(); + let doc = app.session.active().unwrap().doc.id.0; + w.workflow_waiting = Some(json!({"method":"creation-plan","accountId":w.account["id"],"origin":w.origin,"doc":doc})); + let before = w.messages.len(); + let mut value = json!({"accountId":w.account["id"],"origin":w.origin,"doc":doc,"pending":outcome=="unknown"}); + if outcome == "switched" { + app.session.execute("file.new", json!({"width":750,"height":500})).unwrap(); + value["pending"] = json!(false); + } + let result = if outcome == "failed" { Err("local failure".into()) } else { Ok(value) }; + w.workflow_feedback(&app, "creation-plan", &result); + assert!(w.workflow_waiting.is_none()); + if outcome == "switched" { + assert_eq!(w.messages.len(), before); + } else { + let text = w.messages.last().unwrap()["text"].as_str().unwrap(); + match outcome { + "unknown" => assert!(text.contains("待核查") && !text.contains("已生成")), + "failed" => assert!(text.contains("未完成") && !text.contains("已生成")), + _ => assert!(text.contains("等待你确认")), + } + } + } + } + + #[test] + fn unsupported_instructions_are_not_silently_ignored_or_saved_as_selling_direction() { + for intent in ["analyze", "plan", "review_plan", "review_candidates", "fill_output", "export"] { + let (mut w, mut app, requests) = fixture(); + let before = w.creation_direction.clone(); + let read = call(&mut w, &mut app, "pi-workflow-read", json!({})).unwrap(); + assert_eq!(read["width"], 1440); + assert_eq!(read["screenCount"], 3); + assert!(call(&mut w, &mut app, "pi-workflow-propose", json!({"version":read["version"],"intent":intent,"instruction":"改为12屏"})).is_err()); + assert!(w.workflow.proposal().is_none()); + assert_eq!(w.creation_direction, before); + assert!(requests.try_recv().is_err()); + } + } + + #[test] + fn creation_loading_and_failed_reads_never_project_a_previous_products_data() { + for loading in [true, false] { + let (mut w, mut app, requests) = fixture(); + if loading { + w.creation_doc = None; + } else { + w.creation_read_failed = true; + } + let read = call(&mut w, &mut app, "pi-workflow-read", json!({"section":"materials"})).unwrap(); + assert_eq!(read["available"], false); + assert!(read["productEvidence"].is_null()); + assert!(!read.to_string().contains("电脑桌")); + assert!(call(&mut w, &mut app, "pi-workflow-propose", json!({"version":read["version"],"intent":"analyze"})).is_err()); + assert!(requests.try_recv().is_err()); + } + } + + #[test] + fn an_agent_output_navigation_survives_the_template_panels_first_render() { + let (mut w, mut app, requests) = fixture(); + let current = json!({"doc":w.owner_doc,"accountId":w.account["id"],"origin":w.origin, + "creation":w.creation,"templateWorkflow":w.template_workflow,"pending":false}); + w.receive_creation_ui(&app, "creation-read", ¤t); + call(&mut w, &mut app, "pi-workflow-navigate", json!({"stage":"slots","outputId":"main_images"})).unwrap(); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ProMedium); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| w.creation_ui(&mut app, ui)); + output.textures_delta.clear(); + assert_eq!(w.template_ui.inspect()["outputId"], "main_images"); + assert!(!w.pi_write_blocked); + for request in requests.try_iter() { + if let Request::Action(method, _) = request { + assert!(!matches!(method.as_str(), "slot-generate" | "slot-copy" | "creation-facts" | "creation-plan")); + } + } + } +} diff --git a/docs/photocraft-agent-workflow-2026-10-23.md b/docs/photocraft-agent-workflow-2026-10-23.md new file mode 100644 index 00000000..add1e817 --- /dev/null +++ b/docs/photocraft-agent-workflow-2026-10-23.md @@ -0,0 +1,69 @@ +# PhotoCraft Agent 与创作流程协作契约 + +更新:2026-10-23。界面沿用 PhotoCraft 原生、极简的 **Operate** 工作面。本轮三类工具的本地接入、原生交互与一次真实模型只读对话已通过;验收范围见末节,不代表整套生成流程已经通过。 + +## 一份项目,两种操作方式 + +中间工作面负责资料、卖点、方案、图位和画布的直接操作;右侧 Agent 读取同一个项目,协助理解、提出动作、解释结果。用户可以随时在两边切换,不必把所有操作改成聊天。 + +资料草稿、卖点选择、方案版本、候选采用和画布状态各只有一份权威记录。品牌、品类、规则和模板随项目加载,Agent 不要求重复选择。当前提案引用真实对象与版本,不维护聊天专用副本;执行或开始新对话后收起提案,真实操作摘要保留。品牌或品类在回合中变化时,停止使用旧规则,待新回合重新加载对应规则。 + +## 对话如何贯穿流程 + +| 阶段与对话样例 | Agent 的行为与用户入口 | +| --- | --- | +| **资料**:“用这些实拍图整理产品资料,缺的先列出来。” | 读取当前资料,提出“分析资料”动作;用户在本机卡片执行,沿用原分析入口。事实和待确认项写入原工作面,不凭图片编造尺寸、性能或不可见结构。 | +| **卖点**:“再给一批偏简洁、适合这家店铺的卖点。” | 结合已知事实与当前规则提出新批次。生成与采用分开;用户仍在同一集合勾选,沿用每批最多6条、最多12批、跨批最多选择20条的约束。 | +| **方案**:“用选中的卖点做五屏方案,先让我看大纲。” | 提出生成方案或查看方案动作。大纲可在中间直接修改;只有用户明确确认当前版本后,才允许进入对应生图流程。Agent 的一句“已确认”不构成确认。 | +| **图位**:“打开首屏的 A/B,让我选。” | 定位真实图位与已有候选,进入同一 A/B 工作面。成功结果不等于视觉合格或已采用;用户明确采用 A 或 B,再按范围回填。未完成的其他图位保持未完成,不拿占位图或别的产品冒充。 | +| **编辑**:“把当前标题精简一些,其他部分先保留。” | 先读取当前画布和选择,在已有写入许可及版本校验下逐步执行原生命令。实际动作可见、可接手,与手动编辑共用撤销历史;改字不另调生图。 | +| **导出**:“打开当前画布的图片导出设置。” | 打开原生图片导出设置,范围是当前画布,格式和保存位置在原生设置中确认。工程保存、PNG/JPG导出和PSD另存分别以真实回执反馈;打开设置不等于文件已生成。现有分层PSD仍通过“文件→另存为”使用,本轮不冒充已接通Agent专用PSD或多输出自动导出。 | + +资料、品牌、规则或模型变化使方案失效时,两侧都显示同一失效状态,旧确认不得继续授权新图。已有有效确认不重复索要确认;但方案确认、A/B采用、当前屏回填是不同决策,不能互相替代。 + +## 右侧保持精简 + +常驻内容仅保留当前对象的紧凑上下文、输入框、对话/生图模型、设置、发送和必要运行状态。具体动作放在当前对话末尾的一张提案卡片中:短标题、受信目标范围、一句必要说明、一个主按钮;操作记录按需展开。回填必须区分“S01”等单屏与“主图全组”等整组范围,不能依靠模型自由文字描述执行范围。 + +本轮卡片承接动作提案和真实结果入口,不把聊天文字冒充结构化产品资料或方案。执行后更新真实状态,必要时提供“查看资料”“查看方案”“查看候选”等入口;不再复制一套表单、阶段选单、品牌品类参数或说明墙。用户手动操作只产生简短真实摘要,不伪造用户消息或不断追加重复日志。 + +## 接手、上下文与异常 + +- **手动优先。** 用户修改草稿、切项目、切工作面或接手画布后,Agent 必须重新读取状态;旧提案的项目、草稿签名或版本不匹配时停止执行。草稿保留,不以旧 canonical 对象整份覆盖未保存内容。 +- **停止自动应用不等于取消模型。** 已派发结果仍归原任务保存;用户接手后,迟到结果不得覆盖当前画布。本轮写许可失效后不能自行恢复。 +- **上下文按需读取。** 自动带入当前项目身份、所需事实、已选卖点、相关方案/图位及固定规则版本;只加载当前任务相关的品牌、店铺和品类规则。避免每轮重复塞入整份知识库、全部图层、历史图片字节和全部任务日志。历史卡片保留引用,不能代替最新状态读取。 +- **unknown 不重发。** 保留原请求号、幂等信息和原日志,只核查原结果。不得清除 unknown 来开放按钮、把超时当作失败重试,或用新请求替换原任务。独立本地操作是否可用,以现有阶段能力和保护条件为准。 +- **反馈有边界。** 本机草稿保存、云同步、工程文件保存及导出分别反馈。失败保留已有草稿与结果,不将提案、开始执行或部分输出写成完成。 + +## 本轮范围与后续边界 + +本轮实现范围为 **workflow_read / workflow_navigate / workflow_propose**:读取当前工作流、定位原工作面,以及提出以下七类动作。它们不构成任意后台接口或文件操作工具。 + +| intent | 本轮用途 | +| --- | --- | +| analyze | 提出资料分析 | +| selling_points | 提出新一批卖点 | +| plan | 提出生成页面方案 | +| review_plan | 打开当前方案,由用户查看、修改和确认 | +| review_candidates | 打开当前图位候选,由用户比较和采用 | +| fill_output | 按明确输出范围回填,保留原保存 ACK 和版本保护 | +| export | 打开当前画布的原生图片导出设置,不自动导出文件 | + +执行顺序是:**读取/提案 → Pi 轮真正结束、持久化回执释放 busy → 用户点击本机卡片 → 原 action 执行 → 读取真实结果续聊**。轮次未落定时卡片不调用原动作;流式文字结束不等于轮次完成,不能临时清 busy 或直发后台请求绕过原保护。当前屏回填仍须先保存,再核对账户、项目、画布/图位版本与能力;Agent 画布编辑仍走原有受限命令与撤销队列。 + +**本轮不承诺**:Agent 全链自主执行、任意资料写入、自动视觉判优、跨耗时任务持续自主推进,或无限批量生图。后续的类型化资料 patch、跨任务 continuation、多图有限授权需要独立实现及验收,不能通过万能工具提前开放。 + +本轮 `instruction` 只用于卖点表达方向。分析和方案使用当前已配置的资料、选中项、宽度和屏数;其他意图携带非空 `instruction` 会明确拒绝,不能静默忽略要求或把它误写成卖点方向。通过对话直接修改屏数、尺寸及分析/方案的专门要求,应由后续强类型参数接入后再验收。 + +根 Agent 验收应覆盖:手动与对话读取同一状态;旧提案遇修改/切项目被拒;人工方案确认及候选采用不可跳过;Pi 未结束时不绕 busy;回填真实 Save ACK;接手和撤销;unknown 原请求不重发。本轮真实模型调用、耗时流程及导出文件仍须分别出具实际证据,不能以文档或工具注册代替。 + +## 本轮验收结果 + +- Pi SDK 本地回归 **63/63**;原生工作台 **122/122**;后端 **172 通过、5 项专用环境测试未运行**。严格 Clippy、31-crate 分层检查与25项Wasm检查通过。测试覆盖实时草稿、规则域变化、加载失败不读取旧资料、接手、重复点击、Save ACK、迟到结果及未知请求不重放。 +- Windows 原生单窗口验收通过:读取同一份资料 → 工具导航 → 提案在 Pi 未结束时不可执行 → 实际点击卡片打开方案 → 修改颜色后拒绝旧分析建议 → 下一轮读取“米白色”并正确打开主图图位。首次图位初始化重置导航的问题已修复,并加入实际首帧渲染回归。 +- 使用当前账号个人Key与所选 **Grok 4.7** 完成一次真实只读对话,3次调用均为 `complete`;实际工具读取了修改后的资料并打开原生方案工作面。没有生成图片、没有重发请求。三次输入共14,464 token,报告的缓存读取共6,912 token(约47.8%),只代表这次测试,不承诺固定缓存率;测试未配置品牌知识库,不代表真实品牌长上下文的缓存表现。 +- 原 `a771ab28-f214-476d-8a13-4ed7037176f9` 未知请求的当前记录与归档均保持原 SHA256;正式用户资料、素材和草稿未修改。失败测试的本地资料、额外构建测试文件和任务内临时目录已清理,最终模型原始记录及验收证据保留,客户端与隐藏适配进程均已退出。 + +证据位于本机 `output/agent-workflow-integration-20261023/`:`native-smoke-4/acceptance.json`、`pi-final-regression.log`、`native-ui.log`、`native-backend.log`、`native-clippy.log`、`native-layers.log`、`native-wasm.log`、`original-unknown-preserved.json`。原生UI图像审查覆盖1440×900实际窗口;既有布局单测覆盖1100×720至1920×1080及960×600逻辑画幅,不能冒充全部DPI、主题或系统辅助技术验收。 + +本轮**未重跑**由Agent提案卡触发的整套付费资料/卖点/方案生成、多图A/B、生图、跨任务自动续接与分层PSD导出;这些动作的原接口和保护继续保留,后续应按真实产品逐段验收。服务器没有需要随本轮部署的新接口。曾有5个早期测试临时目录(合计45,042字节)被自动审批以 `blocked by policy` 拒绝删除,路径与原因留在 `temporary-cleanup-refusal.json`,未改用其他工具绕过。 From 91796343bf0e1581c202e371f0ccb945325ee3ff Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Wed, 28 Oct 2026 19:30:31 +0800 Subject: [PATCH 6/9] fix(deploy): preserve desktop feature configuration in blue-green releases --- scripts/git-incremental-deploy.ps1 | 9 ++++++++- scripts/git-incremental-deploy.test.mjs | 27 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index e2236c82..0aa5f455 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -1085,7 +1085,7 @@ MIGRATION_AUDIT_SOURCE_COMMIT="$2" REQUIRE_MIGRATION_AUDIT="$3" DEPLOYMENT_STRATEGY="${4:-direct}" DETAIL_PRIVATE_MOUNT="/mnt/PoolOne/apps/youyidian-3d/detail-generator-private" -DETAIL_FEATURE_ENABLED=false +DETAIL_FEATURE_ENABLED=uninitialized log() { printf '[INFO] %s\n' "$*"; } ok() { printf '[OK] %s\n' "$*"; } @@ -1096,6 +1096,10 @@ create_container() { local name="$1" local image="$2" local publish_host_port="${3:-0}" + case "${DETAIL_FEATURE_ENABLED:-}" in + true|false) ;; + *) warn "Detail feature configuration was not prepared; no container was created"; return 1 ;; + esac local deploy_version local transcode_instance_id local sanitized_env_file @@ -3852,6 +3856,9 @@ if [ -n "$LEGACY_IMAGE" ] && container_running "$CONTAINER_NAME"; then esac fi +# Resolve the approved feature configuration before blue-green creates a container. +prepare_detail_generator_release || fail "Detail generator configuration or private persistent storage is invalid; no candidate was created" + # 蓝绿入口和意外残留的直连容器都可能运行 worker;必须逐个证明当前协议、 # 设置 drain 并等待 active 消失。任一状态不明都保留原容器并停止发布。 for TRANSCODE_DRAIN_SOURCE in $TRANSCODE_DRAIN_SOURCES; do diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs index 791346ed..b6fc68e7 100644 --- a/scripts/git-incremental-deploy.test.mjs +++ b/scripts/git-incremental-deploy.test.mjs @@ -490,6 +490,33 @@ test('detail beta configuration validates enabled key/base without exposing prov assert.doesNotMatch(source, /-e [^\n]*DETAIL_GENERATOR_API_KEY/) }) +test('normal blue-green releases prepare the feature before draining or creating a candidate', () => { + const source = deploySource() + const main = source.slice(source.indexOf('\nverify_bootstrap_handoff_if_present\n')) + const prepare = main.indexOf('prepare_detail_generator_release || fail ') + assert.ok(prepare > 0, 'normal deployment must read the approved feature configuration') + assert.ok(prepare < main.indexOf('for TRANSCODE_DRAIN_SOURCE in $TRANSCODE_DRAIN_SOURCES; do')) + assert.ok(prepare < main.indexOf('create_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT"')) + assert.match(source, /DETAIL_FEATURE_ENABLED=uninitialized/) +}) + +test('container creation refuses an unprepared feature flag before any Docker or filesystem operation', () => { + for (const state of ['', 'uninitialized', 'invalid']) { + const script = `set -eu +DETAIL_FEATURE_ENABLED='${state}' +warn() { printf '%s\\n' "$*" >&2; } +docker() { printf 'UNEXPECTED_DOCKER'; return 99; } +mktemp() { printf 'UNEXPECTED_WRITE'; return 99; } +${shellFunction('create_container')} +create_container material-blue local:fixture 0 +` + const result = spawnSync(bash, ['-s'], { input: script, encoding: 'utf8' }) + assert.equal(result.status, 1) + assert.match(result.stderr, /configuration was not prepared/) + assert.doesNotMatch(result.stdout, /UNEXPECTED_/) + } +}) + test('fixed private storage rejects overlapping uploads before creating directories', () => { const check = shellFunction('detail_private_mount_is_safe') const privateRoot = '/mnt/PoolOne/apps/youyidian-3d/detail-generator-private' From e83b0e9d5de5bca00a93a15ea18ddd2fa1e24598 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 29 Oct 2026 11:31:23 +0800 Subject: [PATCH 7/9] fix(deploy): require desktop storage admission before proxy cutover --- scripts/desktop-release-admission.py | 240 +++++++++++++++++++ scripts/desktop-release-admission.test.py | 178 ++++++++++++++ scripts/git-incremental-deploy-macos.mjs | 6 + scripts/git-incremental-deploy.ps1 | 36 ++- scripts/git-incremental-deploy.test.mjs | 35 +++ scripts/install-desktop-release-admission.py | 156 ++++++++++++ 6 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 scripts/desktop-release-admission.py create mode 100644 scripts/desktop-release-admission.test.py create mode 100644 scripts/install-desktop-release-admission.py diff --git a/scripts/desktop-release-admission.py b/scripts/desktop-release-admission.py new file mode 100644 index 00000000..e0544a44 --- /dev/null +++ b/scripts/desktop-release-admission.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Project-scoped, read-only traffic admission; attestation never calls a model.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import tempfile +import time +import urllib.request + +BASE = Path('/mnt/PoolOne/apps/youyidian-3d') +POLICY = BASE / 'release-policy/desktop-admission' +RECEIPTS = POLICY / 'receipts' +ENV_FILE = BASE / '.env' +PRIVATE = BASE / 'detail-generator-private' +PROXY = BASE / 'local-proxy/youyidian-3d-material/nginx.conf' +NETWORK = 'youyidian-3d_app-network' +NAMES = {'youyidian-3d-material-blue', 'youyidian-3d-material-green'} +VERSION = 1 + + +class Rejected(Exception): + """Only fixed, non-secret reason codes may leave this process.""" + + +def require(condition, code): + if not condition: + raise Rejected(code) + + +def command(args): + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=12) + require(result.returncode == 0, 'runtime_inspection_failed') + return result.stdout + except (OSError, subprocess.TimeoutExpired): + raise Rejected('runtime_inspection_failed') from None + + +def physical(path, directory=False, owner=None, mode=None): + """Never follow a substituted private store, policy file or ancestor.""" + path = Path(path) + for part in (path, *path.parents): + require(not part.is_symlink(), 'symlink_rejected') + try: + value = path.stat() + except OSError: + raise Rejected('required_path_missing') from None + require(stat.S_ISDIR(value.st_mode) if directory else stat.S_ISREG(value.st_mode), + 'path_type_mismatch') + if owner is not None: + require((value.st_uid, value.st_gid) == owner, 'path_owner_mismatch') + if mode is not None: + require(stat.S_IMODE(value.st_mode) == mode, 'path_mode_mismatch') + return value + + +def enabled_from_env(text): + # Accept the unambiguous subset of Docker --env-file syntax. In particular, + # quotes/comments in a value are literal, not dotenv syntax. Ambiguity stops + # the release instead of silently turning authorization off. + values = [] + for line in text.removeprefix('\ufeff').splitlines(): + line = line.lstrip() + if not line or line.startswith('#'): + continue + key, sep, value = line.partition('=') + if key == 'DETAIL_GENERATOR_ENABLED': + require(sep and value in ('true', 'false'), 'approved_flag_invalid') + values.append(value) + elif key.strip() == 'DETAIL_GENERATOR_ENABLED': + raise Rejected('approved_flag_invalid') + require(len(values) == 1, 'approved_flag_ambiguous') + return values[0] == 'true' + + +def environment_values(lines, key): + return [line.partition('=')[2] for line in lines if line.partition('=')[0] == key] + + +def config_target(text): + names = set(re.findall(r'^\s*add_header X-Material-Proxy-Target ([^;\s]+) always;\s*$', text, re.M)) + ips = set(re.findall(r'^\s*server (\d+\.\d+\.\d+\.\d+):3000\s+[^;]*;\s*$', text, re.M)) + require(len(names) == 1 and names <= NAMES and len(ips) == 1, 'proxy_target_ambiguous') + return next(iter(names)), next(iter(ips)) + + +def inspect_target(name, expected_enabled, private_identity): + require(name in NAMES, 'container_scope_rejected') + try: + data = json.loads(command(['docker', 'inspect', name])) + except (ValueError, TypeError): + raise Rejected('runtime_inspection_invalid') from None + require(isinstance(data, list) and len(data) == 1, 'runtime_inspection_invalid') + d = data[0] + require(d.get('Name') == '/' + name and d.get('State', {}).get('Running') is True, + 'container_not_running') + container_id, image_id = d.get('Id', ''), d.get('Image', '') + require(re.fullmatch(r'[a-f0-9]{64}', container_id), 'container_identity_invalid') + require(re.fullmatch(r'sha256:[a-f0-9]{64}', image_id), 'image_identity_invalid') + cfg = d.get('Config') or {} + env = cfg.get('Env') or [] + expected = 'true' if expected_enabled else 'false' + require(environment_values(env, 'DETAIL_GENERATOR_ENABLED') == [expected], 'runtime_flag_mismatch') + if expected_enabled: + require(environment_values(env, 'DETAIL_GENERATOR_STORAGE_DIR') == ['/app/detail-generator-private'], + 'runtime_storage_config_mismatch') + mounts = [m for m in d.get('Mounts', []) if m.get('Destination') == '/app/detail-generator-private'] + require(len(mounts) == 1, 'private_mount_missing_or_ambiguous') + mount = mounts[0] + require(mount.get('Source') == str(PRIVATE) and mount.get('Type') == 'bind' and mount.get('RW') is True, + 'private_mount_mismatch') + # Access check only. Never create/delete material files in a gate. + access = "require('fs').accessSync('/app/detail-generator-private',require('fs').constants.R_OK|require('fs').constants.W_OK)" + command(['docker', 'exec', '--user', '1001:1001', name, 'node', '-e', access]) + revision = (cfg.get('Labels') or {}).get('org.opencontainers.image.revision', '') + require(re.fullmatch(r'[a-f0-9]{40}', revision), 'revision_invalid') + require(command(['docker', 'exec', name, 'cat', '/app/DEPLOYMENT_COMMIT']).strip() == revision, + 'baked_revision_mismatch') + network = (d.get('NetworkSettings', {}).get('Networks') or {}).get(NETWORK) or {} + ip = network.get('IPAddress', '') + require(re.fullmatch(r'\d+\.\d+\.\d+\.\d+', ip), 'network_identity_invalid') + return {'container': name, 'containerId': container_id, 'imageId': image_id, + 'revision': revision, 'enabled': expected_enabled, 'privateIdentity': private_identity, + 'ip': ip} + + +def http_json(url): + try: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + request = urllib.request.Request(url, headers={'User-Agent': 'gongxiangp2-desktop-admission/1', 'Connection': 'close'}) + with opener.open(request, timeout=5) as response: + require(response.status == 200, 'candidate_config_unavailable') + return json.loads(response.read(16385)) + except Rejected: + raise + except Exception: + raise Rejected('candidate_config_unavailable') from None + + +def current_snapshot(): + physical(ENV_FILE) + enabled = enabled_from_env(ENV_FILE.read_text(encoding='utf-8-sig')) + identity = None + if enabled: + value = physical(PRIVATE, directory=True, owner=(1001, 1001), mode=0o700) + identity = [value.st_dev, value.st_ino, value.st_uid, value.st_gid] + physical(PROXY) + name, ip = config_target(PROXY.read_text(encoding='utf-8')) + value = inspect_target(name, enabled, identity) + require(value['ip'] == ip, 'proxy_network_mismatch') + config = http_json('http://' + ip + ':3000/api/desktop/config') + require(isinstance(config, dict) and config.get('enabled') is enabled + and config.get('clientId') == 'jiansun-detail-desktop' and config.get('pkce') == 'S256', + 'candidate_config_mismatch') + value.pop('ip') + return value + + +def policy_digest(): + return hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + + +def receipt_path(snapshot): + return RECEIPTS / (snapshot['containerId'] + '.json') + + +def attest(snapshot, entry_sha): + require(re.fullmatch(r'[a-f0-9]{64}', entry_sha), 'entry_digest_invalid') + physical(POLICY, directory=True, owner=(0, 0), mode=0o700) + physical(RECEIPTS, directory=True, owner=(0, 0), mode=0o700) + receipt = {'schema': VERSION, 'policySha256': policy_digest(), 'entrySha256': entry_sha, + 'verifiedAt': int(time.time()), 'snapshot': snapshot} + target = receipt_path(snapshot) + if target.exists(): + physical(target, owner=(0, 0), mode=0o600) + descriptor, temp = tempfile.mkstemp(prefix='.attest-', dir=RECEIPTS) + try: + with os.fdopen(descriptor, 'w', encoding='utf-8') as stream: + os.fchown(stream.fileno(), 0, 0) + os.fchmod(stream.fileno(), 0o600) + json.dump(receipt, stream, sort_keys=True) + stream.flush() + os.fsync(stream.fileno()) + # Recheck identity after HTTP/filesystem observation, before publishing. + require(current_snapshot() == snapshot, 'candidate_changed_during_attestation') + os.replace(temp, target) + finally: + if os.path.exists(temp): + os.unlink(temp) + + +def validate_receipt(snapshot): + physical(POLICY, directory=True, owner=(0, 0), mode=0o700) + physical(RECEIPTS, directory=True, owner=(0, 0), mode=0o700) + target = receipt_path(snapshot) + physical(target, owner=(0, 0), mode=0o600) + try: + receipt = json.loads(target.read_text(encoding='utf-8')) + except (OSError, ValueError): + raise Rejected('receipt_invalid') from None + require(receipt.get('schema') == VERSION and receipt.get('policySha256') == policy_digest() + and re.fullmatch(r'[a-f0-9]{64}', receipt.get('entrySha256', '')) + and receipt.get('snapshot') == snapshot, 'candidate_not_attested') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--attest-config', action='store_true') + parser.add_argument('--entry-sha256', default='') + args = parser.parse_args() + try: + require(os.geteuid() == 0, 'root_required') + snapshot = current_snapshot() + if args.attest_config: + attest(snapshot, args.entry_sha256) + else: + require(not args.entry_sha256, 'unexpected_entry_digest') + validate_receipt(snapshot) + print(json.dumps({'desktopAdmission': 'accepted', 'mode': 'attest' if args.attest_config else 'check', + 'containerId': snapshot['containerId'], 'revision': snapshot['revision'], + 'enabled': snapshot['enabled'], 'privateMountVerified': snapshot['enabled']})) + return 0 + except Rejected as error: + print(json.dumps({'desktopAdmission': 'rejected', 'reason': str(error)}), file=sys.stderr) + return 1 + except Exception: + # Docker output, env values, HTTP bodies and exception details can carry + # credentials. Never forward them to deployment logs or journald. + print(json.dumps({'desktopAdmission': 'rejected', 'reason': 'inspection_failed'}), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/desktop-release-admission.test.py b/scripts/desktop-release-admission.test.py new file mode 100644 index 00000000..d196ea79 --- /dev/null +++ b/scripts/desktop-release-admission.test.py @@ -0,0 +1,178 @@ +import ast +from contextlib import redirect_stderr, redirect_stdout +import copy +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +SOURCE = Path(__file__).with_name('desktop-release-admission.py') +spec = importlib.util.spec_from_file_location('admission', SOURCE) +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) + + +def fixture(): + return {'Name': '/youyidian-3d-material-blue', 'Id': 'a' * 64, 'Image': 'sha256:' + 'b' * 64, + 'State': {'Running': True}, 'Config': {'Env': [ + 'DETAIL_GENERATOR_ENABLED=true', 'DETAIL_GENERATOR_STORAGE_DIR=/app/detail-generator-private', + 'DETAIL_GENERATOR_API_KEY=synthetic-never-print-this'], + 'Labels': {'org.opencontainers.image.revision': 'c' * 40}}, + 'Mounts': [{'Source': str(gate.PRIVATE), 'Destination': '/app/detail-generator-private', 'Type': 'bind', 'RW': True}], + 'NetworkSettings': {'Networks': {gate.NETWORK: {'IPAddress': '172.25.0.4'}}}} + + +class AdmissionTests(unittest.TestCase): + def test_approved_flag_is_explicit_and_unambiguous(self): + self.assertTrue(gate.enabled_from_env('\ufeff# heading\n DETAIL_GENERATOR_ENABLED=true\n')) + self.assertFalse(gate.enabled_from_env('DETAIL_GENERATOR_ENABLED=false\r\n')) + for value in ['', 'true\nDETAIL_GENERATOR_ENABLED=false', '"true"', 'true # comment', 'TRUE', 'true ', '']: + text = '' if not value else 'DETAIL_GENERATOR_ENABLED=' + value + with self.subTest(value=value), self.assertRaises(gate.Rejected): + gate.enabled_from_env(text) + with self.assertRaises(gate.Rejected): + gate.enabled_from_env('DETAIL_GENERATOR_ENABLED =true') + with self.assertRaises(gate.Rejected): + gate.enabled_from_env('DETAIL_GENERATOR_ENABLED=true\nDETAIL_GENERATOR_ENABLED=true') + + def test_exact_project_proxy_target_only(self): + text = ' server 172.25.0.4:3000 max_fails=3;\n add_header X-Material-Proxy-Target youyidian-3d-material-blue always;\n' + self.assertEqual(gate.config_target(text), ('youyidian-3d-material-blue', '172.25.0.4')) + for other in [text.replace('youyidian-3d-material-blue', 'other-project'), + text + 'server 172.25.0.5:3000 max_fails=3;\n', + text + 'add_header X-Material-Proxy-Target youyidian-3d-material-green always;\n', '']: + with self.subTest(other=other), self.assertRaises(gate.Rejected): + gate.config_target(other) + + def inspect(self, data): + calls = [] + def command(args): + calls.append(args) + if args[:2] == ['docker', 'inspect']: + return json.dumps([data]) + if args[-2:] == ['cat', '/app/DEPLOYMENT_COMMIT']: + return 'c' * 40 + return '' + with patch.object(gate, 'command', command): + result = gate.inspect_target('youyidian-3d-material-blue', True, [10, 20, 1001, 1001]) + return result, calls + + def test_valid_runtime_checks_access_without_creating_files(self): + result, calls = self.inspect(fixture()) + self.assertEqual(result['enabled'], True) + self.assertEqual(result['containerId'], 'a' * 64) + self.assertEqual(result['ip'], '172.25.0.4') + self.assertTrue(any('--user' in args and '1001:1001' in args for args in calls)) + self.assertNotIn('synthetic-never-print-this', json.dumps(result)) + self.assertFalse(any(any(x in args for x in ['create', 'run', 'start', 'rm', 'stop']) for args in calls)) + + def test_reproduced_incident_rejected(self): + data = fixture() + data['Config']['Env'][0] = 'DETAIL_GENERATOR_ENABLED=false' + data['Mounts'] = [] + with self.assertRaisesRegex(gate.Rejected, 'runtime_flag_mismatch'): + self.inspect(data) + + def test_every_mount_boundary_is_enforced(self): + cases = [([], 'missing_or_ambiguous'), + ([{**fixture()['Mounts'][0], 'Source': '/other-project/private'}], 'mount_mismatch'), + ([{**fixture()['Mounts'][0], 'RW': False}], 'mount_mismatch'), + ([{**fixture()['Mounts'][0], 'Type': 'volume'}], 'mount_mismatch'), + (fixture()['Mounts'] * 2, 'missing_or_ambiguous')] + for mounts, reason in cases: + data = fixture() + data['Mounts'] = mounts + with self.subTest(mounts=mounts), self.assertRaisesRegex(gate.Rejected, reason): + self.inspect(data) + + def test_duplicate_runtime_flags_never_shadow_approved_setting(self): + data = fixture() + data['Config']['Env'].append('DETAIL_GENERATOR_ENABLED=false') + with self.assertRaisesRegex(gate.Rejected, 'runtime_flag_mismatch'): + self.inspect(data) + + def test_baked_identity_and_network_cannot_be_substituted(self): + for field, value, code in [('Id', 'bad', 'container_identity_invalid'), + ('Image', 'image:latest', 'image_identity_invalid'), + ('Name', '/other-project', 'container_not_running')]: + data = fixture() + data[field] = value + with self.subTest(field=field), self.assertRaisesRegex(gate.Rejected, code): + self.inspect(data) + data = fixture() + data['NetworkSettings']['Networks'] = {'other-network': {'IPAddress': '172.25.0.4'}} + with self.assertRaisesRegex(gate.Rejected, 'network_identity_invalid'): + self.inspect(data) + + def test_missing_receipt_blocks_legacy_entry(self): + with tempfile.TemporaryDirectory(prefix='desktop-admission-test-') as folder: + root = Path(folder) + snapshot = {'containerId': 'a' * 64} + with patch.object(gate, 'RECEIPTS', root), patch.object(gate, 'physical', side_effect=lambda p, **kw: p.stat()): + with self.assertRaises(FileNotFoundError): + gate.validate_receipt(snapshot) + + def test_receipt_is_bound_to_every_current_container_fact(self): + with tempfile.TemporaryDirectory(prefix='desktop-admission-test-') as folder: + root = Path(folder) + snapshot = {'containerId': 'a' * 64, 'enabled': True, 'imageId': 'sha256:' + 'b' * 64, + 'privateIdentity': [10, 20, 1001, 1001]} + receipt = {'schema': 1, 'policySha256': gate.policy_digest(), 'entrySha256': 'd' * 64, 'snapshot': snapshot} + path = root / ('a' * 64 + '.json') + with patch.object(gate, 'RECEIPTS', root), patch.object(gate, 'physical', return_value=None): + path.write_text(json.dumps(receipt)) + gate.validate_receipt(snapshot) + for field, value in [('schema', 2), ('policySha256', 'e' * 64), ('entrySha256', '')]: + changed = {**receipt, field: value} + path.write_text(json.dumps(changed)) + with self.subTest(field=field), self.assertRaises(gate.Rejected): + gate.validate_receipt(snapshot) + path.write_text(json.dumps(receipt)) + for field, value in [('enabled', False), ('imageId', 'sha256:' + 'e' * 64), ('privateIdentity', [10, 99, 1001, 1001])]: + changed = {**snapshot, field: value} + with self.subTest(field=field), self.assertRaises(gate.Rejected): + gate.validate_receipt(changed) + + def test_attestation_rechecks_identity_before_atomic_publication(self): + with tempfile.TemporaryDirectory(prefix='desktop-admission-test-') as folder: + root = Path(folder) + snapshot = {'containerId': 'a' * 64, 'enabled': True} + with patch.object(gate, 'RECEIPTS', root), patch.object(gate, 'physical', return_value=None), \ + patch.object(gate.os, 'fchmod', create=True), patch.object(gate.os, 'fchown', create=True), \ + patch.object(gate, 'current_snapshot', return_value={**snapshot, 'enabled': False}): + with self.assertRaisesRegex(gate.Rejected, 'candidate_changed_during_attestation'): + gate.attest(snapshot, 'd' * 64) + self.assertEqual(list(root.iterdir()), []) + + def test_inspection_failures_do_not_echo_secrets(self): + error_output = io.StringIO() + with patch.object(gate.sys, 'argv', ['guard.py']), patch.object(gate.os, 'geteuid', return_value=0, create=True), \ + patch.object(gate, 'current_snapshot', side_effect=ValueError('synthetic-never-print-this')), redirect_stderr(error_output): + self.assertEqual(gate.main(), 1) + self.assertNotIn('synthetic-never-print-this', error_output.getvalue()) + self.assertIn('inspection_failed', error_output.getvalue()) + + def test_installer_only_hooks_project_start_and_graceful_reload(self): + text = SOURCE.with_name('install-desktop-release-admission.py').read_text(encoding='utf-8') + tree = ast.parse(text) + names = {'BASE', 'POLICY', 'GUARD', 'UNIT', 'UNIT_FILE', 'DROPIN', 'PROXY'} + statements = [n for n in tree.body if (isinstance(n, ast.Assign) and isinstance(n.targets[0], ast.Name) and n.targets[0].id in names) + or (isinstance(n, ast.FunctionDef) and n.name == 'dropin_text')] + scope = {'Path': Path} + exec(compile(ast.Module(body=statements, type_ignores=[]), '', 'exec'), scope) + dropin = scope['dropin_text']() + self.assertIn('ExecStartPre=/usr/bin/python3', dropin) + self.assertIn('ExecReload=\nExecReload=/usr/bin/python3', dropin) + self.assertLess(dropin.index('ExecReload=/usr/bin/python3'), dropin.index('ExecReload=/usr/sbin/nginx')) + self.assertNotIn("'restart'", text) + self.assertNotIn('docker stop', text) + self.assertIn('proxyPidUnchanged', text) + self.assertIn('os.fchown(stream.fileno(), 0, 0)', text) + self.assertIn('os.chown(path, 0, 0)', text) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/git-incremental-deploy-macos.mjs b/scripts/git-incremental-deploy-macos.mjs index 2d88dc3b..40e7c36e 100755 --- a/scripts/git-incremental-deploy-macos.mjs +++ b/scripts/git-incremental-deploy-macos.mjs @@ -12,6 +12,7 @@ const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)) const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, '..') const POWERSHELL_DEPLOY_SCRIPT = path.join(SCRIPT_DIRECTORY, 'git-incremental-deploy.ps1') const PROXY_RECONCILER = path.join(SCRIPT_DIRECTORY, 'local-proxy-reconcile.sh') +const DESKTOP_ADMISSION = path.join(SCRIPT_DIRECTORY, 'desktop-release-admission.py') const TRUSTED_REPOSITORY = 'http://git.1080p.hk/WuYongJie/gongxiangp2.git' const TRUSTED_BRANCH = 'main' @@ -330,6 +331,9 @@ async function main() { const targetCommit = await resolveAndScanExactCommit(options) const powerShellSource = await fs.readFile(POWERSHELL_DEPLOY_SCRIPT, 'utf8') const proxyReconcilerBase64 = (await fs.readFile(PROXY_RECONCILER)).toString('base64') + const admissionSource = (await fs.readFile(DESKTOP_ADMISSION, 'utf8')).replaceAll('\r\n', '\n').replaceAll('\r', '\n') + const admissionDigest = createHash('sha256').update(admissionSource).digest('hex') + const entryDigest = createHash('sha256').update(powerShellSource.replaceAll('\r\n', '\n').replaceAll('\r', '\n')).digest('hex') const values = { __REPO_URL__: TRUSTED_REPOSITORY, @@ -359,6 +363,8 @@ async function main() { __S3_PUBLIC_DOMAIN__: 'https://s3.jiansun.vip/gongxiangpan', __DOWNLOAD_TOKEN_SECRET__: process.env.DOWNLOAD_TOKEN_SECRET || '', __PROXY_RECONCILE_SCRIPT_B64__: proxyReconcilerBase64, + __DESKTOP_ADMISSION_SHA256__: admissionDigest, + __DEPLOY_ENTRY_SHA256__: entryDigest, __DRY_RUN__: options.dryRun ? '1' : '0', __NO_CACHE__: options.noCache ? '1' : '0', __SKIP_BUILD__: '0', diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index 0aa5f455..39870e9a 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -697,6 +697,17 @@ $proxyReconcilerText = [System.IO.File]::ReadAllText($proxyReconcilerPath) $proxyReconcilerText = $proxyReconcilerText.Replace("`r`n", "`n").Replace("`r", "`n") $proxyReconcilerBytes = [System.Text.UTF8Encoding]::new($false).GetBytes($proxyReconcilerText) $proxyReconcilerBase64 = [Convert]::ToBase64String($proxyReconcilerBytes) +$admissionPath = Join-Path $PSScriptRoot 'desktop-release-admission.py' +if (-not (Test-Path -LiteralPath $admissionPath -PathType Leaf)) { + throw 'Desktop release admission policy is missing; deployment is blocked.' +} +$admissionText = [System.IO.File]::ReadAllText($admissionPath).Replace("`r`n", "`n").Replace("`r", "`n") +$entryText = [System.IO.File]::ReadAllText($PSCommandPath).Replace("`r`n", "`n").Replace("`r", "`n") +$digestAlgorithm = [System.Security.Cryptography.SHA256]::Create() +try { + $admissionDigest = [BitConverter]::ToString($digestAlgorithm.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($admissionText))).Replace('-', '').ToLowerInvariant() + $entryDigest = [BitConverter]::ToString($digestAlgorithm.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($entryText))).Replace('-', '').ToLowerInvariant() +} finally { $digestAlgorithm.Dispose() } $tokens = @{ '__REPO_URL__' = ConvertTo-ShellSingleQuoted $RepoUrl @@ -726,6 +737,8 @@ $tokens = @{ '__S3_PUBLIC_DOMAIN__' = ConvertTo-ShellSingleQuoted $cfg.S3PublicDomain '__DOWNLOAD_TOKEN_SECRET__' = ConvertTo-ShellSingleQuoted $DownloadTokenSecret '__PROXY_RECONCILE_SCRIPT_B64__' = ConvertTo-ShellSingleQuoted $proxyReconcilerBase64 + '__DESKTOP_ADMISSION_SHA256__' = ConvertTo-ShellSingleQuoted $admissionDigest + '__DEPLOY_ENTRY_SHA256__' = ConvertTo-ShellSingleQuoted $entryDigest '__DRY_RUN__' = ConvertTo-ShellSingleQuoted ($(if ($DryRun) { '1' } else { '0' })) '__NO_CACHE__' = ConvertTo-ShellSingleQuoted ($(if ($NoCache) { '1' } else { '0' })) '__SKIP_BUILD__' = ConvertTo-ShellSingleQuoted ($(if ($SkipBuild) { '1' } else { '0' })) @@ -1086,6 +1099,9 @@ REQUIRE_MIGRATION_AUDIT="$3" DEPLOYMENT_STRATEGY="${4:-direct}" DETAIL_PRIVATE_MOUNT="/mnt/PoolOne/apps/youyidian-3d/detail-generator-private" DETAIL_FEATURE_ENABLED=uninitialized +DESKTOP_ADMISSION="/mnt/PoolOne/apps/youyidian-3d/release-policy/desktop-admission/guard.py" +DESKTOP_ADMISSION_SHA256=__DESKTOP_ADMISSION_SHA256__ +DEPLOY_ENTRY_SHA256=__DEPLOY_ENTRY_SHA256__ log() { printf '[INFO] %s\n' "$*"; } ok() { printf '[OK] %s\n' "$*"; } @@ -1209,9 +1225,23 @@ command -v nginx >/dev/null 2>&1 || fail "Missing nginx on production server" command -v systemctl >/dev/null 2>&1 || fail "Missing systemctl on production server" command -v sha256sum >/dev/null 2>&1 || fail "Missing sha256sum on production server" command -v timeout >/dev/null 2>&1 || fail "Missing timeout on production server" +command -v python3 >/dev/null 2>&1 || fail "Missing python3 on production server; desktop admission cannot be verified" require_file "$ENV_FILE_PATH" +require_desktop_release_admission() { + [ -f "$DESKTOP_ADMISSION" ] && [ ! -L "$DESKTOP_ADMISSION" ] || return 1 + [ "$(stat -c '%u:%g:%a' "$DESKTOP_ADMISSION")" = "0:0:555" ] || return 1 + [ "$(sha256sum "$DESKTOP_ADMISSION" | awk '{print $1}')" = "$DESKTOP_ADMISSION_SHA256" ] || return 1 + # This drop-in survives an older workstation rewriting the base service. + # Require the real server reload path to invoke admission before nginx. + local reload="" + reload="$(systemctl show "${CONTAINER_NAME}-local-proxy.service" -p ExecReload --value)" || return 1 + case "$reload" in *"$DESKTOP_ADMISSION"*"/usr/sbin/nginx"*) ;; *) return 1 ;; esac +} + +require_desktop_release_admission || fail "Server desktop admission gate is missing or outdated; no container was stopped. Install the reviewed project-scoped policy before releasing." + detail_private_mount_is_safe() { [ "$DETAIL_PRIVATE_MOUNT" = "/mnt/PoolOne/apps/youyidian-3d/detail-generator-private" ] || return 1 case "${DETAIL_PRIVATE_MOUNT}/" in "${UPLOADS_MOUNT}/"*) return 1 ;; esac @@ -2559,6 +2589,8 @@ EOF } switch_proxy() { + require_desktop_release_admission || return 1 + python3 "$DESKTOP_ADMISSION" --attest-config --entry-sha256 "$DEPLOY_ENTRY_SHA256" || return 1 ensure_proxy_service || return 1 if is_proxy_running; then systemctl reload "$PROXY_SERVICE" @@ -3856,7 +3888,9 @@ if [ -n "$LEGACY_IMAGE" ] && container_running "$CONTAINER_NAME"; then esac fi -# Resolve the approved feature configuration before blue-green creates a container. +# Resolve the approved env-file in the normal blue-green path as well as direct +# replacement. Otherwise its initial value silently disables desktop access and +# omits the private persistent mount, even when production configuration is true. prepare_detail_generator_release || fail "Detail generator configuration or private persistent storage is invalid; no candidate was created" # 蓝绿入口和意外残留的直连容器都可能运行 worker;必须逐个证明当前协议、 diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs index b6fc68e7..a520d018 100644 --- a/scripts/git-incremental-deploy.test.mjs +++ b/scripts/git-incremental-deploy.test.mjs @@ -27,6 +27,41 @@ test('generated remote deployment payload is valid bash', () => { assert.equal(result.status, 0, result.stderr || result.stdout) }) +test('server admission blocks proxy changes before any reload or unit rewrite', () => { + for (const phase of ['missing', 'unattested']) { + const input = `set -eu +DESKTOP_ADMISSION=/project/guard.py +DEPLOY_ENTRY_SHA256=${'a'.repeat(64)} +require_desktop_release_admission() { ${phase === 'missing' ? 'return 1' : 'return 0'}; } +python3() { printf 'attestation-rejected\\n'; return 1; } +ensure_proxy_service() { printf 'UNEXPECTED_UNIT_WRITE'; } +is_proxy_running() { return 0; } +systemctl() { printf 'UNEXPECTED_RELOAD'; } +${shellFunction('switch_proxy')} +switch_proxy +` + const result = spawnSync(bash, ['-s'], { input, encoding: 'utf8' }) + assert.equal(result.status, 1) + assert.doesNotMatch(result.stdout + result.stderr, /UNEXPECTED_/) + assert.equal(result.stdout.includes('attestation-rejected'), phase === 'unattested') + } +}) + +test('both publishing clients pin the same server policy and persist a canonical entry digest', () => { + const source = deploySource() + const mac = readFileSync(new URL('./git-incremental-deploy-macos.mjs', import.meta.url), 'utf8') + for (const token of ['__DESKTOP_ADMISSION_SHA256__', '__DEPLOY_ENTRY_SHA256__']) { + assert.ok(source.includes(token)) + assert.ok(mac.includes(token)) + } + const requireGuard = shellFunction('require_desktop_release_admission') + assert.match(requireGuard, /sha256sum "\$DESKTOP_ADMISSION"/) + assert.match(requireGuard, /0:0:555/) + assert.match(requireGuard, /systemctl show .*ExecReload/) + assert.match(source, /require_desktop_release_admission \|\| fail "Server desktop admission gate is missing/) + assert.match(shellFunction('switch_proxy'), /--attest-config --entry-sha256 "\$DEPLOY_ENTRY_SHA256"/) +}) + test('Mac entry renders every canonical remote deployment token', () => { const source = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8') const template = extractRemoteTemplate(source) diff --git a/scripts/install-desktop-release-admission.py b/scripts/install-desktop-release-admission.py new file mode 100644 index 00000000..5ac48978 --- /dev/null +++ b/scripts/install-desktop-release-admission.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Install only this project's admission hook; never restart application containers.""" +import argparse +import base64 +import fcntl +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import types + +BASE = Path('/mnt/PoolOne/apps/youyidian-3d') +POLICY = BASE / 'release-policy/desktop-admission' +GUARD = POLICY / 'guard.py' +UNIT = 'youyidian-3d-material-local-proxy.service' +UNIT_FILE = Path('/etc/systemd/system') / UNIT +DROPIN = Path(str(UNIT_FILE) + '.d/30-desktop-admission.conf') +PROXY = BASE / 'local-proxy/youyidian-3d-material' + + +def dropin_text(): + # An independent drop-in survives legacy writers replacing the base unit. + return ('[Service]\n' + f'ExecStartPre=/usr/bin/python3 {GUARD}\n' + 'ExecReload=\n' + f'ExecReload=/usr/bin/python3 {GUARD}\n' + f'ExecReload=/usr/sbin/nginx -p {PROXY}/ -c {PROXY}/nginx.conf -s reload\n') + + +def run(args): + value = subprocess.run(args, capture_output=True, text=True, timeout=25) + if value.returncode != 0: + raise RuntimeError('system_command_failed') + return value.stdout.strip() + + +def atomic_write(path, data, mode): + fd, temp = tempfile.mkstemp(prefix='.admission-install-', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + os.fchown(stream.fileno(), 0, 0) + os.fchmod(stream.fileno(), mode) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temp, path) + finally: + if os.path.exists(temp): + os.unlink(temp) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--apply', action='store_true') + parser.add_argument('--verify-reload', action='store_true') + parser.add_argument('--expect-container', required=True) + parser.add_argument('--expect-image', required=True) + parser.add_argument('--expect-commit', required=True) + parser.add_argument('--entry-sha256', required=True) + args = parser.parse_args() + handles = [] + try: + if os.geteuid() != 0: + raise RuntimeError('root_required') + if 'STAGED_POLICY_BASE64' in globals(): + source = base64.b64decode(STAGED_POLICY_BASE64, validate=True) + else: + source = Path(__file__).with_name('desktop-release-admission.py').read_bytes() + source = source.replace(b'\r\n', b'\n').replace(b'\r', b'\n') + digest = hashlib.sha256(source).hexdigest() + module = types.ModuleType('desktop_admission_install_probe') + module.__file__ = str(GUARD) + exec(compile(source, str(GUARD), 'exec'), module.__dict__) + require = module.require + # Same lock order as the canonical deployment, scoped to this project. + for name in ['youyidian-3d-material-production-deploy.lock', 'youyidian-3d-material-local-proxy-reconcile.lock']: + flags = os.O_WRONLY | os.O_NOFOLLOW + if args.apply: + flags |= os.O_CREAT + fd = os.open('/run/' + name, flags, 0o600) + handles.append(fd) + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + snapshot = module.current_snapshot() + require(snapshot['containerId'] == args.expect_container and snapshot['imageId'] == args.expect_image + and snapshot['revision'] == args.expect_commit, 'active_identity_changed') + module.physical(UNIT_FILE, owner=(0, 0)) + require(run(['systemctl', 'show', UNIT, '-p', 'FragmentPath', '--value']) == str(UNIT_FILE), 'unit_identity_changed') + require(run(['systemctl', 'is-active', UNIT]) == 'active', 'proxy_not_active') + before_pid = run(['systemctl', 'show', UNIT, '-p', 'MainPID', '--value']) + require(before_pid.isdecimal() and before_pid != '0', 'proxy_pid_invalid') + if GUARD.exists(): + module.physical(GUARD, owner=(0, 0), mode=0o555) + require(hashlib.sha256(GUARD.read_bytes()).hexdigest() == digest, 'existing_policy_requires_reviewed_upgrade') + for path in [POLICY.parent, POLICY, POLICY / 'receipts', DROPIN.parent]: + require(not path.is_symlink(), 'symlink_rejected') + if path.exists(): + module.physical(path, directory=True, owner=(0, 0)) + if DROPIN.exists(): + module.physical(DROPIN, owner=(0, 0), mode=0o644) + require(DROPIN.read_text() == dropin_text(), 'existing_dropin_differs') + result = {'mode': 'apply' if args.apply else 'plan', 'policySha256': digest, + 'unit': UNIT, 'dropIn': str(DROPIN), 'applicationContainerId': snapshot['containerId'], + 'imageId': snapshot['imageId'], 'revision': snapshot['revision'], 'beforeProxyPid': before_pid} + if not args.apply: + print(json.dumps(result)) + return 0 + for path in [POLICY.parent, POLICY, POLICY / 'receipts']: + created = not path.exists() + path.mkdir(mode=0o700, exist_ok=True) + if created: + # NAS root's primary group need not be gid 0. + os.chown(path, 0, 0) + os.chmod(path, 0o700) + module.physical(POLICY, directory=True, owner=(0, 0), mode=0o700) + module.physical(POLICY / 'receipts', directory=True, owner=(0, 0), mode=0o700) + if not GUARD.exists(): + atomic_write(GUARD, source, 0o555) + module.attest(snapshot, args.entry_sha256) + module.validate_receipt(snapshot) + created_dropin_dir = not DROPIN.parent.exists() + DROPIN.parent.mkdir(mode=0o755, exist_ok=True) + if created_dropin_dir: + os.chown(DROPIN.parent, 0, 0) + os.chmod(DROPIN.parent, 0o755) + if not DROPIN.exists(): + atomic_write(DROPIN, dropin_text().encode(), 0o644) + run(['systemctl', 'daemon-reload']) + reload_commands = run(['systemctl', 'show', UNIT, '-p', 'ExecReload', '--value']) + start_commands = run(['systemctl', 'show', UNIT, '-p', 'ExecStartPre', '--value']) + require(str(GUARD) in reload_commands and reload_commands.index(str(GUARD)) < reload_commands.index('/usr/sbin/nginx'), + 'reload_hook_not_active') + require(str(GUARD) in start_commands, 'start_hook_not_active') + if args.verify_reload: + run(['systemctl', 'reload', UNIT]) + require(run(['systemctl', 'show', UNIT, '-p', 'MainPID', '--value']) == before_pid, 'proxy_process_restarted') + require(module.current_snapshot() == snapshot, 'application_changed_during_install') + result.update({'installed': True, 'reloadVerified': args.verify_reload, 'applicationUnchanged': True, + 'proxyPidUnchanged': True, 'startHookVerified': True, 'reloadHookVerified': True}) + print(json.dumps(result)) + return 0 + except Exception as error: + # Do not emit arbitrary subprocess output, HTTP bodies or env values. + allowed = {'root_required', 'system_command_failed'} + reason = str(error) if type(error).__name__ == 'Rejected' or str(error) in allowed else 'installation_stopped' + print(json.dumps({'installed': False, 'reason': reason}), file=sys.stderr) + return 1 + finally: + for fd in handles: + os.close(fd) + + +if __name__ == '__main__': + sys.exit(main()) From 4de34cd3520999e127a6fc5fc7e3e88ac6f12780 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 29 Oct 2026 17:58:20 +0800 Subject: [PATCH 8/9] fix(materials): keep externally fetched product images editable and current --- app/api/external/download/route.ts | 8 + app/api/external/product-assets/route.ts | 5 +- docs/外部接口文档.md | 4 + lib/__tests__/external-download-range.test.ts | 22 +++ lib/__tests__/product-assets-route.test.ts | 32 ++++ .../task-detail-asset-edit-batch.test.ts | 140 +++++++++++++++--- .../task-detail-change-approval.test.ts | 93 ++++++++++-- lib/task-detail-asset-edit.ts | 7 +- lib/task-detail-change-approval.ts | 7 +- 9 files changed, 286 insertions(+), 32 deletions(-) diff --git a/app/api/external/download/route.ts b/app/api/external/download/route.ts index 91e5c1a6..5c4d4d1c 100644 --- a/app/api/external/download/route.ts +++ b/app/api/external/download/route.ts @@ -37,6 +37,13 @@ import { * GET - 下载文件 */ export async function GET(request: NextRequest) { + // Token 绑定素材身份而非某次文件路径;再次访问必须解析当前版本。 + const response = await handleDownload(request); + response.headers.set('Cache-Control', 'private, no-store'); + return response; +} + +async function handleDownload(request: NextRequest) { const token = request.nextUrl.searchParams.get('token'); const rangeHeader = request.headers.get('range'); @@ -447,6 +454,7 @@ async function downloadSingleFile(asset: any, rangeHeader: string | null = null) if (isHttpUrl(normalizedFileUrl)) { try { const response = await fetch(normalizedFileUrl, { + cache: 'no-store', headers: rangeHeader ? { Range: rangeHeader } : undefined, }); if (!response.ok || !response.body) { diff --git a/app/api/external/product-assets/route.ts b/app/api/external/product-assets/route.ts index ac98906c..69c9a8bc 100644 --- a/app/api/external/product-assets/route.ts +++ b/app/api/external/product-assets/route.ts @@ -263,7 +263,10 @@ export async function GET(request: NextRequest) { userAgent: request.headers.get('user-agent') || undefined, }); - return successResponse(responseData); + // fetched 只是获取记录;同一素材后续仍会更新,不能缓存首次获取的清单。 + const response = successResponse(responseData); + response.headers.set('Cache-Control', 'private, no-store'); + return response; } else { // 其他素材类型:返回统一的 files 结构和单个下载链接 const previewUrl = material.previewUrl || ''; diff --git a/docs/外部接口文档.md b/docs/外部接口文档.md index 637afd2c..72b25667 100644 --- a/docs/外部接口文档.md +++ b/docs/外部接口文档.md @@ -1118,6 +1118,10 @@ AI 生图平台的详细字段说明、完整响应示例、错误码和 cURL 首次查询成功后,状态会从 `completed` 变为 `fetched`。 +`fetched` 仅表示该素材曾被外部系统查询过,不会冻结产品图片。有编辑权限的人员仍可更新图片,变更按现有主管审批规则生效;生效时预览、对应分类 ZIP 和全部下载包整体切换为新版本。打包或应用失败时,正式版本保持不变。 + +外部系统应在需要更新时重新查询同一 `materialId` 或 `taskNo`,读取当前资源清单和 `updatedAt`。`downloadUrls.all` 的 Token 绑定素材身份,下载时解析当前正式包;即使链接相同,也应重新请求。查询和下载响应不允许缓存,但素材库无法自动替换外部系统已经保存到本地的文件。 + ### 打包下载说明 - `任务-详情` 的 `downloadUrls.all` 包含:主图分类 ZIP + SKU 分类 ZIP + 详情分类 ZIP(**不含 PSD**) diff --git a/lib/__tests__/external-download-range.test.ts b/lib/__tests__/external-download-range.test.ts index 477a3709..c899be56 100644 --- a/lib/__tests__/external-download-range.test.ts +++ b/lib/__tests__/external-download-range.test.ts @@ -154,6 +154,28 @@ describe('external single-file S3 download range forwarding', () => { expect(new Headers(requestInit?.headers).get('range')).toBeNull() }) + test.each(['all', 'sku'])('相同 Token 重复下载 %s:重新解析最新文件和内容,不缓存旧版本', async (downloadType) => { + ;(verifyDownloadToken as jest.Mock).mockReturnValue({ materialId: 'material-1', downloadType }) + for (const revision of [1, 2, 3]) { + const latestUrl = `https://s3.example.test/materials/${downloadType}-v${revision}.zip` + const payload = Uint8Array.from([revision, revision + 1, revision + 2]) + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + ...makeMaterial(), taskStatus: 'fetched', + assets: [{ id: `asset-${downloadType}`, assetType: downloadType, fileUrl: latestUrl, fileName: `${downloadType}.zip` }], + }) + ;(global.fetch as jest.Mock).mockResolvedValueOnce(new Response(payload, { + headers: { 'Content-Type': 'application/zip', 'Content-Length': String(payload.length), 'Cache-Control': 'public, max-age=31536000' }, + })) + + const response = await GET(makeRequest()) + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(Buffer.from(await response.arrayBuffer())).toEqual(Buffer.from(payload)) + expect(global.fetch).toHaveBeenLastCalledWith(latestUrl, expect.objectContaining({ cache: 'no-store' })) + } + expect(prisma.material.findUnique).toHaveBeenCalledTimes(3) + }) + describe('local file ranges', () => { beforeEach(() => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue(makeMaterial(localFilePath)) diff --git a/lib/__tests__/product-assets-route.test.ts b/lib/__tests__/product-assets-route.test.ts index df7b4332..7c42cff5 100644 --- a/lib/__tests__/product-assets-route.test.ts +++ b/lib/__tests__/product-assets-route.test.ts @@ -225,6 +225,38 @@ describe('external product-assets route', () => { expect(logApiCall).toHaveBeenCalled() }) + test.each(['materialId', 'taskNo'])('%s:已获取素材连续更新后,每次查询返回最新正式图片包', async (queryBy) => { + const id = '35d8644b-1e09-4ce8-9bb0-c529b39ba52e' + const taskNo = 'TASK202601030184' + const lookup = (queryBy === 'materialId' ? getMaterialById : getMaterialByTaskNo) as jest.Mock + for (const revision of [1, 2, 3]) { + const updatedAt = new Date(`2026-10-29T0${revision}:00:00.000Z`) + const url = `uploads/materials/sku-v${revision}.zip` + lookup.mockResolvedValue({ + id, taskNo, materialType: '任务-详情', taskStatus: TASK_STATUS.FETCHED, + productModel: 'GBGJ-SZ0244', isFullSet: false, deletedAt: null, + createdAt: new Date('2026-09-15T00:00:00.000Z'), updatedAt, + assets: [{ id: 'asset-sku', assetType: 'sku', fileUrl: url, + fileName: `sku-v${revision}.zip`, fileSize: BigInt(revision * 100), sortOrder: 0 }], + }) + + const response = await GET(createRequest( + `https://example.com/api/external/product-assets?${queryBy}=${queryBy === 'materialId' ? id : taskNo}`, + )) + const body = await response.json() + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(body.data.updatedAt).toBe(updatedAt.toISOString()) + expect(body.data.assets.sku).toEqual([expect.objectContaining({ + id: 'asset-sku', url: `rewritten:${url}`, fileName: `sku-v${revision}.zip`, fileSize: revision * 100, + })]) + expect(body.data.downloadUrls.all).toBe(`https://download.example/${id}`) + } + + expect(lookup).toHaveBeenCalledTimes(3) + expect(updateTaskStatus).not.toHaveBeenCalled() + }) + test('未提供 taskNo 和 materialId 时应立即失败', async () => { const response = await GET(createRequest('https://example.com/api/external/product-assets')) const body = await response.json() diff --git a/lib/__tests__/task-detail-asset-edit-batch.test.ts b/lib/__tests__/task-detail-asset-edit-batch.test.ts index e65bbfbf..ac7a5581 100644 --- a/lib/__tests__/task-detail-asset-edit-batch.test.ts +++ b/lib/__tests__/task-detail-asset-edit-batch.test.ts @@ -115,6 +115,20 @@ type PreviewRow = { createdAt?: Date | null } +const material = { + id: 'material-1', + userId: 'operator-1', + materialType: '任务-详情', + taskStatus: 'completed', + isIncompleteShell: false, + productModel: 'MODEL-1', + authorName: '作者A', + categoryName: '店铺A', + previewUrl: 'https://preview/existing-cover.webp', + isFullSet: false, + updatedAt: new Date('2026-08-28T08:00:00.000Z'), +} + describe('task-detail asset edit batch data-plane', () => { let tempRoot: string @@ -128,18 +142,7 @@ describe('task-detail asset edit batch data-plane', () => { mockAcquireLock.mockResolvedValue(Object.assign(jest.fn().mockResolvedValue(undefined), { assertHeld: jest.fn(), })) - ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ - id: 'material-1', - userId: 'operator-1', - materialType: '任务-详情', - taskStatus: 'completed', - productModel: 'MODEL-1', - authorName: '作者A', - categoryName: '店铺A', - previewUrl: 'https://preview/existing-cover.webp', - isFullSet: false, - updatedAt: new Date('2026-08-28T08:00:00.000Z'), - }) + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(material) ;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0) ;(prisma.material.update as jest.Mock).mockResolvedValue(undefined) ;(prisma.materialAsset.update as jest.Mock).mockResolvedValue(undefined) @@ -261,7 +264,8 @@ describe('task-detail asset edit batch data-plane', () => { } } - test('sku 追加支持多张按顺序提交,并保留 skuCode/barcodes', async () => { + test.each(['completed', 'fetched'])('%s:sku 追加支持多张按顺序提交,并保留 skuCode/barcodes', async (taskStatus) => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, taskStatus }) const existingSource = await createSolidImage('sku-old.png', 80, 80) const existingPreview = await createSolidImage('sku-old-preview.webp', 80, 80, 'webp') const append1 = await createSolidImage('append-1.png', 90, 90) @@ -336,6 +340,108 @@ describe('task-detail asset edit batch data-plane', () => { }, data: expect.objectContaining({ status: 'applied', applyError: null }), }) + for (const [update] of (prisma.material.update as jest.Mock).mock.calls) { + expect(update.data).not.toHaveProperty('taskStatus') + expect(update.data).not.toHaveProperty('taskFetchedAt') + } + }) + + test.each([false, true])('已获取素材替换三张 SKU 图:总包重建失败=%s,正式版本整体切换', async (rebuildFails) => { + const fetchedAt = new Date('2026-09-22T09:54:50.875Z') + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, taskStatus: 'fetched', taskFetchedAt: fetchedAt }) + const images = await Promise.all([1, 2, 3].map(async (index) => ({ + id: `sku-${index}`, name: `sku-${index}.png`, + source: await createSolidImage(`sku-${index}.png`, 80, 80), + preview: await createSolidImage(`sku-${index}-preview.webp`, 80, 80, 'webp'), + replacement: await createSolidImage(`replacement-${index}.png`, 90, 90), + }))) + configureUploadSources(Object.fromEntries(images.map((image, index) => [`upload-${index}`, { + path: image.replacement.filePath, size: image.replacement.size, fileName: `replacement-${index}.png`, + }]))) + configureAssetQueries({ + previewRows: images.map((image, index) => ({ + id: image.id, assetType: 'sku-preview', fileUrl: image.preview.filePath, + fileName: `sku-${index + 1}-preview.webp`, sortOrder: index, skuCode: `SKU-${index + 1}`, + })), + zipRows: ['main', 'sku', 'detail', 'all'].map((group) => ({ + id: `zip-${group}`, assetType: group, fileUrl: `uploads/materials/${group}-old.zip`, fileName: `${group}-old.zip`, + })), + }) + ;(prisma.materialAsset.findMany as jest.Mock).mockImplementation(async ({ where }) => ( + where.fileUrl.in.map((fileUrl: string, index: number) => ({ id: `new-sku-${index + 1}`, fileUrl })) + )) + mockListZipEntries.mockResolvedValue([...images].reverse().map(({ name }) => ({ name }))) + mockExtractZipEntries.mockResolvedValue(images.map(({ name, source }) => ({ + entryName: name, filePath: source.filePath, size: source.size, + }))) + mockCreateZipFromFiles.mockResolvedValue({ filePath: 'uploads/materials/sku-new.zip', fileName: 'sku-new.zip', fileSize: 321 }) + if (rebuildFails) mockCreateZipFromZips.mockRejectedValueOnce(new Error('总包重建失败')) + else mockCreateZipFromZips.mockResolvedValue({ filePath: 'uploads/materials/all-new.zip', fileName: 'all-new.zip', fileSize: 654 }) + + const editing = batchEditTaskDetailImages({ + materialId: 'material-1', operatorUserId: 'operator-1', approvalRequestId: 'request-atomic', + operations: images.map(({ id }, index) => ({ kind: 'replace', assetId: id, uploadId: `upload-${index}` })), + }) + if (rebuildFails) { + await expect(editing).rejects.toThrow('总包重建失败') + expect(prisma.$transaction).not.toHaveBeenCalled() + expect(prisma.material.update).not.toHaveBeenCalled() + expect(prisma.materialAsset.update).not.toHaveBeenCalled() + expect(mockQueueSkuNotification).not.toHaveBeenCalled() + expect(mockCleanupConsumedUploadSession).not.toHaveBeenCalled() + return + } + await expect(editing).resolves.toMatchObject({ success: true, appliedCount: 3, entryCount: 3 }) + const rebuiltImages = mockCreateZipFromFiles.mock.calls[0][0] as Array<{ path: string; name: string }> + for (const image of images) { + expect(rebuiltImages).toContainEqual(expect.objectContaining({ name: image.name, path: image.replacement.filePath })) + } + expect(mockCreateZipFromZips.mock.calls[0][0]).toEqual([ + { path: 'uploads/materials/main-old.zip', name: 'main-old.zip' }, + { path: 'uploads/materials/sku-new.zip', name: 'sku-new.zip' }, + { path: 'uploads/materials/detail-old.zip', name: 'detail-old.zip' }, + ]) + expect(prisma.materialAsset.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'zip-sku' }, data: expect.objectContaining({ fileUrl: 'uploads/materials/sku-new.zip' }), + })) + expect(prisma.materialAsset.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'zip-all' }, data: expect.objectContaining({ fileUrl: 'uploads/materials/all-new.zip' }), + })) + expect(prisma.material.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ zipFilePath: 'uploads/materials/all-new.zip' }), + })) + const materialUpdate = (prisma.material.update as jest.Mock).mock.calls[0][0].data + expect(materialUpdate).not.toHaveProperty('taskStatus') + expect(materialUpdate).not.toHaveProperty('taskFetchedAt') + const insertedPreviews = (prisma.materialAsset.createMany as jest.Mock).mock.calls[0][0].data + expect(insertedPreviews).toHaveLength(3) + expect(insertedPreviews.every((row: { assetType: string }) => row.assetType === 'sku-preview')).toBe(true) + expect(mockQueueSkuNotification).toHaveBeenCalledWith('material-1', ['new-sku-1', 'new-sku-2', 'new-sku-3']) + }) + + test.each([ + { taskStatus: 'pending', isIncompleteShell: false }, + { taskStatus: 'queued', isIncompleteShell: false }, + { taskStatus: 'uploading', isIncompleteShell: false }, + { taskStatus: 'processing', isIncompleteShell: false }, + { taskStatus: 'failed', isIncompleteShell: false }, + { taskStatus: null, isIncompleteShell: false }, + { taskStatus: 'unknown', isIncompleteShell: false }, + { taskStatus: 'completed', isIncompleteShell: true }, + { taskStatus: 'fetched', isIncompleteShell: true }, + ])('数据层在任何写入前拒绝未完成上传或空壳:%j', async (state) => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, ...state }) + + await expect(batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + operations: [{ kind: 'replace', assetId: 'sku-1', uploadId: 'upload-1' }], + })).rejects.toThrow('素材尚未完成上传') + + expect(prisma.materialAsset.findMany).not.toHaveBeenCalled() + expect(prisma.$transaction).not.toHaveBeenCalled() + expect(mockCreateZipFromFiles).not.toHaveBeenCalled() + expect(mockCleanupConsumedUploadSession).not.toHaveBeenCalled() }) test('详情插图要求 seam 两侧 preview 必须相邻', async () => { @@ -967,14 +1073,14 @@ describe('task-detail asset edit batch data-plane', () => { expect(mockCreateZipFromFiles).not.toHaveBeenCalled() }) - test('私密素材访问门禁先于 canEditAssets,编辑权限不能绕过', async () => { + test.each(['completed', 'fetched'])('%s:私密素材访问门禁先于 canEditAssets,编辑权限不能绕过', async (taskStatus) => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({ id: 'material-1', userId: 'owner-2', materialType: '任务-详情', taskDetailPrivate: true, taskDetailRequesterUserId: 'owner-2', - taskStatus: 'completed', + taskStatus, productModel: 'MODEL-1', authorName: '作者A', categoryName: '店铺A', @@ -997,12 +1103,12 @@ describe('task-detail asset edit batch data-plane', () => { expect(prisma.materialAsset.findMany).not.toHaveBeenCalled() }) - test('审批期间正式素材已更新时在持锁上下文中停止应用', async () => { + test.each(['completed', 'fetched'])('%s:审批期间正式素材已更新时在持锁上下文中停止应用', async (taskStatus) => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({ id: 'material-1', userId: 'operator-1', materialType: '任务-详情', - taskStatus: 'completed', + taskStatus, productModel: 'MODEL-1', authorName: '作者A', categoryName: '店铺A', diff --git a/lib/__tests__/task-detail-change-approval.test.ts b/lib/__tests__/task-detail-change-approval.test.ts index 76983494..f791546e 100644 --- a/lib/__tests__/task-detail-change-approval.test.ts +++ b/lib/__tests__/task-detail-change-approval.test.ts @@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma' import { getEmployeeByUserId } from '@/lib/employee-directory' import { canManageMaterial } from '@/lib/material-authorization' +import { canUserAccessPrivateTaskDetail, canUserEditTaskDetailAssets } from '@/lib/user-permission-inherit' import { assertTaskDetailImageEditReady, batchEditTaskDetailImages, TaskDetailAssetEditError } from '@/lib/task-detail-asset-edit' import { cleanupTaskDetailChangeStaging, @@ -50,7 +51,10 @@ jest.mock('@/lib/performance-survey-report-sync', () => ({ })) jest.mock('@/lib/material-authorization', () => ({ canManageMaterial: jest.fn() })) -jest.mock('@/lib/user-permission-inherit', () => ({ canUserEditTaskDetailAssets: jest.fn() })) +jest.mock('@/lib/user-permission-inherit', () => ({ + canUserEditTaskDetailAssets: jest.fn(), + canUserAccessPrivateTaskDetail: jest.fn(), +})) jest.mock('@/lib/department-permission', () => ({ isSystemAdmin: jest.fn((role: string) => role === 'ADMIN' || role === 'SYSTEM_ADMIN'), })) @@ -78,6 +82,7 @@ const material = { userId: 'owner-1', materialType: '任务-详情', taskStatus: 'completed', + isIncompleteShell: false, taskNo: 'TASK-001', productModel: 'SPU-001', updatedAt: new Date('2026-08-28T00:00:00.000Z'), @@ -129,6 +134,8 @@ describe('任务详情图片变更主管审批', () => { ;(prisma.organizationUnit.findUnique as jest.Mock).mockResolvedValue(null) ;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([]) ;(canManageMaterial as jest.Mock).mockResolvedValue(true) + ;(canUserEditTaskDetailAssets as jest.Mock).mockResolvedValue(false) + ;(canUserAccessPrivateTaskDetail as jest.Mock).mockResolvedValue(false) ;(batchEditTaskDetailImages as jest.Mock).mockResolvedValue({ success: true }) ;(assertTaskDetailImageEditReady as jest.Mock).mockResolvedValue(undefined) ;(queueTaskDetailChangeNotification as jest.Mock).mockResolvedValue('notification-1') @@ -141,7 +148,8 @@ describe('任务详情图片变更主管审批', () => { }) }) - test('M 级人员有权限时直接应用,不创建审批单', async () => { + test.each(['completed', 'fetched'])('%s:M 级人员有权限时直接应用,不创建审批单', async (taskStatus) => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, taskStatus }) ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M')) const result = await submitTaskDetailChange('material-1', { @@ -157,7 +165,15 @@ describe('任务详情图片变更主管审批', () => { expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled() }) - test('P 级人员把替换文件转为持久暂存并创建 24 小时审批单', async () => { + test.each(['completed', 'fetched'])('%s:P 级人员替换三张 SKU 图仍须持久暂存并创建 24 小时审批单', async (taskStatus) => { + const operations = [1, 2, 3].map((index) => ({ + kind: 'replace', assetId: `sku-${index}`, uploadId: `upload-${index}`, + })) + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ + ...material, + taskStatus, + assets: operations.map(({ assetId }) => ({ id: assetId, assetType: 'sku-preview' })), + }) ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('P')) ;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue({ id: 'manager-employee', @@ -170,9 +186,13 @@ describe('任务详情图片变更主管审批', () => { positionLevel: 'M1', userBindings: [{ userId: 'manager-user' }], }) - ;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map([ - ['upload-1', { relativePath: 'task-detail-change-requests/request/new.png', fileName: 'new.png', fileSize: 10 }], - ]))) + ;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map( + operations.map(({ uploadId }, index) => [uploadId, { + relativePath: `task-detail-change-requests/request/new-${index}.png`, + fileName: `new-${index}.png`, + fileSize: 10, + }]), + ))) ;(prisma.taskDetailChangeRequest.create as jest.Mock).mockImplementation(async ({ data }) => ({ ...data, material, @@ -182,25 +202,74 @@ describe('任务详情图片变更主管审批', () => { const result = await submitTaskDetailChange('material-1', { id: 'user-1', role: 'USER', - }, [{ kind: 'replace', assetId: 'asset-1', uploadId: 'upload-1' }]) + }, operations) expect(result.mode).toBe('pending_approval') expect(batchEditTaskDetailImages).not.toHaveBeenCalled() expect(withTaskDetailStagedUploads).toHaveBeenCalledWith(expect.objectContaining({ ownerUserId: 'user-1', - uploadIds: ['upload-1'], + uploadIds: ['upload-1', 'upload-2', 'upload-3'], })) expect(prisma.taskDetailChangeRequest.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'pending_manager_approval', managerDingtalkUserId: 'manager-ding', - operations: [expect.objectContaining({ + operations: operations.map(({ assetId }, index) => expect.objectContaining({ kind: 'replace', - assetId: 'asset-1', - stagedFile: expect.objectContaining({ fileName: 'new.png' }), - })], + assetId, + stagedFile: expect.objectContaining({ fileName: `new-${index}.png` }), + })), }), })) + expect(assertTaskDetailImageEditReady).toHaveBeenCalledWith(expect.objectContaining({ group: 'sku' })) + expect(queueTaskDetailChangeNotification).toHaveBeenCalledTimes(1) + }) + + test.each([ + { taskStatus: 'pending', isIncompleteShell: false }, + { taskStatus: 'queued', isIncompleteShell: false }, + { taskStatus: 'uploading', isIncompleteShell: false }, + { taskStatus: 'processing', isIncompleteShell: false }, + { taskStatus: 'failed', isIncompleteShell: false }, + { taskStatus: null, isIncompleteShell: false }, + { taskStatus: 'unknown', isIncompleteShell: false }, + { taskStatus: 'completed', isIncompleteShell: true }, + { taskStatus: 'fetched', isIncompleteShell: true }, + ])('未完成上传或空壳仍不能提交变更:%j', async (state) => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, ...state }) + + await expect(submitTaskDetailChange('material-1', { id: 'user-1', role: 'USER' }, [ + { kind: 'replace', assetId: 'asset-1', uploadId: 'upload-1' }, + ])).rejects.toThrow('素材尚未完成上传') + + expect(assertTaskDetailImageEditReady).not.toHaveBeenCalled() + expect(withTaskDetailStagedUploads).not.toHaveBeenCalled() + expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled() + expect(batchEditTaskDetailImages).not.toHaveBeenCalled() + expect(queueTaskDetailChangeNotification).not.toHaveBeenCalled() + }) + + test('已被获取不授予编辑权限,也不绕过私密素材准入', async () => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ...material, taskStatus: 'fetched' }) + ;(canManageMaterial as jest.Mock).mockResolvedValue(false) + await expect(submitTaskDetailChange('material-1', { id: 'user-1' }, [ + { kind: 'delete', assetId: 'asset-1' }, + ])).rejects.toMatchObject({ statusCode: 403 }) + + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ + ...material, + taskStatus: 'fetched', + taskDetailPrivate: true, + taskDetailRequesterUserId: 'another-user', + }) + ;(canUserEditTaskDetailAssets as jest.Mock).mockResolvedValue(true) + await expect(submitTaskDetailChange('material-1', { id: 'user-1' }, [ + { kind: 'delete', assetId: 'asset-1' }, + ])).rejects.toMatchObject({ statusCode: 403 }) + + expect(assertTaskDetailImageEditReady).not.toHaveBeenCalled() + expect(withTaskDetailStagedUploads).not.toHaveBeenCalled() + expect(batchEditTaskDetailImages).not.toHaveBeenCalled() }) test('P 级负责人仍需直属主管审批,不能因 isLeadership 绕过', async () => { diff --git a/lib/task-detail-asset-edit.ts b/lib/task-detail-asset-edit.ts index de5ed409..48bec58c 100644 --- a/lib/task-detail-asset-edit.ts +++ b/lib/task-detail-asset-edit.ts @@ -568,6 +568,7 @@ async function loadEditContext(options: { ...TASK_DETAIL_PRIVACY_SELECT, materialType: true, taskStatus: true, + isIncompleteShell: true, productModel: true, authorName: true, categoryName: true, @@ -586,7 +587,11 @@ async function loadEditContext(options: { if (!isProductPackageType(material.materialType)) { throw new TaskDetailAssetEditError('只有任务-详情类型支持单图编辑') } - if (material.taskStatus !== 'completed') { + // 与审批入口一致:外部获取不撤销上传完成状态,空壳仍禁止编辑。 + if ( + material.isIncompleteShell + || (material.taskStatus !== 'completed' && material.taskStatus !== 'fetched') + ) { throw new TaskDetailAssetEditError('素材尚未完成上传,暂不能编辑图片') } diff --git a/lib/task-detail-change-approval.ts b/lib/task-detail-change-approval.ts index 045e4de6..0b86ac79 100644 --- a/lib/task-detail-change-approval.ts +++ b/lib/task-detail-change-approval.ts @@ -180,6 +180,7 @@ async function loadAuthorizedContext(materialId: string, actor: Actor) { materialType: true, ...TASK_DETAIL_PRIVACY_SELECT, taskStatus: true, + isIncompleteShell: true, taskNo: true, productModel: true, updatedAt: true, @@ -198,7 +199,11 @@ async function loadAuthorizedContext(materialId: string, actor: Actor) { if (!isProductPackageType(material.materialType)) { throw new TaskDetailChangeApprovalError('只有任务-详情素材支持图片变更') } - if (material.taskStatus !== 'completed') { + // fetched 是上传完成后的外部获取状态,不能当作仍未完成上传。 + if ( + material.isIncompleteShell + || (material.taskStatus !== 'completed' && material.taskStatus !== 'fetched') + ) { throw new TaskDetailChangeApprovalError('素材尚未完成上传,暂不能修改图片') } From 72674fdc5b2091c0b5ef646f632c4a3b3ddb08e0 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 29 Oct 2026 18:36:29 +0800 Subject: [PATCH 9/9] fix(deploy): preserve live runtime for the bounded material image update --- docs/素材图片更新紧急发布-2026-10-29.md | 15 +++ scripts/material-image-hotfix-base.json | 21 +++ scripts/material-image-hotfix-verify.cjs | 34 +++++ scripts/material-image-hotfix.Dockerfile | 15 +++ scripts/material-image-hotfix.mjs | 158 +++++++++++++++++++++++ scripts/material-image-hotfix.test.mjs | 89 +++++++++++++ 6 files changed, 332 insertions(+) create mode 100644 docs/素材图片更新紧急发布-2026-10-29.md create mode 100644 scripts/material-image-hotfix-base.json create mode 100644 scripts/material-image-hotfix-verify.cjs create mode 100644 scripts/material-image-hotfix.Dockerfile create mode 100644 scripts/material-image-hotfix.mjs create mode 100644 scripts/material-image-hotfix.test.mjs diff --git a/docs/素材图片更新紧急发布-2026-10-29.md b/docs/素材图片更新紧急发布-2026-10-29.md new file mode 100644 index 00000000..c5e20fe5 --- /dev/null +++ b/docs/素材图片更新紧急发布-2026-10-29.md @@ -0,0 +1,15 @@ +# 素材图片更新的限定发布 + +正常发布仍使用完整 Git 源码构建及现有生产门禁。本次用户明确授权本机构建。 + +现场运行版本为 7e230efd998b20be3e4a05ea423d3aa514b9d1d0,对应完整源码未在权威仓库找到;现场还包含正式分支没有的薪酬、岗位接口和 Prisma 结构。直接发布完整 4de34cd3 镜像会回退这些能力,因此该镜像仅作为修复源码的构建验证,不用于切流。 + +限定镜像以当前生产镜像为不可变基底,只增加本次修复的三个编译文件、四个对应源码文件,以及发布身份和非敏感溯源清单。基底文件系统层、启动配置、Prisma 结构、前端静态资源和其他业务模块均须保持。scripts/material-image-hotfix-base.json 锁定当前基底和全部输入哈希;基底、文件、AST 结构、补丁次数发生变化均停止。只按 AST 的已验证字符区间修改,不重新生成其他代码。 + +Docker 从旧式 image store 导出至 containerd store 时会移除仅为空字符串、false 或 null 的旧默认配置项,因此导入 ID 可能变化。必须逐项验证这类默认项、全部 69 个文件系统层及剩余完整运行配置;非默认项丢失或任何业务配置改变均拒绝,不以标签相同代替身份验证。 + +生成器使用既有锁定的 Next.js 内 Acorn 解析器,只输出一份新的构建目录,不接触账户、工程、上传目录或生产数据库。镜像构建先校验基底七个目标文件、原版本和当前 Prisma 哈希,再覆盖,最后校验全部结果并记录独立的发布配方提交及原修复提交。 + +镜像经本机验证后通过 SSH 传输;仍执行既有生产部署脚本内相同的生产阶段,包括发布锁、上传排空、存储、桌面连接、worker、预览基线、代理切流、回滚和公网验收。不得使用未核验的镜像、跳过门禁或直接覆盖运行容器中的文件。 + +此配方仅适用于锁定的这一个线上基底,不能成为通用字符串替换工具。恢复完整现场源码并合入权威分支之前,不得使用旧的正式分支全量镜像覆盖这些现场功能。保留旧镜像及审计证据。 diff --git a/scripts/material-image-hotfix-base.json b/scripts/material-image-hotfix-base.json new file mode 100644 index 00000000..509bfa9e --- /dev/null +++ b/scripts/material-image-hotfix-base.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "baseImageId": "sha256:b28607f43d14609ec21ac8979a53889f002d6661b4ab80c69b8b67acdf9cc02b", + "baseCommit": "7e230efd998b20be3e4a05ea423d3aa514b9d1d0", + "sourceCommit": "4de34cd3520999e127a6fc5fc7e3e88ac6f12780", + "schemaHash": "68224c3a12318452878b03ed843eab3752ca4ab8402d71cf60a4c14f881230d9", + "compiled": { + "app/api/external/download/route.js": "6724bf2fdb5f7c70606ed3ac542b5973a56308ba0d45d93ba324322315d33d98", + "app/api/external/product-assets/route.js": "d649789ba6d7a8e24653ba538ac1291e7d86eb52dd395c3c6129d693c4867c96", + "chunks/29.js": "8295cc4dc7a91da38eab6c340c63ff6b81c902a4c1cad54f6c498e90051bdc5b" + }, + "sources": { + "app/api/external/download/route.ts": "989e6dff7d4a13d517e598dbf32c1cca8fda894687a2c53796d5b02f34e2c61a", + "app/api/external/product-assets/route.ts": "16602244f793db7c51820cd9bb22d414eb735c215a9d68ed4dc36e008eab5265", + "lib/task-detail-asset-edit.ts": "fdf480af9360334aa6881f3f9ae7f4a7224563ddee6d024806dc2644322b1d02", + "lib/task-detail-change-approval.ts": "0e494bb26dd4ab1c0bfec96156ec93899d6f900de7d37ba8b088b96cea231884" + }, + "importedBaseImageId": "sha256:6e70431cffa3b31c98199fc18ad870891c74a2c3607f4f566e6b9298df0f4e72", + "runtimeFingerprint": "7a384a4dfad373975fb3d7b27292506705454a21bea6e9d9ee716393e4c0d481", + "filesystemLayers": 69 +} diff --git a/scripts/material-image-hotfix-verify.cjs b/scripts/material-image-hotfix-verify.cjs new file mode 100644 index 00000000..1ce0bef2 --- /dev/null +++ b/scripts/material-image-hotfix-verify.cjs @@ -0,0 +1,34 @@ +const fs = require('node:fs') +const crypto = require('node:crypto') +const plan = JSON.parse(fs.readFileSync('/tmp/material-hotfix-plan.json', 'utf8')) +const stage = process.argv[2] +if (!['before', 'after'].includes(stage) || plan.version !== 1 || plan.files.length !== 7) throw Error('Invalid hotfix plan') +const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex') +const normalized = bytes => Buffer.from(bytes.toString('utf8').replace(/\r\n/g, '\n')) +const allowed = new Set([ + '.next/server/chunks/29.js', + '.next/server/app/api/external/download/route.js', + '.next/server/app/api/external/product-assets/route.js', + 'app/api/external/download/route.ts', 'app/api/external/product-assets/route.ts', + 'lib/task-detail-asset-edit.ts', 'lib/task-detail-change-approval.ts', +]) +for (const file of plan.files) { + if (!allowed.delete(file.path)) throw Error('Duplicate or unapproved hotfix path') + const bytes = fs.readFileSync('/app/' + file.path) + if (digest(file.normalizeLf ? normalized(bytes) : bytes) !== file[stage]) throw Error('Image file hash mismatch: ' + file.path) +} +if (allowed.size || digest(normalized(fs.readFileSync('/app/prisma/schema.prisma'))) !== plan.schemaHash) { + throw Error('Existing production schema changed') +} +if (stage === 'before') { + if (fs.readFileSync('/app/DEPLOYMENT_COMMIT', 'utf8').trim() !== plan.baseCommit) throw Error('Production base commit mismatch') +} else { + const commit = process.env.VCS_REF + if (!/^[0-9a-f]{40}$/.test(commit || '') || /^0+$/.test(commit)) throw Error('Exact release recipe commit required') + fs.writeFileSync('/app/DEPLOYMENT_COMMIT', commit + '\n') + fs.chmodSync('/app/DEPLOYMENT_COMMIT', 0o444) + fs.writeFileSync('/app/MATERIAL_IMAGE_HOTFIX.json', JSON.stringify({ ...plan, releaseRecipeCommit: commit }) + '\n') + fs.chmodSync('/app/MATERIAL_IMAGE_HOTFIX.json', 0o444) + for (const file of plan.files) fs.chmodSync('/app/' + file.path, 0o444) +} +console.log('Verified bounded material image hotfix: ' + stage) diff --git a/scripts/material-image-hotfix.Dockerfile b/scripts/material-image-hotfix.Dockerfile new file mode 100644 index 00000000..d3057c19 --- /dev/null +++ b/scripts/material-image-hotfix.Dockerfile @@ -0,0 +1,15 @@ +# Incident-only. Caller checks this local tag's immutable image ID against the +# adjacent base lock before and after build, and checks unchanged base layers. +FROM youyidian-3d-material:material-base-b28607f43d14 +USER root +COPY --chown=root:root verified-plan.json /tmp/material-hotfix-plan.json +COPY --chown=root:root verify.cjs /tmp/material-hotfix-verify.cjs +RUN node /tmp/material-hotfix-verify.cjs before +COPY --chown=root:root overlay/ /app/ +ARG VCS_REF +LABEL org.opencontainers.image.revision=$VCS_REF +LABEL com.youyidian.material-hotfix.source-commit=4de34cd3520999e127a6fc5fc7e3e88ac6f12780 +LABEL com.youyidian.material-hotfix.base-image=sha256:b28607f43d14609ec21ac8979a53889f002d6661b4ab80c69b8b67acdf9cc02b +RUN VCS_REF="$VCS_REF" node /tmp/material-hotfix-verify.cjs after && \ + rm /tmp/material-hotfix-plan.json /tmp/material-hotfix-verify.cjs +USER nextjs diff --git a/scripts/material-image-hotfix.mjs b/scripts/material-image-hotfix.mjs new file mode 100644 index 00000000..63e127f5 --- /dev/null +++ b/scripts/material-image-hotfix.mjs @@ -0,0 +1,158 @@ +// Incident-only bounded release; normal releases build the complete Git tree. +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { createRequire } from 'node:module' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const require = createRequire(import.meta.url) +const { parse } = require('next/dist/compiled/acorn') +export const sha256 = value => crypto.createHash('sha256').update(value).digest('hex') +const key = node => node?.key?.name ?? node?.key?.value ?? node?.property?.name ?? node?.property?.value +const isFunction = node => ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node.type) +const messages = ['素材尚未完成上传,暂不能编辑图片', '素材尚未完成上传,暂不能修改图片'] +const ownSources = ['app/api/external/download/route.ts', 'app/api/external/product-assets/route.ts', + 'lib/task-detail-asset-edit.ts', 'lib/task-detail-change-approval.ts'] + +export function walk(node, visit, ancestors = []) { + if (!node || typeof node.type !== 'string') return + visit(node, ancestors) + for (const value of Object.values(node)) { + if (Array.isArray(value)) for (const child of value) walk(child, visit, [...ancestors, node]) + else if (value && typeof value === 'object' && value.type) walk(value, visit, [...ancestors, node]) + } +} +function insist(value, message) { if (!value) throw new Error(message) } + +export function patchCompiled(name, source) { + const ast = parse(source, { ecmaVersion: 'latest' }), edits = [] + let guards = 0, selectors = 0, getters = 0, fetches = 0 + const insert = (start, text) => edits.push({ start, end: start, text }) + if (name === 'chunks/29.js') { + walk(ast, (node, ancestors) => { + if (node.type !== 'Literal' || !messages.includes(node.value)) return + const condition = ancestors.findLast(item => item.type === 'IfStatement') + const owner = ancestors.findLast(isFunction), test = condition?.test + insist(test?.type === 'BinaryExpression' && test.operator === '!==', 'Unexpected upload guard') + const member = test.left.type === 'MemberExpression' ? test.left : test.right + const literal = member === test.left ? test.right : test.left + insist(key(member) === 'taskStatus' && member.object.type === 'Identifier' + && literal.type === 'Literal' && literal.value === 'completed', 'Unexpected guard operands') + const object = member.object.name + edits.push({ start: test.start, end: test.end, + text: '(' + object + '.isIncompleteShell||(' + source.slice(test.start, test.end) + + '&&' + object + '.taskStatus!=="fetched"))' }) + guards++ + let queries = 0 + walk(owner, call => { + if (call.type !== 'CallExpression' || call.callee.type !== 'MemberExpression' + || key(call.callee) !== 'findFirst' || key(call.callee.object) !== 'material') return + const select = call.arguments[0]?.properties?.find(item => key(item) === 'select')?.value + const status = select?.properties?.find(item => key(item) === 'taskStatus') + if (!status) return + insist(!select.properties.some(item => key(item) === 'isIncompleteShell'), 'Selector already patched') + const value = source.slice(status.value.start, status.value.end) + insist(value === '!0' || value === 'true', 'Unexpected selector value') + insert(status.start, 'isIncompleteShell:!0,') + queries++ + }) + insist(queries === 1, 'Material selector missing or ambiguous') + selectors += queries + }) + insist(guards === 2 && selectors === 2, 'Both upload gates must be patched exactly once') + } else { + insist(['app/api/external/download/route.js', 'app/api/external/product-assets/route.js'].includes(name), + 'Unapproved compiled target') + walk(ast, (node, ancestors) => { + if (node.type !== 'Property' || key(node) !== 'GET') return + insist(node.value.type === 'ArrowFunctionExpression' && node.value.body.type === 'Identifier', + 'Unexpected GET export') + const factory = ancestors.findLast(isFunction), exportedName = node.value.body.name + const functions = [] + walk(factory, (item, parents) => { + if (item.type === 'FunctionDeclaration' && item.id.name === exportedName + && parents.findLast(isFunction) === factory) functions.push(item) + }) + insist(functions.length === 1 && functions[0].async, 'GET implementation missing or ambiguous') + const body = functions[0].body + insist(!source.includes('__materialLatestResponse'), 'GET already patched') + insert(body.start + 1, 'const __materialLatestResponse=await(async()=>{') + insert(body.end - 1, '})();__materialLatestResponse.headers.set("Cache-Control","private, no-store");return __materialLatestResponse;') + getters++ + }) + insist(getters === 1, 'Exactly one GET export is required') + if (name === 'app/api/external/download/route.js') { + walk(ast, (node, ancestors) => { + const owner = ancestors.findLast(isFunction) + if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier' + || node.callee.name !== 'fetch' || owner?.id?.name !== 'z') return + const options = node.arguments[1] + insist(options?.type === 'ObjectExpression' + && options.properties.some(item => key(item) === 'headers') + && !options.properties.some(item => key(item) === 'cache'), 'Unexpected download fetch') + insert(options.start + 1, 'cache:"no-store",') + fetches++ + }) + insist(fetches === 1, 'Exactly one original single-file fetch is required') + } + } + edits.sort((a, b) => b.start - a.start) + let boundary = source.length, output = source + for (const edit of edits) { + insist(edit.end <= boundary, 'Overlapping source edits') + output = output.slice(0, edit.start) + edit.text + output.slice(edit.end) + boundary = edit.start + } + parse(output, { ecmaVersion: 'latest' }) + return { output, counts: { guards, selectors, getters, fetches }, edits } +} + +export function createOverlay(input, lock, outputDirectory, sourceRoot) { + insist(lock.version === 1 && /^[0-9a-f]{40}$/.test(lock.sourceCommit) + && /^[0-9a-f]{40}$/.test(lock.baseCommit), 'Invalid versioned source lock') + insist(JSON.stringify(Object.keys(lock.compiled).sort()) === JSON.stringify([ + 'app/api/external/download/route.js', 'app/api/external/product-assets/route.js', 'chunks/29.js', + ]), 'Unapproved compiled lock set') + insist(input.baseImageId === lock.baseImageId, 'Production image identity changed') + insist(JSON.stringify(Object.keys(input.files).sort()) === JSON.stringify(Object.keys(lock.compiled).sort()), + 'Compiled input set changed') + insist(!fs.existsSync(outputDirectory), 'Output directory already exists') + const files = [], results = {} + for (const [name, expected] of Object.entries(lock.compiled)) { + const bytes = Buffer.from(input.files[name].base64, 'base64') + insist(sha256(bytes) === expected, 'Compiled source hash changed: ' + name) + const patched = patchCompiled(name, bytes.toString('utf8')) + results['.next/server/' + name] = Buffer.from(patched.output) + files.push({ path: '.next/server/' + name, before: expected, after: sha256(patched.output), + normalizeLf: false, counts: patched.counts }) + } + for (const name of ownSources) { + const bytes = execFileSync('git', ['show', lock.sourceCommit + ':' + name], { cwd: sourceRoot }) + const normalized = bytes.toString('utf8').replace(/\r\n/g, '\n') + results[name] = bytes + files.push({ path: name, before: lock.sources[name], after: sha256(normalized), normalizeLf: true }) + } + const plan = { version: 1, baseImageId: lock.baseImageId, baseCommit: lock.baseCommit, + sourceCommit: lock.sourceCommit, schemaHash: lock.schemaHash, files } + fs.mkdirSync(outputDirectory) + for (const [name, bytes] of Object.entries(results)) { + const destination = path.join(outputDirectory, 'overlay', name) + fs.mkdirSync(path.dirname(destination), { recursive: true }) + fs.writeFileSync(destination, bytes, { flag: 'wx' }) + } + fs.writeFileSync(path.join(outputDirectory, 'verified-plan.json'), JSON.stringify(plan, null, 2) + '\n', { flag: 'wx' }) + const scripts = path.dirname(fileURLToPath(import.meta.url)) + fs.copyFileSync(path.join(scripts, 'material-image-hotfix.Dockerfile'), path.join(outputDirectory, 'Dockerfile'), fs.constants.COPYFILE_EXCL) + fs.copyFileSync(path.join(scripts, 'material-image-hotfix-verify.cjs'), path.join(outputDirectory, 'verify.cjs'), fs.constants.COPYFILE_EXCL) + return plan +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + const [input, output] = process.argv.slice(2) + insist(input && output, 'Usage: node scripts/material-image-hotfix.mjs ') + const lock = JSON.parse(fs.readFileSync(new URL('./material-image-hotfix-base.json', import.meta.url), 'utf8')) + const plan = createOverlay(JSON.parse(fs.readFileSync(input, 'utf8')), lock, path.resolve(output), + path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')) + console.log(JSON.stringify(plan, null, 2)) +} diff --git a/scripts/material-image-hotfix.test.mjs b/scripts/material-image-hotfix.test.mjs new file mode 100644 index 00000000..f2d5bf3e --- /dev/null +++ b/scripts/material-image-hotfix.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import vm from 'node:vm' +import { createRequire } from 'node:module' +import { patchCompiled, walk } from './material-image-hotfix.mjs' + +const { parse } = createRequire(import.meta.url)('next/dist/compiled/acorn') +const errors = ['素材尚未完成上传,暂不能编辑图片', '素材尚未完成上传,暂不能修改图片'] +const fixture = reverse => errors.map((message, index) => + 'async function guard' + index + '(prisma){const r=await prisma.material.findFirst({select:{taskStatus:true,materialType:true}});' + + 'if(' + (reverse ? '"completed"!==r.taskStatus' : 'r.taskStatus!=="completed"') + + ')throw new Error("' + message + '");return r;}').join('\n') + +for (const reversed of [false, true]) { + test('both compiled gates preserve an explicit allowlist and incomplete shell gate: reversed=' + reversed, async () => { + const patched = patchCompiled('chunks/29.js', fixture(reversed)) + assert.deepEqual(patched.counts, { guards: 2, selectors: 2, getters: 0, fetches: 0 }) + const context = vm.createContext({}) + vm.runInContext(patched.output, context) + for (const status of ['completed', 'fetched', 'pending', 'processing', 'failed', null, 'unknown']) { + for (const isIncompleteShell of [false, true]) { + for (const index of [0, 1]) { + const calls = [] + const prisma = { material: { findFirst: async options => { + calls.push(options) + return { taskStatus: status, isIncompleteShell } + } } } + const action = context['guard' + index](prisma) + if (!isIncompleteShell && ['completed', 'fetched'].includes(status)) { + assert.equal((await action).taskStatus, status) + } else await assert.rejects(action, /素材尚未完成上传/) + assert.equal(calls[0].select.isIncompleteShell, true) + } + } + } + assert.throws(() => patchCompiled('chunks/29.js', patched.output)) + }) +} + +test('missing or changed guard, material selector and duplicate guard fail closed', () => { + const valid = fixture(false) + for (const broken of [ + valid.replace(errors[0], 'other error'), + valid.replace('taskStatus:true', 'taskStatus:false'), + valid.replace('material.findFirst', 'material.findUnique'), + valid.replace('r.taskStatus!=="completed"', 'r.taskStatus==="completed"'), + valid + valid.replaceAll('guard', 'duplicate'), + ]) assert.throws(() => patchCompiled('chunks/29.js', broken)) +}) + +const route = 'const exports={};(function(){function d(a,b){for(const k of Object.keys(b))Object.defineProperty(a,k,{get:b[k]});};' + + 'd(exports,{GET:()=>handler});async function handler(request){if(request.fail)throw Error("original");' + + 'if(request.denied)return request.denied;return request.response;}' + + 'async function z(url,range){return fetch(url,{headers:range?{Range:range}:void 0});}' + + 'async function unrelated(url,range){return fetch(url,{headers:range?{Range:range}:void 0});}})();' + +test('GET wrapper preserves response identity, body, early return and original failures', async () => { + const result = patchCompiled('app/api/external/product-assets/route.js', route) + const context = vm.createContext({}) + vm.runInContext(result.output, context) + const get = vm.runInContext('exports.GET', context) + for (const denied of [false, true]) { + const headers = new Map(), response = { headers, body: 'unchanged original bytes' } + assert.equal(await get(denied ? { denied: response } : { response }), response) + assert.equal(headers.get('Cache-Control'), 'private, no-store') + assert.equal(response.body, 'unchanged original bytes') + } + await assert.rejects(get({ fail: true }), /original/) +}) + +test('only the pinned original single-file fetch gains no-store; Range and other helpers are unchanged', () => { + const result = patchCompiled('app/api/external/download/route.js', route) + assert.deepEqual(result.counts, { guards: 0, selectors: 0, getters: 1, fetches: 1 }) + const calls = [] + walk(parse(result.output, { ecmaVersion: 'latest' }), (node, ancestors) => { + if (node.type === 'CallExpression' && node.callee.name === 'fetch') { + calls.push({ owner: ancestors.findLast(item => item.type === 'FunctionDeclaration').id.name, + keys: node.arguments[1].properties.map(item => item.key.name) }) + } + }) + assert.deepEqual(calls, [{ owner: 'z', keys: ['cache', 'headers'] }, { owner: 'unrelated', keys: ['headers'] }]) + assert.throws(() => patchCompiled('app/api/external/download/route.js', route.replace('function z(', 'function changed('))) + assert.throws(() => patchCompiled('app/api/external/download/route.js', result.output)) +}) + +test('unknown files and ambiguous GET exports are never patched', () => { + assert.throws(() => patchCompiled('app/other.js', route)) + assert.throws(() => patchCompiled('app/api/external/product-assets/route.js', route + route.replaceAll('exports', 'otherExports'))) +})