茶器艺科智造HarmonyOS应用实战-26-订单日期与ORD-2026写死,跨年演示为何仍像旧数据:注入Clock与IdFactory
应用运行到 2027 年,商城首屏仍显示ORD-2026-0118与2026-03-15 14:30。用户从切片页新建订单时,ID 又变成ORD-SL-毫秒时间戳,日期取另一次new Date()。同一个列表里,一部分像历史快照,一部分像实时业务数据;测试若想覆盖跨年,只能依赖执行机器的当前时钟。
指定工程的演示订单确实全部写死在 2026 年。HSP 新建切片订单则在一个方法里多次读取时间:一次Date.now()拼 ID,一次new Date()格式化日期,随后再一次Date.now()计算状态转换截止时刻。三次读取通常接近,但不是同一业务快照;快速连点还可能让毫秒 ID 碰撞。本文建议把“现在是什么时候”和“订单 ID 怎么生成”变成依赖,由Clock与IdFactory注入。生产使用系统时钟,测试使用固定时钟,订单创建全程只取一次 now。
本文聚焦可重复的时间与身份生成:
- 区分固定演示快照、相对当前时间的演示数据和真实订单三种语义。
- 用 Clock 固定跨年、月末和时钟回拨用例。
- 用 IdFactory 处理同毫秒创建与已有 ID 冲突。
- 说明端侧时钟与端侧 ID 的证据上限,避免把演示唯一性当服务端业务唯一性。
一、源码里同时存在三种互不统一的时间写法
ChaqiModels.ets:133-164中,打印中与待处理订单逐条写ORD-2026-...和 2026 年日期,已完成循环也把 ID 年份固定为 2026、日期固定为 2026-02:
{ id: 'ORD-2026-0118', date: '2026-03-15 14:30', /* ... */ } // 已完成订单循环 id: `ORD-2026-${pad}`, date: `2026-02-${dayText} ${hourText}:${minuteText}`HSP 的ChaqiExperiencePage.ets:619-631则使用当前时间与随机延迟:
const orderId = `ORD-SL-${Date.now()}`; const sliceOrder: ShopOrder = { id: orderId, status: 'pending', progress: 0, name: `${presetName}·切片`, material: '陶瓷树脂', date: this.formatOrderDateNow() }; const delayMs = 2000 + Math.floor(Math.random() * 3001); this.pendingSlicePrintFireAtMs = Date.now() + delayMs;当前代码没有把这些订单声明为“固定历史样例”还是“仿实时数据”。如果它们只是教程截图 fixture,固定 2026 未必错误,但界面应明确标注演示快照;若期望每次打开都像近期订单,就需要相对 Clock 生成。不能只把字符串中的 2026 手工改成 2027,因为下一年同样会过期。
二、先选择演示时间语义,再写生成器
演示数据常见三种策略:
| 策略 | 时间来源 | 优点 | 代价 |
|---|---|---|---|
| 固定快照 | fixture 自带 2026 日期 | 截图与测试完全稳定 | 跨年后显旧,必须标注“演示快照” |
| 相对现在 | Clock.now 减若干小时/天 | 每次打开都像近期列表 | 相同版本截图会变化 |
| 服务端样例 | 后端返回带环境标记的数据 | 接近真实协议 | 依赖网络与服务环境 |
本文选择“相对现在 + 测试固定 Clock”作为建议,因为标题场景要求跨年仍保持近期感。第 25 篇的 fixture 仍然成立:样例的名称、材质、状态、进度和时间偏移是固定的;绝对日期由注入 Clock 派生。若产品选择固定快照,则无需 Clock 生成日期,只需改用DEMO-ID 并在界面明确说明。
三、Clock接口只暴露业务需要的当前毫秒
一个最小 Clock 足够,不需要引入完整日期框架:
export interface Clock { nowMs(): number; } export class SystemClock implements Clock { nowMs(): number { return Date.now(); } } export class FixedClock implements Clock { private valueMs: number; constructor(valueMs: number) { this.valueMs = valueMs; } nowMs(): number { return this.valueMs; } }生产代码注入 SystemClock;单测注入 2026-12-31 23:59 或 2027-01-01 00:01 对应的毫秒值。Clock 返回 epoch 毫秒,不决定展示时区。格式化层应明确采用设备本地时区还是业务固定时区;当前getFullYear/getMonth/getDate使用设备本地时区,这一行为可保留,但测试应显式提供时区策略,避免不同环境断言不同日期。
四、相对时间fixture保存offset,不保存过期日期
把第 25 篇订单 fixture 的date改成相对偏移。例如打印中订单发生在 now 前 30 分钟,待处理订单可以在 now 前后若干分钟;是否允许“未来待处理时间”需要产品定义。
export interface TimedOrderFixture { readonly sequence: number; readonly ageMinutes: number; readonly status: OrderStatus; readonly progress: number; readonly name: string; readonly material: string; } const PRINTING_FIXTURES: readonly TimedOrderFixture[] = [ { sequence: 118, ageMinutes: 30, status: 'printing', progress: 67, name: '罗汉杯·兔毫釉', material: '陶瓷树脂' } ];构建时只读一次 anchorMs,再对所有行计算绝对时间。这样列表内部拥有一致参照,不会生成到一半跨过午夜:
export function buildDemoOrders( clock: Clock, idFactory: OrderIdFactory ): ShopOrder[] { const anchorMs = clock.nowMs(); return ALL_FIXTURES.map((item: TimedOrderFixture) => { const createdAtMs = anchorMs - item.ageMinutes * 60 * 1000; return { id: idFactory.demoId(createdAtMs, item.sequence), status: item.status, progress: item.progress, name: item.name, material: item.material, date: formatOrderTime(createdAtMs) }; }); }建议长期把createdAtMs存进 ShopOrder,UI 再格式化,避免 date string 承担排序与时区语义;为缩小迁移,本片段仍返回当前模型需要的 date。
五、IdFactory统一演示ID和切片订单ID
ID 生成不应散落模板字符串。接口可以分别表达 demo 与用户触发订单:
export interface OrderIdFactory { demoId(createdAtMs: number, sequence: number): string; sliceId(createdAtMs: number, occupied: readonly string[]): string; } function fourDigits(value: number): string { if (value < 10) return `000${value}`; if (value < 100) return `00${value}`; if (value < 1000) return `0${value}`; return `${value}`; }演示 ID 可从 createdAt 的年份生成DEMO-2027-0118,明确它不是后端订单;若 UI 必须保留 ORD 前缀,也要有单独 demo 标记。切片 ID 以时间为基底,并在已有订单中检查冲突:
export class LocalOrderIdFactory implements OrderIdFactory { demoId(createdAtMs: number, sequence: number): string { const year = new Date(createdAtMs).getFullYear(); return `DEMO-${year}-${fourDigits(sequence)}`; } sliceId(createdAtMs: number, occupied: readonly string[]): string { const base = `ORD-SL-${createdAtMs}`; let candidate = base; let suffix = 1; while (occupied.includes(candidate)) { candidate = `${base}-${suffix}`; suffix++; } return candidate; } }冲突循环解决当前列表内同毫秒创建,不证明跨设备、跨进程或服务端全局唯一。真实订单应由服务端或正式唯一 ID 协议分配,端侧临时 ID 还需在同步后与服务端 ID 建立映射。
六、一次订单创建只能取一次now快照
新建切片订单时,把 clock 和 factory 放到服务层,先读一次 now:
export function createSliceOrder( clock: Clock, idFactory: OrderIdFactory, occupiedIds: readonly string[], presetName: string, delayMs: number ): PendingSliceOrder { const nowMs = clock.nowMs(); const order: ShopOrder = { id: idFactory.sliceId(nowMs, occupiedIds), status: 'pending', progress: 0, name: `${presetName}·切片`, material: '陶瓷树脂', date: formatOrderTime(nowMs) }; return { order: order, fireAtMs: nowMs + delayMs }; }ID、显示时间和 deadline 都基于同一个 nowMs,不会出现跨分钟或跨年不一致。随机 delay 仍会影响测试,可再注入 DelayPolicy;这不是 Clock 的职责,不应把随机数塞进时钟接口。
页面只负责把结果写入状态与启动定时器。到期判断中的Date.now()也应改为clock.nowMs(),否则创建测试可控、恢复判断仍不可控。
七、跨年与碰撞用例无需等待真实时间
it('uses injected year after new year', 0, () => { const clock = new FixedClock(localEpoch(2027, 1, 2, 9, 30)); const orders = buildDemoOrders(clock, new LocalOrderIdFactory()); expect(orders[0].id.startsWith('DEMO-2027-')).assertTrue(); expect(orders[0].date.startsWith('2027-')).assertTrue(); }); it('avoids same millisecond collision', 0, () => { const ids: string[] = ['ORD-SL-1800000000000']; const factory = new LocalOrderIdFactory(); const next = factory.sliceId(1800000000000, ids); expect(next).assertEqual('ORD-SL-1800000000000-1'); });localEpoch应由测试工具按明确时区构造,或直接使用审阅过的 epoch 常量。还要覆盖月末、闰日、设备时区切换和系统时钟回拨。时钟回拨时,occupied 检查仍保证当前列表不重号,但排序若依赖 createdAt 可能倒退,需要产品定义。
八、验证矩阵分开演示新鲜度与业务唯一性
| 场景 | 注入 | 预期 |
|---|---|---|
| 2026-12-31 | FixedClock | ID/日期采用 2026 |
| 2027-01-01 | FixedClock | 不再硬编码 2026 |
| 同一 now 连续创建 | 固定时钟 + occupied | 后一 ID 带冲突后缀 |
| 不同 now 创建 | 递增 Clock | ID 不同,日期对应各自快照 |
| 跨午夜创建 | 一次 now 快照 | 同一订单 ID 年份与日期一致 |
| 到期判断 | 可推进 Clock | fireAt 前不转换,到达后转换 |
| 真机改时区 | SystemClock | 展示策略符合产品约定 |
| 服务端同步 | 正式环境 | 本地 ID 被映射,不冒充全局 ID |
纯单测可证明固定时刻的字符串与冲突策略;页面计时器需要假时钟或调度器;真实时区和系统时钟变化需要设备验证;服务端唯一性只能由服务协议和回读证明。
九、故障排查与证据边界
| 现象 | 首查 | 常见根因 | 修正 |
|---|---|---|---|
| 2028年仍显示2027 | fixture 是否保存绝对日期 | 又手改了年份 | 保存offset并注入Clock |
| ID年份与date年份不同 | now读取次数 | 跨边界多次取时 | 创建开始只读一次 |
| 测试在不同时区失败 | formatter时区策略 | 依赖机器本地设置 | 测试显式时区 |
| 快速连点出现相同ID | IdFactory是否查occupied | 只拼Date.now | 冲突后缀或序列 |
| 到期用例需要真实等待 | 判断仍调用Date.now | Clock只接入创建 | 所有时间读取统一依赖 |
| 本地ID被当正式订单号 | 前缀与同步协议 | 演示/临时/正式身份混合 | 明确DEMO与本地临时标记 |
源码审计能确认演示 ID/日期固定为 2026,切片订单 ID 使用一次 Date.now,显示时间使用另一次 new Date,deadline 又读取 Date.now;当前格式按设备本地时间输出。本文的 Clock、FixedClock、IdFactory、相对时间 fixture 和创建服务均为建议,没有修改只读项目。本文未运行单测、HAP 构建、跨年设备、时区切换、服务端同步或 CSDN 发布;端侧用例即使通过,也只能证明本地生成契约,不能证明真实订单的服务端唯一性与权威时间。