From dd98cb07350bf48361fa41c76dd0631a1fc94d71 Mon Sep 17 00:00:00 2001 From: pikastech Date: Sat, 18 Jul 2026 06:41:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=8E=92=E5=90=8D?= =?UTF-8?q?=E5=88=86=E6=A1=A3=E9=A2=9D=E5=BA=A6=E5=9B=BA=E5=AE=9A=E8=AE=A1?= =?UTF-8?q?=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unidesk-sub2api/references/codex-pool.md | 18 ++++ .../R3.9_Task_Report.md | 30 +++++++ docs/MDTODO/sub2api-upstream-reliability.md | 2 +- scripts/src/help.ts | 7 +- .../platform-infra-sub2api-codex/credits.ts | 89 ++++++++++++++++--- .../remote-python-credits.ts | 89 ++++++++++++++++--- .../src/platform-infra-sub2api-codex/types.ts | 2 +- 7 files changed, 213 insertions(+), 24 deletions(-) create mode 100644 docs/MDTODO/details/sub2api-upstream-reliability/R3.9_Task_Report.md diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index b1ada79c..9d8977fe 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -131,6 +131,8 @@ bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --mont bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --month --forecast --sale-rate --scenario-without-account '' bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --target PK01 --active-since 24h --amount-usd 20 --reason bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --target PK01 --active-start 2026-07-14T17:00:00 --active-end 2026-07-14T18:00:00 --amount-usd 10 --reason +bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --target PK01 --active-since 24h --rank-tiers ':,*:' --reason +bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --target PK01 --allocations-json '' --reason --confirm bun scripts/cli.ts platform-infra sub2api codex-pool announcements list --target PK01 bun scripts/cli.ts platform-infra sub2api codex-pool announcements get --target PK01 --id bun scripts/cli.ts platform-infra sub2api codex-pool announcements create --target PK01 --title --content --targeting-json '{"any_of":[]}' @@ -177,6 +179,22 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60 - `config/platform-infra/sub2api-codex-pool.yaml#credits.excludedUsers` 声明不得获得客户补偿的精确用户 ID 或邮箱;活跃窗口和显式用户模式都必须排除,并在默认表格中披露匹配 selector 与原因; - 默认 dry-run,逐用户披露完整邮箱、用户 ID、请求数、原生 `input_tokens` / `output_tokens` / 总 Token、Token 字段覆盖情况、当前余额、增加额和目标余额;Token 字段缺失时标记 `partial`,不把缺失值伪装成完整零消耗; - 活跃窗口只允许生成计划;实际写入必须把 dry-run 返回的用户 ID 固定为精确 `--users` selectors,再显式 `--confirm`,防止移动窗口在确认时扩大或缩小名单; + - 按排名分档发放使用固定计划入口: + - dry-run 在活跃窗口增加 `--rank-tiers '<人数>:<金额>,...,*:<金额>'`; + - CLI 按总 Token 降序、请求数降序、用户 ID 升序稳定排名; + - 最后一档必须使用 `*:<金额>` 覆盖剩余合格用户; + - 输出同时披露排名、Token、逐人金额、总额和可直接确认的 `--allocations-json`; + - 排名窗口禁止 `--confirm`,避免移动窗口改变名单; + - 用户明确确认固定窗口、完整用户 ID、逐人金额、总额和原因后,只需把原样 `--allocations-json` 加 `--confirm` 执行一次; + - `--allocations-json` 支持不同用户使用不同正数金额,并在一个批次内逐用户写入和回读余额; + - 计划身份由规范化后的用户 ID、逐人金额和原因共同决定,重试使用同一幂等键; + - 统一金额的 `--users ... --amount-usd ...` 入口及其既有幂等身份保持不变; + - 排名分档发放的最短调用链: + - 第一次 credits 调用生成排名计划,必须为 dry-run; + - 人工审核并明确授权计划输出,不额外重新抓取移动窗口; + - 第二次 credits 调用使用固定 `--allocations-json --confirm` 完成全部异额发放和回读; + - 公告继续使用独立的 create dry-run 与 confirm,正文从已确认计划生成; + - 禁止把充值与公告伪装为原子事务;任一侧失败都必须分别披露实际状态和可重试入口; - 确认写入为每个用户生成由精确名单、金额和原因确定的 `Idempotency-Key`,调用原生 `POST /api/v1/admin/users/:id/balance` 的 `add` 操作,随后逐用户回读余额并对账; - 实际批量充值属于外部财务影响操作,执行前必须取得用户对本次具体名单、金额和原因的明确授权;仅提出设计目标或要求 dry-run 不构成授权。 diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R3.9_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R3.9_Task_Report.md new file mode 100644 index 00000000..8fcae744 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R3.9_Task_Report.md @@ -0,0 +1,30 @@ +# R3.9 任务报告 + +## 目标 + +完成 [UniDesk #2498](https://github.com/pikasTech/unidesk/issues/2498),把按排名分档的异额客户发放从多批手工调用收敛为固定计划入口,不修改 Sub2API 源码、版本或生产运行面。 + +## 通用能力 + +- `--rank-tiers '<人数>:<金额>,...,*:<金额>'` 只允许用于活跃窗口 dry-run。 +- 排名固定按总 Token 降序、请求数降序、用户 ID 升序。 +- dry-run 输出窗口、排名、Token、逐人金额、总额及规范化 `--allocations-json`。 +- `--allocations-json` 使用稳定用户 selector 到正数金额的映射,一个 confirm 完成异额幂等写入与逐用户余额回读。 +- 排名窗口禁止 confirm,确认阶段不重新读取移动窗口决定名单。 +- 既有统一金额 `--users + --amount-usd` 的参数与幂等身份保持不变。 +- credits 与 announcements 局部 help 直接进入子命令,不再被平台根帮助截获。 + +## 最短流程 + +- 第一次 credits 调用生成排名计划,固定完整名单、逐人金额和总额。 +- 用户明确授权该计划后,第二次 credits 调用使用原样 allocations 加 confirm。 +- 公告继续独立 dry-run 和 confirm,避免把两个外部写操作伪装为原子事务。 +- 同类三档 credits 操作由 6 次降为 2 次;包含公告时整套流程由 9 次降为 4 次。 + +## 验证 + +- 局部 `credits --help` 与 `announcements --help` 均返回紧凑文本,无 target job、无输出保护。 +- 缺少最终通配档的 `--rank-tiers` 在本地失败并明确提示。 +- PK01 只读排名 smoke:11 名用户、1 名管理员排除、三档总额 520 USD,输出完整排名和固定 allocations,`mutation=false`、写入数 0。 +- PK01 固定 allocations smoke:11 名用户、异额总计 520 USD,`selection=exact-allocations`、`mutation=false`、写入数 0。 +- 未使用 `--confirm`,未重复充值、未发布公告、未修改 PK01 运行面。 diff --git a/docs/MDTODO/sub2api-upstream-reliability.md b/docs/MDTODO/sub2api-upstream-reliability.md index fab8e3b2..4fab5acd 100644 --- a/docs/MDTODO/sub2api-upstream-reliability.md +++ b/docs/MDTODO/sub2api-upstream-reliability.md @@ -293,7 +293,7 @@ 基于 PK01 Sub2API 原生最近24小时 usage 盘点活跃客户,按 Token 排名制定约 500 USD 的整额分档计划;经用户明确确认后,对固定11名用户按100、50、20 USD三档实际增发总计520 USD,逐用户回读余额,并发布包含北京时间窗口、排名、Token、稳定用户ID、脱敏邮箱和逐人金额的 active popup 公告并完成字段对账,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R3.8_Task_Report.md)。 -### R3.9 +### R3.9 [completed] 完成 [UniDesk #2498](https://github.com/pikasTech/unidesk/issues/2498):增强 Sub2API 排名分档额度发放的固定计划入口,一个 dry-run 从原生 usage 生成包含窗口、排名、Token、稳定用户ID、逐人金额和总额的不可变计划,一个 confirm 完成异额批量幂等写入及逐用户余额回读;档位、金额、窗口和排除项全部参数化,不修改 Sub2API 源码、版本或生产运行面,并将本次流程沉淀为不含一次性值的通用案例,完成任务后将详细报告写入[任务报告](./details/sub2api-upstream-reliability/R3.9_Task_Report.md)。 ## R4 [completed] diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 3d3150cd..ad2d6d41 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -1029,7 +1029,12 @@ async function loadHelp(loader: () => Promise, fallback: unknown): Prom export async function staticNamespaceHelp(args: string[]): Promise { const [top, sub] = args; if (!args.slice(1).some(isHelpToken)) return null; - if (top === "platform-infra" && sub === "sub2api" && args[2] === "codex-pool" && args[3] === "runtime") return null; + if ( + top === "platform-infra" + && sub === "sub2api" + && args[2] === "codex-pool" + && new Set(["runtime", "credits", "announcements", "faults", "profit", "error-passthrough"]).has(args[3] ?? "") + ) return null; if (top === "config") return configHelp(); if (top === "health") return healthHelp(); if (top === "microservice") return microserviceHelp(); diff --git a/scripts/src/platform-infra-sub2api-codex/credits.ts b/scripts/src/platform-infra-sub2api-codex/credits.ts index a3e6ef4f..da6042f2 100644 --- a/scripts/src/platform-infra-sub2api-codex/credits.ts +++ b/scripts/src/platform-infra-sub2api-codex/credits.ts @@ -15,7 +15,9 @@ interface CreditsOptions { timezone: string; excludedUsers: string[]; users: string[]; - amountUsd: number; + amountUsd: number | null; + rankTiers: Array<{ count: number | null; amountUsd: number }>; + allocations: Record; reason: string; confirm: boolean; targetId: string; @@ -35,7 +37,11 @@ export async function codexPoolCredits(config: UniDeskConfig, args: string[]): P action: "platform-infra-sub2api-codex-pool-credits", target: { id: target.id, route: target.route, runtimeMode: target.runtimeMode, endpoint: target.serviceDns }, request: { - selection: options.users.length > 0 ? "exact-users" : options.activeSince === null ? "active-window" : "active-users", + selection: Object.keys(options.allocations).length > 0 + ? "exact-allocations" + : options.users.length > 0 + ? "exact-users" + : options.activeSince === null ? "active-window" : "active-users", activeSince: options.activeSince, activeStart: options.activeStart, activeEnd: options.activeEnd, @@ -45,6 +51,8 @@ export async function codexPoolCredits(config: UniDeskConfig, args: string[]): P excludedUsers: options.excludedUsers, users: options.users, amountUsd: options.amountUsd, + rankTiers: options.rankTiers, + allocations: options.allocations, reason: options.reason, confirm: options.confirm, }, @@ -58,12 +66,14 @@ export async function codexPoolCredits(config: UniDeskConfig, args: string[]): P function parseCreditsOptions(args: string[], creditsConfig: { timezone: string; excludedUsers: string[] }): CreditsOptions { const { timezone, excludedUsers } = creditsConfig; const [action = "grant", ...rest] = args; - if (action !== "grant") throw new Error("credits usage: grant (--active-since 24h|--active-start --active-end |--users ) --amount-usd --reason [--confirm] [--target ] [--json]"); + if (action !== "grant") throw new Error("credits usage: grant (--active-since 24h|--active-start --active-end |--users |--allocations-json ) (--amount-usd |--rank-tiers ) --reason [--confirm] [--target ] [--json]"); let activeSince: string | null = null; let activeStart: string | null = null; let activeEnd: string | null = null; let users: string[] = []; let amountUsd: number | null = null; + let rankTiers: Array<{ count: number | null; amountUsd: number }> = []; + let allocations: Record = {}; let reason: string | null = null; let confirm = false; let targetId = defaultCodexPoolRuntimeTargetId(); @@ -93,6 +103,16 @@ function parseCreditsOptions(args: string[], creditsConfig: { timezone: string; [value, index] = readValue(index, arg); amountUsd = parseAmount(value); } else if (arg.startsWith("--amount-usd=")) amountUsd = parseAmount(arg.slice("--amount-usd=".length)); + else if (arg === "--rank-tiers") { + let value: string; + [value, index] = readValue(index, arg); + rankTiers = parseRankTiers(value); + } else if (arg.startsWith("--rank-tiers=")) rankTiers = parseRankTiers(arg.slice("--rank-tiers=".length)); + else if (arg === "--allocations-json") { + let value: string; + [value, index] = readValue(index, arg); + allocations = parseAllocations(value); + } else if (arg.startsWith("--allocations-json=")) allocations = parseAllocations(arg.slice("--allocations-json=".length)); else if (arg === "--reason") [reason, index] = readValue(index, arg); else if (arg.startsWith("--reason=")) reason = arg.slice("--reason=".length).trim(); else if (arg === "--target") [targetId, index] = readValue(index, arg); @@ -100,8 +120,8 @@ function parseCreditsOptions(args: string[], creditsConfig: { timezone: string; else throw new Error(`unsupported credits option: ${arg}`); } if ((activeStart === null) !== (activeEnd === null)) throw new Error("--active-start and --active-end must be provided together"); - const selectionCount = Number(activeSince !== null) + Number(activeStart !== null) + Number(users.length > 0); - if (selectionCount !== 1) throw new Error("use exactly one of --active-since, --active-start/--active-end, or --users"); + const selectionCount = Number(activeSince !== null) + Number(activeStart !== null) + Number(users.length > 0) + Number(Object.keys(allocations).length > 0); + if (selectionCount !== 1) throw new Error("use exactly one of --active-since, --active-start/--active-end, --users, or --allocations-json"); if (activeSince !== null && !/^[1-9][0-9]*(?:m|h|d)$/u.test(activeSince)) throw new Error("--active-since must use a duration such as 24h"); if (activeStart !== null && !isIsoDateTime(activeStart)) throw new Error("--active-start must use ISO date-time format"); if (activeEnd !== null && !isIsoDateTime(activeEnd)) throw new Error("--active-end must use ISO date-time format"); @@ -110,10 +130,21 @@ function parseCreditsOptions(args: string[], creditsConfig: { timezone: string; if (activeStartUtc !== null && activeEndUtc !== null && activeEndUtc <= activeStartUtc) { throw new Error("credits active window end must be after start"); } - if (confirm && users.length === 0) throw new Error("confirmed credits require the exact --users selectors returned by an active-user dry-run"); - if (amountUsd === null) throw new Error("credits grant requires --amount-usd"); + const activeSelection = activeSince !== null || activeStart !== null; + if (rankTiers.length > 0 && !activeSelection) throw new Error("--rank-tiers requires an active user window"); + if (rankTiers.length > 0 && amountUsd !== null) throw new Error("use exactly one of --amount-usd or --rank-tiers"); + if (Object.keys(allocations).length > 0 && (amountUsd !== null || rankTiers.length > 0)) { + throw new Error("--allocations-json already contains per-user amounts"); + } + if (amountUsd === null && rankTiers.length === 0 && Object.keys(allocations).length === 0) { + throw new Error("credits grant requires --amount-usd, --rank-tiers, or --allocations-json"); + } + if (confirm && activeSelection) throw new Error("confirmed credits require exact --users or --allocations-json selectors returned by a dry-run"); + if (confirm && users.length === 0 && Object.keys(allocations).length === 0) { + throw new Error("confirmed credits require exact selectors returned by an active-user dry-run"); + } if (reason === null || reason.length === 0 || reason.length > 500 || /[\r\n\0]/u.test(reason)) throw new Error("credits grant requires a single-line --reason up to 500 characters"); - return { activeSince, activeStart, activeEnd, activeStartUtc, activeEndUtc, timezone, excludedUsers, users, amountUsd, reason, confirm, targetId, json }; + return { activeSince, activeStart, activeEnd, activeStartUtc, activeEndUtc, timezone, excludedUsers, users, amountUsd, rankTiers, allocations, reason, confirm, targetId, json }; } function isIsoDateTime(value: string): boolean { @@ -173,6 +204,37 @@ function parseAmount(value: string): number { return amount; } +function parseRankTiers(value: string): Array<{ count: number | null; amountUsd: number }> { + const parts = value.split(",").map((item) => item.trim()).filter(Boolean); + if (parts.length === 0 || parts.length > 20) throw new Error("--rank-tiers requires 1 to 20 comma-separated count:amount tiers"); + const tiers = parts.map((part, index) => { + const matched = /^(\*|[1-9][0-9]*):(.+)$/u.exec(part); + if (matched === null) throw new Error("--rank-tiers must use count:amount entries such as 3:100,2:50,*:20"); + const count = matched[1] === "*" ? null : Number(matched[1]); + if (count === null && index !== parts.length - 1) throw new Error("--rank-tiers wildcard must be the final tier"); + return { count, amountUsd: parseAmount(matched[2]!) }; + }); + if (tiers.at(-1)?.count !== null) throw new Error("--rank-tiers requires a final *:amount tier"); + return tiers; +} + +function parseAllocations(value: string): Record { + let parsed: unknown; + try { parsed = JSON.parse(value); } catch { throw new Error("--allocations-json requires a JSON object"); } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("--allocations-json requires a JSON object"); + const entries = Object.entries(parsed); + if (entries.length === 0 || entries.length > 200) throw new Error("--allocations-json requires 1 to 200 exact user selectors"); + const allocations: Record = {}; + for (const [selector, amount] of entries) { + const normalized = selector.trim(); + if (normalized.length === 0 || normalized.length > 256 || /[\r\n\0]/u.test(normalized)) { + throw new Error("--allocations-json contains an unsupported selector"); + } + allocations[normalized] = parseAmount(String(amount)); + } + return allocations; +} + function renderCreditsHelp(): RenderedCliResult { return { ok: true, @@ -183,11 +245,14 @@ function renderCreditsHelp(): RenderedCliResult { " bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --active-since 24h --amount-usd 20 --reason [--confirm]", " bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --active-start --active-end --amount-usd 20 --reason ", " bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --users --amount-usd 20 --reason [--confirm]", + " bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --active-since 24h --rank-tiers '3:100,2:50,*:20' --reason ", + " bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --allocations-json '' --reason [--confirm]", "Notes:", " Default mode is dry-run. --active-since selects distinct active non-admin users from native usage.", " Offset-free fixed-window values use config/platform-infra/sub2api-codex-pool.yaml#credits.timezone and the interval is [start,end).", " YAML credits.excludedUsers selectors are excluded from both active-window and exact-user grants.", " --confirm performs additive balance writes and then reads every selected user back.", + " Ranked windows are dry-run only. Reuse the emitted --allocations-json plan for one heterogeneous confirmation call.", ].join("\n"), contentType: "text/plain", }; @@ -209,9 +274,9 @@ function renderCredits(response: Record): RenderedCliResult { if (typeof report.error === "string") lines.push(`ERROR ${text(report.error)}`); if (rows.length > 0) { lines.push("", renderTable([ - ["EMAIL", "USER_ID", "ROLE", "STATUS", "REQUESTS", "INPUT_TOKENS", "OUTPUT_TOKENS", "TOTAL_TOKENS", "TOKEN_DATA", "BALANCE_BEFORE", "ADD_USD", "BALANCE_AFTER", "WRITE", "RECONCILED"], + ["RANK", "EMAIL", "USER_ID", "ROLE", "STATUS", "REQUESTS", "INPUT_TOKENS", "OUTPUT_TOKENS", "TOTAL_TOKENS", "TOKEN_DATA", "BALANCE_BEFORE", "ADD_USD", "BALANCE_AFTER", "WRITE", "RECONCILED"], ...rows.map((row) => [ - text(row.email), text(row.userId), text(row.role), text(row.status), integer(row.requestCount), integer(row.inputTokens), integer(row.outputTokens), + integer(row.rank), text(row.email), text(row.userId), text(row.role), text(row.status), integer(row.requestCount), integer(row.inputTokens), integer(row.outputTokens), integer(row.totalTokens), tokenData(row), usd(row.balanceBefore), usd(row.amountUsd), usd(row.balanceAfter), text(record(row.write).status), text(row.reconciled), ]), ])); @@ -227,8 +292,12 @@ function renderCredits(response: Record): RenderedCliResult { } if (report.mode === "dry-run") { const confirmationUserIds = Array.isArray(report.confirmationUserIds) ? report.confirmationUserIds : []; + const confirmationAllocations = record(report.confirmationAllocations); const selectors = confirmationUserIds.length > 0 ? confirmationUserIds.join(",") : ""; lines.push("", `CONFIRM SELECTORS --users ${selectors}`); + if (Object.keys(confirmationAllocations).length > 0) { + lines.push(`CONFIRM PLAN --allocations-json '${JSON.stringify(confirmationAllocations)}'`); + } lines.push(confirmationUserIds.length > 0 ? "NEXT: use these exact selectors with --confirm only after explicit user authorization." : "NEXT: no users remain eligible for confirmation."); diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts index 2dd7e6f7..326a28f5 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts @@ -143,7 +143,8 @@ def credits_selected_users(token, payload): selected = [] excluded = [] seen = set() - for selector in payload.get("users") or []: + exact_selectors = payload.get("users") or list((payload.get("allocations") or {}).keys()) + for selector in exact_selectors: user = users_by_id.get(str(selector)) or users_by_email.get(str(selector).strip().lower()) if not isinstance(user, dict): raise RuntimeError("exact user selector not found: " + str(selector)) @@ -159,28 +160,91 @@ def credits_selected_users(token, payload): }) else: selected.append((user, credits_usage_fields(None))) - return selected, excluded, {"kind": "exact-users", "selectors": payload.get("users") or []} + selection_kind = "exact-allocations" if payload.get("allocations") else "exact-users" + return selected, excluded, {"kind": selection_kind, "selectors": exact_selectors} + +def credits_ranked_amounts(selected, payload): + tiers = payload.get("rankTiers") or [] + ranked = sorted(selected, key=lambda item: ( + -int(item[1].get("totalTokens") or 0), + -int(item[1].get("requestCount") or 0), + int(item[0].get("id") or 0), + )) + amounts = {} + tier_index = 0 + tier_used = 0 + for rank, (user, _) in enumerate(ranked, start=1): + while tier_index < len(tiers) - 1: + count = tiers[tier_index].get("count") + if count is None or tier_used < int(count): + break + tier_index += 1 + tier_used = 0 + tier = tiers[tier_index] + amounts[str(user.get("id"))] = profit_decimal(tier.get("amountUsd")) + tier_used += 1 + return ranked, amounts + +def credits_exact_amounts(selected, payload): + allocations = payload.get("allocations") or {} + amounts = {} + for user, _ in selected: + user_id = str(user.get("id")) + email = str(user.get("email") or "").strip().lower() + matched = None + for selector, value in allocations.items(): + normalized = str(selector).strip().lower() + if normalized == user_id or normalized == email: + matched = value + break + if matched is None: + raise RuntimeError("allocation missing for selected user: " + user_id) + amounts[user_id] = profit_decimal(matched) + return amounts def run_credits_grant(): payload = json.loads(base64.b64decode(PAYLOAD_B64).decode("utf-8")) if PAYLOAD_B64 else {} _, token, _ = login() selected, excluded, selection = credits_selected_users(token, payload) - amount = profit_decimal(payload.get("amountUsd")) + uniform_amount = profit_decimal(payload.get("amountUsd")) if payload.get("amountUsd") is not None else None + rank_tiers = payload.get("rankTiers") or [] + allocations = payload.get("allocations") or {} + if rank_tiers: + selected, amount_by_user = credits_ranked_amounts(selected, payload) + elif allocations: + amount_by_user = credits_exact_amounts(selected, payload) + else: + amount_by_user = {str(user.get("id")): uniform_amount for user, _ in selected} confirm = payload.get("confirm") is True - batch_identity = json.dumps({ - "users": sorted(str(value) for value in (payload.get("users") or [])), - "amountUsd": profit_decimal_text(amount), - "reason": payload.get("reason"), - }, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + exact_plan = { + str(user.get("id")): profit_decimal_text(amount_by_user[str(user.get("id"))]) + for user, _ in selected + } + if rank_tiers or allocations: + batch_identity_data = { + "allocations": dict(sorted(exact_plan.items())), + "reason": payload.get("reason"), + } + else: + batch_identity_data = { + "users": sorted(str(value) for value in (payload.get("users") or [])), + "amountUsd": profit_decimal_text(uniform_amount), + "reason": payload.get("reason"), + } + batch_identity = json.dumps(batch_identity_data, ensure_ascii=False, sort_keys=True, separators=(",", ":")) batch_key = hashlib.sha256(batch_identity.encode("utf-8")).hexdigest()[:24] items = [] write_succeeded = 0 write_failed = 0 reconciled = 0 - for user, usage_fields in sorted(selected, key=lambda item: (str(item[0].get("email") or ""), int(item[0].get("id") or 0))): + ranked_mode = bool(rank_tiers) + ordered = selected if ranked_mode else sorted(selected, key=lambda item: (str(item[0].get("email") or ""), int(item[0].get("id") or 0))) + for rank, (user, usage_fields) in enumerate(ordered, start=1): + amount = amount_by_user[str(user.get("id"))] before = profit_decimal(user.get("balance")) after = before + amount item = { + "rank": rank if ranked_mode else None, "userId": user.get("id"), "email": user.get("email"), "role": user.get("role"), "status": user.get("status"), **usage_fields, "balanceBefore": profit_decimal_text(before), "amountUsd": profit_decimal_text(amount), "balanceAfter": profit_decimal_text(after), "write": {"attempted": False, "status": "dry-run", "error": None}, "reconciled": None, @@ -203,14 +267,17 @@ def run_credits_grant(): write_failed += 1 items.append(item) selected_count = len(items) + total_amount = sum((amount_by_user[str(item.get("userId"))] for item in items), profit_decimal(0)) + summary_amount = profit_decimal_text(uniform_amount) if uniform_amount is not None else "varies" return { "ok": not confirm or (write_failed == 0 and reconciled == selected_count), "mode": "confirmed" if confirm else "dry-run", "mutation": confirm and write_succeeded > 0, "selection": selection, "reason": payload.get("reason"), "items": items, "excluded": excluded, "confirmationUserIds": [item.get("userId") for item in items], + "confirmationAllocations": exact_plan if rank_tiers or allocations else {}, "summary": { - "selected": selected_count, "excluded": len(excluded), "amountUsd": profit_decimal_text(amount), - "totalAmountUsd": profit_decimal_text(amount * selected_count), "writeAttempted": selected_count if confirm else 0, + "selected": selected_count, "excluded": len(excluded), "amountUsd": summary_amount, + "totalAmountUsd": profit_decimal_text(total_amount), "writeAttempted": selected_count if confirm else 0, "writeSucceeded": write_succeeded, "writeFailed": write_failed, "reconciled": reconciled, }, "valuesPrinted": False, diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index f520071f..985afd5e 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -408,7 +408,7 @@ export function codexPoolHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool runtime events [--since 24h] [--tail 50000] [--target PK01] [--json|--raw] (目标侧仅筛选 policy marker,聚合由调用方完成)", "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|--month YYYY-MM [--forecast]] [--target PK01] [--sale-rate ] [--scenario-without-account ...] [--accounts [--page-token ]|--missing-costs] [--json]", - "bun scripts/cli.ts platform-infra sub2api codex-pool credits grant (--active-since 24h|--active-start --active-end |--users ) --amount-usd --reason [--target PK01] [--confirm] [--json]", + "bun scripts/cli.ts platform-infra sub2api codex-pool credits grant (--active-since 24h|--active-start --active-end |--users |--allocations-json ) (--amount-usd |--rank-tiers ) --reason [--target PK01] [--confirm] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool announcements list|get|create [--target PK01] [--confirm for create] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough list|apply|delete [--template ] [--target PK01] [--confirm] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account --template [--target PK01] [--confirm]",