增加 Admin 游戏化数据只读查看

新增 5 个 Admin 端点查看用户金币钱包、道具库存、奖励流水、
广告恢复记录和资源变更流水,全部只读 GET,支持分页。
路由注册在 /v1/admin/gamification/ 下。
This commit is contained in:
Wang Zhuoxuan 2026-05-13 22:24:23 +08:00
parent cff1c148de
commit f64c8e2fe4
4 changed files with 161 additions and 1 deletions

View File

@ -121,7 +121,7 @@
|---|------|------|----------|
| G6-1 | 更新 `docs/api-reference.md` | [x] | 文档只保留最终客户端契约,包含挑战组、奖励、商店、背包、周榜、错误码 |
| G6-2 | 更新 `docs/implementation-plan.md` | [x] | 将本计划作为 Phase 1d 或 Game Economy 阶段索引进去 |
| G6-3 | 增加 Admin 配置或只读查看能力 | [ ] | 管理端至少能查看用户金币、道具、奖励流水、广告恢复记录 |
| G6-3 | 增加 Admin 配置或只读查看能力 | [x] | 管理端至少能查看用户金币、道具、奖励流水、广告恢复记录 |
| G6-4 | 增加 E2E 或集成测试 | [ ] | 覆盖游客登录、完成挑战组、广告恢复、购买道具、周榜查询 |
| G6-5 | 增加定时任务入口 | [ ] | 周榜结算和订阅/资源周期任务有可部署入口,支持手动 dry-run |
| G6-6 | 完成最终验证 | [ ] | `bun run typecheck`、`bun run test`、`bun run lint` 通过或记录明确环境阻塞 |

View File

@ -0,0 +1,47 @@
import { FastifyInstance } from 'fastify';
import * as gamificationService from '../../services/admin/gamification-service.js';
export async function adminGamificationRoutes(app: FastifyInstance): Promise<void> {
// ── 用户金币钱包 ─────────────────────────────────────────────
app.get('/users/:id/wallet', async (request) => {
const { id } = request.params as { id: string };
const data = await gamificationService.getUserWallet(id);
return { success: true, data, error: null };
});
// ── 用户道具库存 ─────────────────────────────────────────────
app.get('/users/:id/inventory', async (request) => {
const { id } = request.params as { id: string };
const data = await gamificationService.getUserInventory(id);
return { success: true, data, error: null };
});
// ── 用户奖励流水 ─────────────────────────────────────────────
app.get('/users/:id/rewards', async (request) => {
const { id } = request.params as { id: string };
const { page = '1', limit = '20' } = request.query as Record<string, string>;
const result = await gamificationService.getUserRewardLedger(id, Number(page), Number(limit));
return { success: true, data: result.items, pagination: result.pagination, error: null };
});
// ── 用户广告恢复记录 ─────────────────────────────────────────
app.get('/users/:id/ad-recovery', async (request) => {
const { id } = request.params as { id: string };
const { page = '1', limit = '20' } = request.query as Record<string, string>;
const result = await gamificationService.getUserAdRecoverySessions(id, Number(page), Number(limit));
return { success: true, data: result.items, pagination: result.pagination, error: null };
});
// ── 用户资源变更流水 ─────────────────────────────────────────
app.get('/users/:id/transactions', async (request) => {
const { id } = request.params as { id: string };
const { page = '1', limit = '20' } = request.query as Record<string, string>;
const result = await gamificationService.getUserInventoryTransactions(id, Number(page), Number(limit));
return { success: true, data: result.items, pagination: result.pagination, error: null };
});
}

View File

@ -8,6 +8,7 @@ import { adminSkillTreeRoutes } from './skill-tree.js';
import { adminUsersRoutes } from './users.js';
import { adminStatsRoutes } from './stats.js';
import { adminFeedbackRoutes } from './feedback.js';
import { adminGamificationRoutes } from './gamification.js';
export async function adminRoutes(app: FastifyInstance): Promise<void> {
app.register(adminAuthRoutes);
@ -19,4 +20,5 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
app.register(adminUsersRoutes, { prefix: '/users' });
app.register(adminStatsRoutes, { prefix: '/stats' });
app.register(adminFeedbackRoutes, { prefix: '/feedback' });
app.register(adminGamificationRoutes, { prefix: '/gamification' });
}

View File

@ -0,0 +1,111 @@
import { db } from '../../db/client.js';
import { adRecoverySessions, inventoryTransactions, rewardLedger, userInventoryItems, userWallets } from '../../db/schema.js';
import { desc, eq, sql } from 'drizzle-orm';
// ── 用户钱包 ────────────────────────────────────────────────────────
export async function getUserWallet(userId: string) {
const [wallet] = await db
.select()
.from(userWallets)
.where(eq(userWallets.userId, userId))
.limit(1);
return wallet ?? null;
}
// ── 用户道具库存 ────────────────────────────────────────────────────
export async function getUserInventory(userId: string) {
return db
.select()
.from(userInventoryItems)
.where(eq(userInventoryItems.userId, userId))
.orderBy(desc(userInventoryItems.quantity));
}
// ── 用户奖励流水 ────────────────────────────────────────────────────
export async function getUserRewardLedger(
userId: string,
page = 1,
limit = 20,
): Promise<{ items: unknown[]; pagination: { total: number; page: number; limit: number } }> {
const offset = (page - 1) * limit;
const [items, countResult] = await Promise.all([
db
.select()
.from(rewardLedger)
.where(eq(rewardLedger.userId, userId))
.orderBy(desc(rewardLedger.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`COUNT(*)` })
.from(rewardLedger)
.where(eq(rewardLedger.userId, userId)),
]);
return {
items,
pagination: { total: Number(countResult[0]?.count ?? 0), page, limit },
};
}
// ── 用户广告恢复记录 ────────────────────────────────────────────────
export async function getUserAdRecoverySessions(
userId: string,
page = 1,
limit = 20,
): Promise<{ items: unknown[]; pagination: { total: number; page: number; limit: number } }> {
const offset = (page - 1) * limit;
const [items, countResult] = await Promise.all([
db
.select()
.from(adRecoverySessions)
.where(eq(adRecoverySessions.userId, userId))
.orderBy(desc(adRecoverySessions.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`COUNT(*)` })
.from(adRecoverySessions)
.where(eq(adRecoverySessions.userId, userId)),
]);
return {
items,
pagination: { total: Number(countResult[0]?.count ?? 0), page, limit },
};
}
// ── 用户资源变更流水 ────────────────────────────────────────────────
export async function getUserInventoryTransactions(
userId: string,
page = 1,
limit = 20,
): Promise<{ items: unknown[]; pagination: { total: number; page: number; limit: number } }> {
const offset = (page - 1) * limit;
const [items, countResult] = await Promise.all([
db
.select()
.from(inventoryTransactions)
.where(eq(inventoryTransactions.userId, userId))
.orderBy(desc(inventoryTransactions.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`COUNT(*)` })
.from(inventoryTransactions)
.where(eq(inventoryTransactions.userId, userId)),
]);
return {
items,
pagination: { total: Number(countResult[0]?.count ?? 0), page, limit },
};
}