From eb192eb161b22bd4dc6bd1b8436433bc7bda69b8 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:53:45 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=9B=BF=E6=8D=A2=E7=AA=97=E5=8F=A3=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E5=AF=BC=E8=87=B4=E7=9A=84=E7=B3=BB=E7=BB=9F=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=A0=8F=E5=9B=9E=E5=BD=92=E4=B8=8E=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E8=A2=AB=E6=8B=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 渠道 --config 改为从基线 tauri.conf.json 读取完整 client 窗口对象后展开、只覆盖 title,避免 Tauri JSON Merge Patch 整体替换 app.windows 丢掉 label / decorations / 尺寸 - build-release.test.mjs 新增合并守卫用例:按同一 merge patch 语义复现 Tauri 合并,断言 label=client、decorations=false、1280x800、min 1280x720,且承载 http:default 的 capability 必须包含该 label - check-config.mjs 增补基线 client 窗口 decorations 必须为 false 的门禁 - 更新 AGC 客户端更新检查与下载技术方案,写明渠道配置必须下发完整窗口对象的约定 - pitfalls.md 记录本次回归的现象、根因、现行口径与验证证据 --- .../scripts/build-release.mjs | 21 ++++- .../scripts/build-release.test.mjs | 93 ++++++++++++++++++- .../scripts/check-config.mjs | 8 ++ docs/project-memory/shared-memory/pitfalls.md | 8 ++ ...œ¯方案】AGC客户端更新检查与下载-2026-08-31.md | 3 +- 5 files changed, 129 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 38d6d1c5c..fe13fade7 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -373,6 +373,24 @@ export function buildTauriBuildArguments( ]; } +/** + * 基线 client 窗口契约:渠道配置只允许覆盖标题,其余字段必须逐字沿用。 + * + * `tauri build --config` 走 JSON Merge Patch(tauri-utils 用 `json_patch::merge`): + * 对象递归合并,**数组整体替换**。只下发 `{ title }` 会让 + * `label` / `decorations` / 尺寸全部回落到 Tauri 默认值(label=main、 + * decorations=true、800x600),结果是原生系统标题栏重新出现,并且按 label + * 绑定的 capability(平台 HTTP 权限等)一起失效。 + */ +function readBaseClientWindow() { + const base = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf8')); + const clientWindow = base.app?.windows?.[0]; + if (!clientWindow || typeof clientWindow.label !== 'string') { + throw new Error('AGC 基线配置缺少 client 主窗口,渠道配置无法安全合并'); + } + return clientWindow; +} + /** * 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道, * 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录, @@ -381,13 +399,14 @@ export function buildTauriBuildArguments( export function createChannelConfig( channel = resolveReleaseChannel(), target = defaultTarget(), + baseClientWindow = readBaseClientWindow(), ) { const { productName, identifier } = resolveChannelInstallIdentity(channel); return { productName, identifier, app: { - windows: [{ title: productName }], + windows: [{ ...baseClientWindow, title: productName }], }, plugins: { updater: { diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 4c62cd8b4..3cdd735c1 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -1,5 +1,11 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; @@ -180,7 +186,18 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { productName: `${AGC_PRODUCT_NAME}开发版`, identifier: AGC_APP_IDENTIFIER, app: { - windows: [{ title: `${AGC_PRODUCT_NAME}开发版` }], + windows: [ + { + label: 'client', + title: `${AGC_PRODUCT_NAME}开发版`, + url: 'index.html', + width: 1280, + height: 800, + decorations: false, + minWidth: 1280, + minHeight: 720, + }, + ], }, plugins: { updater: { @@ -245,6 +262,78 @@ test('channel install identity is baked into the same build-time config as the e }); }); +/** + * RFC 7386(tauri-utils 用 `json_patch::merge`)语义:对象递归合并,数组整体替换。 + * 这里按同样语义复现 Tauri CLI 的 `--config` 合并,用来守住"渠道配置不得丢窗口契约"。 + */ +function applyJsonMergePatch(base, patch) { + if (Array.isArray(patch) || typeof patch !== 'object' || patch === null) { + return patch; + } + const merged = + typeof base === 'object' && base !== null && !Array.isArray(base) + ? { ...base } + : {}; + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete merged[key]; + else merged[key] = applyJsonMergePatch(merged[key], value); + } + return merged; +} + +function readBaseTauriConfig() { + return JSON.parse( + readFileSync( + new URL('../src-tauri/tauri.conf.json', import.meta.url), + 'utf8', + ), + ); +} + +test('channel config keeps the client window contract across the Tauri config merge', () => { + const base = readBaseTauriConfig(); + const merged = applyJsonMergePatch(base, { + ...createChannelConfig('release', windowsTarget), + version: base.version, + }); + const [clientWindow] = merged.app.windows; + assert.deepEqual(clientWindow, { + ...base.app.windows[0], + title: '陶泥儿 Release', + }); + // 原生标题栏、尺寸与默认窗口标签都是回归点:任何一项回落都会让自绘标题栏失效, + // 并让按 label 绑定的 capability(平台 HTTP 权限)不再命中。 + assert.equal(clientWindow.label, 'client'); + assert.equal(clientWindow.decorations, false); + assert.equal(clientWindow.width, 1280); + assert.equal(clientWindow.height, 800); + assert.equal(clientWindow.minWidth, 1280); + assert.equal(clientWindow.minHeight, 720); + + const capabilitiesDirectory = new URL( + '../src-tauri/capabilities/', + import.meta.url, + ); + const capabilities = readdirSync(capabilitiesDirectory) + .filter((name) => name.endsWith('.json')) + .map((name) => + JSON.parse(readFileSync(new URL(name, capabilitiesDirectory), 'utf8')), + ); + const httpCapability = capabilities.find((capability) => + (capability.permissions ?? []).some( + (permission) => + permission === 'http:default' || + (typeof permission === 'object' && + permission?.identifier === 'http:default'), + ), + ); + assert.ok(httpCapability, '客户端必须保留承载平台 HTTP 权限的 capability'); + assert.ok( + (httpCapability.windows ?? []).includes(clientWindow.label), + `平台 HTTP capability 必须绑定 ${clientWindow.label} 窗口,实际:${httpCapability.windows}`, + ); +}); + test('channel products keep first-install selection working under the channel product name', () => { const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-')); try { diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 31b7d52f4..b22a44081 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1567,6 +1567,14 @@ if ( ); } +// 窗口外壳由前端 WindowChrome 自绘:基线配置一旦放开 decorations, +// 打包产物会出现系统标题栏与自绘标题栏并存。 +if (clientWindow.decorations !== false) { + throw new Error( + 'AI game creator shell client window must keep native decorations disabled', + ); +} + if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') { throw new Error( 'AI game creator shell Tauri config must retain the non-launcher fallback devUrl', diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 877437f62..2efa4c511 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5939,3 +5939,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现象**:模板清单里的封面 URL 失效(或离线)时,卡片封面上出现浏览器的破碎图片图标,比没有封面更难看。 - **处理**:`TemplateCard` 的 `img` 加 `onError` 直接把自身 `visibility` 设为 `hidden`(不进 state,卡片是 memo 的纯展示组件),留下封面容器本身的中性底色;单测用 `fireEvent.error(cover)` 钉住。 - **关联**:`apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx`、`apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx`。 + +## 2026-09-23 渠道 `--config` 只写窗口标题,打包产物系统标题栏回来了且登录请求被 ACL 拒绝 + +- **现象**:dev 渠道 0.1.129 安装包启动后,窗口顶部同时出现系统标题栏(浅蓝条 + 原生最小化/最大化/关闭)与前端自绘 `WindowChrome`;窗口缩到 816x639(约 800x600 客户区);登录页常驻「无法连接登录服务,请确认配套后端或 API 代理已启动后重试」。应用日志同一秒出现 `startup.window-title.failed: 缺少 client 主窗口`,而 `https://dev.genarrative.world` 在浏览器/curl 下可正常响应。 +- **原因**:`ebb288a6a`(2026-09-23 18:49)为统一渠道产品名,在渠道配置里加了 `app: { windows: [{ title: productName }] }`。Tauri 的 `--config` 合并是 JSON Merge Patch(`tauri-utils/build.rs` 用 `json_patch::merge`):对象递归合并、**数组整体替换**。基线窗口数组被整条换掉后,`label` 回落到默认 `main`(不是 `client`)、`decorations` 回落到 `true`、尺寸回落到 800x600。三条症状同源:① `decorations: true` → 系统标题栏;② 尺寸回落 → 816x639;③ label 不再是 `client` → `capabilities/main.json`(`windows: ["client"]`,承载 `http:default` 与平台 API scope、dialog/opener/updater/剪贴板权限)整条不命中,前端 `fetchClientHttp` 走 `@tauri-apps/plugin-http` 时被 ACL 拒绝并抛错,登录状态检查就报成"连不上服务器"。判断关键:**这类"连不上服务"是权限拒绝,不是网络故障——先看窗口 label 与 capability 的 `windows` 是否还对得上,别去查后端与代理**。 +- **处理(现行口径)**:`createChannelConfig()` 从基线 `src-tauri/tauri.conf.json` 读完整 client 窗口对象后展开、只覆盖 `title`(`readBaseClientWindow()`),渠道配置不得再出现"只写 `title`"的窗口对象。新增守卫:`build-release.test.mjs` 用同语义的 merge patch 复现 Tauri 合并并断言 `label=client` / `decorations=false` / 1280x800 / min 1280x720 且承载 `http:default` 的 capability 必须包含该 label;`check-config.mjs` 增补基线 `decorations !== false` 失败关闭。 +- **验证**:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs scripts/cargo-features.test.mjs scripts/release-oss.test.mjs scripts/prepare-macos-codex.test.mjs`(60/60)、`node apps/ai-game-creator-shell/scripts/check-config.mjs` 通过;`createChannelConfig('dev', …)` 实测输出含 `label: client` 与 `decorations: false`。修复后的安装包尚未重新构建与安装,真机观感与登录链未复核。 +- **关联**:`apps/ai-game-creator-shell/scripts/build-release.mjs`、`apps/ai-game-creator-shell/scripts/build-release.test.mjs`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/src-tauri/capabilities/main.json`、`docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 66fe3e5cb..8bbdbe95a 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -136,7 +136,8 @@ - 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 - 发布入口只解析一次目标,优先级为 CLI `--target value` / `--target=value` / `-t value`、`AGC_BUILD_TARGET`、Windows 默认值;重复/空目标与不支持目标失败关闭。版本高水位、构建 feature/渠道端点、bundle 路径、产物后缀、清单平台键及摘要必须消费同一个发布上下文,不能分别回读默认目标。 - 渠道由 `AGC_UPDATE_CHANNEL` 显式指定,默认 dev;Windows 与 macOS 目标均支持 dev、release 和自定义渠道,目标校验独立进行。 -- 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier` 与 updater 端点:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。 +- 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier`、updater 端点与窗口标题:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。 +- 渠道配置走 Tauri 的 JSON Merge Patch 语义:对象递归合并,**数组整体替换**。因此 `app.windows` 必须按基线 `tauri.conf.json` 的完整 client 窗口对象下发、只覆盖 `title`(脚本从基线读取后展开);任何"只写 `{ title }`"的写法都会让 `label` / `decorations` / 尺寸回落成 Tauri 默认值(`label=main`、`decorations=true`、800x600),表现为打包产物重新出现系统标题栏,并按 label 连带失效承载平台 HTTP 权限等 capability。守卫用例:`build-release.test.mjs` 的渠道配置合并用例与 `check-config.mjs` 的 `decorations` 门禁。 - 定时调度分别判断服务端与客户端 scope:dev 小时调度在提交含 AGC 相关路径时发布对应渠道,纯文档或流水线自身的提交仍只跑 Full Build;release 每日调度在服务端相关路径变化时发布正式 Full Build,在 AGC 相关路径变化时发布 release 客户端,并在同一调度内等待、汇总各 lane 结果,失败 lane 下一轮补发。判定失败或勾选强制触发时按"需要发布"处理。 - 更新摘要不再自动生成:发布脚本不读取提交记录生成 `notes`;只有 `AGC_UPDATE_RELEASE_NOTES` 非空时,才把显式手动文案写入渠道清单和旧协议清单的 `releaseNotes`。未设置时清单不携带更新说明,归档文件 `release-notes.txt` 记录“本次没有可用的更新摘要”。 - 清单里的 `commit` 是非标准字段:更新插件忽略未知字段;发布脚本只为线上排障保留源码 revision,不驱动更新摘要。 From 0dbd279dfc79e586630a9bdb22854ffe891e540c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:56:24 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E5=90=8E=E5=8F=B0=20Dashboard=20=E6=B6=88?= =?UTF-8?q?=E8=80=97=E6=B3=A5=E7=82=B9=E6=94=B9=E4=B8=BA=E5=AF=B9=E5=86=B2?= =?UTF-8?q?=E9=80=80=E8=BF=98=E5=90=8E=E7=9A=84=E5=87=80=E6=B6=88=E8=80=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - consumedMudPoints 改为净额:按北京时间业务日先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日,抵扣不完的丢弃,每日净额非负且区间合计严格等于「消耗 − 退还」 - 退还口径并入 asset_operation_refund 正向流水(生成失败退还、精选审核返还)与 llm_router_consume 正向冲正流水;充值退款追回、余额重置、赠送和 hold 仍不计入 - 契约新增 refundedMudPoints,后端新增「退还泥点」趋势图,后台时段数据并列「退还泥点数」卡,毛消耗可由「消耗 + 退还」核出 - 新增 4 条净额单测(同日对冲 / 回溯抵扣 / 超额归零 / 无退还保原值)与 Dashboard 用例断言 - 同步 Dashboard 运营看板方案指标口径与决策记录 - 验证:cargo test -p api-server --bin api-server admin(136 passed / 0 failed / 1 ignored)、npm run admin-web:typecheck、npx vitest run apps/admin-web/src(218 passed)、cargo fmt --all --check、npm run check:encoding、npm run check:doc-index、git diff --check 全部通过 --- apps/admin-web/src/api/adminApiTypes.ts | 3 + .../src/pages/AdminDashboardPage.test.tsx | 3 + .../src/pages/AdminDashboardPage.tsx | 6 + .../shared-memory/decision-log.md | 8 + ...Ž台管理】Dashboard运营看板方案-2026-06-23.md | 4 +- server-rs/crates/api-server/src/admin.rs | 160 +++++++++++++++--- .../crates/shared-contracts/src/admin.rs | 3 + 7 files changed, 167 insertions(+), 20 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 71159500c..85ca7961a 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -193,7 +193,10 @@ export interface AdminDashboardRangePayload { export interface AdminDashboardMetricsPayload { generatedAssets: number; + /** 已对冲退还(生成失败 / 精选审核返还 / LLM Router 冲正)的净消耗泥点。 */ consumedMudPoints: number; + /** 同期退还泥点,用于核对「消耗 + 退还」的毛消耗口径。 */ + refundedMudPoints: number; totalRegisteredUsers: number; newRegisteredUsers: number; newUserPaymentConversion: AdminDashboardPaymentConversionPayload; diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx index a278a19dc..3653df930 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -34,6 +34,7 @@ const dashboardResponse: AdminDashboardResponse = { metrics: { generatedAssets: 12, consumedMudPoints: 88, + refundedMudPoints: 24, totalRegisteredUsers: 1200, newRegisteredUsers: 16, newUserPaymentConversion: { @@ -105,6 +106,8 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () = expect(await screen.findByText('总计数据')).toBeTruthy(); expect(screen.getByText('时段数据')).toBeTruthy(); expect(screen.getByText('本日生产素材数')).toBeTruthy(); + expect(screen.getByText('本日消耗泥点数')).toBeTruthy(); + expect(screen.getByText('本日退还泥点数')).toBeTruthy(); expect(screen.getByText('总注册用户')).toBeTruthy(); expect(screen.getByText('本日新增用户数')).toBeTruthy(); expect(screen.getByText('新增用户转化与留存')).toBeTruthy(); diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx index 933abf45a..111e7e9a0 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -128,6 +128,12 @@ export function AdminDashboardPage({ value: metrics?.consumedMudPoints ?? 0, unit: '泥点', }, + { + id: 'refunded-mud-points', + label: `${rangePrefix(granularity)}退还泥点数`, + value: metrics?.refundedMudPoints ?? 0, + unit: '泥点', + }, { id: 'new-registered-users', label: `${rangePrefix(granularity)}新增用户数`, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a11c2b6ff..a7cfa58de 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9269,3 +9269,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,controller/useDirectThreadChatSubscription.ts,controller/useDirectProjectChatController.ts}` 与 `apps/ai-game-creator-shell/tests/{directThreadChat.test.ts,appSurface/chat-composer.suite.ts}`。 - 验证:reducer 新增 2 条用例(兜底收口后同名 `turn.started` 不复活且真终态仍能补上结束时间;身份不同的回合不动),appSurface 新增 `stops claiming the turn is running when a failed send left turn.started open`;变异验证:拿掉 controller 里的兜底收口调用后该用例变红(界面仍显示「陶泥儿正在处理」),恢复即绿。 - 边界(未做):根因仍在宿主侧——要在进程内保证开闭配对,应由 Rust 在回合函数退出(含 panic / 任务中止)时补一条终态事件(drop 守卫);本次只做到前端不再跟着说谎。另:兜底收口的回合没有终态时间,仍会落进「`finished` 但拿不到终态时间」那个已知缺口(终态文案要不要藏,见 `DirectProjectTurn.tsx` 与 `DirectChatTurnState` 注释里的 A 项)。 + +## 2026-09-23 后台 Dashboard「消耗泥点」改为对冲退还后的净消耗 + +- 背景:Dashboard 的「消耗泥点数」只累计负向消费流水,生成失败退还、精选审核返还和 LLM Router 正向冲正都不参与抵扣,运营看到的「总消耗」明显高于用户实际花费(用户现场反馈)。 +- 决策:`GET /admin/api/dashboard` 的 `consumedMudPoints` 改为净消耗。先按北京时间业务日累计 `asset_operation_consume` / `llm_router_consume` 的负向流水绝对值,再用同期 `asset_operation_refund` 正向流水和 `llm_router_consume` 正向冲正流水按日抵扣:先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日,抵扣不完的退还丢弃。因此每日净额非负,区间合计严格等于「消耗 − 退还」。新增 `refundedMudPoints` 与「退还泥点」趋势图,前台「消耗泥点数」卡旁并列「退还泥点数」卡,毛消耗可由「消耗 + 退还」核出,不把退还金额藏进净额。 +- 边界(本次不改):用户详情「历史花费」(`profile_wallet_consumption_total` 投影与手动对账)维持既有「退款不冲减」决策,仍只累计负向消费流水;若要改成净额,必须单独走投影语义 + 对账口径变更,不能顺手改这一处。充值退款追回、余额重置、赠送和 hold 继续不计入消耗。 +- 影响范围:`server-rs/crates/api-server/src/admin.rs`、`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/api/adminApiTypes.ts`、`apps/admin-web/src/pages/AdminDashboardPage.tsx`、对应用例与 `docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`。 +- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server dashboard_consumption`(新增 4 条净额用例全绿);`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin`(136 passed / 0 failed / 1 ignored);`npm run admin-web:typecheck`;`npx vitest run apps/admin-web/src`(218 passed);`npm run check:encoding`、`git diff --check`。 diff --git a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md index 0c951c3d2..b26f58358 100644 --- a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md +++ b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md @@ -22,7 +22,8 @@ ## 指标口径 - 生产素材数:`editor_project_resource` 中 `source_type = 'generated'` 的资源,按 `created_at` 映射到北京时间业务日。 -- 消耗泥点数:`profile_wallet_ledger` 中 `source_type = asset_operation_consume` 且 `amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日。 +- 消耗泥点数(净消耗):`profile_wallet_ledger` 中 `source_type` 为 `asset_operation_consume` / `llm_router_consume` 且 `amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日后,再按日对冲同期退还泥点——先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日,抵扣不完的退还丢弃,因此每日净额非负,区间合计严格等于「消耗 − 退还」。充值退款追回、余额重置、赠送和 hold 都不计入。 +- 退还泥点数:`source_type = asset_operation_refund` 的正向流水(生成失败退还、精选审核返还)与 `llm_router_consume` 的正向冲正流水,按 `created_at` 映射到北京时间业务日。它只对冲本看板的消耗口径,不改变用户详情「历史花费」按既有决策的退款不冲减口径。 - 总注册用户:`profile_dashboard_state` 行数。 - 新增用户数:`profile_dashboard_state` 中 `created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月快捷日期范围。 - 新增用户付费率:分母为当前筛选时间窗内的新增用户,分子为这些用户中截至本次查询时已至少完成一次真实支付的去重人数。真实支付以 `profile_recharge_order.paid_at` 存在且不晚于本次查询时刻为准;只创建订单或未支付订单不计,已退款订单仍表示曾经发生过付费转化,因此保留在分子。返回付费人数、新增用户数和四舍五入后的基点率;分母为 0 时 DTO 返回 0,前端百分比显示 `-`。 @@ -33,6 +34,7 @@ - 留存活跃:用户在注册日恰好 `D+1` / `D+7` 的 `tracking_daily_stat` 中存在上述有效 user scope 行;同一用户同日多个事件只计一次,不按“1 / 7 天内累计回访”计算。筛选范围约束注册 cohort,观察日允许晚于筛选结束日。 - 留存成熟条件:目标观察日必须早于当前北京时间业务日;观察日为今天时因当天尚未完整结束而排除。D1、D7 的可观察人数通常不同,分别返回 `eligibleUsers`、`retainedUsers` 与 `rateBasisPoints = round(retainedUsers * 10000 / eligibleUsers)`;分母为 0 时 DTO 返回 0,前端百分比显示 `-`。汇总率按总人数加权,不平均每日百分比。 - 运营汇总页签:复用同一时间窗,展示运营指标卡、素材类型分布和访问模块分布。 +- 趋势图:`消耗泥点` 图展示按日对冲之后的净额,区间合计与「消耗泥点数」指标一致;`退还泥点` 图展示同期退还金额,毛消耗可由「消耗 + 退还」核出,避免把退还金额藏进净额里。 ## 前后端文件 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 2f9838531..176c0e39a 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -2383,6 +2383,7 @@ struct AdminDashboardAssetStats { #[derive(Default)] struct AdminDashboardWalletStats { consumed_mud_points: AdminDashboardSeries, + refunded_mud_points: AdminDashboardSeries, recharged_mud_points: u64, warnings: Vec, } @@ -2419,12 +2420,22 @@ async fn build_admin_dashboard( let user_stats = fetch_admin_dashboard_user_stats(state, &range, now_micros).await?; + let day_keys = range.day_keys(); + // 消耗泥点按日对冲退还:每日净额不为负,区间合计等于「消耗 − 退还」。 + let net_consumed_mud_points = net_admin_dashboard_consumed_mud_points( + &wallet_stats.consumed_mud_points, + &wallet_stats.refunded_mud_points, + &day_keys, + ); + let generated_assets = asset_stats.generated_assets.total(); - let consumed_mud_points = wallet_stats.consumed_mud_points.total(); + let consumed_mud_points = net_consumed_mud_points.total(); + let refunded_mud_points = wallet_stats.refunded_mud_points.total(); let new_registered_users = user_stats.new_registered_users.total(); let metrics = AdminDashboardMetricsPayload { generated_assets, consumed_mud_points, + refunded_mud_points, total_registered_users: user_stats.total_registered_users, new_registered_users, day1_retention: user_stats.day1_retention.clone(), @@ -2450,8 +2461,16 @@ async fn build_admin_dashboard( "consumed-mud-points", "消耗泥点", "泥点", - &wallet_stats.consumed_mud_points, - wallet_stats.consumed_mud_points.total(), + &net_consumed_mud_points, + net_consumed_mud_points.total(), + &range, + ), + build_admin_dashboard_chart( + "refunded-mud-points", + "退还泥点", + "泥点", + &wallet_stats.refunded_mud_points, + refunded_mud_points, &range, ), build_admin_dashboard_chart( @@ -2719,6 +2738,10 @@ async fn fetch_admin_dashboard_wallet_stats( .consumed_mud_points .add(day_key, amount_delta.unsigned_abs()); } + // 生成失败 / 精选审核返还,以及 LLM Router 的正向冲正流水,都按退还对冲消耗。 + "asset_operation_refund" | "llm_router_consume" if amount_delta > 0 => { + stats.refunded_mud_points.add(day_key, amount_delta as u64); + } "points_recharge" if amount_delta > 0 => { stats.recharged_mud_points = stats .recharged_mud_points @@ -2982,6 +3005,40 @@ impl AdminDashboardSeries { } } +/// 消耗泥点按日对冲退还:先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日。 +/// 抵扣不完的退还直接丢弃,保证每日净额非负,且区间合计等于「消耗 − 退还」。 +fn net_admin_dashboard_consumed_mud_points( + consumed: &AdminDashboardSeries, + refunded: &AdminDashboardSeries, + day_keys: &[i64], +) -> AdminDashboardSeries { + let mut remaining: Vec = day_keys + .iter() + .map(|day_key| consumed.value(*day_key)) + .collect(); + for (index, day_key) in day_keys.iter().enumerate() { + let mut pending = refunded.value(*day_key); + if pending == 0 { + continue; + } + for back in (0..=index).rev() { + if pending == 0 { + break; + } + let applied = remaining[back].min(pending); + remaining[back] -= applied; + pending -= applied; + } + } + let mut series = AdminDashboardSeries::default(); + for (index, day_key) in day_keys.iter().enumerate() { + if remaining[index] > 0 { + series.add(*day_key, remaining[index]); + } + } + series +} + fn build_spacetime_schema_url(server_root: &str, database: &str) -> String { format!("{server_root}/v1/database/{database}/schema?{SPACETIME_SCHEMA_VERSION_QUERY}") } @@ -4786,28 +4843,29 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess #[cfg(test)] mod tests { use super::{ - AdminDashboardGranularity, AdminDisplayNameDirectory, EditorShowcaseAssetRecord, - admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result, - admin_editor_asset_group_payload, admin_editor_asset_payload_from_record, - admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk, - apply_admin_database_table_filters, build_admin_asset_read_url_audit, - build_admin_dashboard_chart, build_admin_database_table_row, - build_admin_editor_showcase_campaign_image_confirm_request, + AdminDashboardGranularity, AdminDashboardSeries, AdminDisplayNameDirectory, + EditorShowcaseAssetRecord, admin_dashboard_user_stats_from_record, + admin_dashboard_user_stats_from_result, admin_editor_asset_group_payload, + admin_editor_asset_payload_from_record, admin_editor_showcase_asset_payload_from_record, + append_spacetime_sql_response_chunk, apply_admin_database_table_filters, + build_admin_asset_read_url_audit, build_admin_dashboard_chart, + build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request, build_admin_external_api_key_sql, build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit, clamp_admin_tracking_event_limit, enforce_admin_request_permission, fetch_admin_database_table_rows, finalize_admin_database_table_rows_response, group_admin_editor_asset_records, hash_admin_password, is_admin_account_not_found, - is_safe_spacetime_table_name, normalize_debug_path, normalize_table_count_error, - paginate_admin_editor_asset_records, parse_admin_database_table_rows_sql_response, - parse_admin_external_api_key_row, parse_admin_tracking_event_keys_sql_response, - parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, - parse_timestamp_text_to_micros, resolve_admin_dashboard_range, - resolve_admin_dashboard_range_at, resolve_admin_database_table_sql_limit, - resolve_admin_editor_asset_filters, timestamp_value_to_micros, trim_preview, - validate_admin_editor_asset_cursor, validate_admin_external_api_key_query, - verify_admin_password, wallet_ledger_source_type_to_string, + is_safe_spacetime_table_name, net_admin_dashboard_consumed_mud_points, + normalize_debug_path, normalize_table_count_error, paginate_admin_editor_asset_records, + parse_admin_database_table_rows_sql_response, parse_admin_external_api_key_row, + parse_admin_tracking_event_keys_sql_response, parse_admin_tracking_events_sql_response, + parse_spacetime_sql_count_response, parse_timestamp_text_to_micros, + resolve_admin_dashboard_range, resolve_admin_dashboard_range_at, + resolve_admin_database_table_sql_limit, resolve_admin_editor_asset_filters, + timestamp_value_to_micros, trim_preview, validate_admin_editor_asset_cursor, + validate_admin_external_api_key_query, verify_admin_password, + wallet_ledger_source_type_to_string, }; use axum::{ http::{Method, StatusCode}, @@ -5732,6 +5790,70 @@ mod tests { ); } + /// 消耗泥点必须按退还对冲,且每日净额非负、区间合计等于「消耗 − 退还」。 + #[test] + fn dashboard_consumption_offsets_refunds_in_the_same_day() { + let mut consumed = AdminDashboardSeries::default(); + consumed.add(100, 40); + let mut refunded = AdminDashboardSeries::default(); + refunded.add(100, 15); + + let net = net_admin_dashboard_consumed_mud_points(&consumed, &refunded, &[100, 101]); + + assert_eq!(net.value(100), 25); + assert_eq!(net.total(), 25); + } + + #[test] + fn dashboard_consumption_refund_offsets_earlier_consumption_day() { + let mut consumed = AdminDashboardSeries::default(); + consumed.add(100, 40); + consumed.add(101, 10); + // 精选审核返还发生在消耗之后的业务日,必须回溯抵扣仍有净额的最近一天。 + let mut refunded = AdminDashboardSeries::default(); + refunded.add(102, 30); + + let net = net_admin_dashboard_consumed_mud_points(&consumed, &refunded, &[100, 101, 102]); + + assert_eq!(net.value(100), 20); + assert_eq!(net.value(101), 0); + assert_eq!(net.value(102), 0); + assert_eq!(net.total(), 20); + } + + #[test] + fn dashboard_consumption_refund_beyond_consumption_stops_at_zero() { + let mut consumed = AdminDashboardSeries::default(); + consumed.add(100, 6); + consumed.add(101, 4); + let mut refunded = AdminDashboardSeries::default(); + refunded.add(101, 40); + + let net = net_admin_dashboard_consumed_mud_points(&consumed, &refunded, &[100, 101]); + + assert_eq!(net.value(100), 0); + assert_eq!(net.value(101), 0); + assert_eq!(net.total(), 0); + } + + #[test] + fn dashboard_consumption_without_refunds_keeps_daily_values() { + let mut consumed = AdminDashboardSeries::default(); + consumed.add(100, 40); + consumed.add(101, 8); + + let net = net_admin_dashboard_consumed_mud_points( + &consumed, + &AdminDashboardSeries::default(), + &[100, 101, 102], + ); + + assert_eq!(net.value(100), 40); + assert_eq!(net.value(101), 8); + assert_eq!(net.value(102), 0); + assert_eq!(net.total(), 48); + } + #[test] fn timestamp_value_to_micros_accepts_sql_shapes() { assert_eq!( diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index f9730feb4..a86e202c7 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -731,7 +731,10 @@ pub struct AdminDashboardRangePayload { #[serde(rename_all = "camelCase")] pub struct AdminDashboardMetricsPayload { pub generated_assets: u64, + /// 已对冲退还(生成失败 / 精选审核返还 / LLM Router 冲正)的净消耗泥点。 pub consumed_mud_points: u64, + /// 同期退还泥点,用于核对「消耗 + 退还」的毛消耗口径。 + pub refunded_mud_points: u64, pub total_registered_users: u64, pub new_registered_users: u64, pub day1_retention: AdminDashboardRetentionPayload, From 1b05a1d05ef2ffabd958d1e85be39c39f05e2008 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:10:20 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=85=85=E5=80=BC?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E5=AE=9E=E4=BB=98=E5=8F=A3=E5=BE=84=E3=80=81?= =?UTF-8?q?=E5=8F=91=E6=94=BE=E6=B3=A5=E7=82=B9=E5=88=97=E3=80=81=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=B4=AF=E8=AE=A1=E5=85=85=E5=80=BC=E4=B8=8E=E5=85=91?= =?UTF-8?q?=E6=8D=A2=E7=A0=81=E5=8D=95=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AdminRechargeOrderEntryPayload.paidAmountCents:只有 paid_at 存在的订单才有实付,未支付 / 已关闭 / 已过期固定为 0 - 充值管理列表把「金额 / 泥点」拆成「实付」「发放泥点」两列,未支付行实付显示「未支付」并附订单金额小字;退款面板「订单实付」改读同一字段 - 用户详情充值订单表新增「发放泥点」列,商品列只保留商品名,实付同样按 paidAmountCents 展示 - 用户详情新增 cumulativeRechargedCents:api-server 按 user_id 读取 profile_recharge_order,只累加 paid_at 存在的订单金额(退款不回减),单次上限 500 行,读取失败或命中上限返回 null - 用户详情身份区新增「累计充值」,读不到时显示「未知」,不用用户详情最多 20 条订单在 BFF 或前端近似重算 - 兑换码奖励单位收口为泥点:输入标签与列表列头改「奖励泥点」,单元格带「泥点」单位,避免被当成元 - 新增后端 2 条累计充值口径单测与前端 2 条用例(未支付不显示实付且发放泥点独立成列、累计充值未知态) - 同步后端架构数据契约(实付口径 / 累计充值来源与上限 / 兑换码奖励单位)与决策记录 - 验证:cargo check -p api-server;cargo test -p api-server --bin api-server admin(138 passed / 0 failed / 1 ignored);npm run admin-web:typecheck;npx vitest run apps/admin-web/src(220 passed);cargo fmt --all --check、check:encoding、check:doc-index、git diff --check 通过 --- apps/admin-web/src/api/adminApiTypes.ts | 4 + .../components/AdminUserDetailDialog.test.tsx | 29 ++++++ .../src/components/AdminUserDetailDialog.tsx | 17 +++- .../src/pages/AdminRechargeOrderPage.test.tsx | 35 ++++++++ .../src/pages/AdminRechargeOrderPage.tsx | 21 ++++- .../src/pages/AdminRedeemCodePage.tsx | 6 +- .../shared-memory/decision-log.md | 11 +++ ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- server-rs/crates/api-server/src/admin.rs | 89 ++++++++++++++++++- .../crates/api-server/src/admin_recharge.rs | 15 +++- .../crates/shared-contracts/src/admin.rs | 4 + 11 files changed, 219 insertions(+), 16 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 85ca7961a..e42df5be4 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -958,6 +958,8 @@ export interface AdminRechargeOrderEntryPayload { productTitle: string; productKind: string; amountCents: number; + /** 真实支付金额(分):未支付 / 已关闭 / 已过期订单固定为 0,不能拿订单金额当实付。 */ + paidAmountCents: number; status: string; paymentChannel: string; paidAtMicros?: number | null; @@ -997,6 +999,8 @@ export interface AdminUserDetailResponse { phoneBound: boolean; wechatBound: boolean; historicalConsumedPoints: number; + /** 累计充值金额(分):读取失败或命中读取上限时为 null,前端按未知展示。 */ + cumulativeRechargedCents?: number | null; canReconcileConsumption: boolean; wallet: AdminProfileWalletPayload; rechargeOrders: AdminRechargeOrderEntryPayload[]; diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx index a8ba9f380..0c14b58be 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx @@ -51,6 +51,7 @@ const detail: AdminUserDetailResponse = { phoneBound: true, wechatBound: true, historicalConsumedPoints: 1234, + cumulativeRechargedCents: 128800, canReconcileConsumption: true, wallet, rechargeOrders: [ @@ -62,6 +63,7 @@ const detail: AdminUserDetailResponse = { productTitle: '60泥点', productKind: 'points', amountCents: 600, + paidAmountCents: 600, status: 'paid', paymentChannel: 'wechat_native', paidAtMicros: 1_720_000_000_000_000, @@ -124,7 +126,11 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退 expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy(); expect(screen.getByText('历史花费')).toBeTruthy(); expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy(); + expect(screen.getByText('累计充值')).toBeTruthy(); + expect(screen.getByText('¥1288.00')).toBeTruthy(); expect(screen.getByText('order-1')).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: '实付' })).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy(); await user.keyboard('{Escape}'); await waitFor(() => @@ -133,6 +139,29 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退 await waitFor(() => expect(document.activeElement).toBe(trigger)); }); +test('累计充值读取不到时展示未知,不用订单列表近似', async () => { + vi.mocked(getAdminUserDetail).mockResolvedValue({ + ...detail, + cumulativeRechargedCents: null, + }); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: '查看用户信息' })); + await screen.findByText('陶泥用户'); + + expect(screen.getByText('累计充值')).toBeTruthy(); + expect(screen.getByText('累计充值').nextElementSibling?.textContent).toBe( + '未知', + ); +}); + test('只有陶泥号时按 publicUserCode 查询用户', async () => { const user = userEvent.setup(); render( diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.tsx index 8aa07d1be..66725d952 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.tsx @@ -364,6 +364,7 @@ export function AdminUserDetailDialog({ 订单 商品 实付 + 发放泥点 退款 状态 @@ -377,11 +378,13 @@ export function AdminUserDetailDialog({ {formatMicros(order.createdAtMicros)} + {order.productTitle || order.productId} - {order.productTitle || order.productId} - 发放 {order.pointsDelta} 泥点 + {order.paidAmountCents > 0 + ? formatMoney(order.paidAmountCents) + : '未支付'} - {formatMoney(order.amountCents)} + {order.pointsDelta} 泥点 {formatMoney(order.cumulativeSuccessRefundCents)} 欠账 {order.unrecoveredPoints} 泥点 @@ -435,6 +438,14 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) {
登录方式
{detail.loginMethod || '-'}
+
+
累计充值
+
+ {typeof detail.cumulativeRechargedCents === 'number' + ? formatMoney(detail.cumulativeRechargedCents) + : '未知'} +
+
绑定状态
diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx index 81e4fcbde..79f150653 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx @@ -64,6 +64,7 @@ const baseOrder: AdminRechargeOrderEntryPayload = { productTitle: '60泥点', productKind: 'points', amountCents: 600, + paidAmountCents: 600, status: 'paid', paymentChannel: 'wechat_native', paidAtMicros: 1_720_000_000_000_000, @@ -129,6 +130,40 @@ beforeEach(() => { ); }); +test('未支付订单不显示实付金额,发放泥点单独成列', async () => { + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [ + { + ...baseOrder, + orderId: 'order-pending', + status: 'pending', + paidAtMicros: null, + paidAmountCents: 0, + pointsDelta: 0, + }, + { ...baseOrder, orderId: 'order-paid' }, + ], + }); + renderPage(); + + expect( + await screen.findByRole('columnheader', { name: '实付' }), + ).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy(); + + const unpaidRow = (await screen.findByText('order-pending')).closest( + 'tr', + ) as HTMLElement; + const unpaidCells = within(unpaidRow).getAllByRole('cell'); + expect(unpaidCells[3]?.textContent).toContain('未支付'); + expect(unpaidCells[4]?.textContent).toBe('0 泥点'); + + const paidRow = screen.getByText('order-paid').closest('tr') as HTMLElement; + const paidCells = within(paidRow).getAllByRole('cell'); + expect(paidCells[3]?.textContent).toBe('¥6.00'); + expect(paidCells[4]?.textContent).toBe('60 泥点'); +}); + test('充值订单查询传递全部筛选字段', async () => { const user = userEvent.setup(); renderPage(); diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index d1f4848b0..04188469c 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -658,7 +658,8 @@ export function AdminRechargeOrderPage({ 用户 订单 支付 - 金额 / 泥点 + 实付 + 发放泥点 退款与追回 钱包 状态 @@ -715,7 +716,7 @@ export function AdminRechargeOrderPage({
- {formatMoney(order.amountCents)} - 发放 {order.pointsDelta} 泥点 + {formatOrderPaidAmount(order)} + {order.paidAmountCents > 0 ? null : ( + 订单 {formatMoney(order.amountCents)} + )} + + + {order.pointsDelta} 泥点 累计 {formatMoney(order.cumulativeSuccessRefundCents)} @@ -1312,6 +1318,13 @@ function formatMoney(cents: number) { return `¥${(cents / 100).toFixed(2)}`; } +/** 实付只属于真正支付过的订单:未支付 / 已关闭 / 已过期订单显示“未支付”。 */ +function formatOrderPaidAmount(order: AdminRechargeOrderEntryPayload) { + return order.paidAmountCents > 0 + ? formatMoney(order.paidAmountCents) + : '未支付'; +} + function formatCentsInput(cents: number) { return (cents / 100).toFixed(2); } diff --git a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx index 0e745a308..1519bb867 100644 --- a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx +++ b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx @@ -217,7 +217,7 @@ export function AdminRedeemCodePage({