Files
open-design/specs/current/touchpoint-realtime-vela-handoff.md
CaprikaandGitHub 8fb94d28eb feat(cms): 统一客户端活动点位接入与验收修复 (#7986)
* feat(cms): add verified desktop touchpoint hosts and Test acceptance

* fix(cms): keep Test modal scrim above app chrome

* fix(cms): load host styles and support hover packages

* fix(cms): report mounted host compatibility for restoration

* fix(cms): parse Vela actions in shared manifests without executing them

* fix(cms): preserve authorized actions and correct modal and hover behavior (#8003)

* fix(cms): harden touchpoint host interactions

* fix(cms): preserve modal actions and verify effective close controls

* test(cms): cover hover exit and above-anchor placement

* fix(cms): hide client test controls by default

* fix(cms): persist automatic campaign impressions per account and device

* fix(web): remove host campaign close button

* fix(cms): suppress campaign modals during onboarding

* fix(cms): unify test and production campaign lifecycle (#8118)

* fix(cms): enable safe campaign navigation in test and production

* fix(cms): enforce server-authorized touchpoint lifetimes

* fix(cms): refresh production touchpoints when client language changes

* fix(cms): stabilize hover refresh and synchronize Test locale

* fix(cms): preserve campaigns on focus and remember Test dismissal

* fix(cms): discover Test deployments without reloading

* fix(cms): keep granted touchpoint display authority through transport failures

A refresh that times out or fails in transport is not a withdrawal, but the
shared lifecycle treated every failure the same way: `status = "error"` then
`revoke()`, which tore down a lease the server had already granted and was
still inside its own deadline. One slow poll therefore blanked a running
campaign until the next 30s tick, and a second failure could not restore it
because the value had been dropped.

`abandonAttempt` now names the invariant: cancel the attempt, keep the lease,
and let `armExpiry` retire it at the deadline the server actually granted. The
lease length is the bound, so no retry budget is needed — Test's sixty-second
contract survives exactly one missed poll, and Production survives as many as
its authorization covers.

Three cases still clear, and one of them is new:

  - the failure carries the server's own withdrawal. A 410 is authoritative
    even when its receipt body is unreadable, so `ProductionTouchpointLoadError`
    now marks that one case and the lifecycle honours it. Without this, a
    malformed 410 would have been ridden out as if it were transport noise.
  - nothing is mounted, or the lease was already fenced by `wake`. Page
    recovery has no evidence the activity is still live, so it stays withdrawn.
  - the lease has run out on its own.

This does not fix OPEND-3237, which is a new activity failing its FIRST load:
there is no prior lease to keep and the display stays empty. It fixes the
adjacent class where a mounted activity is erased by a transient refresh.

* fix(cms): keep a failed refresh recoverable inside its own poll cycle

Three changes to how one refresh cycle behaves when it does not go cleanly.
None of them fixes OPEND-3237, whose first-load failure is still unexplained;
they remove the ways a working campaign is lost to an ordinary hiccup.

Bounded retries. A failed attempt used to get its next chance from the fixed
30s tick. For the sixty-second lease the Test runtime is granted, that chance
lands exactly when the lease expires, so a single failure put display on the
edge of going blank with nothing able to recover it. Retries at 1s and 3s give
a fast failure — transport error, 5xx, DNS — several chances while the lease is
still comfortably alive.

A retry must finish inside the cycle that spawned it. `refresh` declines to
start while a request is in flight, so a chain that ran past the next tick
would not merely be late: it would swallow that tick entirely. Worst case was
15+1+15+3+15 = 49s against a 30s interval. Scheduling now requires room for the
retry AND its full budget, which is also what makes "retries cover fast
failures" true in the code rather than only in a comment — an attempt that
burned the whole budget leaves no room by construction and is never retried.
The retry budget resets per cycle, not per success: otherwise the first cycle
to exhaust its retries would leave every later cycle with none.

Request budget 10s -> 15s. One refresh fetches a context and every enabled
placement, so the budget covers a round, not a request. A real round of 11.5s
whose placements all succeeded was being abandoned by the ten-second budget. It
stays well under the 30s interval on purpose.

Forward `accept-encoding` through the touchpoint runtime proxy. Decisions carry
base64 content and run to megabytes, and the proxy built its upstream headers
from scratch, dropping the caller's preference — every refresh pulled the
payload uncompressed, measured at 383KB against 214KB for the same decision.
Because the body is piped through verbatim, upstream's `content-encoding` has
to be handed back with it, or the caller decodes gzip as JSON; both halves land
together. Verified end to end through daemon and web: 2,149,685 -> 1,606,678
bytes on the wire, byte-identical after decode.

An authoritative withdrawal is never retried: a 410 that took a campaign off
the screen must not put it back.

* fix(cms): present each Test campaign modal once per account and device

Test modal presentations now share the production touchpoint-displayed:v1
impression: recorded once visible, read before any new automatic opening,
and persisted across reload, remount, and client restart.

* fix(cms): give the Test control readout an explicit ladder weight
2026-09-17 05:58:48 +00:00

34 KiB
Raw Permalink Blame History

Vela Touchpoint「真实时间活动」测试交接

写于 2026-09-14。本文是 OpenDesign 对 Vela 的待实现依赖交接,不是上线记录。

后续用户已批准本地客户端开发:OpenDesign worktree 已接入共享展示生命周期和 Test 授权字段;本文同步记录客户端实现与验证边界。不执行 Vela 远程部署或数据库操作,既有生产服务端协议、不可变发布快照及 deployments/acceptances 历史不因此改变。Vela 客户端与服务端的待交付要求仍保留。

已决定的产品语义

活动 Test 不再由客户端选择「模拟前/中/后/唤醒」时间。测试者选择一份 Test deployment,Vela 以每次 GET 的服务器当前时间重新判定状态;只有服务端确认 active 且授权在实际挂载时仍有效,客户端才能渲染、挂载并回执。

用户已决定:测试入口以真实时间替换模拟场景,不再默认 active。 这不是把旧 active 改名为 wake,也不是给任一模拟分支保留别名。唯一合法选择是 realtime,状态完全由 Vela 当前时钟计算。

不变量:

  1. context 的唯一 scenario 是 "realtime";旧 before、active、after、wake 一律拒绝,不兼容映射或静默降级。
  2. GET 带 UTC ISO-8601 的 serverTime、startsAt、endsAt、authorizationExpiresAt;均由服务端生成,客户端不得模拟时间或自行签发展示授权。
  3. 每次 runtime GET 用同一个 serverTime 计算状态:serverTime < startsAt 为 before;startsAt <= serverTime < endsAt 为 active;否则为 ended。三个状态均返回完整 metadata 与 identity 的 HTTP 200,不能由 POST、旧 context 或 wake 决定。
  4. acceptance 的 scenario 也只能是 "realtime";Vela 在写入事务中以服务器当前时间和 Test deployment snapshot 再次核对窗口 active,客户端不能传入/控制判定时间。
  5. deployment 的 snapshotHash、artifactHash、manifestHash、内容和既有 acceptance/audit 历史仍是不可变证据。时钟是 runtime 投影,不得重写 snapshot 或历史审计。

全球统一时间:北京时间配置,服务端判定

Admin 排期输入和展示固定使用北京时间 Asia/Shanghai(UTC+08:00),不随配置人员电脑时区变化。保存时将北京时间转换为明确的 UTC instant;接口使用带时区的时间,runtime 统一返回 UTC ISO-8601。不能仅填写 timeZone 字段却把无偏移的日期字符串按浏览器本地时区解析。

开始、结束与展示授权均由服务端控制,Test/Production 使用同一全球时间点;客户端所在地、系统时区及夏令时不改变排期,不实现“当地时间到几点才展示”。例如北京时间 2026-09-14 16:00 对应 2026-09-14T08:00:00.000Z,全球客户端都以这一 instant 为开始边界。本地计时仅用于保守撤载和刷新,不自行决定 active。统一时间边界不承诺网络与 polling 下所有设备同一毫秒挂载。

此处明确目标契约;Admin 输入转换与固定北京时间展示仍需按下方清单验证,不视为已完成的实现证明。

已知活动证据(复现材料,不等于上线)

  • 活动:活动A-HYT测试
  • activity ID:yl5cga2linv5ntp4sz0oc7oo
  • 活动修订:r5
  • Test deployment:odquc46ptdedpba1c42w5a49
  • 排期:2026-09-14 北京时间 15:47–16:00
  • 16:01:06 创建 Test;16:15:35 context 仍投影为 active,模拟时间为 15:53:30;16:15:37–16:15:45 有四个 OD 触点回执。

这证明旧模拟 context 可在真实窗口结束后继续生成 active 与回执,正是本次要切断的路径。它不是一次真实时间验收成功的证据,不能据此 promotion 或宣称上线。

OpenDesign 本地验证(非 live Vela 联调)

本轮在实际 Chromium 中使用本地 HTTP 协议 fixture 和正式桌面宿主桥协议 fixture,经过真实客户端入口验证:Test before 零挂载,active 四触点挂载;活动仍 active 时,断网后短期授权到期使四触点全部撤下,deployment 选择保留,界面不把授权过期误报为活动 ended。生产弹窗也经过真实获取入口验证短期授权先于活动结束时撤下。缺少 Test 授权字段的 fixture 零挂载、零回执。共享 owner 的虚拟时钟测试另覆盖续期、响应耗时、本地钟后退、恢复、请求超时与迟到响应。上述是本地客户端证明,不是 live Vela 联调、原生桌面功能或部署证明。

客户端核心要求:Test 与 Production 必须共用一个展示生命周期

OpenDesign worktree 已合并展示生命周期实现。 TestCampaignModal、ProductionCampaignModal、ProductionCampaignBadge、ProductionCampaignHover 均调用 touchpoint-lifecycle.ts 的 useTouchpointLifecycle;旧的各宿主 polling/lease/wake effects 已移除。独立展示组各自持有实例,但调度、授权和代际隔离只有这一套实现,Test 不再绕开它。Test 选择/验收、生产展示频控和布局留在外层。下表保留重构前审计,不能当作当前代码状态。

重构前调用链与差异(历史基线)

下列行号仅定位重构前版本,符号名便于追溯;不是当前行号或 Vela 客户端的实现证明。

职责 Test 重构前实现 Production 重构前实现 基线判断
生命周期 owner apps/web/src/components/TestCampaignModal.tsx:583–832 的 effect ProductionCampaignModal.tsx:182–353、ProductionCampaignBadge.tsx:63–204、ProductionCampaignHover.tsx:35–98(均在同一 components 目录) 独立;生产三个宿主也分别维护控制逻辑。
获取/刷新 Test effect 建 context、逐 placement GET,30 秒 polling(:817–821) production-touchpoint-loader.ts:23 的 loadProductionTouchpointDecision;modal 初始及 focus/online 获取(:238–344),badge:196、hover:96 另有 RECHECK_MS 周期 没有统一刷新 owner;生产 loader 只是获取复用,连生产宿主的调度也不统一。
时间与到期 realtimeTiming(TestCampaignModal.tsx:484–513)按 serverTime/window 与请求单调时钟计算边界,effect 自己设 timer 生产宿主各自计算授权截止并设 timer;modal:267–270、322–326 Test 仅活动窗口不能替代生产授权 lease。
恢复/断网/迟到响应 Test effect 自有 generation、AbortController、10 秒超时及 focus/pageshow/visibilitychange fence(:615–723、800–831) 生产宿主自有 request/authorization/lease generation;modal focus/online 触发获取,加载异常 clear(:328–351) 恢复策略和请求失效机制尚未合并。
已共享的宿主/渲染 useTestRuntime、TestTouchpointMount、recordVisibleTestTouchpoint modal/badge/hover 读取 Test store;modal/badge 调 TestTouchpointMount,hover 也调用可见性回执入口 仅部分宿主、store 和渲染/回执工具共用。

重构前,App 同时接入 Test/Production 组件,生产 modal 在存在 Test session 时退出独立生产获取 effect;这是 Test 接管宿主而非共享生命周期。重构后仍由 Test 选择接管展示,但 Test 的数据适配器与生产适配器均进入 useTouchpointLifecycle;共享 hook 的 references 已覆盖四个入口。

合并目标与准确职责

目标是同一客户端内一个 lifecycle owner + 两个数据适配器,不是跨 Vela/OD 仓库共享源码。Vela 客户端也需满足同一约束,但本次未重新核查其当前调用链,不能宣称它已完成共享。

  • 数据获取/适配层:分别处理 Test/Production endpoint、认证、locale、deployment/context 选择及响应验证/适配。输出给同一 controller 的是经过身份与能力校验的 decision、服务器时间/活动窗口、展示授权限制或明确的 no-decision/revocation/error 结果。适配器不得拥有 polling、到期 timer、页面事件 listener 或另一个挂载状态机。
  • 唯一共享生命周期:统一拥有 polling/重新获取调度、服务器时间边界、当前授权/可挂载状态、挂载与到期撤载、断网处理、页面恢复、请求取消和迟到响应隔离。Test 的开始边界也必须移入这里,不能继续由 TestCampaignModal 自己驱动,仅抽取一个“共同卸载计时器”仍不合格。
  • 展示宿主:modal/badge/hover 保留各自布局、交互与展示频控,但消费同一 controller 的当前授权和挂载结果;不得保留平行的取数/lease effect。授权失效时所有宿主和异步 mount 回调都受同一代际失效约束,不能重新挂回旧内容。
  • 生命周期之外:Test 选择面板只改变适配器输入;Test context 创建由 Test 适配器处理。recordTestAcceptance(TestCampaignModal.tsx:249–297)及 recordVisibleTestTouchpoint(:300–332)的验收业务留在外层,仅观察共享生命周期已授权且实际可见的结果;轮询、恢复和无效/过期响应不得产生新验收。外层不得反向维持另一套展示时钟。

OpenDesign 实施位置:touchpoint-lifecycle.ts 统一拥有 30 秒 polling、请求超时、服务器相对授权期限、开始边界重新 GET、页面恢复/可见性、请求取消及代际隔离。各宿主的数据适配器只获取和验证响应;生产继续调用 loadProductionTouchpointDecision,Test 在选择作用域创建 context 并获取 placements。相同 immutable decision 的正常 polling 续期保留挂载身份,不重复展示/验收。授权到期撤下内容,不自行将服务器 active 状态改为 ended。

Test deployment 目录发现(OPEND-3172)

“唯一共享生命周期”指展示授权与挂载的 owner,不代表 deployment 目录只能在初始化读取。目录发现由 test-deployment-selection.ts 独立拥有:前台每 30 秒读取目录,focus、online、pageshow 和恢复可见时立即刷新;单次请求 10 秒超时,单飞并取消过期请求。空目录、未开始和已结束的选中活动均不能终止目录发现。

  • 普通客户端沿用服务端最新优先的目录顺序选择第一个受支持 deployment;调试模式保留人工选择,不自动抢选。目录不提供排期授权,未来活动只能在 realtime decision 验证后展示。
  • 相同 deployment 与快照保留原 selection 对象,不能因新 JSON 对象重建 adapter、context 或挂载。替换、快照变化和成功目录确认移除才改变选择;目录请求失败、超时、格式错误不能被当成空目录,也不能延长原展示授权。
  • 账号变化、卸载和页面隐藏取消旧目录请求,迟到结果不得覆盖新选择。目录选择只输入现有 Test adapter;context、decision、开始边界、续期和到期仍由原共享生命周期负责,不在目录层复制展示时钟。
  • 目录刷新不发布临时空 Test session;切换期间由现有未授权 Test session 保持 Test/Production 隔离。旧授权不能随着目录发现或重试被恢复。

生产展示授权约束:必须原样保留上限

生产不是“活动未结束就能一直展示”。共享的 resolveAuthorizationDeadline 为生产保留有效截止:

min(authorizationExpiresAt, endsAt, serverTime + 5 分钟)

  • 三个上限都必须进入共享生命周期;不能只取 endsAt,不能因 polling、Test 切换、重新挂载或页面恢复重新起算并延長旧授权。活动尚 active 但授权先到期时,也必须撤载。
  • 按服务器时间和请求耗时保守换算本地调度;本地时钟变化、网络延迟或页面挂起不能延长已发授权。恢复时先阻止旧授权继续挂载,再重新验证;旧响应和旧 timer 不能复活已失效 decision。
  • 普通窗口 focus 不等同于页面恢复:页面仍可见且当前 lease 尚未到期时,立即后台验证并保留原挂载,不改变原授权截止;同一内容的有效响应只续期。visibilitychange、pageshow、online 仍走暂停展示的恢复路径,focus 时已过期的 lease 也先撤下。请求失败、超时、匹配撤销及到期的清理规则不变。
  • Test 弹窗与 Production 使用同一展示频控:弹窗实际可见即写入 localStorage touchpoint-displayed:v1:<账号>:<活动ID>,同一账号、活动在本设备只自动弹出一次,跨刷新、重新挂载与客户端重启保持;Test 与 Production 共用该记录。已打开的弹窗可在续期、验证与语言切换中保持;此后同一活动的新 deployment、临时空 decision 或关闭后的重新下发均不再自动弹出。账号角标与 hover 不受该频控影响。
  • 生产 loader 的 404 是 no-decision,并不自行撤销有效 lease;只有有效且绑定当前 touchpointDecisionId/deploymentId/activityId/contentVersionId 的 410 receipt 才构成服务端撤销。匹配的撤销清除当前展示,但不停止后续发现新活动。非 Abort 的当前请求失败仍诊断后 clear。恢复期间先暂停旧展示;no-decision 或不匹配撤销只能保留尚未到期的原授权及原截止,不能续期或复活过期授权。
  • 用户已确认:Test 也由服务端返回 authorizationExpiresAt,与 Production 使用相同的短期展示授权规则。 OpenDesign Test DTO 已将此字段设为必填;适配器不得伪造授权或 fallback 到仅受 endsAt 限制。Vela 新响应仍待交付验证。
  • Vela 当前生产服务端最长授权 60 秒(services/api/src/touchpoints/persistence.ts:resolveProductionRuntime);Test 每 GET 使用同一 serverTime 生成 authorizationExpiresAt = min(endsAt, serverTime + 60 秒)。before/ended 也返回该字段及完整 metadata,但不可挂载;ended 的授权时间可早于 serverTime。客户端 5 分钟安全上限不是服务端授权时长,不能延长 60 秒授权。
  • 两种数据源进入同一授权与展示算法:服务器确认 active 且授权有效才能挂载;授权或活动到期取较早者撤载,断网也不续期。只有新的有效服务端响应可以续期;本地计时只能使展示失效,不能自行将 before 激活。

两层契约:API 可不同,展示生命周期相同

Test/Production 保留各自 endpoint、认证、选择方式和响应形状,不要求生产接口改为 Test 三态 200。两个适配器统一输出已验证的 identity/content、服务器窗口及授权限制,交给同一 lifecycle owner;统一 polling、挂载、撤载、恢复和迟到响应隔离。Test 选择与验收外置。生产既有 no-decision、撤销和失败语义按前文保留,不把不同响应混为相同撤销信号。

OpenDesign 本地 DTO 与客户端已按上述分层实现;Vela 侧 DTO、服务端、适配器及 fixtures 仍需迁移并完成真实联调,不能因本地实现完成而宣称两仓已交付。

共享完成的验收要求(两仓整体仍待验收)

  1. 从 Test 和 Production 真实入口分别追踪到同一 lifecycle owner;旧平行 effects/timers/listeners 已移除,适配器仅取数与适配。代码调用链证据与行为验证都要有。
  2. 在同一生命周期的验证入口替换两个实际适配器,覆盖 polling、before→active→ended、离线到期、页面恢复和迟到响应;再分别通过两种环境的实际宿主验证挂载/撤载。
  3. 生产额外覆盖 authorizationExpiresAt 早于 endsAt、5 分钟上限、窗口仍 active 但 lease 已过期、匹配/不匹配 410 receipt、404 不等于撤销;确认 Test 切换和恢复不会延长生产授权。
  4. Test 面板与验收回执外置;非 active/未授权/未实际挂载不验收,polling 不重复回执。生产原有展示频控与交互也必须保留。

此前 27 个相关 Test/host 测试和本地 Chromium Test fixture 的结果,只证明先前 Test 改动的已验证行为;既不证明 Production 已走同一逻辑,也不证明本节共享目标完成。

Wire 契约

OpenDesign 客户端 DTO 位于 @open-design/contracts/api/touchpointTestRuntime。Vela 与 OpenDesign 按下列 wire 契约对齐,但两个仓库不得互相 import对方的 contracts/package;Vela 应用等价的本地 type/schema,不能另造不同字段或语义。

import type {
  TestRuntimeAcceptanceRequest,
  TestRuntimeContext,
  TestRuntimeContextRequest,
  TestRuntimeDecision,
  TestRuntimeScenario,
  TestRuntimeScheduleState,
  TestRuntimeTiming,
} from "@open-design/contracts/api/touchpointTestRuntime";

// TestRuntimeScenario = "realtime"
// TestRuntimeScheduleState = "before" | "active" | "ended"
// TestRuntimeContextRequest = { deploymentId: string; scenario: TestRuntimeScenario }
// TestRuntimeContext = TestRuntimeContextRequest & {
//   testerMemberId?: string; updatedAt: string // ISO-8601
// }
// OpenDesign DTO 已同步授权字段;Vela 等价类型与响应仍需实现/验证。
// TestRuntimeTiming = { serverTime: string; startsAt: string; endsAt: string; authorizationExpiresAt: string } // UTC ISO-8601
// TestRuntimeDecision<Content, StaticActions> = {
//   deploymentId: string; activityId?: string; snapshotHash?: string;
//   artifactHash?: string; manifestHash?: string; placementKey: string;
//   requiredCapabilities: string[]; content: Content; staticActions: StaticActions;
//   testContext: TestRuntimeContext & { scheduleState: TestRuntimeScheduleState }
// } & TestRuntimeTiming
// TestRuntimeAcceptanceRequest = {
//   placementKey: string; hostVersion: string; locale: string;
//   scenario: TestRuntimeScenario; evidence: string
// }

simulatedAt 已删除,不得出现在 context、decision、数据库新写入、客户端校验或 acceptance 依据。testerMemberId 由服务端认证得到;客户端不得伪造它。TestRuntimeDecision<Content, StaticActions> 在顶层保留 deployment/activity/snapshot/artifact/manifest identity、placement、requiredCapabilities、content 和 staticActions,并附 timing/context;它是统一 shape,不得将 non-active 做成缺 metadata/content 的变体。

请求与响应范例

继续使用既有 endpoints。

POST /api/v1/touchpoints/runtime/test-context
Content-Type: application/json

{"deploymentId":"odquc46ptdedpba1c42w5a49","scenario":"realtime"}
201 Created
Content-Type: application/json

{
  "deploymentId":"odquc46ptdedpba1c42w5a49",
  "testerMemberId":"<authenticated-member-id>",
  "scenario":"realtime",
  "updatedAt":"2026-09-14T08:15:35.000Z"
}
GET /api/v1/touchpoints/runtime/test?deploymentId=odquc46ptdedpba1c42w5a49&placementKey=vela.web.console-overlay&locale=zh-CN

以下是局部说明性响应,只展开本次新增/关键字段;content 保持既有完整 payload shape,不是此处省略字段的替代协议。

200 OK
Content-Type: application/json

{
  "deploymentId":"odquc46ptdedpba1c42w5a49",
  "activityId":"<existing-activity-id>",
  "snapshotHash":"sha256:<existing-64-hex-snapshot-hash>",
  "artifactHash":"sha256:<existing-64-hex-artifact-hash>",
  "manifestHash":"sha256:<existing-64-hex-manifest-hash>",
  "placementKey":"vela.web.console-overlay",
  "requiredCapabilities":["hover","static-action"],
  "staticActions":[],
  "content":{"id":"<existing-content-version-id>","placementKey":"vela.web.console-overlay","...":"existing payload unchanged"},
  "testContext":{
    "deploymentId":"odquc46ptdedpba1c42w5a49",
    "testerMemberId":"<authenticated-member-id>",
    "scenario":"realtime",
    "updatedAt":"2026-09-14T08:15:35.000Z",
    "scheduleState":"ended"
  },
  "serverTime":"2026-09-14T08:15:45.000Z",
  "startsAt":"2026-09-14T07:47:00.000Z",
  "endsAt":"2026-09-14T08:00:00.000Z",
  "authorizationExpiresAt":"2026-09-14T08:00:00.000Z"
}

startsAt < endsAt 必须成立;serverTime 可以在窗口外。临界点 start 包含、end 排除。Vela 用单一 serverTime 同时生成 response 与 scheduleState,避免同一响应自相矛盾。

同一 serverTime 还用于生成最长 60 秒的 authorizationExpiresAt。active 响应必须满足 serverTime < authorizationExpiresAt <= min(endsAt, serverTime + 60 秒)。缺少/非法授权时间或超出上限不得挂载、回执或续期;before/ended 带完整字段不代表授权展示。before 必须重新 GET 得到 active 且有效的新授权,不能按本地计时启用旧响应。

POST /api/v1/touchpoints/test-deployments/odquc46ptdedpba1c42w5a49/acceptances
Content-Type: application/json

{
  "placementKey":"vela.web.console-overlay",
  "hostVersion":"<existing-host-version>",
  "locale":"zh-CN",
  "scenario":"realtime",
  "evidence":"https://<existing-approved-evidence-location>",
  "hostCompatibility":{"...":"existing optional report shape"}
}

窗口不是 active 时,服务端必须拒绝写入(保持项目现有错误风格;可用明确码 test_schedule_not_active),不创建 acceptance,避免 promotion 将其误认为验收齐全。

Vela 实施定位与改法

以下位置来自 powerformer/vela 检索到的当前实现(commit 7cbaeb015196c8ed4bdd669905b3811250335b59),是待迁移引用,不代表已修改。

位置 当前职责/旧行为 Vela 必须改动
services/api/src/touchpoints/routes.ts:testContextSchema、POST /api/v1/touchpoints/runtime/test-context schema 接受 before/active/after/wake,再调 repository.selectTestContext 改为严格 { deploymentId, scenario: z.literal("realtime") };继续从认证 principal 填 tester;旧值或额外 simulatedAt 返回既有 invalid_test_context 400。
同文件:GET /api/v1/touchpoints/runtime/test 调 repository.resolveTestRuntime({ deploymentId, placementKey, locale, testerMemberId }) 后直接返回 decision 保留认证、查询、404、413;repository 每 GET 取一次 now,返回完整 decision、顶层 timing 及重算 state。route 不缓存 decision。before/ended 仍 200。
同文件:POST /api/v1/touchpoints/test-deployments/:id/acceptances 接受任意非空 scenario 后调 recordTestAcceptance schema 收紧为 z.literal("realtime");repository 写入事务内再判真实 active,不能只作 route 前置检查。
services/api/src/touchpoints/persistence.ts:TestScenario、TestContext、TestContextRow、toTestContext、simulatedTimeForScenario、scheduleStateAt、TestRuntimeDecision 四种模拟场景;context 有 simulated_at;模拟函数生成时刻;decision 无顶层 timing scenario 只留 realtime;删 simulatedAt/simulated_at 投影和 simulatedTimeForScenario;scheduleStateAt 可保留为纯函数但输入必须是本次 server instant;decision 顶层加 serverTime/startsAt/endsAt/authorizationExpiresAt。
同文件:PostgresTouchpointRepository.selectTestContext、resolveTestRuntime、recordTestAcceptance context 写入/更新;resolve 拼 Test snapshot/context;acceptance 写证据 select 只持久化 realtime 与 updated_at;resolve 每 GET 用 now + snapshot window 构建完整 timing/state 及最长 60 秒授权;acceptance 的 active 判定与写入在同一事务。
touchpoints 的 test-context schema migration(路径/命名遵从 Vela 现有 migration 约定) 当前 touchpoints.test_contexts 含 scenario 与 simulated_at;API SQL mock 也引用它 context 是可重选临时状态,不是 deployment/acceptance 审计。clean cutover 下:停写旧服务后令旧 contexts 失效并清理,DB owner 批准后收紧 schema 到 realtime 并移除 simulated_at。若不能安全清理,可保留 legacy 存储但 runtime 必须拒绝读取/解释它;不得改 deployments、acceptances 或其历史。
apps/web/src/components/test-campaign-contract.ts TestScenario、VelaTestContext、VelaTestDecision 和 velaTestDecisionMatchesSelection 比较 simulatedAt,并要求 active 切到 realtime DTO:删旧 scenario/simulatedAt;校验完整授权字段、最长 60 秒上限、ISO timing、startsAt < endsAt、state 与 serverTime/window 一致;保留 snapshot、placement、capabilities、staticActions 绑定。non-active 保留同形 content,但不挂载。
apps/web/src/components/test-campaign-console.tsx、apps/web/src/components/production-campaign-float.tsx 历史检索入口,非本次 Vela 调用链核查结果 在 Vela 客户端内核查并合并到同一生命周期;console 保留选择/验收外层,Test/Production 获取适配器负责协议差异。不能仅迁移 realtime DTO 或复用 float 就声称共享,必须提交实际调用链与两环境验收证据。

需迁移引用与验证

已检索到的引用:

  • services/api/test/touchpoints-test-context.test.ts:mock touchpoints.test_contexts SQL 与 context persistence。
  • services/api/test/touchpoints-browser-runtime-routes.test.ts:runtime routes/依赖注入。
  • services/api/test/touchpoints-activities.test.ts:活动/Test runtime 行为。
  • services/api/test/e2e/touchpoints-restore-lifecycle.test.ts:Test deployment 生命周期与 promotion/restore 边界。
  • apps/web/tests/unit/production-campaign-float.test.ts、apps/web/tests/browser-agent/production-campaign-float.spec.ts:Web 浮层 fixture/消费。
  • apps/web/src/components/test-campaign-console.tsx、apps/web/src/components/production-campaign-float.tsx、apps/web/src/components/test-campaign-contract.ts:Admin/Vela Web 类型、选择校验、渲染入口。
  • services/api/src/touchpoints/promotion.ts 与 routes.ts promotion path:acceptance_incomplete 阻断仍必须只承认真实 active 窗口形成的 acceptance。

实现时对上述导出类型/方法先跑 references,再全量迁移实际命中处;不要臆测或改动未检索到的消费者。

历史 acceptance 有效性:同库与跨库必须同时过滤

Vela review 补充的交付要求:同库 promotion 和跨库 getPromotionEvidence 必须消费同一份有效性判定;仅收紧新 acceptance 写入不能隔离旧证据。本次不声称远程 Vela 已实现该过滤。

  • 先过滤,再计算必需 placement 的完整性。有效记录必须为 scenario === "realtime",绑定目标 Test deployment、不可变 snapshot/hash、placement 及既有身份/兼容性要求;可信服务端验收时刻必须位于 [startsAt, endsAt)。只有 scenario 字符串不够,缺失可信时间/绑定信息的记录不得计入。
  • 旧 before/active/after/wake 或未知 scenario 保留审计但不计入;不得改名 realtime、补造时间或重写历史。判断的是验收发生时刻,不是 promotion 当前时刻;已结束活动中的有效历史 realtime 验收不会仅因现在过期而失效。
  • 同库 promotion:过滤后未覆盖所有必需 placement,沿用 acceptance_incomplete 阻断。
  • 跨库 getPromotionEvidence:导出/聚合前应用同一过滤,不得把原始记录 count 或未过滤的“齐全”布尔值作为证据。跨库消费侧须验证证据来源、绑定和有效性契约,拒绝旧导出或不可验证记录,不能绕过源侧过滤。格式按 Vela 现有跨库协议落地,不在本文臆造字段。
  • 两条路径均须验收:仅旧模拟记录失败;旧新混合但有效覆盖不足失败;完整有效 realtime 证据通过;错 snapshot/placement、窗口外或缺少可信时间不计入;跨库导出/消费后结论一致。原始审计记录保持不变。

严格协议切换:Test 维护窗口与旧页面刷新

以下为待另行批准的操作方案,本文不执行。严格协议切换没有新旧兼容期;仅写“先 Vela、后 OD”不足以保护两仓发布之间的 Test 使用者。

  1. 维护前准备好两仓新协议、各客户端共享生命周期、两条 promotion 证据过滤及可分发构建。确认能暂停 Test 而不影响 Production;若没有现成暂停/版本识别设施,须先补齐,不能假定已有开关。
  2. 进入 Test 维护窗口:通知测试者关闭旧 Test 页面,暂停选择、展示和验收;服务端受控暂停 Test context/runtime/acceptance 入口及相关 promotion 证据导出/消费。仅隐藏选择面板不够,未刷新的旧页面也必须被拒绝。Production runtime 保持服务。
  3. 停止旧服务写入后,再由 DB owner 单独批准 migration;Vela schema、persistence、routes、active-only acceptance 和两条证据过滤作为同一发布单元切换。令旧临时 contexts 失效,不允许新旧实例同时按不同语义服务 Test;不重写 deployment、acceptance 历史或 snapshot。
  4. 升级并刷新客户端:Vela Web 和 OD Web 刷新至新 bundle;OD 打包客户端升级并重启至新构建,仅刷新旧版本不能代替升级。测试者重新选择 deployment,创建 realtime context,不恢复旧模拟 context。未升级/未刷新的页面继续被服务端拒绝,并有明确维护/升级/刷新提示;提示和版本识别是发布前提,不是本文声称已实现的功能。不得静默兼容旧 scenario。
  5. 维护期间先走受控验收:新链路 before/active/ended、离线到期、页面恢复、迟到响应、生产授权早于活动结束、同库/跨库证据过滤,以及旧页面实际被拒绝。确认每个客户端两条入口进入同一 lifecycle owner 后,再开放 Test。维护窗口结束取决于完整链路验收,不取决于某一个仓库部署完成。

失败时保持 Test 不可用;不得通过恢复模拟 active、重放旧 context 或放松历史证据过滤回滚。以上是操作交接,不构成远程部署或数据库修改授权。

边界

  • 未选择 context、deployment 不存在、placement 不匹配、无 runtime 授权保持现有 404/认证失败语义;不得以虚假 active 掩盖。
  • before/ended 不是 404:必须 HTTP 200,含完整既有 identity、snapshot hashes、placement、capabilities、staticActions、context、timing 和同形 content。它们供客户端等待开始/停用;客户端/host 非 active 时禁止挂载 content、发 runtime event 或 acceptance,并必须撤下已显示触点。
  • updatedAt 是 context 选择更新时间,不替代 serverTime,不能判排期。客户端本地钟只可用于刷新/展示,不决定 active。
  • 本轮仅修改 OpenDesign 本地客户端、契约、构建入口和相关测试/交接记录,不修改 Vela 运行时代码或远程环境。“不改生产”指不借此改变生产服务端协议、展示授权上限及既有推广门槛。历史证据过滤仍由 Vela 补齐;不重写 deployment/acceptance 审计或内容快照,不更改凭证和数据库连接。

Vela 与客户端后续验收清单

  • Admin 在不同电脑时区下配置/回显同一北京时间,保存的 UTC startsAt/endsAt 不变;Test/Production 客户端切换 Asia/Shanghai、UTC、America/Los_Angeles 后,对同一服务器 instant 的开始、结束和授权判定一致,覆盖开始包含、结束排除以及当地夏令时切换。

  • 各客户端内 Test/Production 实际入口进入同一生命周期;旧平行调度/lease effects 移除;适配器仅取数、认证、选择和响应适配。

  • 生产 authorizationExpiresAt、endsAt、serverTime + 5 分钟上限与 404/410/加载失败语义保留;两环境分别验证 polling、挂载、离线、恢复和迟到响应。

  • Test DTO、服务端、两仓适配器和 fixtures 同步 authorizationExpiresAt;两种数据源均验证活动仍 active 但授权先过期、断网不续期、恢复获取新授权、before 不被本地激活、缺失授权字段严格拒绝。

  • Test 面板与 acceptance 留在生命周期之外;Test fixture 不被当作 Production 共享证据。

  • 同库 promotion 与跨库 getPromotionEvidence 使用同一历史验收有效性规则,混合/无效证据不能满足完整性。

  • Test 维护窗口、旧页面刷新/旧版本升级、未升级请求拒绝、失败保持 Test 不可用均已受控演练;Production 不因 Test 切换停服。

  • POST context 只接受严格 body {deploymentId,scenario:"realtime"};四种旧值、额外 simulatedAt、畸形 body 均 400。

  • 201 context 无 simulatedAt,仅有 realtime scenario、deployment、认证得到的 tester、updatedAt。

  • 同一 deployment 在真实 starts 前、窗口内、ends 后的 GET 都是 HTTP 200,含合法 timing 与完整 metadata/identity,并依次为 before/active/ended;不重 POST context;只有 active 且授权有效才挂载 content。

  • GET 每次由 server clock 判定;旧 context updatedAt/历史模拟时刻不能让结束窗口变 active。

  • decision 的 identity/content/manifest/artifact/snapshot hashes、placement、capabilities、staticActions 保持精确绑定 immutable snapshot。

  • acceptance 只接受 realtime,before/ended 由服务器拒绝且不落库;active 成功写入仍可走既有 promotion。

  • migration 仅在 DB owner 批准后清理临时旧 contexts,或保留 legacy 但让 runtime 拒绝它;不 mutation deployments、acceptances、历史审计或发布 snapshot hash。

  • Vela Web console/float/fixtures 无 simulated fallback;无效时间 response 不展示/不回执,non-active 200 仅等待/停用。

  • 运行 Vela API scoped tests(test-context、browser runtime routes、activities、promotion/restore lifecycle)、Vela Web consumer tests,并做真实时钟本地 API smoke。

部署阻碍与交付状态

交付面 状态 可宣称的范围
OpenDesign 当前 worktree 客户端与 contracts 已接入共享生命周期及必填 Test 授权;本地测试与浏览器协议 fixture 验证 覆盖本地客户端,不代表 Vela 新协议已部署;真实联调与正式发布仍待完成。
Vela runtime、persistence、migration、acceptance 与 Vela Web 消费者 待实现、待部署、待真实窗口验证 不能将本文协议、OpenDesign worktree 代码或 A-HYT 的旧模拟回执表述为 Vela 已部署或真实时间活动已上线。

剩余上线阻碍:Vela 客户端共享生命周期与服务端授权响应;migration、每 GET realtime、active-only acceptance、同库 promotion 与跨库 getPromotionEvidence 历史过滤;维护窗口、旧页面策略与两仓真实窗口联调。OpenDesign 本地实现不替代这些交付,也不构成部署或数据库修改授权。