版本与环境
@objectstack/* 17.0.0-rc.6(rest / console / core / spec)
- 复现环境:全新空库 SQLite dev 实例,
OS_LOCALIZATION_TIMEZONE=Asia/Shanghai,容器 TZ=Asia/Shanghai
- 触发入口:Console 列表工具栏原生「导出」按钮(CSV 与 XLSX 均复现)
现象
界面按业务时区(东八区)正确显示日期时间,但导出文件中同一字段按 UTC 输出,相差 8 小时;跨日记录在导出文件中会退到前一天(月初记录退到上个月,月度对账直接对不上)。
| 字段(datetime) |
界面显示(东八区) |
导出文件值 |
差值 |
| 扫码时间 |
2026/8/1 上午6:00 |
2026-07-31 22:00:00 |
−8h,跨月退到 7 月 |
| 下工时间 |
2026/8/13 下午5:00 |
2026-08-13 09:00:00 |
−8h |
| created_at(系统字段) |
当地 16:59:35 |
2026-08-13 08:59:35 |
−8h |
CSV 与 XLSX 输出完全相同的 UTC 字符串(XLSX 中为文本单元格,非带时区的日期型)。已在 3 个不同对象上复现;formatCellValue() 按字段类型分派,与对象无关,波及全部含 date/datetime 列的导出。
复现步骤
- 全新实例,
OS_LOCALIZATION_TIMEZONE=Asia/Shanghai;
- 任一对象造一条 datetime 值为当地
2026-08-01 06:00(落库 2026-07-31T22:00:00.000Z)的记录;
- 列表页确认界面显示
2026/8/1 上午6:00;
- 点工具栏「导出」下载 CSV 或 XLSX;
- 文件中该列为
2026-07-31 22:00:00(期望:2026-08-01 06:00:00)。
根因(已定位到函数)
调用链:Console exportDownload() → GET /api/v1/data/{object}/export?format=…&fields=…(请求无时区参数)→ @objectstack/rest src/rest-server.ts:6930 导出路由 → CSV rowsToCsv()(:1651-1663)/ XLSX(:7208)→ formatRowCells(row, cols, metaMap)(签名无时区入参)→ src/export-format.ts:174-181:
/** `YYYY-MM-DD` (date) or `YYYY-MM-DD HH:mm:ss` (datetime), in UTC. */
function formatDate(value: unknown, withTime: boolean): unknown {
const d = toDate(value);
if (!d) return value;
const ymd = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
if (!withTime) return ymd;
return `${ymd} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`;
}
getUTC* 写死,由同文件 formatCellValue()(:251-252)对 type: 'date' / type: 'datetime' 无条件调用。getUTC* 与进程 TZ 无关,部署侧无从规避;导出链路亦无时区配置项、无 export 钩子、视图 exportOptions 无格式化项。
三条佐证,倾向这是疏漏而非有意设计:
- 时区在这条路径上拿得到、只是没接:导出路由第一步
resolveExecCtx(src/rest-server.ts:6935)取到的 ExecutionContext 已带 timezone(resolveLocalizationContext() 级联 平台默认→全局→租户),格式化层未使用;
- 同平台别处认时区:自动编号日期令牌按 ADR-0053「business timezone」渲染,fallback 才是 UTC(
@objectstack/spec RenderAutonumberInput.timezone);导出格式化与该语义不一致;
- 导出自身两套时钟:同文件
exportContentDisposition()(src/export-format.ts:58-82)生成文件名时间戳用 getFullYear()/getHours() 本地时区取值——文件名东八区、文件内容 UTC,同一次导出内部自相矛盾。
建议修法
把 ExecutionContext.timezone 透传进 formatRowCells / formatCellValue,formatDate 用 Intl.DateTimeFormat(…, { timeZone }) 按业务时区取日历分量;timezone 缺省时维持现状(UTC),保证向后兼容。
来源
下游实施项目验收中发现并完成定位:steedos-labs/os-project-titanwind-ehr#1269(含完整取证:3 对象实测截图、导出原件、逐介入点排查记录)。修复前下游导出文件无法用于月度对账,盼排期。
版本与环境
@objectstack/*17.0.0-rc.6(rest / console / core / spec)OS_LOCALIZATION_TIMEZONE=Asia/Shanghai,容器TZ=Asia/Shanghai现象
界面按业务时区(东八区)正确显示日期时间,但导出文件中同一字段按 UTC 输出,相差 8 小时;跨日记录在导出文件中会退到前一天(月初记录退到上个月,月度对账直接对不上)。
2026/8/1 上午6:002026-07-31 22:00:002026/8/13 下午5:002026-08-13 09:00:002026-08-13 08:59:35CSV 与 XLSX 输出完全相同的 UTC 字符串(XLSX 中为文本单元格,非带时区的日期型)。已在 3 个不同对象上复现;
formatCellValue()按字段类型分派,与对象无关,波及全部含 date/datetime 列的导出。复现步骤
OS_LOCALIZATION_TIMEZONE=Asia/Shanghai;2026-08-01 06:00(落库2026-07-31T22:00:00.000Z)的记录;2026/8/1 上午6:00;2026-07-31 22:00:00(期望:2026-08-01 06:00:00)。根因(已定位到函数)
调用链:Console
exportDownload()→GET /api/v1/data/{object}/export?format=…&fields=…(请求无时区参数)→@objectstack/restsrc/rest-server.ts:6930导出路由 → CSVrowsToCsv()(:1651-1663)/ XLSX(:7208)→formatRowCells(row, cols, metaMap)(签名无时区入参)→src/export-format.ts:174-181:getUTC*写死,由同文件formatCellValue()(:251-252)对type: 'date'/type: 'datetime'无条件调用。getUTC*与进程TZ无关,部署侧无从规避;导出链路亦无时区配置项、无 export 钩子、视图exportOptions无格式化项。三条佐证,倾向这是疏漏而非有意设计:
resolveExecCtx(src/rest-server.ts:6935)取到的ExecutionContext已带timezone(resolveLocalizationContext()级联 平台默认→全局→租户),格式化层未使用;@objectstack/specRenderAutonumberInput.timezone);导出格式化与该语义不一致;exportContentDisposition()(src/export-format.ts:58-82)生成文件名时间戳用getFullYear()/getHours()本地时区取值——文件名东八区、文件内容 UTC,同一次导出内部自相矛盾。建议修法
把
ExecutionContext.timezone透传进formatRowCells/formatCellValue,formatDate用Intl.DateTimeFormat(…, { timeZone })按业务时区取日历分量;timezone缺省时维持现状(UTC),保证向后兼容。来源
下游实施项目验收中发现并完成定位:steedos-labs/os-project-titanwind-ehr#1269(含完整取证:3 对象实测截图、导出原件、逐介入点排查记录)。修复前下游导出文件无法用于月度对账,盼排期。