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

This commit is contained in:
2026-09-23 20:13:56 +08:00
19 changed files with 510 additions and 35 deletions
+7
View File
@@ -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',