diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index d4815e7..7c2e369 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -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` 通过或记录明确环境阻塞 | diff --git a/src/routes/admin/gamification.ts b/src/routes/admin/gamification.ts new file mode 100644 index 0000000..b4a3356 --- /dev/null +++ b/src/routes/admin/gamification.ts @@ -0,0 +1,47 @@ +import { FastifyInstance } from 'fastify'; +import * as gamificationService from '../../services/admin/gamification-service.js'; + +export async function adminGamificationRoutes(app: FastifyInstance): Promise { + // ── 用户金币钱包 ───────────────────────────────────────────── + + 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; + 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; + 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; + const result = await gamificationService.getUserInventoryTransactions(id, Number(page), Number(limit)); + return { success: true, data: result.items, pagination: result.pagination, error: null }; + }); +} diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index 76bc38a..39f4ea5 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -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 { app.register(adminAuthRoutes); @@ -19,4 +20,5 @@ export async function adminRoutes(app: FastifyInstance): Promise { app.register(adminUsersRoutes, { prefix: '/users' }); app.register(adminStatsRoutes, { prefix: '/stats' }); app.register(adminFeedbackRoutes, { prefix: '/feedback' }); + app.register(adminGamificationRoutes, { prefix: '/gamification' }); } diff --git a/src/services/admin/gamification-service.ts b/src/services/admin/gamification-service.ts new file mode 100644 index 0000000..e963e64 --- /dev/null +++ b/src/services/admin/gamification-service.ts @@ -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`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`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`COUNT(*)` }) + .from(inventoryTransactions) + .where(eq(inventoryTransactions.userId, userId)), + ]); + + return { + items, + pagination: { total: Number(countResult[0]?.count ?? 0), page, limit }, + }; +}