forked from xiangwang25/whale-town-end-v2
feat: add task reward economy APIs
This commit is contained in:
@@ -31,6 +31,7 @@ import { UserProfilesModule } from './core/db/user_profiles/user_profiles.module
|
|||||||
import { MaintenanceMiddleware } from './core/security_core/maintenance.middleware';
|
import { MaintenanceMiddleware } from './core/security_core/maintenance.middleware';
|
||||||
import { ContentTypeMiddleware } from './core/security_core/content_type.middleware';
|
import { ContentTypeMiddleware } from './core/security_core/content_type.middleware';
|
||||||
import { SocialModule } from './business/social/social.module';
|
import { SocialModule } from './business/social/social.module';
|
||||||
|
import { TasksModule } from './business/tasks/tasks.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查数据库配置是否完整 by angjustinl 2025-12-17
|
* 检查数据库配置是否完整 by angjustinl 2025-12-17
|
||||||
@@ -88,6 +89,7 @@ function isDatabaseConfigured(): boolean {
|
|||||||
UserProfilesModule.forRoot(),
|
UserProfilesModule.forRoot(),
|
||||||
PlayerAssetsModule.forRoot(),
|
PlayerAssetsModule.forRoot(),
|
||||||
UserWalletsModule.forRoot(),
|
UserWalletsModule.forRoot(),
|
||||||
|
TasksModule.forRoot(),
|
||||||
// Zulip账号关联模块 - 全局单例,其他模块无需重复导入
|
// Zulip账号关联模块 - 全局单例,其他模块无需重复导入
|
||||||
ZulipAccountsModule.forRoot(),
|
ZulipAccountsModule.forRoot(),
|
||||||
LoginCoreModule,
|
LoginCoreModule,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { LoginCoreService } from '../../core/login_core/login_core.service';
|
|||||||
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
|
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
|
||||||
import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service';
|
import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service';
|
||||||
import { AccountProfileService } from '../auth/account_profile.service';
|
import { AccountProfileService } from '../auth/account_profile.service';
|
||||||
|
import { TaskService } from '../tasks/task.service';
|
||||||
|
|
||||||
// ========== 接口定义 ==========
|
// ========== 接口定义 ==========
|
||||||
|
|
||||||
@@ -262,6 +263,7 @@ export class ChatService {
|
|||||||
@Inject('ZulipAccountsService')
|
@Inject('ZulipAccountsService')
|
||||||
private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService,
|
private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService,
|
||||||
private readonly accountProfileService: AccountProfileService,
|
private readonly accountProfileService: AccountProfileService,
|
||||||
|
private readonly taskService: TaskService,
|
||||||
) {
|
) {
|
||||||
this.logger.log('ChatService初始化完成');
|
this.logger.log('ChatService初始化完成');
|
||||||
}
|
}
|
||||||
@@ -445,6 +447,11 @@ export class ChatService {
|
|||||||
.catch(e => this.logger.warn('Zulip同步失败', { error: (e as Error).message }));
|
.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('聊天消息发送完成', {
|
this.logger.log('聊天消息发送完成', {
|
||||||
operation: 'sendChatMessage',
|
operation: 'sendChatMessage',
|
||||||
messageId,
|
messageId,
|
||||||
|
|||||||
@@ -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 { MALL_CATEGORIES, MALL_ITEMS, findMallItem } from './mall_catalog';
|
||||||
import { InventoryService } from '../player/inventory.service';
|
import { InventoryService } from '../player/inventory.service';
|
||||||
import { EconomyService } from '../player/economy.service';
|
import { EconomyService } from '../player/economy.service';
|
||||||
import { PlayerStateService } from '../player/player_state.service';
|
import { PlayerStateService } from '../player/player_state.service';
|
||||||
import { PlayerInventoryPayload, PlayerSnapshotPayload, PlayerWalletPayload } from '../player/player.types';
|
import { PlayerInventoryPayload, PlayerSnapshotPayload, PlayerWalletPayload } from '../player/player.types';
|
||||||
|
import { TaskService } from '../tasks/task.service';
|
||||||
|
|
||||||
interface IUserWalletsService {
|
interface IUserWalletsService {
|
||||||
getBalance(userId: bigint): Promise<{ balance: number; currency: 'whale_coin'; user_id: string }>;
|
getBalance(userId: bigint): Promise<{ balance: number; currency: 'whale_coin'; user_id: string }>;
|
||||||
@@ -51,11 +52,14 @@ export interface MallCatalogPayload {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MallService {
|
export class MallService {
|
||||||
|
private readonly logger = new Logger(MallService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('IUserWalletsService') private readonly userWalletsService: IUserWalletsService,
|
@Inject('IUserWalletsService') private readonly userWalletsService: IUserWalletsService,
|
||||||
private readonly inventoryService: InventoryService,
|
private readonly inventoryService: InventoryService,
|
||||||
private readonly economyService: EconomyService,
|
private readonly economyService: EconomyService,
|
||||||
private readonly playerStateService: PlayerStateService,
|
private readonly playerStateService: PlayerStateService,
|
||||||
|
private readonly taskService: TaskService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getWallet(userId: bigint) {
|
async getWallet(userId: bigint) {
|
||||||
@@ -123,6 +127,10 @@ export class MallService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.inventoryService.grantAsset(userId, 'skin', item.skinId as string, 'purchase');
|
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([
|
const [inventory, snapshot] = await Promise.all([
|
||||||
this.inventoryService.listInventory(userId),
|
this.inventoryService.listInventory(userId),
|
||||||
this.playerStateService.getSnapshot(userId),
|
this.playerStateService.getSnapshot(userId),
|
||||||
|
|||||||
12
src/business/tasks/dto/report_task_activity.dto.ts
Normal file
12
src/business/tasks/dto/report_task_activity.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -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='玩家任务进度表';
|
||||||
36
src/business/tasks/player_task_progress.entity.ts
Normal file
36
src/business/tasks/player_task_progress.entity.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
35
src/business/tasks/task.service.ts
Normal file
35
src/business/tasks/task.service.ts
Normal file
@@ -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<TaskBoardPayload> {
|
||||||
|
return await this.taskProgressStore.getBoard(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordClientActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> {
|
||||||
|
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<TaskBoardPayload> {
|
||||||
|
return await this.taskProgressStore.recordActivity(userId, activity, targetId?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async claim(userId: bigint, taskId: string): Promise<TaskClaimResult> {
|
||||||
|
return await this.taskProgressStore.claim(userId, taskId.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
189
src/business/tasks/task_catalog.ts
Normal file
189
src/business/tasks/task_catalog.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
140
src/business/tasks/task_progress_database.service.ts
Normal file
140
src/business/tasks/task_progress_database.service.ts
Normal file
@@ -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<PlayerTaskProgress>,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly walletService: UserWalletsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getBoard(userId: bigint): Promise<TaskBoardPayload> {
|
||||||
|
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<TaskBoardPayload> {
|
||||||
|
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<TaskClaimResult> {
|
||||||
|
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<PlayerTaskProgress>, userId: bigint, cycle: WeeklyCycle): Promise<void> {
|
||||||
|
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<PlayerTaskProgress>, userId: bigint, cycle: WeeklyCycle, lock = false): Promise<PlayerTaskProgress[]> {
|
||||||
|
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<PlayerTaskProgress>, rows: PlayerTaskProgress[]): Promise<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();
|
||||||
|
await repository.save(bonus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/business/tasks/task_progress_memory.service.ts
Normal file
147
src/business/tasks/task_progress_memory.service.ts
Normal file
@@ -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<string, MemoryProgressRow>();
|
||||||
|
|
||||||
|
constructor(@Inject('IUserWalletsService') private readonly walletService: IUserWalletsService) {}
|
||||||
|
|
||||||
|
async getBoard(userId: bigint): Promise<TaskBoardPayload> {
|
||||||
|
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<TaskBoardPayload> {
|
||||||
|
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<TaskClaimResult> {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/business/tasks/tasks.controller.ts
Normal file
48
src/business/tasks/tasks.controller.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const data = await this.taskService.claim(BigInt(user.sub), taskId);
|
||||||
|
res.status(HttpStatus.OK).json({ success: true, data, message: '任务奖励已领取' });
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/business/tasks/tasks.module.ts
Normal file
51
src/business/tasks/tasks.module.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/business/tasks/tasks.types.ts
Normal file
78
src/business/tasks/tasks.types.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<TaskBoardPayload>;
|
||||||
|
recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload>;
|
||||||
|
claim(userId: bigint, taskId: string): Promise<TaskClaimResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { EntityManager, Repository } from 'typeorm';
|
||||||
import { UserWallets } from './user_wallets.entity';
|
import { UserWallets } from './user_wallets.entity';
|
||||||
import { WalletTransactions } from './wallet_transactions.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<EarnWalletResult> {
|
||||||
|
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(
|
private async createTransaction(
|
||||||
userId: bigint,
|
userId: bigint,
|
||||||
type: string,
|
type: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user