From 28e0396a652d990d76be3a5b29ca79687bd55003 Mon Sep 17 00:00:00 2001 From: hailin Date: Fri, 2 Jan 2026 18:22:51 -0800 Subject: [PATCH] feat(pending-actions): add user pending actions system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a fully optional pending actions system that allows admins to configure specific tasks that users must complete after login. Backend (identity-service): - Add UserPendingAction model to Prisma schema - Add migration for user_pending_actions table - Add PendingActionService with full CRUD operations - Add user-facing API (GET list, POST complete) - Add admin API (CRUD, batch create) Admin Web: - Add pending actions management page - Support single/batch create, edit, cancel, delete - View action details including completion time - Filter by userId, actionCode, status Flutter Mobile App: - Add PendingActionService and PendingActionCheckService - Add PendingActionsPage for forced task execution - Integrate into splash_page login flow - Users must complete all pending tasks in priority order 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../migration.sql | 26 + .../identity-service/prisma/schema.prisma | 39 + .../controllers/pending-action.controller.ts | 239 ++++++ .../src/api/dto/request/index.ts | 1 + .../src/api/dto/request/pending-action.dto.ts | 167 +++++ .../src/api/dto/response/index.ts | 1 + .../api/dto/response/pending-action.dto.ts | 73 ++ .../identity-service/src/app.module.ts | 10 +- .../services/pending-action.service.ts | 359 +++++++++ .../app/(dashboard)/pending-actions/page.tsx | 688 ++++++++++++++++++ .../pending-actions.module.scss | 294 ++++++++ .../src/components/layout/Sidebar/Sidebar.tsx | 1 + .../admin-web/src/hooks/usePendingActions.ts | 118 +++ .../src/infrastructure/api/endpoints.ts | 11 + .../src/services/pendingActionService.ts | 79 ++ frontend/admin-web/src/types/index.ts | 1 + .../src/types/pending-action.types.ts | 132 ++++ .../lib/core/constants/api_endpoints.dart | 4 + .../lib/core/di/injection_container.dart | 14 + .../pending_action_check_service.dart | 77 ++ .../core/services/pending_action_service.dart | 196 +++++ .../auth/presentation/pages/splash_page.dart | 29 +- .../pages/pending_actions_page.dart | 496 +++++++++++++ .../mobile-app/lib/routes/app_router.dart | 8 + .../mobile-app/lib/routes/route_names.dart | 3 + .../mobile-app/lib/routes/route_paths.dart | 3 + 26 files changed, 3064 insertions(+), 5 deletions(-) create mode 100644 backend/services/identity-service/prisma/migrations/20260102000000_add_user_pending_actions/migration.sql create mode 100644 backend/services/identity-service/src/api/controllers/pending-action.controller.ts create mode 100644 backend/services/identity-service/src/api/dto/request/pending-action.dto.ts create mode 100644 backend/services/identity-service/src/api/dto/response/pending-action.dto.ts create mode 100644 backend/services/identity-service/src/application/services/pending-action.service.ts create mode 100644 frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx create mode 100644 frontend/admin-web/src/app/(dashboard)/pending-actions/pending-actions.module.scss create mode 100644 frontend/admin-web/src/hooks/usePendingActions.ts create mode 100644 frontend/admin-web/src/services/pendingActionService.ts create mode 100644 frontend/admin-web/src/types/pending-action.types.ts create mode 100644 frontend/mobile-app/lib/core/services/pending_action_check_service.dart create mode 100644 frontend/mobile-app/lib/core/services/pending_action_service.dart create mode 100644 frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart diff --git a/backend/services/identity-service/prisma/migrations/20260102000000_add_user_pending_actions/migration.sql b/backend/services/identity-service/prisma/migrations/20260102000000_add_user_pending_actions/migration.sql new file mode 100644 index 00000000..5763da94 --- /dev/null +++ b/backend/services/identity-service/prisma/migrations/20260102000000_add_user_pending_actions/migration.sql @@ -0,0 +1,26 @@ +-- ============================================================================= +-- 创建用户待办操作表 +-- 用于后台配置指定用户登录后需要执行的特定操作 +-- 完全可选,不影响现有任何功能 +-- ============================================================================= + +-- 创建 user_pending_actions 表 +CREATE TABLE IF NOT EXISTS "user_pending_actions" ( + "id" BIGSERIAL NOT NULL, + "user_id" BIGINT NOT NULL, + "action_code" VARCHAR(50) NOT NULL, + "action_params" JSONB, + "status" VARCHAR(20) NOT NULL DEFAULT 'PENDING', + "priority" INTEGER NOT NULL DEFAULT 0, + "expires_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completed_at" TIMESTAMP(3), + "created_by" VARCHAR(50), + + CONSTRAINT "user_pending_actions_pkey" PRIMARY KEY ("id") +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS "idx_user_pending_action" ON "user_pending_actions"("user_id", "status"); +CREATE INDEX IF NOT EXISTS "idx_action_code" ON "user_pending_actions"("action_code"); +CREATE INDEX IF NOT EXISTS "idx_pending_action_status" ON "user_pending_actions"("status"); diff --git a/backend/services/identity-service/prisma/schema.prisma b/backend/services/identity-service/prisma/schema.prisma index eadf9495..6369b28f 100644 --- a/backend/services/identity-service/prisma/schema.prisma +++ b/backend/services/identity-service/prisma/schema.prisma @@ -450,3 +450,42 @@ model KycConfig { @@map("kyc_configs") } + +// ============================================ +// 用户待办操作表 +// 用于后台配置指定用户登录后需要执行的特定操作 +// 完全可选,不影响现有任何功能 +// ============================================ +model UserPendingAction { + id BigInt @id @default(autoincrement()) + userId BigInt @map("user_id") + + // 操作代码: ADOPTION_WIZARD, SETTLE_REWARDS, BIND_PHONE, FORCE_KYC 等 + // 前端根据此代码决定跳转到哪个页面/流程 + actionCode String @map("action_code") @db.VarChar(50) + + // 操作参数 (JSON格式,完全自定义) + // 例如: { "treeType": "durian", "rewardIds": ["r1", "r2"], "amount": 100 } + actionParams Json? @map("action_params") + + // 操作状态: PENDING(待执行), COMPLETED(已完成), CANCELLED(已取消) + status String @default("PENDING") @db.VarChar(20) + + // 优先级 (数值越大优先级越高,前端按此排序执行) + priority Int @default(0) + + // 可选过期时间 (过期后前端可忽略) + expiresAt DateTime? @map("expires_at") + + // 时间戳 + createdAt DateTime @default(now()) @map("created_at") + completedAt DateTime? @map("completed_at") + + // 创建人 (后台管理员) + createdBy String? @map("created_by") @db.VarChar(50) + + @@index([userId, status], name: "idx_user_pending_action") + @@index([actionCode], name: "idx_action_code") + @@index([status], name: "idx_pending_action_status") + @@map("user_pending_actions") +} diff --git a/backend/services/identity-service/src/api/controllers/pending-action.controller.ts b/backend/services/identity-service/src/api/controllers/pending-action.controller.ts new file mode 100644 index 00000000..306b528a --- /dev/null +++ b/backend/services/identity-service/src/api/controllers/pending-action.controller.ts @@ -0,0 +1,239 @@ +import { + Controller, + Post, + Get, + Put, + Delete, + Body, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, + ApiParam, +} from '@nestjs/swagger'; +import { PendingActionService } from '@/application/services/pending-action.service'; +import { + JwtAuthGuard, + CurrentUser, + CurrentUserData, +} from '@/shared/guards/jwt-auth.guard'; +import { + CreatePendingActionDto, + UpdatePendingActionDto, + QueryPendingActionsDto, + BatchCreatePendingActionDto, + CompletePendingActionDto, +} from '@/api/dto/request/pending-action.dto'; + +// ========== 用户端控制器 ========== + +@ApiTags('用户待办操作 - 用户端') +@Controller('user/pending-actions') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class PendingActionController { + constructor(private readonly pendingActionService: PendingActionService) {} + + /** + * 获取我的待办操作列表 + * 前端登录成功后调用此接口,获取需要执行的特定操作 + */ + @Get() + @ApiOperation({ + summary: '获取我的待办操作列表', + description: '获取当前用户待执行的操作列表,按优先级降序排列。前端根据 actionCode 决定跳转到哪个页面。', + }) + @ApiResponse({ + status: 200, + description: '成功', + schema: { + example: { + code: 'OK', + message: 'success', + data: [ + { + id: '1', + userId: '12345', + actionCode: 'ADOPTION_WIZARD', + actionParams: { treeType: 'durian', location: '马来西亚' }, + status: 'PENDING', + priority: 10, + expiresAt: null, + createdAt: '2025-01-01T00:00:00.000Z', + }, + ], + }, + }, + }) + async getMyPendingActions(@CurrentUser() user: CurrentUserData) { + const result = await this.pendingActionService.getMyPendingActions(user.userId); + return { + code: 'OK', + message: 'success', + data: result, + }; + } + + /** + * 标记操作完成 + */ + @Post(':id/complete') + @ApiOperation({ + summary: '标记操作完成', + description: '用户完成操作后调用此接口标记完成', + }) + @ApiParam({ name: 'id', description: '操作ID' }) + async completeAction( + @CurrentUser() user: CurrentUserData, + @Param('id') actionId: string, + @Body() dto: CompletePendingActionDto, + ) { + const result = await this.pendingActionService.completeAction( + user.userId, + actionId, + dto.resultData, + ); + return { + code: 'OK', + message: '操作已完成', + data: result, + }; + } +} + +// ========== 后台管理控制器 ========== + +@ApiTags('用户待办操作 - 后台管理') +@Controller('admin/pending-actions') +export class AdminPendingActionController { + constructor(private readonly pendingActionService: PendingActionService) {} + + /** + * 创建用户待办操作 + */ + @Post() + @ApiOperation({ + summary: '创建用户待办操作', + description: '为指定用户创建一个待办操作,用户登录后会看到此操作', + }) + async createAction(@Body() dto: CreatePendingActionDto) { + const result = await this.pendingActionService.createAction(dto, 'admin'); + return { + code: 'OK', + message: '操作已创建', + data: result, + }; + } + + /** + * 批量创建用户待办操作 + */ + @Post('batch') + @ApiOperation({ + summary: '批量创建用户待办操作', + description: '为多个用户批量创建相同的待办操作', + }) + async batchCreateActions(@Body() dto: BatchCreatePendingActionDto) { + const result = await this.pendingActionService.batchCreateActions(dto, 'admin'); + return { + code: 'OK', + message: `成功创建 ${result.successCount} 个操作`, + data: result, + }; + } + + /** + * 查询用户待办操作列表 + */ + @Get() + @ApiOperation({ + summary: '查询用户待办操作列表', + description: '支持按用户ID、操作代码、状态筛选,分页返回', + }) + async queryActions(@Query() dto: QueryPendingActionsDto) { + const result = await this.pendingActionService.queryActions(dto); + return { + code: 'OK', + message: 'success', + data: result, + }; + } + + /** + * 获取单个操作详情 + */ + @Get(':id') + @ApiOperation({ + summary: '获取操作详情', + }) + @ApiParam({ name: 'id', description: '操作ID' }) + async getAction(@Param('id') actionId: string) { + const result = await this.pendingActionService.getAction(actionId); + return { + code: 'OK', + message: 'success', + data: result, + }; + } + + /** + * 更新用户待办操作 + */ + @Put(':id') + @ApiOperation({ + summary: '更新用户待办操作', + description: '更新操作的参数、状态、优先级、过期时间等', + }) + @ApiParam({ name: 'id', description: '操作ID' }) + async updateAction( + @Param('id') actionId: string, + @Body() dto: UpdatePendingActionDto, + ) { + const result = await this.pendingActionService.updateAction(actionId, dto); + return { + code: 'OK', + message: '操作已更新', + data: result, + }; + } + + /** + * 取消用户待办操作 (软删除) + */ + @Post(':id/cancel') + @ApiOperation({ + summary: '取消用户待办操作', + description: '将操作状态改为 CANCELLED,用户将不再看到此操作', + }) + @ApiParam({ name: 'id', description: '操作ID' }) + async cancelAction(@Param('id') actionId: string) { + const result = await this.pendingActionService.cancelAction(actionId); + return { + code: 'OK', + message: '操作已取消', + data: result, + }; + } + + /** + * 删除用户待办操作 (硬删除) + */ + @Delete(':id') + @ApiOperation({ + summary: '删除用户待办操作', + description: '彻底删除操作记录', + }) + @ApiParam({ name: 'id', description: '操作ID' }) + async deleteAction(@Param('id') actionId: string) { + await this.pendingActionService.deleteAction(actionId); + return { + code: 'OK', + message: '操作已删除', + }; + } +} diff --git a/backend/services/identity-service/src/api/dto/request/index.ts b/backend/services/identity-service/src/api/dto/request/index.ts index f71740dd..5b7441cd 100644 --- a/backend/services/identity-service/src/api/dto/request/index.ts +++ b/backend/services/identity-service/src/api/dto/request/index.ts @@ -19,3 +19,4 @@ export * from './bind-email.dto'; export * from './unbind-email.dto'; export * from './register-without-sms-verify.dto'; export * from './change-phone.dto'; +export * from './pending-action.dto'; diff --git a/backend/services/identity-service/src/api/dto/request/pending-action.dto.ts b/backend/services/identity-service/src/api/dto/request/pending-action.dto.ts new file mode 100644 index 00000000..61e3030f --- /dev/null +++ b/backend/services/identity-service/src/api/dto/request/pending-action.dto.ts @@ -0,0 +1,167 @@ +import { + IsString, + IsOptional, + IsNotEmpty, + IsInt, + IsObject, + IsDateString, + IsEnum, + Min, + MaxLength, + IsArray, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +/** + * 操作状态枚举 + */ +export enum PendingActionStatus { + PENDING = 'PENDING', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', +} + +/** + * 创建用户待办操作 DTO (后台管理) + */ +export class CreatePendingActionDto { + @ApiProperty({ example: '12345', description: '目标用户ID' }) + @IsString() + @IsNotEmpty({ message: '用户ID不能为空' }) + userId: string; + + @ApiProperty({ + example: 'ADOPTION_WIZARD', + description: '操作代码,如: ADOPTION_WIZARD, SETTLE_REWARDS, BIND_PHONE, FORCE_KYC', + }) + @IsString() + @IsNotEmpty({ message: '操作代码不能为空' }) + @MaxLength(50) + actionCode: string; + + @ApiPropertyOptional({ + example: { treeType: 'durian', location: '马来西亚' }, + description: '操作参数 (JSON对象)', + }) + @IsOptional() + @IsObject() + actionParams?: Record; + + @ApiPropertyOptional({ example: 10, description: '优先级 (数值越大优先级越高)' }) + @IsOptional() + @IsInt() + @Min(0) + priority?: number; + + @ApiPropertyOptional({ + example: '2025-12-31T23:59:59Z', + description: '过期时间 (ISO 8601格式)', + }) + @IsOptional() + @IsDateString() + expiresAt?: string; +} + +/** + * 批量创建用户待办操作 DTO + */ +export class BatchCreatePendingActionDto { + @ApiProperty({ type: [String], description: '目标用户ID列表' }) + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + userIds: string[]; + + @ApiProperty({ example: 'ADOPTION_WIZARD', description: '操作代码' }) + @IsString() + @IsNotEmpty({ message: '操作代码不能为空' }) + @MaxLength(50) + actionCode: string; + + @ApiPropertyOptional({ description: '操作参数' }) + @IsOptional() + @IsObject() + actionParams?: Record; + + @ApiPropertyOptional({ example: 0, description: '优先级' }) + @IsOptional() + @IsInt() + @Min(0) + priority?: number; + + @ApiPropertyOptional({ description: '过期时间' }) + @IsOptional() + @IsDateString() + expiresAt?: string; +} + +/** + * 更新用户待办操作 DTO + */ +export class UpdatePendingActionDto { + @ApiPropertyOptional({ description: '操作参数' }) + @IsOptional() + @IsObject() + actionParams?: Record; + + @ApiPropertyOptional({ enum: PendingActionStatus, description: '状态' }) + @IsOptional() + @IsEnum(PendingActionStatus) + status?: PendingActionStatus; + + @ApiPropertyOptional({ description: '优先级' }) + @IsOptional() + @IsInt() + @Min(0) + priority?: number; + + @ApiPropertyOptional({ description: '过期时间' }) + @IsOptional() + @IsDateString() + expiresAt?: string; +} + +/** + * 查询用户待办操作 DTO (后台管理) + */ +export class QueryPendingActionsDto { + @ApiPropertyOptional({ description: '用户ID' }) + @IsOptional() + @IsString() + userId?: string; + + @ApiPropertyOptional({ description: '操作代码' }) + @IsOptional() + @IsString() + actionCode?: string; + + @ApiPropertyOptional({ enum: PendingActionStatus, description: '状态' }) + @IsOptional() + @IsEnum(PendingActionStatus) + status?: PendingActionStatus; + + @ApiPropertyOptional({ example: 1, description: '页码' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ example: 20, description: '每页数量' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number; +} + +/** + * 用户标记操作完成 DTO + */ +export class CompletePendingActionDto { + @ApiPropertyOptional({ description: '完成时附带的数据' }) + @IsOptional() + @IsObject() + resultData?: Record; +} diff --git a/backend/services/identity-service/src/api/dto/response/index.ts b/backend/services/identity-service/src/api/dto/response/index.ts index 1e8628d1..2bce9283 100644 --- a/backend/services/identity-service/src/api/dto/response/index.ts +++ b/backend/services/identity-service/src/api/dto/response/index.ts @@ -1,2 +1,3 @@ export * from './user-profile.dto'; export * from './device.dto'; +export * from './pending-action.dto'; diff --git a/backend/services/identity-service/src/api/dto/response/pending-action.dto.ts b/backend/services/identity-service/src/api/dto/response/pending-action.dto.ts new file mode 100644 index 00000000..c30d9c86 --- /dev/null +++ b/backend/services/identity-service/src/api/dto/response/pending-action.dto.ts @@ -0,0 +1,73 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * 用户待办操作响应 DTO + */ +export class PendingActionResponseDto { + @ApiProperty({ example: '1', description: '操作ID' }) + id: string; + + @ApiProperty({ example: '12345', description: '用户ID' }) + userId: string; + + @ApiProperty({ example: 'ADOPTION_WIZARD', description: '操作代码' }) + actionCode: string; + + @ApiPropertyOptional({ + example: { treeType: 'durian', location: '马来西亚' }, + description: '操作参数', + }) + actionParams: Record | null; + + @ApiProperty({ example: 'PENDING', description: '状态' }) + status: string; + + @ApiProperty({ example: 10, description: '优先级' }) + priority: number; + + @ApiPropertyOptional({ description: '过期时间' }) + expiresAt: Date | null; + + @ApiProperty({ description: '创建时间' }) + createdAt: Date; + + @ApiPropertyOptional({ description: '完成时间' }) + completedAt: Date | null; + + @ApiPropertyOptional({ description: '创建人' }) + createdBy: string | null; +} + +/** + * 用户待办操作列表响应 DTO (带分页) + */ +export class PendingActionListResponseDto { + @ApiProperty({ type: [PendingActionResponseDto], description: '操作列表' }) + items: PendingActionResponseDto[]; + + @ApiProperty({ example: 100, description: '总数' }) + total: number; + + @ApiProperty({ example: 1, description: '当前页' }) + page: number; + + @ApiProperty({ example: 20, description: '每页数量' }) + limit: number; +} + +/** + * 批量创建结果响应 DTO + */ +export class BatchCreateResultDto { + @ApiProperty({ example: 10, description: '成功创建数量' }) + successCount: number; + + @ApiProperty({ example: 2, description: '失败数量' }) + failedCount: number; + + @ApiPropertyOptional({ + type: [String], + description: '失败的用户ID列表', + }) + failedUserIds?: string[]; +} diff --git a/backend/services/identity-service/src/app.module.ts b/backend/services/identity-service/src/app.module.ts index 2195742e..f2a0aa73 100644 --- a/backend/services/identity-service/src/app.module.ts +++ b/backend/services/identity-service/src/app.module.ts @@ -24,12 +24,17 @@ import { AuthController } from '@/api/controllers/auth.controller'; import { TotpController } from '@/api/controllers/totp.controller'; import { InternalController } from '@/api/controllers/internal.controller'; import { KycController, AdminKycController } from '@/api/controllers/kyc.controller'; +import { + PendingActionController, + AdminPendingActionController, +} from '@/api/controllers/pending-action.controller'; // Application Services import { UserApplicationService } from '@/application/services/user-application.service'; import { TokenService } from '@/application/services/token.service'; import { TotpService } from '@/application/services/totp.service'; import { KycApplicationService } from '@/application/services/kyc-application.service'; +import { PendingActionService } from '@/application/services/pending-action.service'; import { BlockchainWalletHandler } from '@/application/event-handlers/blockchain-wallet.handler'; import { MpcKeygenCompletedHandler } from '@/application/event-handlers/mpc-keygen-completed.handler'; import { WalletRetryTask } from '@/application/tasks/wallet-retry.task'; @@ -134,13 +139,14 @@ export class DomainModule {} TokenService, TotpService, KycApplicationService, + PendingActionService, // Event Handlers - 通过注入到 UserApplicationService 来确保它们被初始化 BlockchainWalletHandler, MpcKeygenCompletedHandler, // Tasks - 定时任务 WalletRetryTask, ], - exports: [UserApplicationService, TokenService, TotpService, KycApplicationService], + exports: [UserApplicationService, TokenService, TotpService, KycApplicationService, PendingActionService], }) export class ApplicationModule {} @@ -156,6 +162,8 @@ export class ApplicationModule {} InternalController, KycController, AdminKycController, + PendingActionController, + AdminPendingActionController, ], providers: [UserAccountRepositoryImpl], }) diff --git a/backend/services/identity-service/src/application/services/pending-action.service.ts b/backend/services/identity-service/src/application/services/pending-action.service.ts new file mode 100644 index 00000000..6fa8ba20 --- /dev/null +++ b/backend/services/identity-service/src/application/services/pending-action.service.ts @@ -0,0 +1,359 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; +import { ApplicationError } from '@/shared/exceptions/domain.exception'; +import { + PendingActionStatus, + CreatePendingActionDto, + UpdatePendingActionDto, + QueryPendingActionsDto, + BatchCreatePendingActionDto, +} from '@/api/dto/request/pending-action.dto'; +import { + PendingActionResponseDto, + PendingActionListResponseDto, + BatchCreateResultDto, +} from '@/api/dto/response/pending-action.dto'; + +/** + * 用户待办操作服务 + * + * 提供用户待办操作的增删改查功能,用于后台配置指定用户登录后需要执行的特定操作。 + * 完全独立,不影响现有任何功能。 + */ +@Injectable() +export class PendingActionService { + private readonly logger = new Logger(PendingActionService.name); + + constructor(private readonly prisma: PrismaService) {} + + // ==================== 用户端接口 ==================== + + /** + * 获取当前用户的待办操作列表 + * 返回 PENDING 状态且未过期的操作,按优先级降序排列 + */ + async getMyPendingActions(userId: string): Promise { + this.logger.log(`[PendingAction] Getting pending actions for user: ${userId}`); + + const now = new Date(); + const actions = await this.prisma.userPendingAction.findMany({ + where: { + userId: BigInt(userId), + status: PendingActionStatus.PENDING, + OR: [ + { expiresAt: null }, + { expiresAt: { gt: now } }, + ], + }, + orderBy: [ + { priority: 'desc' }, + { createdAt: 'asc' }, + ], + }); + + return actions.map((action) => this.toResponseDto(action)); + } + + /** + * 用户标记操作完成 + */ + async completeAction( + userId: string, + actionId: string, + resultData?: Record, + ): Promise { + this.logger.log(`[PendingAction] Completing action: ${actionId} for user: ${userId}`); + + const action = await this.prisma.userPendingAction.findFirst({ + where: { + id: BigInt(actionId), + userId: BigInt(userId), + }, + }); + + if (!action) { + throw new ApplicationError('操作不存在'); + } + + if (action.status !== PendingActionStatus.PENDING) { + throw new ApplicationError('操作已完成或已取消'); + } + + const updated = await this.prisma.userPendingAction.update({ + where: { id: BigInt(actionId) }, + data: { + status: PendingActionStatus.COMPLETED, + completedAt: new Date(), + // 如果有结果数据,合并到 actionParams 中 + ...(resultData && { + actionParams: { + ...(action.actionParams as object || {}), + _result: resultData, + }, + }), + }, + }); + + this.logger.log(`[PendingAction] Action completed: ${actionId}`); + + return this.toResponseDto(updated); + } + + // ==================== 后台管理接口 ==================== + + /** + * 创建用户待办操作 (后台管理) + */ + async createAction( + dto: CreatePendingActionDto, + createdBy?: string, + ): Promise { + this.logger.log(`[PendingAction] Creating action for user: ${dto.userId}, code: ${dto.actionCode}`); + + // 验证用户是否存在 + const user = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(dto.userId) }, + select: { userId: true }, + }); + + if (!user) { + throw new ApplicationError('用户不存在'); + } + + const action = await this.prisma.userPendingAction.create({ + data: { + userId: BigInt(dto.userId), + actionCode: dto.actionCode, + actionParams: dto.actionParams ?? undefined, + priority: dto.priority ?? 0, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, + createdBy, + }, + }); + + this.logger.log(`[PendingAction] Action created: ${action.id}`); + + return this.toResponseDto(action); + } + + /** + * 批量创建用户待办操作 (后台管理) + */ + async batchCreateActions( + dto: BatchCreatePendingActionDto, + createdBy?: string, + ): Promise { + this.logger.log(`[PendingAction] Batch creating actions for ${dto.userIds.length} users, code: ${dto.actionCode}`); + + let successCount = 0; + const failedUserIds: string[] = []; + + for (const userId of dto.userIds) { + try { + // 验证用户是否存在 + const user = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { userId: true }, + }); + + if (!user) { + failedUserIds.push(userId); + continue; + } + + await this.prisma.userPendingAction.create({ + data: { + userId: BigInt(userId), + actionCode: dto.actionCode, + actionParams: dto.actionParams ?? undefined, + priority: dto.priority ?? 0, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, + createdBy, + }, + }); + + successCount++; + } catch (error) { + this.logger.warn(`[PendingAction] Failed to create action for user: ${userId}, error: ${error.message}`); + failedUserIds.push(userId); + } + } + + this.logger.log(`[PendingAction] Batch create result: success=${successCount}, failed=${failedUserIds.length}`); + + return { + successCount, + failedCount: failedUserIds.length, + failedUserIds: failedUserIds.length > 0 ? failedUserIds : undefined, + }; + } + + /** + * 查询用户待办操作列表 (后台管理) + */ + async queryActions(dto: QueryPendingActionsDto): Promise { + const page = dto.page ?? 1; + const limit = dto.limit ?? 20; + const skip = (page - 1) * limit; + + const where: any = {}; + + if (dto.userId) { + where.userId = BigInt(dto.userId); + } + if (dto.actionCode) { + where.actionCode = dto.actionCode; + } + if (dto.status) { + where.status = dto.status; + } + + const [items, total] = await Promise.all([ + this.prisma.userPendingAction.findMany({ + where, + orderBy: [ + { createdAt: 'desc' }, + ], + skip, + take: limit, + }), + this.prisma.userPendingAction.count({ where }), + ]); + + return { + items: items.map((action) => this.toResponseDto(action)), + total, + page, + limit, + }; + } + + /** + * 获取单个操作详情 (后台管理) + */ + async getAction(actionId: string): Promise { + const action = await this.prisma.userPendingAction.findUnique({ + where: { id: BigInt(actionId) }, + }); + + if (!action) { + throw new ApplicationError('操作不存在'); + } + + return this.toResponseDto(action); + } + + /** + * 更新用户待办操作 (后台管理) + */ + async updateAction( + actionId: string, + dto: UpdatePendingActionDto, + ): Promise { + this.logger.log(`[PendingAction] Updating action: ${actionId}`); + + const action = await this.prisma.userPendingAction.findUnique({ + where: { id: BigInt(actionId) }, + }); + + if (!action) { + throw new ApplicationError('操作不存在'); + } + + const updateData: any = {}; + + if (dto.actionParams !== undefined) { + updateData.actionParams = dto.actionParams; + } + if (dto.status !== undefined) { + updateData.status = dto.status; + if (dto.status === PendingActionStatus.COMPLETED) { + updateData.completedAt = new Date(); + } + } + if (dto.priority !== undefined) { + updateData.priority = dto.priority; + } + if (dto.expiresAt !== undefined) { + updateData.expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : null; + } + + const updated = await this.prisma.userPendingAction.update({ + where: { id: BigInt(actionId) }, + data: updateData, + }); + + this.logger.log(`[PendingAction] Action updated: ${actionId}`); + + return this.toResponseDto(updated); + } + + /** + * 删除用户待办操作 (后台管理) + */ + async deleteAction(actionId: string): Promise { + this.logger.log(`[PendingAction] Deleting action: ${actionId}`); + + const action = await this.prisma.userPendingAction.findUnique({ + where: { id: BigInt(actionId) }, + }); + + if (!action) { + throw new ApplicationError('操作不存在'); + } + + await this.prisma.userPendingAction.delete({ + where: { id: BigInt(actionId) }, + }); + + this.logger.log(`[PendingAction] Action deleted: ${actionId}`); + } + + /** + * 取消用户待办操作 (后台管理) + * 软删除,将状态改为 CANCELLED + */ + async cancelAction(actionId: string): Promise { + this.logger.log(`[PendingAction] Cancelling action: ${actionId}`); + + const action = await this.prisma.userPendingAction.findUnique({ + where: { id: BigInt(actionId) }, + }); + + if (!action) { + throw new ApplicationError('操作不存在'); + } + + if (action.status !== PendingActionStatus.PENDING) { + throw new ApplicationError('只能取消待处理的操作'); + } + + const updated = await this.prisma.userPendingAction.update({ + where: { id: BigInt(actionId) }, + data: { + status: PendingActionStatus.CANCELLED, + }, + }); + + this.logger.log(`[PendingAction] Action cancelled: ${actionId}`); + + return this.toResponseDto(updated); + } + + // ==================== Helper Methods ==================== + + private toResponseDto(action: any): PendingActionResponseDto { + return { + id: action.id.toString(), + userId: action.userId.toString(), + actionCode: action.actionCode, + actionParams: action.actionParams as Record | null, + status: action.status, + priority: action.priority, + expiresAt: action.expiresAt, + createdAt: action.createdAt, + completedAt: action.completedAt, + createdBy: action.createdBy, + }; + } +} diff --git a/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx b/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx new file mode 100644 index 00000000..2cb6ac33 --- /dev/null +++ b/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx @@ -0,0 +1,688 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { Modal, toast, Button } from '@/components/common'; +import { PageContainer } from '@/components/layout'; +import { cn } from '@/utils/helpers'; +import { formatDateTime } from '@/utils/formatters'; +import { + usePendingActions, + useCreatePendingAction, + useBatchCreatePendingActions, + useUpdatePendingAction, + useCancelPendingAction, + useDeletePendingAction, +} from '@/hooks/usePendingActions'; +import { + PendingAction, + PendingActionStatus, + ACTION_CODE_OPTIONS, + STATUS_OPTIONS, + getStatusInfo, + getActionCodeLabel, +} from '@/types/pending-action.types'; +import styles from './pending-actions.module.scss'; + +/** + * 用户待办操作管理页面 + */ +export default function PendingActionsPage() { + // 筛选状态 + const [filters, setFilters] = useState({ + userId: '', + actionCode: '', + status: '' as PendingActionStatus | '', + page: 1, + limit: 20, + }); + + // 弹窗状态 + const [showCreateModal, setShowCreateModal] = useState(false); + const [showBatchModal, setShowBatchModal] = useState(false); + const [editingAction, setEditingAction] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [viewingAction, setViewingAction] = useState(null); + + // 表单状态 + const [formData, setFormData] = useState({ + userId: '', + actionCode: 'ADOPTION_WIZARD', + actionParams: '', + priority: 0, + expiresAt: '', + }); + + // 批量创建表单 + const [batchFormData, setBatchFormData] = useState({ + userIds: '', + actionCode: 'ADOPTION_WIZARD', + actionParams: '', + priority: 0, + expiresAt: '', + }); + + // 数据查询 + const { data, isLoading, error, refetch } = usePendingActions({ + userId: filters.userId || undefined, + actionCode: filters.actionCode || undefined, + status: filters.status || undefined, + page: filters.page, + limit: filters.limit, + }); + + // Mutations + const createMutation = useCreatePendingAction(); + const batchCreateMutation = useBatchCreatePendingActions(); + const updateMutation = useUpdatePendingAction(); + const cancelMutation = useCancelPendingAction(); + const deleteMutation = useDeletePendingAction(); + + // 打开创建弹窗 + const handleCreate = () => { + setEditingAction(null); + setFormData({ + userId: '', + actionCode: 'ADOPTION_WIZARD', + actionParams: '', + priority: 0, + expiresAt: '', + }); + setShowCreateModal(true); + }; + + // 打开编辑弹窗 + const handleEdit = (action: PendingAction) => { + setEditingAction(action); + setFormData({ + userId: action.userId, + actionCode: action.actionCode, + actionParams: action.actionParams ? JSON.stringify(action.actionParams, null, 2) : '', + priority: action.priority, + expiresAt: action.expiresAt ? action.expiresAt.slice(0, 16) : '', + }); + setShowCreateModal(true); + }; + + // 打开批量创建弹窗 + const handleBatchCreate = () => { + setBatchFormData({ + userIds: '', + actionCode: 'ADOPTION_WIZARD', + actionParams: '', + priority: 0, + expiresAt: '', + }); + setShowBatchModal(true); + }; + + // 提交创建/编辑表单 + const handleSubmit = async () => { + if (!editingAction && !formData.userId.trim()) { + toast.error('请输入用户ID'); + return; + } + + let actionParams: Record | undefined; + if (formData.actionParams.trim()) { + try { + actionParams = JSON.parse(formData.actionParams); + } catch { + toast.error('操作参数格式错误,请输入有效的 JSON'); + return; + } + } + + try { + if (editingAction) { + await updateMutation.mutateAsync({ + id: editingAction.id, + data: { + actionParams, + priority: formData.priority, + expiresAt: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined, + }, + }); + toast.success('操作已更新'); + } else { + await createMutation.mutateAsync({ + userId: formData.userId.trim(), + actionCode: formData.actionCode, + actionParams, + priority: formData.priority, + expiresAt: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined, + }); + toast.success('操作已创建'); + } + setShowCreateModal(false); + } catch (err) { + toast.error((err as Error).message || '操作失败'); + } + }; + + // 提交批量创建 + const handleBatchSubmit = async () => { + const userIds = batchFormData.userIds + .split(/[\n,]/) + .map((id) => id.trim()) + .filter(Boolean); + + if (userIds.length === 0) { + toast.error('请输入用户ID'); + return; + } + + let actionParams: Record | undefined; + if (batchFormData.actionParams.trim()) { + try { + actionParams = JSON.parse(batchFormData.actionParams); + } catch { + toast.error('操作参数格式错误,请输入有效的 JSON'); + return; + } + } + + try { + const result = await batchCreateMutation.mutateAsync({ + userIds, + actionCode: batchFormData.actionCode, + actionParams, + priority: batchFormData.priority, + expiresAt: batchFormData.expiresAt + ? new Date(batchFormData.expiresAt).toISOString() + : undefined, + }); + toast.success(`成功创建 ${result.successCount} 个操作${result.failedCount > 0 ? `,${result.failedCount} 个失败` : ''}`); + setShowBatchModal(false); + } catch (err) { + toast.error((err as Error).message || '操作失败'); + } + }; + + // 取消操作 + const handleCancel = async (id: string) => { + try { + await cancelMutation.mutateAsync(id); + toast.success('操作已取消'); + } catch (err) { + toast.error((err as Error).message || '取消失败'); + } + }; + + // 删除操作 + const handleDelete = async (id: string) => { + try { + await deleteMutation.mutateAsync(id); + toast.success('操作已删除'); + setDeleteConfirm(null); + } catch (err) { + toast.error((err as Error).message || '删除失败'); + } + }; + + // 搜索处理 + const handleSearch = useCallback(() => { + setFilters((prev) => ({ ...prev, page: 1 })); + refetch(); + }, [refetch]); + + // 翻页 + const handlePageChange = (page: number) => { + setFilters((prev) => ({ ...prev, page })); + }; + + return ( + +
+ {/* 页面标题 */} +
+

用户待办操作管理

+

+ 配置指定用户登录后需要执行的特定操作,如认种向导、结算奖励等 +

+
+ + {/* 主内容卡片 */} +
+ {/* 筛选区域 */} +
+ setFilters({ ...filters, userId: e.target.value })} + /> + + + + +
+ + +
+ + {/* 列表 */} +
+ {isLoading ? ( +
加载中...
+ ) : error ? ( +
+ {(error as Error).message || '加载失败'} + +
+ ) : !data || data.items.length === 0 ? ( +
+ 暂无数据,点击"新建操作"创建第一条待办操作 +
+ ) : ( + <> + {/* 表格 */} + + + + + + + + + + + + + + + {data.items.map((action) => { + const statusInfo = getStatusInfo(action.status); + return ( + + + + + + + + + + + ); + })} + +
ID用户ID操作类型状态优先级创建时间过期时间操作
{action.id}{action.userId} + + {getActionCodeLabel(action.actionCode)} + + + + {statusInfo.label} + + {action.priority}{formatDateTime(action.createdAt)}{action.expiresAt ? formatDateTime(action.expiresAt) : '-'} +
+ + {action.status === 'PENDING' && ( + <> + + + + )} + +
+
+ + {/* 分页 */} + {data.total > filters.limit && ( +
+ + 共 {data.total} 条,第 {data.page} / {Math.ceil(data.total / data.limit)} 页 + +
+ + +
+
+ )} + + )} +
+
+ + {/* 创建/编辑弹窗 */} + setShowCreateModal(false)} + footer={ +
+ + +
+ } + width={600} + > +
+ {!editingAction && ( +
+ + setFormData({ ...formData, userId: e.target.value })} + placeholder="请输入目标用户ID" + /> +
+ )} + +
+ + +
+ +
+ +