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] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=85=85=E5=80=BC=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E5=AE=9E=E4=BB=98=E5=8F=A3=E5=BE=84=E3=80=81=E5=8F=91?= =?UTF-8?q?=E6=94=BE=E6=B3=A5=E7=82=B9=E5=88=97=E3=80=81=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=B4=AF=E8=AE=A1=E5=85=85=E5=80=BC=E4=B8=8E=E5=85=91=E6=8D=A2?= =?UTF-8?q?=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({