2ac445fe11
- userDetail.wallet 去掉已删除的 balance 字段,补齐 totalBalance/dailyFreePoints/monthlyPoints/refundDebtPoints/userId - 与 recharge-orders 路由的 wallet helper、AdminUserDetailDialog 的读取字段保持一致,避免本地预览显示 undefined
1244 lines
35 KiB
JavaScript
1244 lines
35 KiB
JavaScript
// 临时脚本:给本地后台灌入假数据的假 API(仅用于人工查看渲染效果与截图)。
|
||
// 用法:node scripts/tmp-admin-fake-api.mjs [port]
|
||
import http from 'node:http';
|
||
|
||
const port = Number(process.argv[2] ?? 8099);
|
||
|
||
/** 与后端 `MEMBERSHIP_UNLIMITED_CONCURRENCY` 一致的假数据哨兵。
|
||
*
|
||
* 后端定义:`server-rs/crates/module-runtime/src/membership/limits.rs`
|
||
* (`pub const MEMBERSHIP_UNLIMITED_CONCURRENCY: u32 = 128`;`>=` 该值视为不设上限)。
|
||
* 改后端常量时请同步这里,否则本地预览的「不限并发」会与线上不一致。 */
|
||
const UNLIMITED_CONCURRENCY_THRESHOLD = 128;
|
||
|
||
const admin = {
|
||
subject: 'admin:local',
|
||
username: 'owner',
|
||
displayName: '本地体验账号',
|
||
roles: ['admin', 'owner'],
|
||
accountRole: 'owner',
|
||
tabPermissions: [],
|
||
actionPermissions: [],
|
||
issuedAt: new Date(Date.now() - 60_000).toISOString(),
|
||
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||
};
|
||
|
||
const avatar = (text, background) =>
|
||
`data:image/svg+xml;utf8,${encodeURIComponent(
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="20" fill="${background}"/><text x="48" y="60" font-size="40" font-family="sans-serif" text-anchor="middle" fill="#fff">${text}</text></svg>`,
|
||
)}`;
|
||
|
||
const cover = (label, background) =>
|
||
`data:image/svg+xml;utf8,${encodeURIComponent(
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200"><rect width="320" height="200" fill="${background}"/><text x="160" y="112" font-size="28" font-family="sans-serif" text-anchor="middle" fill="#fff">${label}</text></svg>`,
|
||
)}`;
|
||
|
||
const now = Date.now();
|
||
const iso = (offsetMinutes) =>
|
||
new Date(now - offsetMinutes * 60_000).toISOString();
|
||
|
||
/** 素材查询列表里的假素材。 */
|
||
function editorAsset(assetId, label, ownerUserId, authorDisplayName, code) {
|
||
return {
|
||
assetId,
|
||
ownerUserId,
|
||
authorDisplayName,
|
||
authorPublicUserCode: code,
|
||
folderId: 'folder_local',
|
||
label,
|
||
assetObjectId: `${assetId}-object`,
|
||
imageSrc: cover(label, '#b6623f'),
|
||
objectKey: `assets/${assetId}.png`,
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: 'a warm clay-style illustration',
|
||
actualPrompt: 'a warm clay-style illustration, soft light',
|
||
model: 'seedream-4',
|
||
provider: 'vectorengine',
|
||
taskId: `task_${assetId}`,
|
||
groupTaskId: null,
|
||
assetKind: 'image',
|
||
generationInputs: { size: '1024x1024' },
|
||
sourceResourceId: null,
|
||
sourceImageSrc: null,
|
||
generationCostMudPoints: 12,
|
||
refundMudPoints: 0,
|
||
createdAt: iso(60),
|
||
updatedAt: iso(30),
|
||
};
|
||
}
|
||
|
||
/** 精选审核列表里的假素材。 */
|
||
function showcaseAsset(
|
||
showcaseId,
|
||
label,
|
||
ownerUserId,
|
||
authorDisplayName,
|
||
code,
|
||
) {
|
||
return {
|
||
showcaseId,
|
||
assetId: `${showcaseId}_asset`,
|
||
ownerUserId,
|
||
authorDisplayName,
|
||
authorPublicUserCode: code,
|
||
label,
|
||
imageSrc: cover(label, '#8f4b26'),
|
||
objectKey: `showcase/${showcaseId}.png`,
|
||
width: 1024,
|
||
height: 1536,
|
||
prompt: 'steampunk town at dusk',
|
||
actualPrompt: 'steampunk town at dusk, warm clay palette',
|
||
model: 'seedream-4',
|
||
provider: 'vectorengine',
|
||
taskId: `task_${showcaseId}`,
|
||
sourceResourceId: null,
|
||
sourceImageSrc: null,
|
||
sourceObjectKey: null,
|
||
sourceAssetObjectId: null,
|
||
sourceLabel: null,
|
||
assetKind: 'image',
|
||
generationInputs: { size: '1024x1536' },
|
||
thumbnailSrc: null,
|
||
generationCostMudPoints: 16,
|
||
refundMudPoints: 0,
|
||
reviewStatus: 'pending',
|
||
displayEnabled: false,
|
||
likeCount: 12,
|
||
assetDeletedWhilePending: false,
|
||
reviewedByAdminUserId: null,
|
||
reviewNote: null,
|
||
refundLedgerId: null,
|
||
refundCompletedAt: null,
|
||
submittedAt: iso(45),
|
||
};
|
||
}
|
||
|
||
const tableStats = [
|
||
['account_profile', 1284, null],
|
||
['editor_asset', 8632, null],
|
||
['project_snapshot', 412, null],
|
||
['tracking_event', 92_431, null],
|
||
['wallet_ledger', 5281, null],
|
||
['ip_blacklist', null, '不可统计(private 或当前身份不可见)'],
|
||
];
|
||
|
||
const trackingEntries = [
|
||
{
|
||
eventId: 'evt_01HQ8Z3K7M4N2P',
|
||
eventKey: 'daily_login',
|
||
eventTitle: '每日登录',
|
||
scopeKind: 'user',
|
||
scopeId: 'u_1001',
|
||
dayKey: 20260929,
|
||
userId: 'u_1001',
|
||
ownerUserId: null,
|
||
profileId: 'p_1001',
|
||
moduleKey: 'profile',
|
||
metadataJson: '{"channel":"wechat","days":3}',
|
||
occurredAt: iso(12),
|
||
},
|
||
{
|
||
eventId: 'evt_01HQ8Z3K7M4N3Q',
|
||
eventKey: 'editor_generate_image',
|
||
eventTitle: '画板生成图片',
|
||
scopeKind: 'project',
|
||
scopeId: 'proj_7788',
|
||
dayKey: 20260929,
|
||
userId: 'u_1002',
|
||
// 事件用户与项目归属人通常不是同一个人,假数据按这个口径给。
|
||
ownerUserId: 'u_1005',
|
||
profileId: null,
|
||
moduleKey: 'editor',
|
||
metadataJson:
|
||
'{"model":"seedream-4","size":"2048x2048","cost":12,"ok":true,"seed":null}',
|
||
occurredAt: iso(48),
|
||
},
|
||
{
|
||
eventId: 'evt_01HQ8Z3K7M4N4R',
|
||
eventKey: 'agc_project_publish',
|
||
eventTitle: '发布独立 App 游戏',
|
||
scopeKind: 'game',
|
||
scopeId: 'game_9001',
|
||
dayKey: 20260928,
|
||
userId: 'u_1003',
|
||
ownerUserId: null,
|
||
profileId: null,
|
||
moduleKey: 'ai-game-creator',
|
||
metadataJson: '{"version":7,"platform":"windows"}',
|
||
occurredAt: iso(600),
|
||
},
|
||
{
|
||
// 作者在自己项目里生成,事件用户与归属人相同:不该出现第二个重复按钮。
|
||
eventId: 'evt_01HQ8Z3K7M4N5S',
|
||
eventKey: 'editor_generate_video',
|
||
eventTitle: '画板生成视频',
|
||
scopeKind: 'project',
|
||
scopeId: 'proj_9001',
|
||
dayKey: 20260928,
|
||
userId: 'u_1003',
|
||
ownerUserId: 'u_1003',
|
||
profileId: 'p_1003',
|
||
moduleKey: 'editor',
|
||
metadataJson: '{"model":"video-1","seconds":5,"cost":40}',
|
||
occurredAt: iso(900),
|
||
},
|
||
];
|
||
|
||
const inviteEntries = [
|
||
{
|
||
userId: 'u_8801',
|
||
inviteCode: 'TAONIER-VIP-2026',
|
||
metadata: { userTags: ['vip', '内测'] },
|
||
startsAt: iso(60 * 24 * 7),
|
||
expiresAt: null,
|
||
status: 'active',
|
||
createdAt: iso(60 * 24 * 7),
|
||
updatedAt: iso(60 * 24 * 2),
|
||
},
|
||
{
|
||
userId: 'u_8802',
|
||
inviteCode: 'BETA-CREATOR',
|
||
metadata: { userTags: ['creator'] },
|
||
startsAt: null,
|
||
expiresAt: iso(-60 * 24 * 3),
|
||
status: 'active',
|
||
createdAt: iso(60 * 24 * 20),
|
||
updatedAt: iso(60 * 24 * 19),
|
||
},
|
||
{
|
||
userId: 'u_8803',
|
||
inviteCode: 'EXPIRED-CODE',
|
||
metadata: {},
|
||
startsAt: iso(60 * 24 * 40),
|
||
expiresAt: iso(60 * 24 * 10),
|
||
status: 'expired',
|
||
createdAt: iso(60 * 24 * 40),
|
||
updatedAt: iso(60 * 24 * 10),
|
||
},
|
||
];
|
||
|
||
const inviteOperations = [
|
||
{
|
||
operationId: 'op_1',
|
||
codeKind: 'invite',
|
||
code: 'TAONIER-VIP-2026',
|
||
action: 'create',
|
||
operatorUserId: 'u_admin',
|
||
operatorDisplayName: '本地体验账号',
|
||
createdAt: iso(60 * 24 * 7),
|
||
},
|
||
{
|
||
operationId: 'op_2',
|
||
codeKind: 'invite',
|
||
code: 'BETA-CREATOR',
|
||
action: 'update',
|
||
operatorUserId: 'u_admin',
|
||
operatorDisplayName: '本地体验账号',
|
||
createdAt: iso(60 * 24 * 19),
|
||
},
|
||
];
|
||
|
||
const gates = [
|
||
{
|
||
gateKey: 'agc:template-library',
|
||
enabled: true,
|
||
rolloutPercent: 40,
|
||
allowUserIds: ['u_1001', 'u_1002'],
|
||
allowUserTags: ['vip'],
|
||
denyUserIds: [],
|
||
description: 'AGC 模板库灰度',
|
||
updatedAt: iso(120),
|
||
},
|
||
{
|
||
gateKey: 'editor:showcase',
|
||
enabled: false,
|
||
rolloutPercent: 0,
|
||
allowUserIds: [],
|
||
allowUserTags: [],
|
||
denyUserIds: ['u_9001'],
|
||
description: '精选作品投放开关',
|
||
updatedAt: iso(60 * 30),
|
||
},
|
||
];
|
||
|
||
const snapshotItems = [
|
||
{
|
||
userId: 'u_1001',
|
||
projectId: 'proj_7788',
|
||
projectName: '陶泥小世界',
|
||
syncRevision: 12,
|
||
syncedAtMs: now - 1000 * 60 * 26,
|
||
fileCount: 184,
|
||
totalBytes: 24_318_712,
|
||
status: 'ready',
|
||
channel: 'dev',
|
||
authorDisplayName: '阿陶',
|
||
authorPublicUserCode: 'TN-1001',
|
||
},
|
||
{
|
||
userId: 'u_1002',
|
||
projectId: 'proj_8100',
|
||
projectName: '纸片拼图计划',
|
||
syncRevision: 3,
|
||
syncedAtMs: now - 1000 * 60 * 180,
|
||
fileCount: 59,
|
||
totalBytes: 5_120_004,
|
||
status: 'partial',
|
||
channel: 'dev',
|
||
authorDisplayName: '小满',
|
||
authorPublicUserCode: 'TN-1002',
|
||
},
|
||
{
|
||
userId: 'u_1003',
|
||
projectId: 'proj_4412',
|
||
projectName: '三消工坊',
|
||
syncRevision: 27,
|
||
syncedAtMs: now - 1000 * 60 * 60 * 30,
|
||
fileCount: 402,
|
||
totalBytes: 88_120_330,
|
||
status: 'unverified',
|
||
channel: 'dev',
|
||
authorDisplayName: '老周',
|
||
authorPublicUserCode: 'TN-1003',
|
||
},
|
||
];
|
||
|
||
const errorReports = [
|
||
{
|
||
batchId: 'batch_20260929_01',
|
||
eventCount: 6,
|
||
logCount: 2,
|
||
fingerprint: 'fp_9f2a',
|
||
source: 'agc-desktop',
|
||
status: 'new',
|
||
userId: 'u_1002',
|
||
createdAt: iso(45),
|
||
updatedAt: iso(45),
|
||
},
|
||
{
|
||
batchId: 'batch_20260928_07',
|
||
eventCount: 3,
|
||
logCount: 1,
|
||
fingerprint: 'fp_4b71',
|
||
source: 'web',
|
||
status: 'in-progress',
|
||
userId: 'u_1001',
|
||
createdAt: iso(60 * 20),
|
||
updatedAt: iso(60 * 4),
|
||
},
|
||
{
|
||
batchId: 'batch_20260927_02',
|
||
eventCount: 11,
|
||
logCount: 4,
|
||
fingerprint: 'fp_1c05',
|
||
source: 'agc-mobile',
|
||
status: 'resolved',
|
||
userId: 'u_1004',
|
||
createdAt: iso(60 * 40),
|
||
updatedAt: iso(60 * 36),
|
||
},
|
||
];
|
||
|
||
const errorReportDetail = {
|
||
...errorReports[0],
|
||
firstFingerprint: 'fp_9f2a',
|
||
firstSource: 'agc-desktop',
|
||
note: '已定位到随包 Codex 版本过旧,等待渠道重新发布。',
|
||
events: [
|
||
{
|
||
level: 'error',
|
||
message: 'Codex app-server JSON-RPC 失败:items must not be empty',
|
||
},
|
||
{ level: 'warn', message: 'provider request retried once' },
|
||
],
|
||
logNames: ['agc-main.log', 'agc-provider.log'],
|
||
};
|
||
|
||
const dashboard = {
|
||
range: {
|
||
granularity: 'day',
|
||
anchorDate: '2026-09-29',
|
||
periodStartDate: '2026-09-23',
|
||
periodEndDate: '2026-09-29',
|
||
periodLabel: '2026-09-23 ~ 2026-09-29',
|
||
},
|
||
metrics: {
|
||
generatedAssets: 438,
|
||
consumedMudPoints: 96_400,
|
||
refundedMudPoints: 3_120,
|
||
totalRegisteredUsers: 12_480,
|
||
newRegisteredUsers: 128,
|
||
newUserPaymentConversion: {
|
||
paidUsers: 21,
|
||
newRegisteredUsers: 128,
|
||
rateBasisPoints: 1_641,
|
||
},
|
||
day1Retention: {
|
||
eligibleUsers: 1_240,
|
||
retainedUsers: 508,
|
||
rateBasisPoints: 4_096,
|
||
},
|
||
day7Retention: {
|
||
eligibleUsers: 1_102,
|
||
retainedUsers: 198,
|
||
rateBasisPoints: 1_796,
|
||
},
|
||
visitUsers: 942,
|
||
totalVisitUsers: 31_204,
|
||
visitCount: 5_180,
|
||
totalVisitCount: 184_902,
|
||
currentUsers: 63,
|
||
},
|
||
charts: [
|
||
{
|
||
id: 'new_users',
|
||
title: '新增注册',
|
||
unit: '人',
|
||
total: 128,
|
||
buckets: [
|
||
{ key: '2026-09-23', label: '09-23', value: 12 },
|
||
{ key: '2026-09-24', label: '09-24', value: 18 },
|
||
{ key: '2026-09-25', label: '09-25', value: 15 },
|
||
{ key: '2026-09-26', label: '09-26', value: 22 },
|
||
{ key: '2026-09-27', label: '09-27', value: 24 },
|
||
{ key: '2026-09-28', label: '09-28', value: 21 },
|
||
{ key: '2026-09-29', label: '09-29', value: 16 },
|
||
],
|
||
},
|
||
{
|
||
id: 'generated_assets',
|
||
title: '生成素材',
|
||
unit: '个',
|
||
total: 438,
|
||
buckets: [
|
||
{ key: '2026-09-23', label: '09-23', value: 52 },
|
||
{ key: '2026-09-24', label: '09-24', value: 61 },
|
||
{ key: '2026-09-25', label: '09-25', value: 58 },
|
||
{ key: '2026-09-26', label: '09-26', value: 70 },
|
||
{ key: '2026-09-27', label: '09-27', value: 66 },
|
||
{ key: '2026-09-28', label: '09-28', value: 74 },
|
||
{ key: '2026-09-29', label: '09-29', value: 57 },
|
||
],
|
||
},
|
||
],
|
||
operations: {
|
||
cards: [
|
||
{ id: 'paid_orders', label: '支付订单', value: 37, unit: '单' },
|
||
{ id: 'refund_orders', label: '退款订单', value: 4, unit: '单' },
|
||
{ id: 'published_games', label: '公开游戏', value: 12, unit: '个' },
|
||
],
|
||
assetKindBreakdown: [
|
||
{ key: 'image', label: '图片', value: 268 },
|
||
{ key: 'video', label: '视频', value: 74 },
|
||
{ key: 'audio', label: '音频', value: 58 },
|
||
{ key: 'text', label: '文本', value: 38 },
|
||
],
|
||
moduleVisitBreakdown: [
|
||
{ key: 'image-editor', label: '图片画板', value: 2_180 },
|
||
{ key: 'agc', label: '独立 App', value: 1_640 },
|
||
{ key: 'profile', label: '个人中心', value: 1_360 },
|
||
],
|
||
},
|
||
warnings: [],
|
||
generatedAt: new Date().toISOString(),
|
||
};
|
||
|
||
const userDetail = {
|
||
userId: 'u_1001',
|
||
publicUserCode: 'TN-1001',
|
||
displayName: '阿陶',
|
||
avatarUrl: avatar('陶', '#b6623f'),
|
||
phoneBound: true,
|
||
createdAt: iso(60 * 24 * 120),
|
||
lastLoginAt: iso(30),
|
||
wallet: {
|
||
userId: 'u_1001',
|
||
totalBalance: 1860,
|
||
spendableBalance: 1660,
|
||
dailyFreePoints: 0,
|
||
monthlyPoints: 0,
|
||
permanentPoints: 1200,
|
||
heldPoints: 200,
|
||
refundDebtPoints: 0,
|
||
walletFrozen: false,
|
||
manualFrozen: false,
|
||
refundDebtFrozen: false,
|
||
},
|
||
membership: {
|
||
plan: 'plus',
|
||
status: 'active',
|
||
cycleKind: 'monthly',
|
||
cycleIndex: 1,
|
||
cycleCount: 1,
|
||
cycleGrantedPoints: 600,
|
||
cycleRemainingPoints: 320,
|
||
startedAtMicros: 1_790_640_000_000_000,
|
||
expiresAtMicros: 1_793_232_000_000_000,
|
||
cycleResetsAtMicros: 1_793_232_000_000_000,
|
||
updatedAtMicros: 1_790_640_000_000_000,
|
||
},
|
||
rechargeOrders: [],
|
||
restrictions: {},
|
||
tags: ['vip', '内测'],
|
||
};
|
||
|
||
function parseJsonBody(requestBody) {
|
||
try {
|
||
return JSON.parse(requestBody);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function route(pathname, method, url, requestBody = '') {
|
||
if (pathname === '/admin/api/login' && method === 'POST') {
|
||
return { token: 'local-fake-token', admin };
|
||
}
|
||
if (pathname === '/admin/api/me') {
|
||
return { admin };
|
||
}
|
||
if (pathname === '/admin/api/overview') {
|
||
return {
|
||
service: {
|
||
bindHost: '127.0.0.1',
|
||
bindPort: 8082,
|
||
jwtIssuer: 'genarrative-local',
|
||
adminEnabled: true,
|
||
spacetimeServerUrl: 'http://127.0.0.1:3101',
|
||
spacetimeDatabase: 'genarrative-dev',
|
||
},
|
||
database: {
|
||
databaseIdentity: 'local-identity-8f21',
|
||
ownerIdentity: 'owner-identity-4c90',
|
||
hostType: 'standalone',
|
||
schemaTableNames: tableStats.map(([name]) => name),
|
||
tableStats: tableStats.map(([tableName, rowCount, errorMessage]) => ({
|
||
tableName,
|
||
rowCount,
|
||
errorMessage,
|
||
})),
|
||
fetchErrors: [],
|
||
},
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/dashboard') {
|
||
return dashboard;
|
||
}
|
||
if (pathname === '/admin/api/tracking/events') {
|
||
return { entries: trackingEntries, total: trackingEntries.length };
|
||
}
|
||
if (pathname === '/admin/api/tracking/event-keys') {
|
||
return {
|
||
eventKeys: [
|
||
{
|
||
eventKey: 'daily_login',
|
||
eventTitle: '每日登录',
|
||
scopeKinds: ['user'],
|
||
},
|
||
{
|
||
eventKey: 'editor_generate_image',
|
||
eventTitle: '画板生成图片',
|
||
scopeKinds: ['project'],
|
||
},
|
||
{
|
||
eventKey: 'agc_project_publish',
|
||
eventTitle: '发布独立 App 游戏',
|
||
scopeKinds: ['game'],
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/profile/invite-codes') {
|
||
return { entries: inviteEntries, operations: inviteOperations };
|
||
}
|
||
if (pathname === '/admin/api/profile/tasks') {
|
||
return {
|
||
entries: [
|
||
{
|
||
taskId: 'task_daily_login',
|
||
title: '每日登录',
|
||
description: '登录后到任务中心领取',
|
||
eventKey: 'daily_login',
|
||
cycle: 'daily',
|
||
scopeKind: 'user',
|
||
threshold: 1,
|
||
rewardPoints: 20,
|
||
enabled: true,
|
||
sortOrder: 1,
|
||
createdBy: 'u_admin',
|
||
createdByDisplayName: '本地体验账号',
|
||
createdAt: iso(60 * 24 * 30),
|
||
updatedBy: 'u_admin',
|
||
updatedByDisplayName: '本地体验账号',
|
||
updatedAt: iso(60 * 6),
|
||
},
|
||
{
|
||
taskId: 'task_generate_image',
|
||
title: '生成 3 张图片',
|
||
description: '画板生成成功后计数',
|
||
eventKey: 'editor_generate_image',
|
||
cycle: 'daily',
|
||
scopeKind: 'project',
|
||
threshold: 3,
|
||
rewardPoints: 60,
|
||
enabled: true,
|
||
sortOrder: 2,
|
||
createdBy: 'u_admin',
|
||
createdByDisplayName: '本地体验账号',
|
||
createdAt: iso(60 * 24 * 12),
|
||
updatedBy: 'u_admin',
|
||
updatedByDisplayName: '本地体验账号',
|
||
updatedAt: iso(60 * 20),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/agc/tracking-events') {
|
||
return {
|
||
entries: [
|
||
{
|
||
eventId: 'agc_evt_01',
|
||
schemaVersion: 1,
|
||
eventName: 'workspace_opened',
|
||
eventTime: iso(25),
|
||
receivedAt: iso(24),
|
||
userId: 'u_1001',
|
||
editorSessionId: 'session_8801',
|
||
projectId: 'proj_7788',
|
||
creativeTaskId: null,
|
||
agentRunId: null,
|
||
agentTurnId: null,
|
||
status: 'ok',
|
||
errorCode: null,
|
||
source: 'desktop',
|
||
clientVersion: '0.1.158',
|
||
properties: { workspace: 'project-development' },
|
||
batchId: 'batch_agc_01',
|
||
},
|
||
{
|
||
eventId: 'agc_evt_02',
|
||
schemaVersion: 1,
|
||
eventName: 'codex_runner_started',
|
||
eventTime: iso(90),
|
||
receivedAt: iso(89),
|
||
userId: 'u_1002',
|
||
editorSessionId: 'session_8802',
|
||
projectId: 'proj_8100',
|
||
creativeTaskId: 'task_5566',
|
||
agentRunId: 'run_778',
|
||
agentTurnId: 'turn_1',
|
||
status: 'failed',
|
||
errorCode: 'RUNNER_SPAWN_FAILED',
|
||
source: 'desktop',
|
||
clientVersion: '0.1.154',
|
||
properties: { exitCode: -1073741502 },
|
||
batchId: 'batch_agc_02',
|
||
},
|
||
],
|
||
nextCursor: 'agc-cursor-2',
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/feature-gates') {
|
||
return { gates };
|
||
}
|
||
if (pathname === '/admin/api/profile/recharge-orders') {
|
||
const wallet = (overrides) => ({
|
||
userId: 'u_1001',
|
||
totalBalance: 1_860,
|
||
spendableBalance: 1_660,
|
||
dailyFreePoints: 0,
|
||
monthlyPoints: 0,
|
||
permanentPoints: 1_200,
|
||
heldPoints: 200,
|
||
refundDebtPoints: 0,
|
||
manualFrozen: false,
|
||
refundDebtFrozen: false,
|
||
walletFrozen: false,
|
||
manualRestriction: null,
|
||
...overrides,
|
||
});
|
||
const orders = [
|
||
{
|
||
orderId: 'order_20260929_01',
|
||
userId: 'user-1',
|
||
user: {
|
||
userId: 'user-1',
|
||
publicUserCode: 'TN-1001',
|
||
displayName: '阿陶',
|
||
avatarUrl: null,
|
||
},
|
||
productId: 'prod_points_600',
|
||
productTitle: '600 泥点',
|
||
productKind: 'points',
|
||
amountCents: 3_000,
|
||
paidAmountCents: 3_000,
|
||
status: 'paid',
|
||
paymentChannel: 'wechat_native',
|
||
paidAtMicros: 1_790_640_000_000_000,
|
||
providerTransactionId: 'wx_42000012342026092901',
|
||
createdAtMicros: 1_790_640_000_000_000,
|
||
pointsDelta: 600,
|
||
cumulativeSuccessRefundCents: 0,
|
||
targetRecoveryPoints: 0,
|
||
recoveredPoints: 0,
|
||
unrecoveredPoints: 0,
|
||
recoveryStatus: null,
|
||
refunds: [],
|
||
activeHold: null,
|
||
remainingRefundableCents: 3_000,
|
||
refundEligible: true,
|
||
refundBlockReasonCode: null,
|
||
wallet: wallet({}),
|
||
},
|
||
{
|
||
orderId: 'order_20260928_07',
|
||
userId: 'user-2',
|
||
user: {
|
||
userId: 'user-2',
|
||
publicUserCode: 'TN-1002',
|
||
displayName: '小满',
|
||
avatarUrl: null,
|
||
},
|
||
productId: 'prod_points_1200',
|
||
productTitle: '1200 泥点 + 赠送 100',
|
||
productKind: 'points',
|
||
amountCents: 6_000,
|
||
paidAmountCents: 6_000,
|
||
status: 'refunded',
|
||
paymentChannel: 'wechat_mp',
|
||
paidAtMicros: 1_790_560_000_000_000,
|
||
providerTransactionId: 'wx_42000012342026092807',
|
||
createdAtMicros: 1_790_560_000_000_000,
|
||
pointsDelta: 1_300,
|
||
cumulativeSuccessRefundCents: 6_000,
|
||
targetRecoveryPoints: 1_300,
|
||
recoveredPoints: 980,
|
||
unrecoveredPoints: 320,
|
||
recoveryStatus: 'partial',
|
||
refunds: [
|
||
{
|
||
refundId: 'refund_01',
|
||
outRefundNo: 'out_refund_01',
|
||
status: 'succeeded',
|
||
amountCents: 6_000,
|
||
reason: '用户申请退款',
|
||
createdAtMicros: 1_790_570_000_000_000,
|
||
updatedAtMicros: 1_790_580_000_000_000,
|
||
providerRefundId: 'wx_refund_01',
|
||
lastErrorCode: null,
|
||
operatorDisplayName: '本地体验账号',
|
||
},
|
||
],
|
||
activeHold: null,
|
||
remainingRefundableCents: 0,
|
||
refundEligible: false,
|
||
refundBlockReasonCode: 'already_refunded',
|
||
wallet: wallet({
|
||
userId: 'user-2',
|
||
totalBalance: 320,
|
||
spendableBalance: 0,
|
||
permanentPoints: 0,
|
||
heldPoints: 320,
|
||
refundDebtPoints: 320,
|
||
refundDebtFrozen: true,
|
||
walletFrozen: true,
|
||
}),
|
||
},
|
||
];
|
||
return {
|
||
orders,
|
||
total: orders.length,
|
||
limit: 50,
|
||
offset: 0,
|
||
scannedCount: orders.length,
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/profile/membership-plans') {
|
||
const plan = (
|
||
planId,
|
||
title,
|
||
rank,
|
||
monthPriceCents,
|
||
yearPriceCents,
|
||
periodPoints,
|
||
modelAccess,
|
||
concurrentJobLimit,
|
||
enabled,
|
||
) => ({
|
||
plan: planId,
|
||
title,
|
||
rank,
|
||
monthPriceCents,
|
||
yearPriceCents,
|
||
periodPoints,
|
||
modelAccess,
|
||
concurrentJobLimit,
|
||
unlimitedConcurrency:
|
||
concurrentJobLimit >= UNLIMITED_CONCURRENCY_THRESHOLD,
|
||
enabled,
|
||
});
|
||
const plans = [
|
||
plan('normal', '非会员', 0, 0, 0, 0, 'basic', 1, false),
|
||
plan('starter', '入门会员', 1, 3_900, 39_000, 400, 'basic', 1, true),
|
||
plan('plus', '轻享会员', 2, 9_900, 99_000, 1_150, 'full', 3, true),
|
||
plan('pro', '专业会员', 3, 29_900, 299_000, 3_650, 'full', 5, true),
|
||
plan('max', '旗舰会员', 4, 69_900, 699_000, 8_650, 'full', 128, true),
|
||
];
|
||
// 本地预览按请求体回显被保存的档位:把提交字段覆盖到对应档位行,避免保存非 starter 档后表单被回填成 starter。
|
||
if (method === 'POST') {
|
||
const submitted = parseJsonBody(requestBody);
|
||
const target =
|
||
plans.find((entry) => entry.plan === submitted?.plan) ?? plans[1];
|
||
const merged = { ...target, ...(submitted ?? {}) };
|
||
return {
|
||
...merged,
|
||
unlimitedConcurrency:
|
||
Number(merged.concurrentJobLimit) >= UNLIMITED_CONCURRENCY_THRESHOLD,
|
||
};
|
||
}
|
||
return {
|
||
entries: plans,
|
||
unlimitedConcurrencyThreshold: UNLIMITED_CONCURRENCY_THRESHOLD,
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/profile/recharge-products') {
|
||
const product = (productId, title, priceCents, pointsAmount) => ({
|
||
productId,
|
||
title,
|
||
priceCents,
|
||
kind: 'points',
|
||
pointsAmount,
|
||
// 已退役列:真实后端固定写 0,假数据也保持一致。
|
||
bonusPoints: 0,
|
||
durationDays: 0,
|
||
badgeLabel: '',
|
||
description: `${pointsAmount} 泥点`,
|
||
enabled: true,
|
||
sortOrder: 1,
|
||
createdBy: 'u_admin',
|
||
createdAt: iso(60 * 24 * 30),
|
||
updatedBy: 'u_admin',
|
||
updatedAt: iso(60 * 24 * 2),
|
||
});
|
||
return {
|
||
entries: [
|
||
product('prod_points_600', '600 泥点', 3_000, 600),
|
||
product('prod_points_1200', '1200 泥点', 6_000, 1_200),
|
||
],
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/profile/redeem-codes') {
|
||
return {
|
||
entries: [
|
||
{
|
||
userId: 'u_9001',
|
||
code: 'WELCOME-600',
|
||
rewardPoints: 600,
|
||
mode: 'once',
|
||
metadata: { campaign: '2026-09 新玩家' },
|
||
startsAt: iso(60 * 24 * 30),
|
||
expiresAt: null,
|
||
status: 'active',
|
||
createdAt: iso(60 * 24 * 30),
|
||
updatedAt: iso(60 * 24 * 3),
|
||
},
|
||
{
|
||
userId: 'u_9002',
|
||
code: 'SUMMER-200',
|
||
rewardPoints: 200,
|
||
mode: 'once',
|
||
metadata: {},
|
||
startsAt: null,
|
||
expiresAt: iso(-60 * 24 * 5),
|
||
status: 'expired',
|
||
createdAt: iso(60 * 24 * 90),
|
||
updatedAt: iso(60 * 24 * 88),
|
||
},
|
||
],
|
||
operations: [
|
||
{
|
||
operationId: 'op_redeem_1',
|
||
codeKind: 'redeem',
|
||
code: 'WELCOME-600',
|
||
action: 'create',
|
||
operatorUserId: 'u_admin',
|
||
operatorDisplayName: '本地体验账号',
|
||
createdAt: iso(60 * 24 * 30),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/accounts') {
|
||
return {
|
||
accounts: [
|
||
{
|
||
accountId: 'acc_owner',
|
||
username: 'owner',
|
||
displayName: '本地体验账号',
|
||
accountRole: 'owner',
|
||
enabled: true,
|
||
tabPermissions: [],
|
||
createdAt: iso(60 * 24 * 200),
|
||
updatedAt: iso(60 * 24 * 2),
|
||
},
|
||
{
|
||
accountId: 'acc_ops',
|
||
username: 'ops_chen',
|
||
displayName: '陈运营',
|
||
accountRole: 'member',
|
||
enabled: true,
|
||
tabPermissions: ['tracking', 'error-reports', 'invite'],
|
||
createdAt: iso(60 * 24 * 90),
|
||
updatedAt: iso(60 * 24 * 5),
|
||
},
|
||
{
|
||
accountId: 'acc_review',
|
||
username: 'review_liu',
|
||
displayName: '刘审核',
|
||
accountRole: 'member',
|
||
enabled: false,
|
||
tabPermissions: ['editor-showcase', 'game-distribution'],
|
||
createdAt: iso(60 * 24 * 45),
|
||
updatedAt: iso(60 * 24 * 30),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/database/tables') {
|
||
return {
|
||
tables: [...tableStats.map(([name]) => name), 'external_api_key'],
|
||
fetchErrors: [],
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/external-api-keys') {
|
||
const keys = [
|
||
{
|
||
keyId: 'key_01',
|
||
ownerUserId: 'u_1001',
|
||
publicUserCode: 'TN-1001',
|
||
name: '画板自动化',
|
||
keyPrefix: 'gnr_live_9f2a',
|
||
scopes: ['canvas.write', 'asset.read'],
|
||
createdAt: iso(60 * 24 * 30),
|
||
lastUsedAt: iso(90),
|
||
revokedAt: null,
|
||
updatedAt: iso(90),
|
||
status: 'active',
|
||
},
|
||
{
|
||
keyId: 'key_02',
|
||
ownerUserId: 'u_1002',
|
||
publicUserCode: 'TN-1002',
|
||
name: '外部编辑器(已撤销)',
|
||
keyPrefix: 'gnr_live_4b71',
|
||
scopes: ['canvas.read'],
|
||
createdAt: iso(60 * 24 * 60),
|
||
lastUsedAt: iso(60 * 24 * 20),
|
||
revokedAt: iso(60 * 24 * 19),
|
||
updatedAt: iso(60 * 24 * 19),
|
||
status: 'revoked',
|
||
},
|
||
];
|
||
const sortColumn = url.searchParams.get('sortColumn') ?? 'createdAt';
|
||
const sortDirection =
|
||
(url.searchParams.get('sortDirection') ?? 'desc') === 'asc' ? 1 : -1;
|
||
const sorted = [...keys].sort(
|
||
(left, right) =>
|
||
String(left[sortColumn] ?? '').localeCompare(
|
||
String(right[sortColumn] ?? ''),
|
||
'zh-CN',
|
||
) * sortDirection,
|
||
);
|
||
return {
|
||
keys: sorted,
|
||
total: sorted.length,
|
||
limit: Number(url.searchParams.get('limit') ?? 50) || 50,
|
||
offset: Number(url.searchParams.get('offset') ?? 0) || 0,
|
||
scannedCount: sorted.length,
|
||
scanLimit: 500,
|
||
scanLimitReached: false,
|
||
};
|
||
}
|
||
if (/^\/admin\/api\/database\/tables\/[^/]+\/rows$/.test(pathname)) {
|
||
const columns = [
|
||
'invitee_user_id',
|
||
'inviter_user_id',
|
||
'invite_code',
|
||
'bound_at',
|
||
];
|
||
const allRows = [
|
||
{
|
||
key: 'row_1',
|
||
cells: {
|
||
invitee_user_id: 'u_1001',
|
||
inviter_user_id: 'u_1002',
|
||
invite_code: 'TAONIER-VIP-2026',
|
||
bound_at: iso(120),
|
||
},
|
||
},
|
||
{
|
||
key: 'row_2',
|
||
cells: {
|
||
invitee_user_id: 'u_1003',
|
||
inviter_user_id: 'u_1002',
|
||
invite_code: 'TAONIER-VIP-2026',
|
||
bound_at: iso(600),
|
||
},
|
||
},
|
||
{
|
||
key: 'row_3',
|
||
cells: {
|
||
invitee_user_id: 'u_1004',
|
||
inviter_user_id: 'u_1005',
|
||
invite_code: 'BETA-CREATOR',
|
||
bound_at: iso(2880),
|
||
},
|
||
},
|
||
];
|
||
// 后台的排序是服务端排序,假 API 也按 sortColumn / sortDirection 重排,
|
||
// 否则本地点表头看不出任何变化。
|
||
const sortColumn = url.searchParams.get('sortColumn') ?? '';
|
||
const sortDirection =
|
||
url.searchParams.get('sortDirection') === 'desc' ? -1 : 1;
|
||
const sortedRows = sortColumn
|
||
? [...allRows].sort((left, right) => {
|
||
const l = String(left.cells[sortColumn] ?? '');
|
||
const r = String(right.cells[sortColumn] ?? '');
|
||
return l.localeCompare(r, 'zh-CN') * sortDirection;
|
||
})
|
||
: allRows;
|
||
const limit = Number(url.searchParams.get('limit') ?? 100) || 100;
|
||
const page = Number(url.searchParams.get('page') ?? 1) || 1;
|
||
const pageRows = sortedRows.slice((page - 1) * limit, page * limit);
|
||
return {
|
||
tableName: 'profile_referral_relation',
|
||
columns,
|
||
rows: pageRows,
|
||
totalReturned: pageRows.length,
|
||
limit,
|
||
page,
|
||
totalMatched: sortedRows.length,
|
||
scannedCount: sortedRows.length,
|
||
scanLimit: 50_000,
|
||
scanLimitReached: false,
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/agc-templates') {
|
||
return {
|
||
revision: 'rev_local_7',
|
||
writable: true,
|
||
templates: [
|
||
{
|
||
id: 'tpl_platformer',
|
||
title: '平台跳跃模板',
|
||
summary: '横版跳跃基础关卡、金币与终点判定,可直接改名改封面。',
|
||
tags: ['横版', '内置美术'],
|
||
runtime: 'web',
|
||
engine: 'Genarrative Runtime',
|
||
engineVersion: '1.4.0',
|
||
templateVersion: '3',
|
||
enabled: true,
|
||
coverUrl: cover('平台跳跃', '#b6623f'),
|
||
zipSizeBytes: 4_318_712,
|
||
},
|
||
{
|
||
id: 'tpl_puzzle',
|
||
title: '拼图关模板',
|
||
summary: '带参考图的拼图关卡,支持难度与提示次数配置。',
|
||
tags: ['拼图', '休闲'],
|
||
runtime: 'web',
|
||
engine: 'Genarrative Runtime',
|
||
engineVersion: '1.4.0',
|
||
templateVersion: '2',
|
||
enabled: false,
|
||
coverUrl: cover('拼图关', '#8f4b26'),
|
||
zipSizeBytes: 2_004_120,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/agc-models') {
|
||
return {
|
||
revision: 7,
|
||
defaultModelId: 'model_sol',
|
||
models: [
|
||
{
|
||
id: 'model_sol',
|
||
alias: '标准模型',
|
||
modelId: 'gpt-5.6-sol',
|
||
enabled: true,
|
||
access: 'full',
|
||
},
|
||
{
|
||
id: 'model_terra',
|
||
alias: '快速模型',
|
||
modelId: 'gpt-5.6-terra',
|
||
enabled: true,
|
||
access: 'basic',
|
||
},
|
||
{
|
||
id: 'model_legacy',
|
||
alias: '旧版模型',
|
||
modelId: 'gpt-5.4',
|
||
enabled: false,
|
||
access: 'basic',
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/game-distribution/games') {
|
||
const game = (gameId, title, authorName, status, playCount, versions) => ({
|
||
gameId,
|
||
title,
|
||
author: { id: `u_${gameId}`, name: authorName, avatarUrl: null },
|
||
status,
|
||
versionCount: versions.length,
|
||
playCount,
|
||
activeVersionId: versions[0]?.versionId ?? null,
|
||
publicationRevision: versions[0]?.versionNumber ?? 0,
|
||
createdAt: iso(60 * 24 * 20),
|
||
updatedAt: iso(60 * 3),
|
||
deletedAt: null,
|
||
versions,
|
||
});
|
||
const version = (versionId, versionNumber, status, bytes) => ({
|
||
versionId,
|
||
versionNumber,
|
||
status,
|
||
packageBytes: bytes,
|
||
packageSha256: `${versionId}0f4e9d1c7b3a5e8f2b6d4c9a1e7f3b5d8c2a4e6f`,
|
||
createdAt: iso(60 * 24 * 5),
|
||
reviewedAt: iso(60 * 24 * 4),
|
||
publishedAt: iso(60 * 24 * 3),
|
||
reviewReason: null,
|
||
entryUrl: `https://games.example.com/${versionId}/index.html`,
|
||
});
|
||
const allGames = [
|
||
game('game_1001', '陶泥小世界', '阿陶', 'published', 345, [
|
||
version('ver_1001_7', 7, 'published', 18_400_000),
|
||
version('ver_1001_6', 6, 'published', 17_900_000),
|
||
]),
|
||
game('game_1002', '纸片拼图计划', '小满', 'suspended', 96, [
|
||
version('ver_1002_3', 3, 'suspended', 5_120_004),
|
||
]),
|
||
game('game_1003', '三消工坊', '老周', 'unpublished', 12, []),
|
||
{
|
||
...game('game_1004', '已删除的试作', '阿陶', 'unpublished', 4, []),
|
||
status: 'deleted',
|
||
deletedAt: iso(60 * 2),
|
||
},
|
||
];
|
||
const limit = Math.min(
|
||
Math.max(Number(url.searchParams.get('limit') ?? 50) || 50, 1),
|
||
200,
|
||
);
|
||
const keyword = (url.searchParams.get('keyword') ?? '')
|
||
.trim()
|
||
.toLowerCase();
|
||
const owner = (url.searchParams.get('owner') ?? '').trim();
|
||
const status = (url.searchParams.get('status') ?? '').trim();
|
||
const offset = Number(url.searchParams.get('cursor') ?? 0) || 0;
|
||
// 状态/关键词/作者过滤与游标语义跟服务端保持一致:默认排除已删除作品。
|
||
const filtered = allGames.filter((entry) => {
|
||
if (status === 'deleted') {
|
||
if (entry.status !== 'deleted') return false;
|
||
} else if (status) {
|
||
if (entry.status !== status || entry.deletedAt) return false;
|
||
} else if (entry.deletedAt) {
|
||
return false;
|
||
}
|
||
if (owner && entry.author.id !== owner) return false;
|
||
if (
|
||
keyword &&
|
||
!entry.title.toLowerCase().includes(keyword) &&
|
||
!entry.gameId.toLowerCase().includes(keyword) &&
|
||
!entry.author.id.toLowerCase().includes(keyword)
|
||
) {
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
const page = filtered.slice(offset, offset + limit);
|
||
const nextOffset = offset + page.length;
|
||
return {
|
||
games: page,
|
||
nextCursor: nextOffset < filtered.length ? String(nextOffset) : null,
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/editor-assets') {
|
||
return {
|
||
entries: [
|
||
editorAsset('asset_01', '红发精灵立绘', 'u_1001', '阿陶', 'TN-1001'),
|
||
editorAsset('asset_02', '森林场景背景', 'u_1002', '小满', 'TN-1002'),
|
||
],
|
||
nextCursor: null,
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/editor-showcase/assets') {
|
||
return {
|
||
entries: [
|
||
showcaseAsset(
|
||
'showcase_01',
|
||
'蒸汽朋克小镇',
|
||
'u_1002',
|
||
'小满',
|
||
'TN-1002',
|
||
),
|
||
showcaseAsset('showcase_02', '纸片森林', 'u_1003', '老周', 'TN-1003'),
|
||
],
|
||
nextCursor: null,
|
||
};
|
||
}
|
||
if (pathname === '/admin/api/project-snapshots/channels') {
|
||
return { defaultChannel: 'dev', channels: ['dev', 'release'] };
|
||
}
|
||
if (pathname === '/admin/api/project-snapshots') {
|
||
return { items: snapshotItems, nextCursor: 'cursor-2' };
|
||
}
|
||
if (pathname === '/admin/api/error-reports') {
|
||
return {
|
||
reports: errorReports,
|
||
total: errorReports.length,
|
||
offset: 0,
|
||
limit: 20,
|
||
hasMore: false,
|
||
};
|
||
}
|
||
if (pathname.startsWith('/admin/api/error-reports/')) {
|
||
return errorReportDetail;
|
||
}
|
||
if (pathname === '/admin/api/profile/users/detail') {
|
||
return userDetail;
|
||
}
|
||
return {};
|
||
}
|
||
|
||
const server = http.createServer((request, response) => {
|
||
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`);
|
||
const chunks = [];
|
||
request.on('data', (chunk) => {
|
||
chunks.push(chunk);
|
||
});
|
||
request.on('end', () => {
|
||
const requestBody = Buffer.concat(chunks).toString('utf8');
|
||
const payload = route(
|
||
url.pathname,
|
||
request.method ?? 'GET',
|
||
url,
|
||
requestBody,
|
||
);
|
||
const body = JSON.stringify({ data: payload });
|
||
response.writeHead(200, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'x-genarrative-response-envelope': 'v1',
|
||
});
|
||
response.end(body);
|
||
});
|
||
});
|
||
|
||
server.listen(port, '127.0.0.1', () => {
|
||
console.log(`fake admin api on http://127.0.0.1:${port}`);
|
||
});
|