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({
|
- {entry.rewardPoints} |
+ {entry.rewardPoints} 泥点 |
Result Option {
+ let sql = format!(
+ "SELECT amount_cents, paid_at FROM profile_recharge_order WHERE user_id = {} LIMIT {}",
+ quote_sql_string(user_id),
+ ADMIN_USER_RECHARGE_SUMMARY_ROW_LIMIT
+ );
+ let rows = match fetch_admin_dashboard_rows(state, &sql).await {
+ Ok(rows) => rows,
+ Err(message) => {
+ warn!(user_id, error = %message, "读取用户累计充值失败");
+ return None;
+ }
+ };
+ if rows.len() >= ADMIN_USER_RECHARGE_SUMMARY_ROW_LIMIT as usize {
+ warn!(
+ user_id,
+ limit = ADMIN_USER_RECHARGE_SUMMARY_ROW_LIMIT,
+ "用户累计充值命中单次读取上限,按未知处理"
+ );
+ return None;
+ }
+ Some(sum_admin_user_recharged_cents(&rows))
+}
+
+/// 汇总 `profile_recharge_order` 查询行:只累加 `paid_at` 存在(真实支付过)的订单金额。
+/// SpacetimeDB HTTP SQL 会把 `Option` 返回成 `[micros]`、`[0, micros]` 或 `[1, []]` 等 SATS 形态。
+fn sum_admin_user_recharged_cents(rows: &[Value]) -> u64 {
+ let mut total = 0_u64;
+ for row in rows {
+ let Some(columns) = row.as_array() else {
+ continue;
+ };
+ let Some(amount_cents) = columns.first().and_then(value_to_i64) else {
+ continue;
+ };
+ let paid = columns
+ .get(1)
+ .is_some_and(|value| timestamp_value_to_micros(value).is_some());
+ if paid && amount_cents > 0 {
+ total = total.saturating_add(amount_cents as u64);
+ }
+ }
+ total
+}
+
fn build_admin_dashboard_chart(
id: &str,
title: &str,
@@ -4863,9 +4919,9 @@ mod tests {
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,
+ sum_admin_user_recharged_cents, 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},
@@ -5854,6 +5910,33 @@ mod tests {
assert_eq!(net.total(), 48);
}
+ /// 累计充值只累加真实支付过的订单:未支付订单的 paid_at 是 SATS None,不能计入。
+ #[test]
+ fn cumulative_recharge_counts_only_paid_orders() {
+ let rows = vec![
+ json!([6000, [1778207451731746_i64]]),
+ json!([1200, [0, [1778207451731746_i64]]]),
+ json!([9900, [1, []]]),
+ json!([8800, null]),
+ json!([300, "2026-05-07T08:30:51.731746Z"]),
+ json!([1000, [1, []]]),
+ ];
+
+ assert_eq!(sum_admin_user_recharged_cents(&rows), 7500);
+ }
+
+ #[test]
+ fn cumulative_recharge_ignores_malformed_rows() {
+ let rows = vec![
+ json!("not-a-row"),
+ json!(["abc", [123]]),
+ json!([]),
+ json!([0, [123]]),
+ ];
+
+ assert_eq!(sum_admin_user_recharged_cents(&rows), 0);
+ }
+
#[test]
fn timestamp_value_to_micros_accepts_sql_shapes() {
assert_eq!(
diff --git a/server-rs/crates/api-server/src/admin_recharge.rs b/server-rs/crates/api-server/src/admin_recharge.rs
index 28492509e..950e53d50 100644
--- a/server-rs/crates/api-server/src/admin_recharge.rs
+++ b/server-rs/crates/api-server/src/admin_recharge.rs
@@ -45,7 +45,10 @@ use shared_contracts::admin::{
use spacetime_client::SpacetimeClientError;
use crate::{
- admin::{AdminDisplayNameDirectory, AuthenticatedAdmin, load_admin_display_name_directory},
+ admin::{
+ AdminDisplayNameDirectory, AuthenticatedAdmin, fetch_admin_user_cumulative_recharged_cents,
+ load_admin_display_name_directory,
+ },
api_response::json_success_body,
http_error::AppError,
request_context::RequestContext,
@@ -153,6 +156,8 @@ pub async fn admin_get_user_detail(
let admin_display_names = load_admin_display_name_directory(&state)
.await
.map_err(|error| spacetime_error_response(&request_context, error))?;
+ let cumulative_recharged_cents =
+ fetch_admin_user_cumulative_recharged_cents(&state, &user.id).await;
Ok(json_success_body(
Some(&request_context),
@@ -167,6 +172,7 @@ pub async fn admin_get_user_detail(
phone_bound: user.phone_number_masked.is_some(),
wechat_bound: user.wechat_bound,
historical_consumed_points: wallet_detail.historical_consumed_points,
+ cumulative_recharged_cents,
can_reconcile_consumption: admin.can(ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE),
wallet: map_wallet(wallet_detail.wallet, Some(&admin_display_names)),
recharge_orders: orders
@@ -935,6 +941,12 @@ fn map_order_entry(
let settlement = entry.settlement.as_ref();
let remaining_refundable_cents = remaining_refundable_cents(&entry);
let block_reason = order_refund_block_reason(&entry).map(str::to_string);
+ // 未支付订单不能拿订单金额当实付;只有 paid_at 存在的订单才有实付金额。
+ let paid_amount_cents = if entry.order.paid_at_micros.is_some() {
+ entry.order.amount_cents
+ } else {
+ 0
+ };
AdminRechargeOrderEntryPayload {
order_id: entry.order.order_id,
user_id: entry.order.user_id,
@@ -943,6 +955,7 @@ fn map_order_entry(
product_title: entry.order.product_title,
product_kind: entry.order.kind.as_str().to_string(),
amount_cents: entry.order.amount_cents,
+ paid_amount_cents,
status: entry.order.status.as_str().to_string(),
payment_channel: entry.order.payment_channel,
paid_at_micros: entry.order.paid_at_micros,
diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs
index a86e202c7..19ed5f06b 100644
--- a/server-rs/crates/shared-contracts/src/admin.rs
+++ b/server-rs/crates/shared-contracts/src/admin.rs
@@ -1130,6 +1130,8 @@ pub struct AdminRechargeOrderEntryPayload {
pub product_title: String,
pub product_kind: String,
pub amount_cents: u64,
+ /// 真实支付金额(分):只有支付过的订单才有实付,未支付 / 已关闭 / 已过期订单固定为 0。
+ pub paid_amount_cents: u64,
pub status: String,
pub payment_channel: String,
pub paid_at_micros: Option,
@@ -1175,6 +1177,8 @@ pub struct AdminUserDetailResponse {
pub phone_bound: bool,
pub wechat_bound: bool,
pub historical_consumed_points: u64,
+ /// 累计充值金额(分):该用户全部支付过的充值订单金额之和;读取失败或命中读取上限时为 None。
+ pub cumulative_recharged_cents: Option,
pub can_reconcile_consumption: bool,
pub wallet: AdminProfileWalletPayload,
pub recharge_orders: Vec,
|