diff --git a/src/app.module.ts b/src/app.module.ts index 4b6a315..75a6d8f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -31,6 +31,7 @@ import { UserProfilesModule } from './core/db/user_profiles/user_profiles.module import { MaintenanceMiddleware } from './core/security_core/maintenance.middleware'; import { ContentTypeMiddleware } from './core/security_core/content_type.middleware'; import { SocialModule } from './business/social/social.module'; +import { TasksModule } from './business/tasks/tasks.module'; /** * 检查数据库配置是否完整 by angjustinl 2025-12-17 @@ -88,6 +89,7 @@ function isDatabaseConfigured(): boolean { UserProfilesModule.forRoot(), PlayerAssetsModule.forRoot(), UserWalletsModule.forRoot(), + TasksModule.forRoot(), // Zulip账号关联模块 - 全局单例,其他模块无需重复导入 ZulipAccountsModule.forRoot(), LoginCoreModule, diff --git a/src/business/chat/chat.service.ts b/src/business/chat/chat.service.ts index 1838572..e7b4cc8 100644 --- a/src/business/chat/chat.service.ts +++ b/src/business/chat/chat.service.ts @@ -40,6 +40,7 @@ import { LoginCoreService } from '../../core/login_core/login_core.service'; import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service'; import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service'; import { AccountProfileService } from '../auth/account_profile.service'; +import { TaskService } from '../tasks/task.service'; // ========== 接口定义 ========== @@ -262,6 +263,7 @@ export class ChatService { @Inject('ZulipAccountsService') private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService, private readonly accountProfileService: AccountProfileService, + private readonly taskService: TaskService, ) { this.logger.log('ChatService初始化完成'); } @@ -445,6 +447,11 @@ export class ChatService { .catch(e => this.logger.warn('Zulip同步失败', { error: (e as Error).message })); } + if (normalizedScope === 'global') { + await this.taskService.recordActivity(BigInt(session.userId), 'public_message_sent') + .catch((error: unknown) => this.logger.warn('记录公共聊天任务失败', { error: error instanceof Error ? error.message : String(error) })); + } + this.logger.log('聊天消息发送完成', { operation: 'sendChatMessage', messageId, diff --git a/src/business/mall/mall.service.ts b/src/business/mall/mall.service.ts index 914fe12..f679b83 100644 --- a/src/business/mall/mall.service.ts +++ b/src/business/mall/mall.service.ts @@ -1,9 +1,10 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; import { MALL_CATEGORIES, MALL_ITEMS, findMallItem } from './mall_catalog'; import { InventoryService } from '../player/inventory.service'; import { EconomyService } from '../player/economy.service'; import { PlayerStateService } from '../player/player_state.service'; import { PlayerInventoryPayload, PlayerSnapshotPayload, PlayerWalletPayload } from '../player/player.types'; +import { TaskService } from '../tasks/task.service'; interface IUserWalletsService { getBalance(userId: bigint): Promise<{ balance: number; currency: 'whale_coin'; user_id: string }>; @@ -51,11 +52,14 @@ export interface MallCatalogPayload { @Injectable() export class MallService { + private readonly logger = new Logger(MallService.name); + constructor( @Inject('IUserWalletsService') private readonly userWalletsService: IUserWalletsService, private readonly inventoryService: InventoryService, private readonly economyService: EconomyService, private readonly playerStateService: PlayerStateService, + private readonly taskService: TaskService, ) {} async getWallet(userId: bigint) { @@ -123,6 +127,10 @@ export class MallService { } await this.inventoryService.grantAsset(userId, 'skin', item.skinId as string, 'purchase'); + if (!alreadyOwned) { + await this.taskService.recordActivity(userId, 'skin_purchased', item.itemId) + .catch((error: unknown) => this.logger.warn(`记录首次皮肤任务失败: ${error instanceof Error ? error.message : String(error)}`)); + } const [inventory, snapshot] = await Promise.all([ this.inventoryService.listInventory(userId), this.playerStateService.getSnapshot(userId), diff --git a/src/business/tasks/dto/report_task_activity.dto.ts b/src/business/tasks/dto/report_task_activity.dto.ts new file mode 100644 index 0000000..18bb077 --- /dev/null +++ b/src/business/tasks/dto/report_task_activity.dto.ts @@ -0,0 +1,12 @@ +import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; +import { TASK_ACTIVITY_TYPES } from '../task_catalog'; + +export class ReportTaskActivityDto { + @IsIn(TASK_ACTIVITY_TYPES) + activity: typeof TASK_ACTIVITY_TYPES[number]; + + @IsOptional() + @IsString() + @MaxLength(64) + target_id?: string; +} diff --git a/src/business/tasks/migrations/create-player-task-progress.sql b/src/business/tasks/migrations/create-player-task-progress.sql new file mode 100644 index 0000000..7913c94 --- /dev/null +++ b/src/business/tasks/migrations/create-player-task-progress.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS `player_task_progress` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` bigint NOT NULL COMMENT '关联users.id', + `task_id` varchar(80) NOT NULL COMMENT '静态任务ID', + `cycle_key` varchar(32) NOT NULL COMMENT '任务周期键', + `progress` int NOT NULL DEFAULT 0 COMMENT '当前进度', + `activity_state` json NOT NULL COMMENT '去重活动目标等状态', + `completed_at` timestamp NULL DEFAULT NULL COMMENT '完成时间', + `claimed_at` timestamp NULL DEFAULT NULL COMMENT '领奖时间', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_player_task_progress_task_cycle` (`user_id`, `task_id`, `cycle_key`), + KEY `idx_player_task_progress_user_cycle` (`user_id`, `cycle_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='玩家任务进度表'; diff --git a/src/business/tasks/player_task_progress.entity.ts b/src/business/tasks/player_task_progress.entity.ts new file mode 100644 index 0000000..792f1df --- /dev/null +++ b/src/business/tasks/player_task_progress.entity.ts @@ -0,0 +1,36 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('player_task_progress') +@Index('uq_player_task_progress_task_cycle', ['user_id', 'task_id', 'cycle_key'], { unique: true }) +@Index('idx_player_task_progress_user_cycle', ['user_id', 'cycle_key']) +export class PlayerTaskProgress { + @PrimaryGeneratedColumn({ type: 'bigint', comment: '主键ID' }) + id: bigint; + + @Column({ type: 'bigint', nullable: false, comment: '关联users.id' }) + user_id: bigint; + + @Column({ type: 'varchar', length: 80, nullable: false, comment: '静态任务ID' }) + task_id: string; + + @Column({ type: 'varchar', length: 32, nullable: false, comment: '任务周期键' }) + cycle_key: string; + + @Column({ type: 'int', nullable: false, default: 0, comment: '当前进度' }) + progress: number; + + @Column({ type: 'json', nullable: false, comment: '去重活动目标等状态' }) + activity_state: Record; + + @Column({ type: 'timestamp', nullable: true, comment: '完成时间' }) + completed_at: Date | null; + + @Column({ type: 'timestamp', nullable: true, comment: '领奖时间' }) + claimed_at: Date | null; + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', comment: '创建时间' }) + created_at: Date; + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP', comment: '更新时间' }) + updated_at: Date; +} diff --git a/src/business/tasks/task.service.ts b/src/business/tasks/task.service.ts new file mode 100644 index 0000000..5463246 --- /dev/null +++ b/src/business/tasks/task.service.ts @@ -0,0 +1,35 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { TaskActivityType } from './task_catalog'; +import { TaskBoardPayload, TaskClaimResult, TaskProgressStore } from './tasks.types'; + +const CLIENT_ACTIVITY_TYPES: TaskActivityType[] = [ + 'guide_opened', + 'notice_viewed', + 'map_visited', + 'course_board_opened', + 'facility_interacted', +]; + +@Injectable() +export class TaskService { + constructor(@Inject('ITaskProgressStore') private readonly taskProgressStore: TaskProgressStore) {} + + async getBoard(userId: bigint): Promise { + return await this.taskProgressStore.getBoard(userId); + } + + async recordClientActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise { + if (!CLIENT_ACTIVITY_TYPES.includes(activity)) { + throw new BadRequestException('该任务活动只能由服务器业务记录'); + } + return await this.taskProgressStore.recordActivity(userId, activity, targetId?.trim()); + } + + async recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise { + return await this.taskProgressStore.recordActivity(userId, activity, targetId?.trim()); + } + + async claim(userId: bigint, taskId: string): Promise { + return await this.taskProgressStore.claim(userId, taskId.trim()); + } +} diff --git a/src/business/tasks/task_catalog.ts b/src/business/tasks/task_catalog.ts new file mode 100644 index 0000000..9f9d113 --- /dev/null +++ b/src/business/tasks/task_catalog.ts @@ -0,0 +1,189 @@ +export const NEWBIE_CYCLE_KEY = 'newbie'; + +export const TASK_ACTIVITY_TYPES = [ + 'guide_opened', + 'notice_viewed', + 'map_visited', + 'course_board_opened', + 'facility_interacted', + 'public_message_sent', + 'skin_purchased', +] as const; + +export type TaskActivityType = typeof TASK_ACTIVITY_TYPES[number]; +export type TaskGroup = 'newbie' | 'weekly'; +export type TaskProgressMode = 'count' | 'unique_target'; + +export interface TaskDefinition { + id: string; + group: TaskGroup; + title: string; + description: string; + reward: number; + target: number; + activity?: TaskActivityType; + progress_mode?: TaskProgressMode; + allowed_targets?: string[]; + optional?: boolean; + bonus?: boolean; + sort_order: number; +} + +export interface WeeklyCycle { + key: string; + starts_at: string; + ends_at: string; +} + +export interface TaskProgressState { + targets?: string[]; +} + +export const NEWBIE_TASKS: TaskDefinition[] = [ + { + id: 'newbie_guide', + group: 'newbie', + title: '翻阅新人手册', + description: '打开新人引导,了解鲸镇的基本操作。', + reward: 40, + target: 1, + activity: 'guide_opened', + sort_order: 10, + }, + { + id: 'newbie_notice', + group: 'newbie', + title: '查看镇务公告', + description: '在广场查看一次公告栏。', + reward: 60, + target: 1, + activity: 'notice_viewed', + sort_order: 20, + }, + { + id: 'newbie_work_zone', + group: 'newbie', + title: '探索打工区', + description: '前往打工区,看看小镇的工作与学习入口。', + reward: 80, + target: 1, + activity: 'map_visited', + allowed_targets: ['work_zone'], + sort_order: 30, + }, + { + id: 'newbie_course_board', + group: 'newbie', + title: '浏览课程板', + description: '在打工区打开 Datawhale 课程看板。', + reward: 120, + target: 1, + activity: 'course_board_opened', + sort_order: 40, + }, + { + id: 'newbie_first_skin', + group: 'newbie', + title: '选择你的形象', + description: '在鲸鱼商城购买任意一款皮肤。此任务可跳过。', + reward: 100, + target: 1, + activity: 'skin_purchased', + optional: true, + sort_order: 50, + }, +]; + +export const WEEKLY_TASKS: TaskDefinition[] = [ + { + id: 'weekly_explore', + group: 'weekly', + title: '海风巡游', + description: '探索两个不同的开放地图。', + reward: 100, + target: 2, + activity: 'map_visited', + progress_mode: 'unique_target', + allowed_targets: ['square', 'work_zone', 'whale_cafe'], + sort_order: 10, + }, + { + id: 'weekly_course', + group: 'weekly', + title: '本周学习计划', + description: '打开一次 Datawhale 课程看板。', + reward: 100, + target: 1, + activity: 'course_board_opened', + sort_order: 20, + }, + { + id: 'weekly_interact', + group: 'weekly', + title: '和小镇打招呼', + description: '与两个不同的公共设施或 NPC 互动。', + reward: 100, + target: 2, + activity: 'facility_interacted', + progress_mode: 'unique_target', + allowed_targets: ['welcome_board', 'notice_board', 'npc'], + sort_order: 30, + }, + { + id: 'weekly_public_message', + group: 'weekly', + title: '分享此刻', + description: '在公共频道成功发送一条消息。', + reward: 100, + target: 1, + activity: 'public_message_sent', + sort_order: 40, + }, +]; + +export const WEEKLY_COMPLETION_BONUS: TaskDefinition = { + id: 'weekly_completion_bonus', + group: 'weekly', + title: '本周任务书结算', + description: '完成本周全部四项任务后领取额外奖励。', + reward: 200, + target: 1, + bonus: true, + sort_order: 90, +}; + +export function getCurrentWeeklyCycle(now: Date = new Date()): WeeklyCycle { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + const parts = Object.fromEntries(formatter.formatToParts(now) + .filter((part) => part.type !== 'literal') + .map((part) => [part.type, part.value])); + const year = Number(parts.year); + const month = Number(parts.month); + const day = Number(parts.day); + const chinaDateAsUtc = Date.UTC(year, month - 1, day); + const weekday = new Date(chinaDateAsUtc).getUTCDay(); + const daysSinceMonday = (weekday + 6) % 7; + const mondayAsUtc = chinaDateAsUtc - daysSinceMonday * 24 * 60 * 60 * 1000; + const monday = new Date(mondayAsUtc); + const cycleDate = monday.toISOString().slice(0, 10); + const startsAt = new Date(mondayAsUtc - 8 * 60 * 60 * 1000); + const endsAt = new Date(startsAt.getTime() + 7 * 24 * 60 * 60 * 1000); + return { + key: `weekly:${cycleDate}`, + starts_at: startsAt.toISOString(), + ends_at: endsAt.toISOString(), + }; +} + +export function getTaskDefinitions(): TaskDefinition[] { + return [...NEWBIE_TASKS, ...WEEKLY_TASKS, WEEKLY_COMPLETION_BONUS]; +} + +export function getTaskCycleKey(definition: TaskDefinition, cycle: WeeklyCycle): string { + return definition.group === 'weekly' ? cycle.key : NEWBIE_CYCLE_KEY; +} diff --git a/src/business/tasks/task_progress_database.service.ts b/src/business/tasks/task_progress_database.service.ts new file mode 100644 index 0000000..59de797 --- /dev/null +++ b/src/business/tasks/task_progress_database.service.ts @@ -0,0 +1,140 @@ +import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, Repository } from 'typeorm'; +import { UserWalletsService } from '../../core/db/user_wallets/user_wallets.service'; +import { + getCurrentWeeklyCycle, + getTaskCycleKey, + getTaskDefinitions, + NEWBIE_CYCLE_KEY, + TaskActivityType, + TaskDefinition, + TaskProgressState, + WEEKLY_COMPLETION_BONUS, + WEEKLY_TASKS, + WeeklyCycle, +} from './task_catalog'; +import { PlayerTaskProgress } from './player_task_progress.entity'; +import { buildTaskBoard, TaskBoardPayload, TaskClaimResult, TaskProgressRow, TaskProgressStore } from './tasks.types'; + +@Injectable() +export class TaskProgressDatabaseService implements TaskProgressStore { + constructor( + @InjectRepository(PlayerTaskProgress) private readonly progressRepository: Repository, + private readonly dataSource: DataSource, + private readonly walletService: UserWalletsService, + ) {} + + async getBoard(userId: bigint): Promise { + const cycle = getCurrentWeeklyCycle(); + await this.ensureRows(this.progressRepository, userId, cycle); + const rows = await this.findRows(this.progressRepository, userId, cycle); + await this.syncWeeklyBonus(this.progressRepository, rows); + return buildTaskBoard(rows, cycle); + } + + async recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise { + const cycle = getCurrentWeeklyCycle(); + return await this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(PlayerTaskProgress); + await this.ensureRows(repository, userId, cycle); + const rows = await this.findRows(repository, userId, cycle, true); + for (const definition of getTaskDefinitions()) { + if (definition.bonus || definition.activity !== activity) continue; + const row = this.findRow(rows, definition, cycle); + this.applyActivity(definition, row, targetId); + } + await repository.save(rows); + await this.syncWeeklyBonus(repository, rows); + return buildTaskBoard(rows, cycle); + }); + } + + async claim(userId: bigint, taskId: string): Promise { + const cycle = getCurrentWeeklyCycle(); + const definition = getTaskDefinitions().find((item) => item.id === taskId); + if (!definition) throw new BadRequestException('任务不存在'); + return await this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(PlayerTaskProgress); + await this.ensureRows(repository, userId, cycle); + const rows = await this.findRows(repository, userId, cycle, true); + await this.syncWeeklyBonus(repository, rows); + const row = this.findRow(rows, definition, cycle); + if (!row.completed_at) throw new BadRequestException('任务尚未完成'); + if (row.claimed_at) throw new ConflictException('任务奖励已领取'); + const walletResult = await this.walletService.earnInTransaction( + manager, + userId, + definition.reward, + 'task_reward', + `${getTaskCycleKey(definition, cycle)}:${definition.id}`, + `任务奖励:${definition.title}`, + ); + row.claimed_at = new Date(); + await repository.save(row); + return { + board: buildTaskBoard(rows, cycle), + wallet: { + user_id: userId.toString(), + balance: walletResult.wallet.balance, + currency: 'whale_coin', + }, + }; + }); + } + + private async ensureRows(repository: Repository, userId: bigint, cycle: WeeklyCycle): Promise { + const values = getTaskDefinitions().map((definition) => ({ + user_id: userId, + task_id: definition.id, + cycle_key: getTaskCycleKey(definition, cycle), + progress: 0, + activity_state: {}, + completed_at: null, + claimed_at: null, + })); + await repository.createQueryBuilder().insert().values(values).orIgnore().execute(); + } + + private async findRows(repository: Repository, userId: bigint, cycle: WeeklyCycle, lock = false): Promise { + return await repository.find({ + where: { user_id: userId, cycle_key: In([cycle.key, NEWBIE_CYCLE_KEY]) }, + ...(lock ? { lock: { mode: 'pessimistic_write' as const } } : {}), + }); + } + + private findRow(rows: PlayerTaskProgress[], definition: TaskDefinition, cycle: WeeklyCycle): PlayerTaskProgress { + const cycleKey = getTaskCycleKey(definition, cycle); + const row = rows.find((item) => item.task_id === definition.id && item.cycle_key === cycleKey); + if (!row) throw new Error(`任务进度缺失: ${definition.id}`); + return row; + } + + private applyActivity(definition: TaskDefinition, row: PlayerTaskProgress, targetId?: string): void { + if (row.completed_at) return; + if (definition.allowed_targets && (!targetId || !definition.allowed_targets.includes(targetId))) return; + if (definition.progress_mode === 'unique_target') { + if (!targetId) return; + const state = row.activity_state as TaskProgressState; + const targets = Array.isArray(state.targets) ? state.targets.filter((item): item is string => typeof item === 'string') : []; + if (targets.includes(targetId)) return; + targets.push(targetId); + row.activity_state = { ...state, targets }; + row.progress = Math.min(definition.target, targets.length); + } else { + row.progress = Math.min(definition.target, row.progress + 1); + } + if (row.progress >= definition.target) row.completed_at = new Date(); + } + + private async syncWeeklyBonus(repository: Repository, rows: PlayerTaskProgress[]): Promise { + const bonus = rows.find((row) => row.task_id === WEEKLY_COMPLETION_BONUS.id); + if (!bonus || bonus.completed_at) return; + const complete = WEEKLY_TASKS.every((definition) => rows.some((row) => row.task_id === definition.id && row.completed_at)); + if (complete) { + bonus.progress = 1; + bonus.completed_at = new Date(); + await repository.save(bonus); + } + } +} diff --git a/src/business/tasks/task_progress_memory.service.ts b/src/business/tasks/task_progress_memory.service.ts new file mode 100644 index 0000000..836014a --- /dev/null +++ b/src/business/tasks/task_progress_memory.service.ts @@ -0,0 +1,147 @@ +import { BadRequestException, ConflictException, Inject, Injectable } from '@nestjs/common'; +import { PlayerWalletPayload } from '../player/player.types'; +import { + getCurrentWeeklyCycle, + getTaskCycleKey, + getTaskDefinitions, + NEWBIE_CYCLE_KEY, + TaskActivityType, + TaskDefinition, + TaskProgressState, + WEEKLY_COMPLETION_BONUS, + WEEKLY_TASKS, +} from './task_catalog'; +import { buildTaskBoard, TaskBoardPayload, TaskClaimResult, TaskProgressRow, TaskProgressStore } from './tasks.types'; + +interface IUserWalletsService { + earn(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise<{ wallet: { balance: number } }>; +} + +interface MemoryProgressRow extends TaskProgressRow { + user_id: bigint; + created_at: Date; + updated_at: Date; +} + +@Injectable() +export class TaskProgressMemoryService implements TaskProgressStore { + private readonly rows = new Map(); + + constructor(@Inject('IUserWalletsService') private readonly walletService: IUserWalletsService) {} + + async getBoard(userId: bigint): Promise { + const cycle = getCurrentWeeklyCycle(); + const rows = this.ensureRows(userId, cycle.key); + this.syncWeeklyBonus(rows); + return buildTaskBoard(rows, cycle); + } + + async recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise { + const cycle = getCurrentWeeklyCycle(); + const rows = this.ensureRows(userId, cycle.key); + for (const definition of getTaskDefinitions()) { + if (definition.bonus || definition.activity !== activity) continue; + const row = this.findRow(rows, definition, cycle.key); + this.applyActivity(definition, row, targetId); + } + this.syncWeeklyBonus(rows); + return buildTaskBoard(rows, cycle); + } + + async claim(userId: bigint, taskId: string): Promise { + const cycle = getCurrentWeeklyCycle(); + const rows = this.ensureRows(userId, cycle.key); + this.syncWeeklyBonus(rows); + const definition = getTaskDefinitions().find((item) => item.id === taskId); + if (!definition) { + throw new BadRequestException('任务不存在'); + } + const row = this.findRow(rows, definition, cycle.key); + if (!row.completed_at) { + throw new BadRequestException('任务尚未完成'); + } + if (row.claimed_at) { + throw new ConflictException('任务奖励已领取'); + } + const result = await this.walletService.earn( + userId, + definition.reward, + 'task_reward', + `${getTaskCycleKey(definition, cycle)}:${definition.id}`, + `任务奖励:${definition.title}`, + ); + row.claimed_at = new Date(); + row.updated_at = new Date(); + const wallet: PlayerWalletPayload = { + user_id: userId.toString(), + balance: result.wallet.balance, + currency: 'whale_coin', + }; + return { board: buildTaskBoard(rows, cycle), wallet }; + } + + private ensureRows(userId: bigint, weeklyCycleKey: string): MemoryProgressRow[] { + for (const definition of getTaskDefinitions()) { + const cycleKey = definition.group === 'weekly' ? weeklyCycleKey : NEWBIE_CYCLE_KEY; + const key = this.rowKey(userId, definition.id, cycleKey); + if (!this.rows.has(key)) { + const now = new Date(); + this.rows.set(key, { + user_id: userId, + task_id: definition.id, + cycle_key: cycleKey, + progress: 0, + activity_state: {}, + completed_at: null, + claimed_at: null, + created_at: now, + updated_at: now, + }); + } + } + return getTaskDefinitions().map((definition) => { + const cycleKey = definition.group === 'weekly' ? weeklyCycleKey : NEWBIE_CYCLE_KEY; + return this.rows.get(this.rowKey(userId, definition.id, cycleKey)) as MemoryProgressRow; + }); + } + + private findRow(rows: MemoryProgressRow[], definition: TaskDefinition, weeklyCycleKey: string): MemoryProgressRow { + const cycleKey = definition.group === 'weekly' ? weeklyCycleKey : NEWBIE_CYCLE_KEY; + const row = rows.find((item) => item.task_id === definition.id && item.cycle_key === cycleKey); + if (!row) throw new Error(`任务进度缺失: ${definition.id}`); + return row; + } + + private applyActivity(definition: TaskDefinition, row: MemoryProgressRow, targetId?: string): void { + if (row.completed_at) return; + if (definition.allowed_targets && (!targetId || !definition.allowed_targets.includes(targetId))) return; + if (definition.progress_mode === 'unique_target') { + if (!targetId) return; + const state = row.activity_state as TaskProgressState; + const targets = Array.isArray(state.targets) ? state.targets.filter((item): item is string => typeof item === 'string') : []; + if (targets.includes(targetId)) return; + targets.push(targetId); + row.activity_state = { ...state, targets }; + row.progress = Math.min(definition.target, targets.length); + } else { + row.progress = Math.min(definition.target, row.progress + 1); + } + if (row.progress >= definition.target) row.completed_at = new Date(); + row.updated_at = new Date(); + } + + private syncWeeklyBonus(rows: MemoryProgressRow[]): void { + const bonus = rows.find((row) => row.task_id === WEEKLY_COMPLETION_BONUS.id); + if (!bonus || bonus.completed_at) return; + const complete = WEEKLY_TASKS.every((definition) => rows.some((row) => row.task_id === definition.id && row.completed_at)); + if (complete) { + bonus.progress = 1; + bonus.completed_at = new Date(); + bonus.updated_at = new Date(); + } + } + + private rowKey(userId: bigint, taskId: string, cycleKey: string): string { + return `${userId.toString()}:${taskId}:${cycleKey}`; + } +} diff --git a/src/business/tasks/tasks.controller.ts b/src/business/tasks/tasks.controller.ts new file mode 100644 index 0000000..8656454 --- /dev/null +++ b/src/business/tasks/tasks.controller.ts @@ -0,0 +1,48 @@ +import { Body, Controller, Get, HttpStatus, Param, Post, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { CurrentUser } from '../../gateway/auth/current_user.decorator'; +import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard'; +import { JwtPayload } from '../../core/login_core/login_core.service'; +import { ReportTaskActivityDto } from './dto/report_task_activity.dto'; +import { TaskService } from './task.service'; + +@ApiTags('tasks') +@ApiBearerAuth() +@Controller('tasks') +@UseGuards(JwtAuthGuard) +export class TasksController { + constructor(private readonly taskService: TaskService) {} + + @Get('board') + @ApiOperation({ summary: '获取玩家任务书' }) + @SwaggerApiResponse({ status: 200, description: '任务书获取成功' }) + async getBoard(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise { + const data = await this.taskService.getBoard(BigInt(user.sub)); + res.status(HttpStatus.OK).json({ success: true, data, message: '任务书获取成功' }); + } + + @Post('activities') + @ApiOperation({ summary: '上报客户端白名单任务活动' }) + @ApiBody({ type: ReportTaskActivityDto }) + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + async reportActivity( + @CurrentUser() user: JwtPayload, + @Body() dto: ReportTaskActivityDto, + @Res() res: Response, + ): Promise { + const data = await this.taskService.recordClientActivity(BigInt(user.sub), dto.activity, dto.target_id); + res.status(HttpStatus.OK).json({ success: true, data, message: '任务进度已更新' }); + } + + @Post(':taskId/claim') + @ApiOperation({ summary: '领取任务奖励' }) + async claim( + @CurrentUser() user: JwtPayload, + @Param('taskId') taskId: string, + @Res() res: Response, + ): Promise { + const data = await this.taskService.claim(BigInt(user.sub), taskId); + res.status(HttpStatus.OK).json({ success: true, data, message: '任务奖励已领取' }); + } +} diff --git a/src/business/tasks/tasks.module.ts b/src/business/tasks/tasks.module.ts new file mode 100644 index 0000000..b4da581 --- /dev/null +++ b/src/business/tasks/tasks.module.ts @@ -0,0 +1,51 @@ +import { DynamicModule, Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { LoginCoreModule } from '../../core/login_core/login_core.module'; +import { PlayerTaskProgress } from './player_task_progress.entity'; +import { TaskProgressDatabaseService } from './task_progress_database.service'; +import { TaskProgressMemoryService } from './task_progress_memory.service'; +import { TaskService } from './task.service'; +import { TasksController } from './tasks.controller'; + +@Global() +@Module({}) +export class TasksModule { + static forDatabase(): DynamicModule { + return { + module: TasksModule, + global: true, + imports: [LoginCoreModule, TypeOrmModule.forFeature([PlayerTaskProgress])], + controllers: [TasksController], + providers: [ + TaskProgressDatabaseService, + { provide: 'ITaskProgressStore', useExisting: TaskProgressDatabaseService }, + TaskService, + ], + exports: [TaskService, 'ITaskProgressStore'], + }; + } + + static forMemory(): DynamicModule { + return { + module: TasksModule, + global: true, + imports: [LoginCoreModule], + controllers: [TasksController], + providers: [ + TaskProgressMemoryService, + { provide: 'ITaskProgressStore', useExisting: TaskProgressMemoryService }, + TaskService, + ], + exports: [TaskService, 'ITaskProgressStore'], + }; + } + + static forRoot(useMemory?: boolean): DynamicModule { + const shouldUseMemory = useMemory ?? ( + process.env.NODE_ENV === 'test' || + process.env.USE_MEMORY_STORAGE === 'true' || + !process.env.DB_HOST + ); + return shouldUseMemory ? this.forMemory() : this.forDatabase(); + } +} diff --git a/src/business/tasks/tasks.types.ts b/src/business/tasks/tasks.types.ts new file mode 100644 index 0000000..1a013be --- /dev/null +++ b/src/business/tasks/tasks.types.ts @@ -0,0 +1,78 @@ +import { PlayerWalletPayload } from '../player/player.types'; +import { getTaskCycleKey, NEWBIE_TASKS, TaskActivityType, TaskDefinition, WEEKLY_COMPLETION_BONUS, WEEKLY_TASKS, WeeklyCycle } from './task_catalog'; + +export interface TaskProgressRow { + task_id: string; + cycle_key: string; + progress: number; + activity_state: Record; + completed_at: Date | null; + claimed_at: Date | null; +} + +export interface TaskPayload { + id: string; + title: string; + description: string; + reward: number; + target: number; + progress: number; + optional: boolean; + bonus: boolean; + completed: boolean; + claimed: boolean; + claimable: boolean; +} + +export interface TaskBoardPayload { + weekly_cycle: WeeklyCycle; + newbie_tasks: TaskPayload[]; + weekly_tasks: TaskPayload[]; + weekly_bonus: TaskPayload; +} + +export interface TaskClaimResult { + board: TaskBoardPayload; + wallet: PlayerWalletPayload; +} + +export interface TaskProgressStore { + getBoard(userId: bigint): Promise; + recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise; + claim(userId: bigint, taskId: string): Promise; +} + +export function toTaskPayload(definition: TaskDefinition, row: TaskProgressRow): TaskPayload { + const completed = row.completed_at != null; + const claimed = row.claimed_at != null; + return { + id: definition.id, + title: definition.title, + description: definition.description, + reward: definition.reward, + target: definition.target, + progress: Math.min(definition.target, Math.max(0, row.progress)), + optional: Boolean(definition.optional), + bonus: Boolean(definition.bonus), + completed, + claimed, + claimable: completed && !claimed, + }; +} + +export function buildTaskBoard(rows: TaskProgressRow[], cycle: WeeklyCycle): TaskBoardPayload { + const rowsByKey = new Map(rows.map((row) => [`${row.task_id}:${row.cycle_key}`, row])); + const rowFor = (definition: TaskDefinition): TaskProgressRow => { + const row = rowsByKey.get(`${definition.id}:${getTaskCycleKey(definition, cycle)}`); + if (!row) { + throw new Error(`任务进度缺失: ${definition.id}`); + } + return row; + }; + return { + weekly_cycle: cycle, + newbie_tasks: NEWBIE_TASKS.map((definition) => toTaskPayload(definition, rowFor(definition))), + weekly_tasks: WEEKLY_TASKS.map((definition) => toTaskPayload(definition, rowFor(definition))), + weekly_bonus: toTaskPayload(WEEKLY_COMPLETION_BONUS, rowFor(WEEKLY_COMPLETION_BONUS)), + }; +} diff --git a/src/core/db/user_wallets/user_wallets.service.ts b/src/core/db/user_wallets/user_wallets.service.ts index 1200b34..1ffbd1b 100644 --- a/src/core/db/user_wallets/user_wallets.service.ts +++ b/src/core/db/user_wallets/user_wallets.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, Repository } from 'typeorm'; import { UserWallets } from './user_wallets.entity'; import { WalletTransactions } from './wallet_transactions.entity'; @@ -96,6 +96,60 @@ export class UserWalletsService { }; } + async earnInTransaction( + manager: EntityManager, + userId: bigint, + amount: number, + referenceType: string, + referenceId: string, + note?: string, + ): Promise { + if (!Number.isInteger(amount) || amount < 0) { + throw new BadRequestException('鲸币收入数量不正确'); + } + + const walletRepository = manager.getRepository(UserWallets); + const transactionRepository = manager.getRepository(WalletTransactions); + let wallet = await walletRepository.findOne({ + where: { user_id: userId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!wallet) { + wallet = walletRepository.create({ + user_id: userId, + balance: DEFAULT_INITIAL_WHALE_COINS, + created_at: new Date(), + updated_at: new Date(), + }); + wallet = await walletRepository.save(wallet); + await transactionRepository.save(transactionRepository.create({ + user_id: userId, + type: 'grant', + amount: DEFAULT_INITIAL_WHALE_COINS, + balance_after: wallet.balance, + reference_type: 'registration', + reference_id: 'initial_wallet', + note: '新用户初始鲸币', + created_at: new Date(), + })); + } + + wallet.balance += amount; + wallet.updated_at = new Date(); + const savedWallet = await walletRepository.save(wallet); + const transaction = await transactionRepository.save(transactionRepository.create({ + user_id: userId, + type: 'earn', + amount, + balance_after: savedWallet.balance, + reference_type: referenceType, + reference_id: referenceId, + note: note || null, + created_at: new Date(), + })); + return { wallet: savedWallet, transaction }; + } + private async createTransaction( userId: bigint, type: string,