Files
Genarrative/scripts/admin-web-fake-api.mjs
T
k88936 a48b7f8de8
Project CI / Backend tests (pull_request) Failing after 48s
Project CI / Native shell tests (pull_request) Failing after 3m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m21s
Project CI / Frontend tests (pull_request) Failing after 47s
Project CI / AI game creator shell web tests (pull_request) Failing after 39s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m6s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m23s
Project CI / Repository checks (pull_request) Successful in 6m31s
Merge remote-tracking branch 'origin/master' into feat/pricing-plan
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
2026-10-05 10:50:30 +08:00

1245 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 临时脚本:给本地后台灌入假数据的假 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: {
balance: 1860,
permanentPoints: 1200,
spendableBalance: 1660,
heldPoints: 200,
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} 泥点`,
tier: 'none',
membershipPeriodPoints: 0,
membershipPeriodDays: 0,
membershipQueueLimit: 0,
membershipDiscountBps: 0,
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}`);
});