Merge remote-tracking branch 'origin/master' into fix/acl-elevation-single-flight
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m9s
Project CI / Backend tests (pull_request) Successful in 4m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m14s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m4s
Project CI / Frontend tests (pull_request) Successful in 2m27s
Project CI / Native shell tests (pull_request) Successful in 6m16s
Project CI / Repository checks (pull_request) Successful in 2m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m45s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m9s
Project CI / Backend tests (pull_request) Successful in 4m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m14s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m4s
Project CI / Frontend tests (pull_request) Successful in 2m27s
Project CI / Native shell tests (pull_request) Successful in 6m16s
Project CI / Repository checks (pull_request) Successful in 2m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m45s
This commit is contained in:
@@ -193,7 +193,10 @@ export interface AdminDashboardRangePayload {
|
||||
|
||||
export interface AdminDashboardMetricsPayload {
|
||||
generatedAssets: number;
|
||||
/** 已对冲退还(生成失败 / 精选审核返还 / LLM Router 冲正)的净消耗泥点。 */
|
||||
consumedMudPoints: number;
|
||||
/** 同期退还泥点,用于核对「消耗 + 退还」的毛消耗口径。 */
|
||||
refundedMudPoints: number;
|
||||
totalRegisteredUsers: number;
|
||||
newRegisteredUsers: number;
|
||||
newUserPaymentConversion: AdminDashboardPaymentConversionPayload;
|
||||
@@ -955,6 +958,8 @@ export interface AdminRechargeOrderEntryPayload {
|
||||
productTitle: string;
|
||||
productKind: string;
|
||||
amountCents: number;
|
||||
/** 真实支付金额(分):未支付 / 已关闭 / 已过期订单固定为 0,不能拿订单金额当实付。 */
|
||||
paidAmountCents: number;
|
||||
status: string;
|
||||
paymentChannel: string;
|
||||
paidAtMicros?: number | null;
|
||||
@@ -994,6 +999,8 @@ export interface AdminUserDetailResponse {
|
||||
phoneBound: boolean;
|
||||
wechatBound: boolean;
|
||||
historicalConsumedPoints: number;
|
||||
/** 累计充值金额(分):读取失败或命中读取上限时为 null,前端按未知展示。 */
|
||||
cumulativeRechargedCents?: number | null;
|
||||
canReconcileConsumption: boolean;
|
||||
wallet: AdminProfileWalletPayload;
|
||||
rechargeOrders: AdminRechargeOrderEntryPayload[];
|
||||
|
||||
@@ -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(
|
||||
<AdminUserReferenceButton
|
||||
token="admin-token"
|
||||
userId="user-1"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
|
||||
@@ -364,6 +364,7 @@ export function AdminUserDetailDialog({
|
||||
<th>订单</th>
|
||||
<th>商品</th>
|
||||
<th>实付</th>
|
||||
<th>发放泥点</th>
|
||||
<th>退款</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
@@ -377,11 +378,13 @@ export function AdminUserDetailDialog({
|
||||
</span>
|
||||
<small>{formatMicros(order.createdAtMicros)}</small>
|
||||
</td>
|
||||
<td>{order.productTitle || order.productId}</td>
|
||||
<td>
|
||||
{order.productTitle || order.productId}
|
||||
<small>发放 {order.pointsDelta} 泥点</small>
|
||||
{order.paidAmountCents > 0
|
||||
? formatMoney(order.paidAmountCents)
|
||||
: '未支付'}
|
||||
</td>
|
||||
<td>{formatMoney(order.amountCents)}</td>
|
||||
<td>{order.pointsDelta} 泥点</td>
|
||||
<td>
|
||||
{formatMoney(order.cumulativeSuccessRefundCents)}
|
||||
<small>欠账 {order.unrecoveredPoints} 泥点</small>
|
||||
@@ -435,6 +438,14 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) {
|
||||
<dt>登录方式</dt>
|
||||
<dd>{detail.loginMethod || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>累计充值</dt>
|
||||
<dd>
|
||||
{typeof detail.cumulativeRechargedCents === 'number'
|
||||
? formatMoney(detail.cumulativeRechargedCents)
|
||||
: '未知'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>绑定状态</dt>
|
||||
<dd>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}新增用户数`,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -658,7 +658,8 @@ export function AdminRechargeOrderPage({
|
||||
<th>用户</th>
|
||||
<th>订单</th>
|
||||
<th>支付</th>
|
||||
<th>金额 / 泥点</th>
|
||||
<th>实付</th>
|
||||
<th>发放泥点</th>
|
||||
<th>退款与追回</th>
|
||||
<th>钱包</th>
|
||||
<th>状态</th>
|
||||
@@ -715,7 +716,7 @@ export function AdminRechargeOrderPage({
|
||||
<div className="admin-refund-summary-grid">
|
||||
<Metric
|
||||
label="订单实付"
|
||||
value={formatMoney(refundOrder.amountCents)}
|
||||
value={formatMoney(refundOrder.paidAmountCents)}
|
||||
/>
|
||||
<Metric
|
||||
label="累计已退"
|
||||
@@ -1118,8 +1119,13 @@ function RechargeOrderRow({
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{formatMoney(order.amountCents)}</strong>
|
||||
<small>发放 {order.pointsDelta} 泥点</small>
|
||||
<strong>{formatOrderPaidAmount(order)}</strong>
|
||||
{order.paidAmountCents > 0 ? null : (
|
||||
<small>订单 {formatMoney(order.amountCents)}</small>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span>{order.pointsDelta} 泥点</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>累计 {formatMoney(order.cumulativeSuccessRefundCents)}</span>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ export function AdminRedeemCodePage({
|
||||
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>奖励点数</span>
|
||||
<span>奖励泥点</span>
|
||||
<input
|
||||
min={1}
|
||||
step={1}
|
||||
@@ -319,7 +319,7 @@ export function AdminRedeemCodePage({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>奖励</th>
|
||||
<th>奖励泥点</th>
|
||||
<th>状态</th>
|
||||
<th>有效期</th>
|
||||
</tr>
|
||||
@@ -337,7 +337,7 @@ export function AdminRedeemCodePage({
|
||||
</button>
|
||||
<small>{redeemModeLabel(entry.mode)}</small>
|
||||
</td>
|
||||
<td>{entry.rewardPoints}</td>
|
||||
<td>{entry.rewardPoints} 泥点</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-status ${redeemValidityClass(entry)}`}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -9277,3 +9277,22 @@ 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`。
|
||||
|
||||
## 2026-09-23 后台充值订单实付口径、发放泥点列、用户累计充值与兑换码单位
|
||||
|
||||
- 背景:充值管理列表与用户详情把订单金额当实付展示,未支付订单也显示非 0 实付;发放泥点挤在「金额 / 泥点」一格或商品列小字里;用户详情看不到该用户累计充值额度;兑换码页的奖励数字没有单位,运营无法判断是元还是泥点。
|
||||
- 决策(实付只有支付过的订单才有):`AdminRechargeOrderEntryPayload` 新增 `paidAmountCents`,由 api-server 按订单 `paid_at` 是否存在判定——存在才等于订单金额,未支付 / 已关闭 / 已过期固定为 0。后台前端实付列显示 `未支付`(并附订单金额小字),退款面板「订单实付」读同一字段;订单金额 `amountCents` 不再被当作实付。
|
||||
- 决策(发放泥点独立成列):充值管理列表的表头由「金额 / 泥点」拆成「实付」与「发放泥点」两列,用户详情充值订单表同样新增「发放泥点」列,商品列只保留商品名;未支付订单发放为 0 泥点,与实付口径一致。
|
||||
- 决策(累计充值由后端算):用户详情新增 `cumulativeRechargedCents`,api-server 按 `user_id` 读取 `profile_recharge_order`、只累加 `paid_at` 存在的订单金额(退款不回减),单次读取上限 500 行;读取失败或命中上限返回 `null`,前端显示「读取失败」,不用用户详情最多 20 条订单在 BFF 或前端近似重算。此次只新增 BFF 字段,未改 SpacetimeDB 表结构与 procedure。
|
||||
- 决策(兑换码奖励是泥点):兑换码 `rewardPoints` 是奖励泥点(兑换成功按 `redeem_code_reward` 流水进钱包),后台输入标签改为「奖励泥点」、列表列头与单元格都带「泥点」单位。
|
||||
- 影响范围:`server-rs/crates/api-server/src/{admin.rs,admin_recharge.rs}`、`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/api/adminApiTypes.ts`、`apps/admin-web/src/pages/{AdminRechargeOrderPage.tsx,AdminRedeemCodePage.tsx}`、`apps/admin-web/src/components/AdminUserDetailDialog.tsx`、对应三个用例文件与后端架构数据契约文档。
|
||||
- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server cumulative_recharge`(2 条新用例)与 `cargo check -p api-server`;`npm run admin-web:typecheck`;`npx vitest run apps/admin-web/src`(220 passed),其中三个定向文件 33 passed(新增「未支付订单不显示实付金额,发放泥点单独成列」与「累计充值读取不到时展示未知,不用订单列表近似」)。
|
||||
- 边界(未验证):未连真实生产库核对历史订单的累计充值数值,也未跑真实栈 API smoke。
|
||||
|
||||
@@ -5947,3 +5947,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`。
|
||||
|
||||
@@ -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,前端百分比显示 `-`。汇总率按总人数加权,不平均每日百分比。
|
||||
- 运营汇总页签:复用同一时间窗,展示运营指标卡、素材类型分布和访问模块分布。
|
||||
- 趋势图:`消耗泥点` 图展示按日对冲之后的净额,区间合计与「消耗泥点数」指标一致;`退还泥点` 图展示同期退还金额,毛消耗可由「消耗 + 退还」核出,避免把退还金额藏进净额里。
|
||||
|
||||
## 前后端文件
|
||||
|
||||
|
||||
@@ -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,不驱动更新摘要。
|
||||
|
||||
@@ -176,7 +176,7 @@ npm run check:server-rs-ddd
|
||||
22. 后台主动退款只支持 `wechat_mp`、`wechat_jsapi`、`wechat_h5`、`wechat_native` 普通 V3 泥点订单。`api-server` 必须先按正式支付渠道和商品类型拦截不支持的订单,再做微信支付订单查单预检,然后调用 SpacetimeDB procedure 原子创建退款 hold;只有 hold 成功才允许调用微信退款。`wechat_mp_virtual`、历史非正式渠道值、会员、未支付、对账未完成、退款已满额、人工冻结、退款欠账或永久泥点不足必须在调用普通 V3 provider 前 fail-closed。
|
||||
23. `profile_recharge_refund_hold` 以稳定 `out_refund_no` 为主键,保存订单、用户、本次退款金额、占用永久泥点、管理员、原因和 `active / settled / released` 状态。重试只有在订单、`out_refund_no`、退款金额、管理员和归一化原因全部与原 hold 一致时才可复用,任一不一致都按幂等内容冲突 fail-closed,不能用新原因调用微信后保留旧审计。部分退款的 hold 在累计应追回增量之外额外保留 1 泥点并发舍入缓冲,全额退款不加缓冲;活动 hold 不改变钱包总额,但普通钱包消费必须预留全部活动 hold;成功退款 observation 扣款并结算匹配 hold,关闭退款释放 hold,外部退款追回不得消耗其他活动 hold。
|
||||
24. 退款欠账继续以 `profile_recharge_order_refund_settlement.unrecovered_points` 为唯一真相;不新增平行 debt 累计。`profile_wallet_manual_restriction` 只保存人工冻结,普通消费同时检查人工冻结与退款欠账。后续永久泥点到账后继续偿还欠账,每日免费与会员周期泥点不参与;解除人工冻结不得清除退款欠账限制。
|
||||
25. 管理员充值订单、用户详情、历史花费手动对账、退款预检/执行、应急退款号登记、退款人工复核和钱包冻结接口只留在 `api-server` 管理员鉴权路由。用户详情中的历史花费泥点数读取 `profile_wallet_consumption_total` 投影;已有投影时,每次 `asset_operation_consume` 负向流水落账在同一事务内按主键 O(1) 原子累加,退款不回减,充值退款追回、余额重置、赠送和 hold 均不计入。首次上线必须在停止业务写入的维护窗口内,由 owner 调用 `POST /admin/api/profile/users/initialize-consumption-projections`,一次扫描全部权威钱包流水,为每个已有钱包流水的用户初始化存量投影;接口成功后才能恢复流量。维护遗漏或新用户缺行时,首次消费按该用户索引一次性重建(当前消费流水已经在同一事务中,不能重复加本次金额);钱包详情首次读取也保留同一按用户兜底。`POST /admin/api/profile/users/reconcile-consumption` 是显式手动对账入口:owner 始终可用,member 必须单独持有 `profile-wallet-consumption-reconcile` 操作权限,任何一级 Tab 都不自动附带;用户详情只在后端返回 `canReconcileConsumption=true` 时展示按钮。操作经二次确认后扫描该用户全部权威钱包流水、比较并校准投影,同时记录管理员和对账时间。退款人工复核 BFF 必须从管理员会话写入操作人,要求非空原因,返回微信退款交易号、订单总额以及获批错误码等正式审计字段,并调用 runtime service identity 受限 procedure;后台确认面板必须展示这些后端事实,前端不得自行改 settlement、钱包冻结或消费累计。外部微信副作用由 `platform-wechat` 执行,退款/hold/钱包事务留在 `spacetime-module`,后台前端只展示 BFF 返回的正式状态。
|
||||
25. 管理员充值订单、用户详情、历史花费手动对账、退款预检/执行、应急退款号登记、退款人工复核和钱包冻结接口只留在 `api-server` 管理员鉴权路由。用户详情中的历史花费泥点数读取 `profile_wallet_consumption_total` 投影;已有投影时,每次 `asset_operation_consume` 负向流水落账在同一事务内按主键 O(1) 原子累加,退款不回减,充值退款追回、余额重置、赠送和 hold 均不计入。首次上线必须在停止业务写入的维护窗口内,由 owner 调用 `POST /admin/api/profile/users/initialize-consumption-projections`,一次扫描全部权威钱包流水,为每个已有钱包流水的用户初始化存量投影;接口成功后才能恢复流量。维护遗漏或新用户缺行时,首次消费按该用户索引一次性重建(当前消费流水已经在同一事务中,不能重复加本次金额);钱包详情首次读取也保留同一按用户兜底。`POST /admin/api/profile/users/reconcile-consumption` 是显式手动对账入口:owner 始终可用,member 必须单独持有 `profile-wallet-consumption-reconcile` 操作权限,任何一级 Tab 都不自动附带;用户详情只在后端返回 `canReconcileConsumption=true` 时展示按钮。操作经二次确认后扫描该用户全部权威钱包流水、比较并校准投影,同时记录管理员和对账时间。退款人工复核 BFF 必须从管理员会话写入操作人,要求非空原因,返回微信退款交易号、订单总额以及获批错误码等正式审计字段,并调用 runtime service identity 受限 procedure;后台确认面板必须展示这些后端事实,前端不得自行改 settlement、钱包冻结或消费累计。外部微信副作用由 `platform-wechat` 执行,退款/hold/钱包事务留在 `spacetime-module`,后台前端只展示 BFF 返回的正式状态。充值订单实付金额(`paidAmountCents`)同样由后端判定:只有 `paid_at` 存在的订单才有实付,未支付、已关闭、已过期订单固定为 `0`,后台前端不得拿订单金额(`amountCents`)顶替实付;订单发放泥点(`pointsDelta`)必须与实付分开成列展示。用户详情的累计充值金额(`cumulativeRechargedCents`)由 api-server 按 `user_id` 受控读取 `profile_recharge_order`、只累加 `paid_at` 存在的订单 `amount_cents`(退款不回减)得出;读取失败或命中单次读取上限时返回 `null`,前端按未知展示,不得用用户详情最多 20 条订单在 BFF 或前端近似重算。
|
||||
|
||||
## 用户钱包与编辑器生成扣费契约
|
||||
|
||||
@@ -767,7 +767,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- Rust 结构体:`ProfileRedeemCode`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 生效时间:复用邀请码时间窗口语义,`starts_at` / `expires_at` 均可为空;两者同时存在时必须满足 `starts_at < expires_at`。开始时刻计入有效区间,截止时刻不计入有效区间。用户兑换时以后端接收的 `redeemed_at_micros` 判定:未到开始时间拒绝为“兑换码未生效”,到达或超过截止时间拒绝为“兑换码已过期”。
|
||||
- 后台契约:`AdminUpsertProfileRedeemCodeRequest` 通过可空 `startsAt` / `expiresAt` 接收 RFC3339 时间,列表与保存响应同步返回这两个字段;后台页只负责输入、回填和显示,真正兑换判定留在后端事务路径。
|
||||
- 后台契约:`AdminUpsertProfileRedeemCodeRequest` 通过可空 `startsAt` / `expiresAt` 接收 RFC3339 时间,列表与保存响应同步返回这两个字段;后台页只负责输入、回填和显示,真正兑换判定留在后端事务路径。`reward_points` 是奖励泥点(兑换成功后按 `redeem_code_reward` 流水进入泥点钱包),不是金额;后台奖励输入与列表必须带泥点单位。
|
||||
|
||||
### `profile_redeem_code_usage`
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ use spacetime_client::{
|
||||
};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::sync::{OnceCell, Semaphore};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
admin_accounts::normalize_admin_account_username,
|
||||
@@ -2383,6 +2384,7 @@ struct AdminDashboardAssetStats {
|
||||
#[derive(Default)]
|
||||
struct AdminDashboardWalletStats {
|
||||
consumed_mud_points: AdminDashboardSeries,
|
||||
refunded_mud_points: AdminDashboardSeries,
|
||||
recharged_mud_points: u64,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
@@ -2419,12 +2421,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 +2462,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 +2739,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
|
||||
@@ -2809,6 +2833,61 @@ async fn fetch_admin_dashboard_rows(state: &AppState, sql: &str) -> Result<Vec<V
|
||||
extract_first_sql_rows(payload)
|
||||
}
|
||||
|
||||
/// 用户详情累计充值的单次读取上限:单个用户的历史订单远少于该值。
|
||||
const ADMIN_USER_RECHARGE_SUMMARY_ROW_LIMIT: u32 = 500;
|
||||
|
||||
/// 读取用户累计充值金额(分):扫描该用户全部充值订单,只累加真实支付过的订单。
|
||||
/// 口径与 module 的 `profile_recharge_order_counts_as_paid_purchase` 一致(`paid_at` 存在),
|
||||
/// 未支付 / 已关闭 / 已过期订单不计,退款也不回减。读取失败或命中读取上限时返回 None,
|
||||
/// 由调用方与前端按“未知”展示,不能把部分结果当累计值。
|
||||
pub(crate) async fn fetch_admin_user_cumulative_recharged_cents(
|
||||
state: &AppState,
|
||||
user_id: &str,
|
||||
) -> Option<u64> {
|
||||
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<Timestamp>` 返回成 `[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,
|
||||
@@ -2982,6 +3061,40 @@ impl AdminDashboardSeries {
|
||||
}
|
||||
}
|
||||
|
||||
/// 消耗泥点按日对冲退还:先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日。
|
||||
/// 抵扣不完的退还直接丢弃,保证每日净额非负,且区间合计等于「消耗 − 退还」。
|
||||
fn net_admin_dashboard_consumed_mud_points(
|
||||
consumed: &AdminDashboardSeries,
|
||||
refunded: &AdminDashboardSeries,
|
||||
day_keys: &[i64],
|
||||
) -> AdminDashboardSeries {
|
||||
let mut remaining: Vec<u64> = 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,26 +4899,27 @@ 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,
|
||||
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,
|
||||
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,
|
||||
};
|
||||
@@ -5732,6 +5846,97 @@ 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);
|
||||
}
|
||||
|
||||
/// 累计充值只累加真实支付过的订单:未支付订单的 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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -1127,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<i64>,
|
||||
@@ -1172,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<u64>,
|
||||
pub can_reconcile_consumption: bool,
|
||||
pub wallet: AdminProfileWalletPayload,
|
||||
pub recharge_orders: Vec<AdminRechargeOrderEntryPayload>,
|
||||
|
||||
Reference in New Issue
Block a user