diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index 56049e47..fe92f752 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -88,6 +88,8 @@ bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --target PK01 --account --kind model-mapping --model bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --since 24h bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --since 24h --json +bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --since 24h --accounts +bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --since 24h --missing-costs bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status --target D601 bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image build --target D601 --confirm @@ -99,8 +101,11 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60 - `profit` 只读聚合 Sub2API 原生 `/api/v1/admin/usage`: - 默认窗口、盈利分组、自用分组、排除分组、人民币售价和账号采购成本来源由 `config/platform-infra/sub2api-codex-pool.yaml#profit` 声明; - 原生 `actual_cost` 是标准 API 美元计费基数;`saleRateCnyPerApiUsd` 和账号名称末尾成本都表示每 1 美元标准 API 计费对应的人民币单价,不做汇率换算; - - 默认 Kubernetes 列表按完整账号名称显示请求数、Token、标准 API 美元计费基数、人民币采购单价、人民币收入、人民币上游成本和人民币利润贡献; - - 汇总同时给出“不扣自用成本”和“扣除自用成本”两种口径,自用分组不计收入; + - 默认 Kubernetes 风格输出优先给出总收入、已确认成本、扣除已知成本后的待定利润基数、未知成本对应的 API USD 基数、完整性和参数化利润公式; + - 显式 `--json` 返回同一份稳定紧凑汇总,不携带完整账号数组,避免大窗口触发全局输出保护; + - `--accounts` 固定返回一页完整账号名称、账号 ID、请求数、Token、标准 API 美元计费基数、人民币采购单价、人民币收入、人民币上游成本和人民币利润贡献;存在后续页时返回由分类、分组、账号 ID 和名称生成的 opaque `--page-token` 命令,不提供手工 `limit`; + - `--missing-costs` 单独返回缺失成本账号的完整名称、账号 ID、分组、分类和未知成本 API USD 基数,避免与账号分页叠加后再次触发全局输出保护; + - 汇总同时给出“不扣自用成本”和“扣除自用成本”两种口径,自用分组不计收入;缺失成本时 `profitBeforeUnknownCostsCny` 只表示扣除已知成本后的待定利润基数,最终利润必须按输出公式继续扣除未知账号成本,禁止把未知成本当作 0;完整状态下该基数才等于最终利润; - 账号名称末尾缺少采购成本系数,或存在未分类流量时,相关口径必须显示 `partial`,禁止把缺失值当作零; - `--json` 保留精确十进制字符串、原生来源、时间窗、分页数和完整性明细;命令不修改 runtime。 diff --git a/docs/MDTODO/artificer-runtime-capabilities.md b/docs/MDTODO/artificer-runtime-capabilities.md index dbe6c35c..298678ee 100644 --- a/docs/MDTODO/artificer-runtime-capabilities.md +++ b/docs/MDTODO/artificer-runtime-capabilities.md @@ -121,6 +121,6 @@ 解决 [UniDesk #2034](https://github.com/pikasTech/unidesk/issues/2034):确认 `908c5515a` 与 `dcff1b205` 汇合后产生的重复 `pikainc/pikaoa` override 已由 `3bded7e0` / PR #2020 精确删除;通过 PR #2038 保护并吸收固定 `/root/unidesk` 的 4 个并行提交,再按 P0 执行 `stash -u`、`pull --ff-only` 与 `stash apply`,不新增 parser/validator、warning normalization、credential authority、fallback、锁或租约;最后以 #2537 continuation task 做最短 Artificer canary,完成任务后将详细报告写入[任务报告](./details/artificer-runtime-capabilities/R9_Task_Report.md)。 -## R10 [in_progress] +## R10 [completed] 解决 [UniDesk #2044](https://github.com/pikasTech/unidesk/issues/2044):定位首次引入 `/responses/compact` 响应消息缺少 `role` 的提交或镜像版本,并精确反向移除该回归以恢复既有 compact 合同;保持 Artificer、Codex app-server、Sub2API 与原 session 单一路径,不增加 retry、fallback、role 猜测、第二 authority 或新 session 绕行;经 YAML-first 自动交付后复用 HWLAB #2541 原 session continuation 验证,属于 Artificer 自身运行链故障,必须由原生子代理执行,完成任务后将详细报告写入[任务报告](./details/artificer-runtime-capabilities/R10_Task_Report.md)。 diff --git a/docs/MDTODO/details/artificer-runtime-capabilities/R10_Task_Report.md b/docs/MDTODO/details/artificer-runtime-capabilities/R10_Task_Report.md new file mode 100644 index 00000000..b032592e --- /dev/null +++ b/docs/MDTODO/details/artificer-runtime-capabilities/R10_Task_Report.md @@ -0,0 +1,99 @@ +# R10 任务报告 + +## 结果 + +- 已解决 [UniDesk #2044](https://github.com/pikasTech/unidesk/issues/2044) 跟踪的 Artificer `/responses/compact` 协议回归。 +- PK01 已恢复 Sub2API `0.1.155`。 +- HWLAB #2541 原 session 在同一 continuation 路径成功 compact 并继续执行。 +- 本次没有创建新 session,也没有增加以下机制: + - retry; + - fallback; + - `role` 猜测; + - 兼容层; + - 第二 authority。 + +## 引入点 + +- 首次把故障重新引入当前 PK01 运行面的是: + - UniDesk commit:`6829826ef7ab3ff707de9503834b161bc36b85dd`; + - [PR #2021](https://github.com/pikasTech/unidesk/pull/2021)。 +- 该变更在 2026-07-14 10:29Z 唯一修改 owning YAML: + - PK01 镜像从 `0.1.155` 回退到 `0.1.147`; + - 只验证镜像、health 与 dry-run; + - 没有给出业务回退原因; + - 没有执行 compact 合同验收。 + +### Upstream 证据 + +- `16c0613b` 明确记录: + - `v0.1.147` 会把 unary compact JSON 原样返回给按 Responses SSE 消费的 Codex。 +- `2cffe1cf` / `000f6dc6` 修复: + - SSE 到 JSON 时没有保留 raw `output_item.done` 与 compact item。 +- `84bb7d07` 保留: + - `remote_compaction_v2` 原生 Responses 链。 +- 版本包含关系: + - `0.1.155` 包含上述修复; + - `0.1.147` 不包含上述修复。 + +### 运行面证据 + +- 故障 trace:`8eda7eb2719f69bcdbf9cd48f438e6a0`。 +- Trace 中 AgentRun manager 与 runner span 均存在。 +- 原 run:`run_d52387b2aae64b2e8026c81aaf72869a`。 +- 该 run 从 2026-07-14 14:11Z 开始,在不同 payload 位置重复报 `missing field role`。 +- 错误位置变化证明故障不是固定位置的传输截断。 + +## 精确修复 + +- 修复 commit:[`4646760c`](https://github.com/pikasTech/unidesk/commit/4646760c208c537c0a573a57d6370e209a13d0d6)。 +- 修复 PR:[#2047](https://github.com/pikasTech/unidesk/pull/2047)。 +- merge commit:`9235a8c5c62baebb7afaf79cace3c7b3f737db96`。 +- 唯一代码差异: + - 路径:`config/platform-infra/sub2api.yaml#targets.PK01.image.tag`; + - 修改:`0.1.147` 恢复为 `0.1.155`; + - 语义:精确反向移除 `6829826e`。 + +## 交付验证 + +- 受控 apply job:`platform_infra_sub2api_apply_pk01_20260714152435687_5ae6e6`。 +- Job 结果: + - 状态:`succeeded`; + - exit:`0`; + - 耗时:18 秒; + - 公网 exposure 子任务:成功。 +- 实际镜像:`docker.1panel.live/weishaw/sub2api:0.1.155`。 +- 实际 revision:`41cec0db059ffb82d0efdcfcf07a24ab51fbfe97`。 +- `status` 与 `validate` 全部通过。 + +## 原 session 验收 + +- 复用 session:`sess_artificer_b7854f952db117b2cd630180`。 +- Continuation 资源: + - run:`run_14c95984920146e29c49918abda0d850`; + - command:`cmd_c5dd41287dea41148abc19f79241ca50`。 +- 该 run 完成 thread resume 与 compact 后明确报告: + - “Compact 上下文已成功恢复”。 +- 随后已连续执行: + - Target worktree skill 读取; + - 现场核对; + - 实现命令。 +- 该 run 已越过原失败点,未再出现 `missing field role`。 + +## 边界与风险 + +- 本任务没有修改: + - AgentRun; + - Codex app-server; + - 账号池 runtime; + - Kafka 投影; + - 数据库; + - Secret; + - Redis; + - Caddy; + - 其他 target。 +- PK01 host-Docker 当前不由 PR merge 自动 rollout 覆盖: + - 本次按唯一受控 `platform-infra sub2api apply` 入口收敛; + - 该交付边界不影响本次 compact 修复成立。 +- HWLAB #2541 的剩余页面实现: + - 已由恢复后的原 Artificer run 继续执行; + - 不属于 R10 closeout 范围。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.5_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.5_Task_Report.md new file mode 100644 index 00000000..b47e4123 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.5_Task_Report.md @@ -0,0 +1,19 @@ +# R1.3.5 任务报告 + +## 结果 + +统一业务伙伴与甲方分类主数据已合并到 `pikainc/pikaoa master`。伙伴、分类和联系人使用服务端 UUIDv7;伙伴与分类通过 typed ID 关联,合同、发票及后续模块只引用伙伴 ID。空业务伙伴 PATCH 返回 typed `invalid_argument`,不会刷新 `updatedAt` 或写入 store、audit、outbox。 + +## 交付 + +- Issue: [pikasTech/unidesk#1990](https://github.com/pikasTech/unidesk/issues/1990) +- PR: [pikainc/pikaoa#5](https://github.com/pikainc/pikaoa/pull/5) +- 合并方式: merge,未 squash + +## 验证 + +子代理在 NC01 完成伙伴/category PATCH 专项、一次性 PostgreSQL 全仓测试、`go vet ./...` 和 `make build`。主代理审核了 UUIDv7、平级关系、分类筛选、同事务审计/outbox 与空 PATCH 行为,未发现阻塞问题。 + +## 边界 + +未连接 PK01、未部署正式 namespace;真实专用测试集群集成验收由 R1.3.10 与 R1.5.7 接续。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.6_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.6_Task_Report.md new file mode 100644 index 00000000..9a06fcfe --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.6_Task_Report.md @@ -0,0 +1,19 @@ +# R1.3.6 任务报告 + +## 结果 + +统一附件元数据、对象引用和可替换文件存储端口已合并到 `pikainc/pikaoa master`。附件和引用分别使用独立 UUIDv7,引用仅保存 `attachmentId`、`objectType` 与 `objectId`;元数据通过平级 `/api/v1/attachments/{id}` 读取,下载前重新校验字节数与 SHA-256。 + +## 交付 + +- Issue: [pikasTech/unidesk#1991](https://github.com/pikasTech/unidesk/issues/1991) +- PR: [pikainc/pikaoa#6](https://github.com/pikainc/pikaoa/pull/6) +- 合并方式: 在吸收伙伴 head 后使用 merge,未 squash + +## 验证 + +子代理在 NC01 完成附件、伙伴和 CLI 联合专项、一次性 PostgreSQL 全仓测试、`go vet ./...`、`make build` 与 `git diff --check`。主代理审核了平级资源、完整性、路径约束、授权入口以及事务内审计/outbox,未发现阻塞问题。 + +## 边界 + +未连接 PK01、未部署正式 namespace;对象存在性与跨模块一致性不作为首版阻塞门禁,专用测试集群集成验收由 R1.3.10 与 R1.5.7 接续。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.36_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.36_Task_Report.md new file mode 100644 index 00000000..23643775 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R2.2.36_Task_Report.md @@ -0,0 +1,29 @@ +# R2.2.36 任务报告 + +## 交付 + +由 Artificer task `qt_009a43bec988410398c5b205093fa546` 在独立 worktree `/root/unidesk/.worktree/sub2api-profit-summary` 完成 Sub2API 利润统计 CLI 优化,交付 [Issue #2043](https://github.com/pikasTech/unidesk/issues/2043) 与 [PR #2048](https://github.com/pikasTech/unidesk/pull/2048)。最终源码提交为 `12b13509`,PR preflight 为 `MERGEABLE/CLEAN/ready`。 + +首次 Artificer runner 因 `agentrun-v02-tool-github-repository-overrides` Secret 尚未受控同步而进入 `CreateContainerConfigError`。原生 worker 在既有 [Issue #2014](https://github.com/pikasTech/unidesk/issues/2014) / MDTODO `R5.8` 边界内完成单对象 `secret-sync`,脱敏验证 `present=true`、fingerprint 匹配、`valuesPrinted=false`;原 runner 原位恢复并继续业务 turn,没有复制 task 或手工写 Secret。 + +## CLI 优化结果 + +- 默认 Kubernetes 风格输出不再先展开全部账号,而是直接返回收入、已确认盈利池/自用成本、未知成本扣除前的利润基数、未知成本 API USD 基数、完整性和两种参数化利润公式。 +- 显式 JSON 返回同一稳定紧凑汇总,不嵌入完整账号数组,保留账号总数与语义化下钻命令。 +- `--accounts` 固定每页 8 条,使用由分类、分组、账号 ID 和名称构成的 opaque `--page-token`;实测连续两页零重叠,避免同一账号跨分组重复导致游标错误。 +- `--missing-costs` 单独返回缺失成本账号的完整名称、ID、分类、分组和 API USD 基数。 +- 不提供手工 `limit`,不依赖 dump 或外部 `jq`,账号名称继续完整显示。 +- 主代理 review 将误导性的 `confirmedProfitCny` 纠正为 `profitBeforeUnknownCostsCny`;该字段只表示扣除已知成本后的待定利润基数,完整状态下才等于最终利润。 + +## 验证 + +- PK01 7 天默认汇总:约 123 秒,stdout 1161 字节,未触发输出保护。 +- PK01 7 天 JSON:2473 字节,可直接解析,不含完整账号数组。 +- PK01 7 天账号页:5526 字节,返回 8/32 条及下一页 opaque cursor,未触发输出保护。 +- PK01 7 天缺失成本下钻:返回 12 条完整账号名称、ID 与 API USD 基数,未触发输出保护。 +- 短窗口复测确认新利润字段和人类表头语义一致;完整状态下待定利润基数与最终利润相等。 +- `git diff --check`、相关 Bun 语法检查和 PR preflight 通过。 + +## 边界 + +所有 Sub2API 查询均为原生 admin usage 只读聚合,`mutation=false`。未修改 runtime、账号、分组、优先级、配置、部署或人民币倍率算法。7 天原生聚合仍约需 2 分钟;在 60 秒短连接执行面中使用既有 submit-and-poll 方式验收。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 5264e648..b7eb9b03 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -37,11 +37,11 @@ #### R1.3.4 [completed] 实现 pgx 同事务审计、PostgreSQL outbox、Watermill handler pipeline、幂等 Worker 与 OTel/Prometheus,执行记录见 [pikasTech/unidesk#1985](https://github.com/pikasTech/unidesk/issues/1985),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.4_Task_Report.md)。 -#### R1.3.5 [in_progress] +#### R1.3.5 [completed] 实现统一业务伙伴、甲方分类、角色、联系人、标签、归档、筛选和 CLI;持久化 REST 资源使用 UUIDv7,伙伴与分类只通过 typed ID 关联,并将业务、审计、outbox 保持同事务,执行记录见 [pikasTech/unidesk#1990](https://github.com/pikasTech/unidesk/issues/1990),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.5_Task_Report.md)。 -#### R1.3.6 [in_progress] +#### R1.3.6 [completed] 实现统一附件元数据、对象引用、可替换存储端口、完整性校验、授权 API 和 CLI;附件使用 UUIDv7 平级资源身份,业务对象仅通过 typed ID 关联,执行记录见 [pikasTech/unidesk#1991](https://github.com/pikasTech/unidesk/issues/1991),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.6_Task_Report.md)。 @@ -54,9 +54,12 @@ #### R1.3.9 [in_progress] 复用 append-only 审计唯一真相实现 UUIDv7 平级审计资源、RBAC 查询 API、actor/resource/action/time/requestId/traceId 筛选和 audit list/get CLI,执行记录见 [pikasTech/unidesk#1994](https://github.com/pikasTech/unidesk/issues/1994),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.9_Task_Report.md)。 -### R1.4 +#### R1.3.10 [in_progress] -在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 +增加可由 YAML-first 专用测试集群临时 Job 调用的统一 PostgreSQL 后端验证入口,明确披露 DSN presence 和 integration passed/skipped,去掉迁移测试对最新编号的共享硬编码,执行记录见 [pikainc/pikaoa#10](https://github.com/pikainc/pikaoa/issues/10),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.10_Task_Report.md)。 +### R1.4 [in_progress] + +前端与后端并行实现 HWLAB v0.3 同族 Vue 企业工作台、响应式页面、平级 UUIDv7 资源与同路径业务流;最终在 CLI 后端验收通过后,通过同一公开 HTTP API 完成 Web 等价集成验收,执行记录见 [pikainc/pikaoa#11](https://github.com/pikainc/pikaoa/issues/11),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 ### R1.5 [in_progress] diff --git a/docs/MDTODO/sub2api-upstream-reliability.md b/docs/MDTODO/sub2api-upstream-reliability.md index f2ec002e..208bcdac 100644 --- a/docs/MDTODO/sub2api-upstream-reliability.md +++ b/docs/MDTODO/sub2api-upstream-reliability.md @@ -130,7 +130,7 @@ #### R2.2.35 [completed] 只读计算 PK01 Sub2API 最近7天成本与利润:使用已修正的 CNY per API USD 口径,从原生 admin usage 按完整账号名称聚合标准 API 美元计费基数、人民币收入和人民币采购成本,分别给出不扣除自用成本与扣除自用成本的人民币利润;不修改 runtime,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.35_Task_Report.md)。 -#### R2.2.36 [in_progress] +#### R2.2.36 [completed] 由 Artificer 优化 Sub2API 利润统计 CLI 的单次聚合与渐进披露,减少 7 天成本利润计算中的重复查询、外部解析和手工算术;默认 Kubernetes 风格与显式 JSON 都应直接保留紧凑汇总,账号明细语义化下钻,缺失历史成本时输出参数化利润公式;不修改 runtime 或部署,关联 [GitHub issue #2043](https://github.com/pikasTech/unidesk/issues/2043),完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R2.2.36_Task_Report.md)。 ### R2.3 [completed] diff --git a/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md b/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md index 2858e184..8989bbb2 100644 --- a/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md +++ b/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md @@ -7,6 +7,7 @@ | v0.1 | 待本版本提交 | 2026-07-13 | 创建企业办公平台预期终态、模块架构和首批合同与发票能力规格。 | | v0.2 | 待本版本提交 | 2026-07-14 | 统一平级 REST 资源、UUIDv7 全局标识和仅通过 typed ID 建立对象关联的契约。 | | v0.3 | 待本版本提交 | 2026-07-14 | 将审计语义校验收敛为非阻塞 warning 和最小安全归一化,避免开发期复杂审计门禁阻塞业务。 | +| v0.4 | 待本版本提交 | 2026-07-14 | 允许 Web 与后端并行开发,最终以同一公开 HTTP API 和 CLI/Web 等价业务流完成集成验收。 | 修改历史只记录规格语义变更,不记录实现进度、阶段基线或一次性证据。 @@ -63,7 +64,8 @@ PikaOA 终态必须同时具备: - CLI-first 交付: - 每个后端业务用例先提供稳定 CLI 入口; - CLI 与 Web 复用同一 HTTP API、身份、权限和业务语义; - - 后端、数据库、审计和可观测性通过 CLI 原入口验收后,才实现对应前端工作流; + - Web 与后端可以依据已冻结的资源和 API 契约并行开发; + - 最终集成验收先以 CLI 证明后端业务语义,再由 Web 通过同一公开 HTTP API 重复等价业务流; - 自动化和人工排障不依赖浏览器才能完成核心业务闭环。 - 可扩展业务模块: - 每个模块拥有独立领域模型、应用服务、持久化迁移、权限声明、HTTP API 和前端路由; @@ -217,7 +219,8 @@ CLI 是后端业务的第一用户入口: - 默认输出适合人工扫描的紧凑文本,机器调用显式请求 JSON; - 长任务使用提交与短轮询,不保持无界阻塞连接; - 输出包含请求标识和 traceId,但不显示密码、token、完整数据库连接串或附件正文; -- 前端开发必须以对应 CLI 用例已经通过为前置。 +- Web 可以与 CLI 和后端并行开发,尚未完成的后端集成差异集中在 API client 适配层并形成非阻塞 warning; +- 对应业务能力只有在 CLI 后端验收通过,且 Web 通过同一公开 HTTP API 完成等价业务流后,才能完成最终验收。 业务 API 在首期采用模块化单体内核: @@ -443,9 +446,9 @@ Prometheus 必须覆盖: - 健康状态区分应用 ready、数据库 ready、迁移 ready、OTel exporter warning 和 metrics scrape 状态; - OTel export 超时或 trace backend 暂时不可用只输出 `warning=true`、`blocking=false`,不改变成功业务事务终态。 -### 6.11 PIKAOA-L0-REQ-011 CLI-first 业务验收 +### 6.11 PIKAOA-L0-REQ-011 CLI-first 集成验收 -每个后端业务能力必须先通过 `pikaoa` CLI 验收,再实现对应 Web 页面。CLI 必须调用同一公开 HTTP API、使用同一用户身份与权限,并覆盖员工、伙伴、合同版本、发票废弃、审计和模块枚举。 +每个后端业务能力必须通过 `pikaoa` CLI 验收。Web 可以依据已冻结的资源与 API 契约并行开发,但最终必须与 CLI 调用同一公开 HTTP API、使用同一用户身份与权限,并覆盖员工、伙伴、合同版本、发票废弃、审计和模块枚举的等价业务语义。 接受标准包括: @@ -453,7 +456,8 @@ Prometheus 必须覆盖: - 默认文本输出有界且非空,显式 JSON 输出为单一合法 JSON; - 业务失败返回稳定非零退出码和 typed error,不以 traceback 或空输出代替; - CLI 业务成功同时返回资源标识、requestId 和 traceId; -- 后端 CLI 验收未通过时,对应前端任务保持未开始。 +- 前后端开发不互设启动门禁;后端 CLI 尚未通过或 API 尚未收敛时,集成差异只能形成非阻塞 warning,不能宣称对应业务能力已经完成最终验收; +- 最终验收必须先证明 CLI 后端业务语义,再由 Web 通过同一公开 HTTP API 重复等价业务流并比对结果。 ### 6.12 PIKAOA-L0-REQ-012 非阻塞版本告警 @@ -482,7 +486,7 @@ Web、API、Worker 和 CI/CD 必须输出结构化日志、健康状态和 trace - 通过 CLI 创建关联合同 v2 的发票,确认合同、版本和甲方关系正确。 - 通过 CLI 标记发票废弃,确认原因和审计存在,默认有效汇总不包含该发票。 - 使用 CLI 普通员工账号确认允许的业务操作可用、管理员操作不可用。 -- 确认 CLI 后端验收通过后,再用 Web 重复同一业务主路径并比对结果。 +- Web 与后端可以并行实现;最终确认 CLI 后端验收通过后,再用 Web 通过同一公开 HTTP API 重复业务主路径并比对结果。 ### 8.2 扩展性验收 diff --git a/scripts/src/platform-infra-sub2api-codex/profit.ts b/scripts/src/platform-infra-sub2api-codex/profit.ts index f2199b3d..10ad3a89 100644 --- a/scripts/src/platform-infra-sub2api-codex/profit.ts +++ b/scripts/src/platform-infra-sub2api-codex/profit.ts @@ -10,8 +10,13 @@ interface ProfitOptions { since: string; targetId: string; json: boolean; + accounts: boolean; + missingCosts: boolean; + pageToken: string | null; } +const PROFIT_ACCOUNT_PAGE_SIZE = 8; + export async function codexPoolProfit(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { const pool = readCodexPoolConfig(); if (args.includes("--help")) return renderProfitHelp(pool.profit.defaultWindow); @@ -21,6 +26,9 @@ export async function codexPoolProfit(config: UniDeskConfig, args: string[]): Pr const remote = await runRemoteCodexPoolScript(config, "profit", profitScript(payload, pool, target), target); const report = parseJsonOutput(remote.stdout); const ok = remote.exitCode === 0 && boolField(report, "ok", false); + const projectedReport = projectProfitReport(report, options); + const baseCommand = `bun scripts/cli.ts platform-infra sub2api codex-pool profit --target ${target.id} --since ${options.since}`; + const accountPage = record(record(projectedReport).accountPage); const response = { ok, action: "platform-infra-sub2api-codex-pool-profit", @@ -30,7 +38,16 @@ export async function codexPoolProfit(config: UniDeskConfig, args: string[]): Pr mutation: false, configPath: "config/platform-infra/sub2api-codex-pool.yaml#profit", }, - report, + report: projectedReport, + disclosure: { + accountsIncluded: options.accounts, + missingCostsIncluded: options.missingCosts, + accountCount: arrayOfRecords(record(report).accounts).length, + accounts: `${baseCommand} --accounts`, + accountsNext: accountPage.nextPageToken ? `${baseCommand} --accounts --page-token ${text(accountPage.nextPageToken)}` : null, + missingCosts: `${baseCommand} --missing-costs`, + json: `${baseCommand} --json`, + }, remote: compactCapture(remote, { full: !ok || report === null }), valuesPrinted: false, }; @@ -41,6 +58,9 @@ function parseProfitOptions(args: string[], defaultWindow: string): ProfitOption let since = defaultWindow; let targetId = defaultCodexPoolRuntimeTargetId(); let json = false; + let accounts = false; + let missingCosts = false; + let pageToken: string | null = null; const readValue = (index: number, option: string): [string, number] => { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error(`${option} requires a value`); @@ -49,6 +69,11 @@ function parseProfitOptions(args: string[], defaultWindow: string): ProfitOption for (let index = 0; index < args.length; index += 1) { const arg = args[index]!; if (arg === "--json") json = true; + else if (arg === "--accounts") accounts = true; + else if (arg === "--missing-costs") missingCosts = true; + else if (arg === "--page-token") { + [pageToken, index] = readValue(index, "--page-token"); + } else if (arg.startsWith("--page-token=")) pageToken = arg.slice("--page-token=".length).trim(); else if (arg === "--since") { [since, index] = readValue(index, "--since"); } else if (arg.startsWith("--since=")) since = arg.slice("--since=".length).trim(); @@ -59,7 +84,10 @@ function parseProfitOptions(args: string[], defaultWindow: string): ProfitOption } if (!/^[1-9][0-9]*(?:s|m|h|d)$/u.test(since)) throw new Error("--since must be a duration such as 24h, 90m, or 7d"); if (!targetId || /[\r\n\0]/u.test(targetId)) throw new Error("--target has an unsupported format"); - return { since, targetId, json }; + if (accounts && missingCosts) throw new Error("--accounts and --missing-costs are separate semantic disclosures"); + if (pageToken !== null && !accounts) throw new Error("--page-token requires --accounts"); + if (pageToken !== null && (!pageToken || /[\r\n\0\s]/u.test(pageToken))) throw new Error("--page-token has an unsupported format"); + return { since, targetId, json, accounts, missingCosts, pageToken }; } function renderProfitHelp(defaultWindow: string): RenderedCliResult { @@ -69,13 +97,16 @@ function renderProfitHelp(defaultWindow: string): RenderedCliResult { renderedText: [ "SUB2API CODEX-POOL PROFIT", "Usage:", - " bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h] [--target PK01] [--json]", + " bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h] [--target PK01] [--accounts [--page-token ]|--missing-costs] [--json]", "Options:", ` --since Native usage window; YAML default is ${defaultWindow}.`, " --target ", + " --accounts Include one fixed-size account page with complete names and IDs.", + " --page-token Continue from the opaque token returned by the previous account page.", + " --missing-costs Include the missing-cost account list and API USD bases.", " --json Emit exact structured values.", "Notes:", - " Default output is a Kubernetes-style account list plus both profit scopes.", + " Default output is a compact summary; use --accounts or --missing-costs for semantic disclosure.", " This command is read-only and does not change Sub2API runtime state.", ].join("\n"), contentType: "text/plain", @@ -88,37 +119,57 @@ function renderProfit(response: Record): RenderedCliResult { const policy = record(report.policy); const summary = record(report.summary); const completeness = record(report.completeness); + const confirmedCosts = record(summary.confirmedCostsCny); + const profitBeforeUnknownCosts = record(summary.profitBeforeUnknownCostsCny); + const unknownBasis = record(summary.unknownCostBasisApiUsd); + const formulas = record(summary.profitFormula); + const disclosure = record(response.disclosure); const lines = [ `SUB2API PROFIT status=${text(report.status)} target=${text(record(response.target).id)} window=${text(window.since)}`, `WINDOW ${text(window.startAt)} -> ${text(window.endAt)} SOURCE native-admin-usage/${text(record(report.source).usageCostField)}`, `POLICY sale-rate=${text(policy.saleRateCnyPerApiUsd)}CNY/API_USD account-cost=${text(policy.accountCostSource)}`, "", - "ACCOUNTS", + "SUMMARY", ]; - const accounts = arrayOfRecords(report.accounts); lines.push(table( - ["GROUP", "CLASS", "ACCOUNT", "ID", "REQ", "TOKENS", "API_USD", "COST_CNY/USD", "REVENUE_CNY", "UPSTREAM_CNY", "PROFIT_CNY", "STATUS"], - accounts.map((row) => [ - text(row.groupName), text(row.class), text(row.accountName), text(row.accountId), text(row.requestCount), compactTokens(row.tokenCount), - apiUsd(row.apiAmountUsd), text(row.costRateCnyPerApiUsd), cny(row.revenueCny), cny(row.upstreamCostCny), cny(row.profitContributionCny), text(row.completeness), - ]), - )); - lines.push("", "PROFIT"); - lines.push(table( - ["SCOPE", "STATUS", "REVENUE_CNY", "PROFIT_POOL_CNY", "SELF_USE_CNY", "PROFIT_CNY"], + ["SCOPE", "STATUS", "REVENUE_CNY", "PROFIT_POOL_COST_CNY", "SELF_USE_COST_CNY", "PROFIT_BEFORE_UNKNOWN_COSTS_CNY", "UNKNOWN_API_USD"], [ - ["不扣自用", text(record(summary.withoutSelfUse).status), cny(summary.revenueCny), cny(summary.profitPoolCostCny), "-", cny(record(summary.withoutSelfUse).profitCny)], - ["扣除自用", text(record(summary.withSelfUse).status), cny(summary.revenueCny), cny(summary.profitPoolCostCny), cny(summary.selfUseCostCny), cny(record(summary.withSelfUse).profitCny)], + ["不扣自用", text(record(summary.withoutSelfUse).status), cny(summary.revenueCny), cny(confirmedCosts.profitPool), "-", cny(profitBeforeUnknownCosts.withoutSelfUse), apiUsd(unknownBasis.profitPool)], + ["扣除自用", text(record(summary.withSelfUse).status), cny(summary.revenueCny), cny(confirmedCosts.profitPool), cny(confirmedCosts.selfUse), cny(profitBeforeUnknownCosts.withSelfUse), apiUsd(unknownBasis.total)], ], )); + lines.push("", "FORMULA"); + lines.push(`- 不扣自用: ${text(formulas.withoutSelfUse)}`); + lines.push(`- 扣除自用: ${text(formulas.withSelfUse)}`); + lines.push("", `COMPLETENESS status=${text(report.status)} missing-cost=${text(completeness.missingCostAccountCount)} unclassified=${text(completeness.unclassifiedTrafficCount)} missing-groups=${text(completeness.configuredGroupMissingCount)}`); + lines.push(`NEXT accounts: ${text(disclosure.accounts)}`); + lines.push(`NEXT missing-costs: ${text(disclosure.missingCosts)}`); + const accounts = arrayOfRecords(report.accounts); + if (accounts.length > 0) { + lines.push("", "ACCOUNTS"); + lines.push(table( + ["GROUP", "CLASS", "ACCOUNT", "ID", "REQ", "TOKENS", "API_USD", "COST_CNY/USD", "REVENUE_CNY", "UPSTREAM_CNY", "PROFIT_CNY", "STATUS"], + accounts.map((row) => [ + text(row.groupName), text(row.class), text(row.accountName), text(row.accountId), text(row.requestCount), compactTokens(row.tokenCount), + apiUsd(row.apiAmountUsd), text(row.costRateCnyPerApiUsd), cny(row.revenueCny), cny(row.upstreamCostCny), cny(row.profitContributionCny), text(row.completeness), + ]), + )); + const accountPage = record(report.accountPage); + if (accountPage.nextPageToken) lines.push(`NEXT account-page: ${text(disclosure.accountsNext)}`); + } const missingCosts = arrayOfRecords(completeness.missingCostAccounts); - const unclassified = arrayOfRecords(completeness.unclassifiedTraffic); - const missingGroups = Array.isArray(completeness.configuredGroupsMissingAtRuntime) ? completeness.configuredGroupsMissingAtRuntime : []; - if (missingCosts.length || unclassified.length || missingGroups.length) { - lines.push("", "COMPLETENESS"); - for (const row of missingCosts) lines.push(`- missing-cost group=${text(row.groupName)} account=${text(row.accountName)} id=${text(row.accountId)}`); - for (const row of unclassified) lines.push(`- unclassified-traffic group=${text(row.groupName)} account=${text(row.accountName)} requests=${text(row.requestCount)}`); - for (const group of missingGroups) lines.push(`- configured-group-missing ${text(group)}`); + if (missingCosts.length > 0) { + lines.push("", "MISSING COST ACCOUNTS"); + for (const row of missingCosts) lines.push(`- class=${text(row.class)} group=${text(row.groupName)} account=${text(row.accountName)} id=${text(row.accountId)} api-usd=${text(row.apiAmountUsd)}`); + } + if (accounts.length > 0 || missingCosts.length > 0) { + const unclassified = arrayOfRecords(completeness.unclassifiedTraffic); + const missingGroups = Array.isArray(completeness.configuredGroupsMissingAtRuntime) ? completeness.configuredGroupsMissingAtRuntime : []; + if (unclassified.length || missingGroups.length) { + lines.push("", "COMPLETENESS DETAILS"); + for (const row of unclassified) lines.push(`- unclassified-traffic group=${text(row.groupName)} account=${text(row.accountName)} requests=${text(row.requestCount)}`); + for (const group of missingGroups) lines.push(`- configured-group-missing ${text(group)}`); + } } return { ok: response.ok === true, @@ -129,6 +180,60 @@ function renderProfit(response: Record): RenderedCliResult { }; } +function projectProfitReport(report: Record | null, options: ProfitOptions): Record | null { + if (report === null) return null; + const { accounts: rawAccounts, completeness: rawCompleteness, ...summaryReport } = report; + const accounts = arrayOfRecords(rawAccounts); + const completeness = record(rawCompleteness); + const compactCompleteness = { + missingCostAccountCount: arrayOfRecords(completeness.missingCostAccounts).length, + unclassifiedTrafficCount: arrayOfRecords(completeness.unclassifiedTraffic).length, + configuredGroupMissingCount: Array.isArray(completeness.configuredGroupsMissingAtRuntime) ? completeness.configuredGroupsMissingAtRuntime.length : 0, + }; + const compactReport = { + ...summaryReport, + accountCount: accounts.length, + completeness: compactCompleteness, + }; + if (options.missingCosts) { + return { + ...compactReport, + completeness: { + ...compactCompleteness, + missingCostAccounts: arrayOfRecords(completeness.missingCostAccounts), + }, + }; + } + if (!options.accounts) return compactReport; + let startIndex = 0; + if (options.pageToken !== null) { + const tokenIndex = accounts.findIndex((row) => encodeProfitAccountPageToken(row) === options.pageToken); + if (tokenIndex < 0) throw new Error(`profit account page token not found: ${options.pageToken}`); + startIndex = tokenIndex + 1; + } + const pageAccounts = accounts.slice(startIndex, startIndex + PROFIT_ACCOUNT_PAGE_SIZE); + const hasNext = startIndex + pageAccounts.length < accounts.length; + const nextPageToken = hasNext && pageAccounts.length > 0 ? encodeProfitAccountPageToken(pageAccounts.at(-1)!) : null; + return { + ...compactReport, + accounts: pageAccounts, + accountPage: { + pageSize: PROFIT_ACCOUNT_PAGE_SIZE, + returned: pageAccounts.length, + nextPageToken, + }, + }; +} + +function encodeProfitAccountPageToken(account: Record): string { + return Buffer.from(JSON.stringify([ + account.class ?? null, + account.groupId ?? null, + account.accountId ?? null, + account.accountName ?? null, + ]), "utf8").toString("base64url"); +} + function renderProfitJson(response: Record): RenderedCliResult { return { ok: response.ok === true, diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-profit.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-profit.ts index 405f7a34..6f2762d8 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-profit.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-profit.ts @@ -178,11 +178,13 @@ def run_profit_report(): revenue = sum((profit_decimal(row["revenueCny"]) for row in profit_accounts), profit_decimal(0)) profit_cost = sum((profit_decimal(row["upstreamCostCny"]) for row in profit_accounts if row["upstreamCostCny"] is not None), profit_decimal(0)) self_cost = sum((profit_decimal(row["upstreamCostCny"]) for row in self_accounts if row["upstreamCostCny"] is not None), profit_decimal(0)) - without_self = revenue - profit_cost - with_self = without_self - self_cost + unknown_profit_api_usd = sum((profit_decimal(row["apiAmountUsd"]) for row in missing_profit), profit_decimal(0)) + unknown_self_api_usd = sum((profit_decimal(row["apiAmountUsd"]) for row in missing_self), profit_decimal(0)) + profit_before_unknown_without_self = revenue - profit_cost + profit_before_unknown_with_self = profit_before_unknown_without_self - self_cost without_complete = not missing_profit and not unclassified with_complete = without_complete and not missing_self - account_rows.sort(key=lambda row: (row["class"], row["groupName"], -profit_decimal(row["apiAmountUsd"]), str(row["accountName"]))) + account_rows.sort(key=lambda row: (row["class"], row["groupName"], str(row["accountId"]), str(row["accountName"]))) return { "ok": True, "mode": "profit", @@ -194,13 +196,29 @@ def run_profit_report(): "accounts": account_rows, "summary": { "revenueCny": profit_decimal_text(revenue), - "profitPoolCostCny": profit_decimal_text(profit_cost), - "selfUseCostCny": profit_decimal_text(self_cost), - "withoutSelfUse": {"status": "complete" if without_complete else "partial", "profitCny": profit_decimal_text(without_self) if without_complete else None}, - "withSelfUse": {"status": "complete" if with_complete else "partial", "profitCny": profit_decimal_text(with_self) if with_complete else None}, + "confirmedCostsCny": { + "profitPool": profit_decimal_text(profit_cost), + "selfUse": profit_decimal_text(self_cost), + "total": profit_decimal_text(profit_cost + self_cost), + }, + "profitBeforeUnknownCostsCny": { + "withoutSelfUse": profit_decimal_text(profit_before_unknown_without_self), + "withSelfUse": profit_decimal_text(profit_before_unknown_with_self), + }, + "unknownCostBasisApiUsd": { + "profitPool": profit_decimal_text(unknown_profit_api_usd), + "selfUse": profit_decimal_text(unknown_self_api_usd), + "total": profit_decimal_text(unknown_profit_api_usd + unknown_self_api_usd), + }, + "profitFormula": { + "withoutSelfUse": "profitBeforeUnknownCostsCny.withoutSelfUse - sum(missing profit account apiAmountUsd * unknown costRateCnyPerApiUsd)", + "withSelfUse": "profitBeforeUnknownCostsCny.withSelfUse - sum(all missing account apiAmountUsd * unknown costRateCnyPerApiUsd)", + }, + "withoutSelfUse": {"status": "complete" if without_complete else "partial", "profitCny": profit_decimal_text(profit_before_unknown_without_self) if without_complete else None}, + "withSelfUse": {"status": "complete" if with_complete else "partial", "profitCny": profit_decimal_text(profit_before_unknown_with_self) if with_complete else None}, }, "completeness": { - "missingCostAccounts": [{"groupName": row["groupName"], "accountId": row["accountId"], "accountName": row["accountName"]} for row in missing_profit + missing_self], + "missingCostAccounts": [{"class": row["class"], "groupName": row["groupName"], "accountId": row["accountId"], "accountName": row["accountName"], "apiAmountUsd": row["apiAmountUsd"]} for row in missing_profit + missing_self], "unclassifiedTraffic": [{"groupName": row["groupName"], "accountId": row["accountId"], "accountName": row["accountName"], "requestCount": row["requestCount"]} for row in unclassified], "configuredGroupsMissingAtRuntime": sorted(configured_names - runtime_names), }, diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index 08d407ae..63b669be 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -374,7 +374,7 @@ export function codexPoolHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool runtime get --account [--target PK01] [--json|--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime errors [--group |--all-groups] [--platform ] [--page-token ] [--account ] [--since 24h] [--tail 50000] [--target PK01] [--json|--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool faults [--level P0|P1|P2] [--group ] [--account ] [--model ] [--stream sync|stream] [--endpoint ] [--request-id ] [--page-token ] [--target PK01] [--json]", - "bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h] [--target PK01] [--json]", + "bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h] [--target PK01] [--accounts [--page-token ]|--missing-costs] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account --template [--target PK01] [--confirm]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --accounts --template [--target PK01] [--confirm] [--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --account --kind temp-unschedulable [--target PK01] [--confirm]", diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index d8f39c4a..e690c7fc 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -424,7 +424,7 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool validate", "bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id ", "bun scripts/cli.ts platform-infra sub2api codex-pool feedback --user [--window 30m] [--page-token |--request-id ] [--json]", - "bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h] [--target PK01] [--json]", + "bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h] [--target PK01] [--accounts [--page-token ]|--missing-costs] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account unidesk-codex-hy --confirm", "bun scripts/cli.ts platform-infra sub2rank plan [--target NC01]", @@ -508,7 +508,7 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool validate", "bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id ", "bun scripts/cli.ts platform-infra sub2api codex-pool feedback --user [--window 30m] [--page-token |--request-id ] [--json]", - "bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h] [--target PK01] [--json]", + "bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h] [--target PK01] [--accounts [--page-token ]|--missing-costs] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status", ], module: "scripts/src/platform-infra-sub2api-codex.ts",