diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 06e3189..126f4c9 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -31,7 +31,7 @@ |---|------|------|----------| | G0-1 | 梳理游戏化规则常量模块 | [x] | 新增集中规则定义,覆盖红心、挑战组、XP、等级、金币、道具、广告恢复、周榜周期 | | G0-2 | 新增挑战组数据模型 | [x] | 支持 challenge session、session answers、组状态、正确数、完成时间、幂等提交 | -| G0-3 | 新增钱包和道具库存模型 | [ ] | 支持金币余额、道具库存、道具获得/消耗流水 | +| G0-3 | 新增钱包和道具库存模型 | [x] | 支持金币余额、道具库存、道具获得/消耗流水 | | G0-4 | 新增奖励流水模型 | [ ] | 记录奖励来源、幂等 key、奖励快照、发放前后状态 | | G0-5 | 新增每日任务或每日进度模型 | [ ] | 可统计每日首组挑战、每日任务完成、每日高奖励次数 | | G0-6 | 新增周 XP 统计模型或扩展周榜快照 | [ ] | 可按自然周统计 XP,支持每周一刷新和历史快照 | diff --git a/src/db/schema.ts b/src/db/schema.ts index 0a126c5..474fb07 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -210,6 +210,58 @@ export const challengeSessionAnswers = mysqlTable('challenge_session_answers', { foreignKey({ columns: [table.questionId], foreignColumns: [questions.id] }), ]); +// ── Wallets and Inventory ────────────────────────────────────────── + +// 用户金币钱包,所有余额变化必须通过流水记录追踪。 +export const userWallets = mysqlTable('user_wallets', { + userId: char('user_id', { length: 36 }).primaryKey(), + coinsBalance: int('coins_balance').default(0), // 当前金币余额。 + lifetimeCoinsEarned: int('lifetime_coins_earned').default(0), // 历史累计获得金币。 + lifetimeCoinsSpent: int('lifetime_coins_spent').default(0), // 历史累计消耗金币。 + createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。 + updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。 +}, (table) => [ + foreignKey({ columns: [table.userId], foreignColumns: [users.id] }), +]); + +// 用户道具库存,以 item_id 聚合当前可用数量和最近有效期。 +export const userInventoryItems = mysqlTable('user_inventory_items', { + id: char('id', { length: 36 }).primaryKey(), + userId: char('user_id', { length: 36 }).notNull(), + itemId: mysqlEnum('item_id', ['streak_shield', 'double_xp_potion', 'heart_supply', 'hint_feather', 'mascot_outfit']).notNull(), // 道具标识。 + quantity: int('quantity').default(0), // 当前库存数量。 + activeUntil: datetime('active_until'), // 时效型道具的生效截止时间。 + metadata: json('metadata').$type>(), // 装扮、头像框等展示权益的扩展信息。 + createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。 + updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。 +}, (table) => [ + uniqueIndex('uk_inventory_user_item').on(table.userId, table.itemId), + index('idx_inventory_user_active').on(table.userId, table.activeUntil), + foreignKey({ columns: [table.userId], foreignColumns: [users.id] }), +]); + +// 钱包和道具库存流水,记录金币与道具的获得、消耗和调整来源。 +export const inventoryTransactions = mysqlTable('inventory_transactions', { + id: char('id', { length: 36 }).primaryKey(), + userId: char('user_id', { length: 36 }).notNull(), + inventoryItemId: char('inventory_item_id', { length: 36 }), + itemId: mysqlEnum('item_id', ['coins', 'streak_shield', 'double_xp_potion', 'heart_supply', 'hint_feather', 'mascot_outfit']).notNull(), // 流水涉及的资源。 + direction: mysqlEnum('direction', ['grant', 'consume', 'adjust']).notNull(), // 获得、消耗或运营调整。 + quantityDelta: int('quantity_delta').notNull(), // 资源数量变化,消耗为负数。 + balanceAfter: int('balance_after'), // 变更后的金币余额或道具库存。 + sourceType: mysqlEnum('source_type', ['challenge', 'daily_task', 'level_up', 'theme_node', 'chest', 'shop_purchase', 'ad_recovery', 'subscription', 'admin_grant', 'system_adjust']).notNull(), // 资源变化来源。 + sourceId: varchar('source_id', { length: 120 }), // 来源业务 ID,如挑战组、订单或广告会话。 + idempotencyKey: varchar('idempotency_key', { length: 160 }), // 幂等边界,防止重复发放或重复扣减。 + snapshot: json('snapshot').$type>(), // 本次变更的上下文快照。 + createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), // 创建时间。 +}, (table) => [ + uniqueIndex('uk_inventory_transaction_idempotency').on(table.userId, table.idempotencyKey), + index('idx_inventory_transaction_user_created').on(table.userId, table.createdAt), + index('idx_inventory_transaction_source').on(table.sourceType, table.sourceId), + foreignKey({ columns: [table.userId], foreignColumns: [users.id] }), + foreignKey({ columns: [table.inventoryItemId], foreignColumns: [userInventoryItems.id] }), +]); + // ── Question Ratings ────────────────────────────────────────────── // 用户对题目的好坏反馈数据。