feat(admin): 实现用户管理功能完整前后端架构
## 概述 为 admin-web 用户管理页面实现完整的前后端架构,采用事件驱动 CQRS 模式, 通过 Kafka 事件同步用户数据到本地物化视图,避免跨服务 HTTP 调用。 ## admin-service 后端变更 ### 数据库 Schema - UserQueryView: 用户查询视图表 (通过 Kafka 事件同步) - EventConsumerOffset: 事件消费位置追踪 - ProcessedEvent: 已处理事件记录 (幂等性) ### 新增组件 - IUserQueryRepository: 用户查询仓储接口 - UserQueryRepositoryImpl: 用户查询仓储实现 - UserEventConsumerService: Kafka 事件消费者 - UserController: 用户管理 API 控制器 ### API 端点 - GET /admin/users: 用户列表 (分页/筛选/排序) - GET /admin/users/🆔 用户详情 - GET /admin/users/stats/summary: 用户统计 ## identity-service 变更 - 新增 UserProfileUpdatedEvent 事件 - updateProfile 方法现在会发布事件 ## admin-web 前端变更 - userService: 用户 API 服务封装 - useUsers/useUserDetail: React Query hooks - 用户管理页面接入真实 API - 添加加载骨架屏/错误重试/空数据提示 ## 架构特点 - CQRS: 读从本地视图,写触发事件 - 事件驱动: Kafka 事件同步,微服务解耦 - Outbox 模式: 可靠事件发布 - 幂等性: ProcessedEvent 防重复处理 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
92850d8c62
commit
ca619bff0b
|
|
@ -110,3 +110,83 @@ enum TargetType {
|
||||||
NEW_USER // 新用户
|
NEW_USER // 新用户
|
||||||
VIP // VIP用户
|
VIP // VIP用户
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// User Query View (用户查询视图 - 通过 Kafka 事件同步)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// 用户查询视图 - 本地物化视图,通过消费 Kafka 事件同步维护
|
||||||
|
/// 用于 admin-web 用户管理页面的查询,避免跨服务 HTTP 调用
|
||||||
|
model UserQueryView {
|
||||||
|
userId BigInt @id @map("user_id")
|
||||||
|
accountSequence String @unique @map("account_sequence") @db.VarChar(12)
|
||||||
|
|
||||||
|
// 基本信息 (来自 identity-service 事件)
|
||||||
|
nickname String? @db.VarChar(100)
|
||||||
|
avatarUrl String? @map("avatar_url") @db.Text
|
||||||
|
phoneNumberMasked String? @map("phone_number_masked") @db.VarChar(20) // 脱敏: 138****8888
|
||||||
|
|
||||||
|
// 推荐关系
|
||||||
|
inviterSequence String? @map("inviter_sequence") @db.VarChar(12)
|
||||||
|
|
||||||
|
// KYC 状态
|
||||||
|
kycStatus String @default("NOT_VERIFIED") @map("kyc_status") @db.VarChar(20)
|
||||||
|
|
||||||
|
// 认种统计 (来自 planting-service 事件)
|
||||||
|
personalAdoptionCount Int @default(0) @map("personal_adoption_count")
|
||||||
|
teamAddressCount Int @default(0) @map("team_address_count")
|
||||||
|
teamAdoptionCount Int @default(0) @map("team_adoption_count")
|
||||||
|
|
||||||
|
// 授权统计 (来自 authorization-service 事件)
|
||||||
|
provinceAdoptionCount Int @default(0) @map("province_adoption_count")
|
||||||
|
cityAdoptionCount Int @default(0) @map("city_adoption_count")
|
||||||
|
|
||||||
|
// 排名
|
||||||
|
leaderboardRank Int? @map("leaderboard_rank")
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
status String @default("ACTIVE") @db.VarChar(20)
|
||||||
|
isOnline Boolean @default(false) @map("is_online")
|
||||||
|
|
||||||
|
// 时间戳
|
||||||
|
registeredAt DateTime @map("registered_at")
|
||||||
|
lastActiveAt DateTime? @map("last_active_at")
|
||||||
|
syncedAt DateTime @default(now()) @map("synced_at")
|
||||||
|
|
||||||
|
@@index([accountSequence])
|
||||||
|
@@index([nickname])
|
||||||
|
@@index([status])
|
||||||
|
@@index([registeredAt])
|
||||||
|
@@index([personalAdoptionCount])
|
||||||
|
@@index([inviterSequence])
|
||||||
|
@@map("user_query_view")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Kafka Event Tracking (事件消费追踪)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// 事件消费位置追踪 - 用于幂等性和断点续传
|
||||||
|
model EventConsumerOffset {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
consumerGroup String @map("consumer_group") @db.VarChar(100)
|
||||||
|
topic String @db.VarChar(100)
|
||||||
|
partition Int
|
||||||
|
offset BigInt
|
||||||
|
updatedAt DateTime @default(now()) @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([consumerGroup, topic, partition])
|
||||||
|
@@map("event_consumer_offsets")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已处理事件记录 - 用于幂等性检查
|
||||||
|
model ProcessedEvent {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
eventId String @unique @map("event_id") @db.VarChar(100)
|
||||||
|
eventType String @map("event_type") @db.VarChar(50)
|
||||||
|
processedAt DateTime @default(now()) @map("processed_at")
|
||||||
|
|
||||||
|
@@index([eventType])
|
||||||
|
@@index([processedAt])
|
||||||
|
@@map("processed_events")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
Inject,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ListUsersDto } from '../dto/request/user-query.dto';
|
||||||
|
import { UserListResponseDto, UserListItemDto, UserDetailDto } from '../dto/response/user.dto';
|
||||||
|
import {
|
||||||
|
IUserQueryRepository,
|
||||||
|
USER_QUERY_REPOSITORY,
|
||||||
|
UserQueryItem,
|
||||||
|
UserQueryFilters,
|
||||||
|
UserQuerySort,
|
||||||
|
} from '../../domain/repositories/user-query.repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户管理控制器
|
||||||
|
*
|
||||||
|
* 为 admin-web 提供用户查询接口
|
||||||
|
*/
|
||||||
|
@Controller('admin/users')
|
||||||
|
export class UserController {
|
||||||
|
constructor(
|
||||||
|
@Inject(USER_QUERY_REPOSITORY)
|
||||||
|
private readonly userQueryRepository: IUserQueryRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户列表
|
||||||
|
* GET /admin/users
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async listUsers(@Query() query: ListUsersDto): Promise<UserListResponseDto> {
|
||||||
|
// 构建筛选条件
|
||||||
|
const filters: UserQueryFilters = {
|
||||||
|
keyword: query.keyword,
|
||||||
|
status: query.status,
|
||||||
|
kycStatus: query.kycStatus,
|
||||||
|
hasInviter: query.hasInviter,
|
||||||
|
minAdoptions: query.minAdoptions,
|
||||||
|
maxAdoptions: query.maxAdoptions,
|
||||||
|
registeredAfter: query.registeredAfter ? new Date(query.registeredAfter) : undefined,
|
||||||
|
registeredBefore: query.registeredBefore ? new Date(query.registeredBefore) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建排序条件
|
||||||
|
const sort: UserQuerySort | undefined = query.sortBy ? {
|
||||||
|
field: query.sortBy as UserQuerySort['field'],
|
||||||
|
order: query.sortOrder || 'desc',
|
||||||
|
} : undefined;
|
||||||
|
|
||||||
|
// 查询数据
|
||||||
|
const result = await this.userQueryRepository.findMany(
|
||||||
|
filters,
|
||||||
|
{ page: query.page || 1, pageSize: query.pageSize || 10 },
|
||||||
|
sort,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取所有用户的团队总认种数用于计算百分比
|
||||||
|
const totalTeamAdoptions = result.items.reduce((sum, item) => sum + item.teamAdoptionCount, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: result.items.map((item) => this.mapToListItem(item, totalTeamAdoptions)),
|
||||||
|
total: result.total,
|
||||||
|
page: result.page,
|
||||||
|
pageSize: result.pageSize,
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户详情
|
||||||
|
* GET /admin/users/:id
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getUserDetail(@Param('id') id: string): Promise<UserDetailDto> {
|
||||||
|
let user: UserQueryItem | null = null;
|
||||||
|
|
||||||
|
// 尝试作为账号序列号查询
|
||||||
|
if (id.startsWith('D')) {
|
||||||
|
user = await this.userQueryRepository.findByAccountSequence(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是序列号或未找到,尝试作为 userId 查询
|
||||||
|
if (!user) {
|
||||||
|
try {
|
||||||
|
const userId = BigInt(id);
|
||||||
|
user = await this.userQueryRepository.findById(userId);
|
||||||
|
} catch {
|
||||||
|
// 无法转换为 BigInt,忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(`用户 ${id} 不存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mapToDetail(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户统计信息
|
||||||
|
* GET /admin/users/stats/summary
|
||||||
|
*/
|
||||||
|
@Get('stats/summary')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getUserStats(): Promise<{
|
||||||
|
totalUsers: number;
|
||||||
|
activeUsers: number;
|
||||||
|
frozenUsers: number;
|
||||||
|
verifiedUsers: number;
|
||||||
|
}> {
|
||||||
|
const [total, active, frozen, verified] = await Promise.all([
|
||||||
|
this.userQueryRepository.count(),
|
||||||
|
this.userQueryRepository.count({ status: 'ACTIVE' }),
|
||||||
|
this.userQueryRepository.count({ status: 'FROZEN' }),
|
||||||
|
this.userQueryRepository.count({ kycStatus: 'VERIFIED' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers: total,
|
||||||
|
activeUsers: active,
|
||||||
|
frozenUsers: frozen,
|
||||||
|
verifiedUsers: verified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Private Methods ====================
|
||||||
|
|
||||||
|
private mapToListItem(item: UserQueryItem, totalTeamAdoptions: number): UserListItemDto {
|
||||||
|
// 计算省市认种百分比
|
||||||
|
const provincePercentage = totalTeamAdoptions > 0
|
||||||
|
? Math.round((item.provinceAdoptionCount / totalTeamAdoptions) * 100)
|
||||||
|
: 0;
|
||||||
|
const cityPercentage = totalTeamAdoptions > 0
|
||||||
|
? Math.round((item.cityAdoptionCount / totalTeamAdoptions) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId: item.userId.toString(),
|
||||||
|
accountSequence: item.accountSequence,
|
||||||
|
avatar: item.avatarUrl,
|
||||||
|
nickname: item.nickname,
|
||||||
|
personalAdoptions: item.personalAdoptionCount,
|
||||||
|
teamAddresses: item.teamAddressCount,
|
||||||
|
teamAdoptions: item.teamAdoptionCount,
|
||||||
|
provincialAdoptions: {
|
||||||
|
count: item.provinceAdoptionCount,
|
||||||
|
percentage: provincePercentage,
|
||||||
|
},
|
||||||
|
cityAdoptions: {
|
||||||
|
count: item.cityAdoptionCount,
|
||||||
|
percentage: cityPercentage,
|
||||||
|
},
|
||||||
|
referrerId: item.inviterSequence,
|
||||||
|
ranking: item.leaderboardRank,
|
||||||
|
status: this.mapStatus(item.status),
|
||||||
|
isOnline: item.isOnline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToDetail(item: UserQueryItem): UserDetailDto {
|
||||||
|
const listItem = this.mapToListItem(item, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...listItem,
|
||||||
|
phoneNumberMasked: item.phoneNumberMasked,
|
||||||
|
kycStatus: item.kycStatus,
|
||||||
|
registeredAt: item.registeredAt.toISOString(),
|
||||||
|
lastActiveAt: item.lastActiveAt?.toISOString() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapStatus(status: string): 'active' | 'frozen' | 'deactivated' {
|
||||||
|
switch (status.toUpperCase()) {
|
||||||
|
case 'ACTIVE':
|
||||||
|
return 'active';
|
||||||
|
case 'FROZEN':
|
||||||
|
return 'frozen';
|
||||||
|
case 'DEACTIVATED':
|
||||||
|
return 'deactivated';
|
||||||
|
default:
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { IsOptional, IsString, IsInt, IsBoolean, Min, Max, IsDateString, IsIn } from 'class-validator';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户列表查询 DTO
|
||||||
|
*/
|
||||||
|
export class ListUsersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
keyword?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['ACTIVE', 'FROZEN', 'DEACTIVATED'])
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['NOT_VERIFIED', 'PENDING', 'VERIFIED', 'REJECTED'])
|
||||||
|
kycStatus?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
@IsBoolean()
|
||||||
|
hasInviter?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
minAdoptions?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxAdoptions?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
registeredAfter?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
registeredBefore?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize?: number = 10;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['registeredAt', 'personalAdoptionCount', 'teamAdoptionCount', 'leaderboardRank'])
|
||||||
|
sortBy?: string = 'registeredAt';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['asc', 'desc'])
|
||||||
|
sortOrder?: 'asc' | 'desc' = 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户详情 DTO
|
||||||
|
*/
|
||||||
|
export class GetUserDetailDto {
|
||||||
|
@IsString()
|
||||||
|
id!: string; // 可以是 userId 或 accountSequence
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* 用户列表项响应 DTO
|
||||||
|
*/
|
||||||
|
export class UserListItemDto {
|
||||||
|
accountId!: string;
|
||||||
|
accountSequence!: string;
|
||||||
|
avatar!: string | null;
|
||||||
|
nickname!: string | null;
|
||||||
|
personalAdoptions!: number;
|
||||||
|
teamAddresses!: number;
|
||||||
|
teamAdoptions!: number;
|
||||||
|
provincialAdoptions!: {
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
};
|
||||||
|
cityAdoptions!: {
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
};
|
||||||
|
referrerId!: string | null;
|
||||||
|
ranking!: number | null;
|
||||||
|
status!: 'active' | 'frozen' | 'deactivated';
|
||||||
|
isOnline!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户列表响应 DTO
|
||||||
|
*/
|
||||||
|
export class UserListResponseDto {
|
||||||
|
items!: UserListItemDto[];
|
||||||
|
total!: number;
|
||||||
|
page!: number;
|
||||||
|
pageSize!: number;
|
||||||
|
totalPages!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户详情响应 DTO
|
||||||
|
*/
|
||||||
|
export class UserDetailDto extends UserListItemDto {
|
||||||
|
phoneNumberMasked!: string | null;
|
||||||
|
kycStatus!: string;
|
||||||
|
registeredAt!: string;
|
||||||
|
lastActiveAt!: string | null;
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,11 @@ import { NotificationMapper } from './infrastructure/persistence/mappers/notific
|
||||||
import { NotificationRepositoryImpl } from './infrastructure/persistence/repositories/notification.repository.impl';
|
import { NotificationRepositoryImpl } from './infrastructure/persistence/repositories/notification.repository.impl';
|
||||||
import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository';
|
import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository';
|
||||||
import { AdminNotificationController, MobileNotificationController } from './api/controllers/notification.controller';
|
import { AdminNotificationController, MobileNotificationController } from './api/controllers/notification.controller';
|
||||||
|
// User Management imports
|
||||||
|
import { UserQueryRepositoryImpl } from './infrastructure/persistence/repositories/user-query.repository.impl';
|
||||||
|
import { USER_QUERY_REPOSITORY } from './domain/repositories/user-query.repository';
|
||||||
|
import { UserController } from './api/controllers/user.controller';
|
||||||
|
import { UserEventConsumerService } from './infrastructure/kafka/user-event-consumer.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -46,6 +51,7 @@ import { AdminNotificationController, MobileNotificationController } from './api
|
||||||
DownloadController,
|
DownloadController,
|
||||||
AdminNotificationController,
|
AdminNotificationController,
|
||||||
MobileNotificationController,
|
MobileNotificationController,
|
||||||
|
UserController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
|
|
@ -72,6 +78,12 @@ import { AdminNotificationController, MobileNotificationController } from './api
|
||||||
provide: NOTIFICATION_REPOSITORY,
|
provide: NOTIFICATION_REPOSITORY,
|
||||||
useClass: NotificationRepositoryImpl,
|
useClass: NotificationRepositoryImpl,
|
||||||
},
|
},
|
||||||
|
// User Management
|
||||||
|
{
|
||||||
|
provide: USER_QUERY_REPOSITORY,
|
||||||
|
useClass: UserQueryRepositoryImpl,
|
||||||
|
},
|
||||||
|
UserEventConsumerService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
/**
|
||||||
|
* 用户查询仓储接口
|
||||||
|
* 用于 admin-web 用户管理页面的查询操作
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UserQueryItem {
|
||||||
|
userId: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
nickname: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
phoneNumberMasked: string | null;
|
||||||
|
inviterSequence: string | null;
|
||||||
|
kycStatus: string;
|
||||||
|
personalAdoptionCount: number;
|
||||||
|
teamAddressCount: number;
|
||||||
|
teamAdoptionCount: number;
|
||||||
|
provinceAdoptionCount: number;
|
||||||
|
cityAdoptionCount: number;
|
||||||
|
leaderboardRank: number | null;
|
||||||
|
status: string;
|
||||||
|
isOnline: boolean;
|
||||||
|
registeredAt: Date;
|
||||||
|
lastActiveAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserQueryFilters {
|
||||||
|
keyword?: string; // 搜索关键词 (账号序列号/昵称)
|
||||||
|
status?: string; // 状态筛选
|
||||||
|
kycStatus?: string; // KYC状态筛选
|
||||||
|
hasInviter?: boolean; // 是否有推荐人
|
||||||
|
minAdoptions?: number; // 最小认种数
|
||||||
|
maxAdoptions?: number; // 最大认种数
|
||||||
|
registeredAfter?: Date; // 注册时间开始
|
||||||
|
registeredBefore?: Date; // 注册时间结束
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserQuerySort {
|
||||||
|
field: 'registeredAt' | 'personalAdoptionCount' | 'teamAdoptionCount' | 'leaderboardRank';
|
||||||
|
order: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserQueryPagination {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserQueryResult {
|
||||||
|
items: UserQueryItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const USER_QUERY_REPOSITORY = Symbol('USER_QUERY_REPOSITORY');
|
||||||
|
|
||||||
|
export interface IUserQueryRepository {
|
||||||
|
/**
|
||||||
|
* 查询用户列表
|
||||||
|
*/
|
||||||
|
findMany(
|
||||||
|
filters: UserQueryFilters,
|
||||||
|
pagination: UserQueryPagination,
|
||||||
|
sort?: UserQuerySort,
|
||||||
|
): Promise<UserQueryResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户ID查询用户详情
|
||||||
|
*/
|
||||||
|
findById(userId: bigint): Promise<UserQueryItem | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据账号序列号查询用户详情
|
||||||
|
*/
|
||||||
|
findByAccountSequence(accountSequence: string): Promise<UserQueryItem | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建或更新用户视图记录 (用于 Kafka 事件同步)
|
||||||
|
*/
|
||||||
|
upsert(data: {
|
||||||
|
userId: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
phoneNumberMasked?: string | null;
|
||||||
|
inviterSequence?: string | null;
|
||||||
|
kycStatus?: string;
|
||||||
|
status?: string;
|
||||||
|
registeredAt: Date;
|
||||||
|
}): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户资料
|
||||||
|
*/
|
||||||
|
updateProfile(
|
||||||
|
userId: bigint,
|
||||||
|
data: {
|
||||||
|
nickname?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
},
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新认种统计
|
||||||
|
*/
|
||||||
|
updateAdoptionStats(
|
||||||
|
userId: bigint,
|
||||||
|
data: {
|
||||||
|
personalAdoptionCount?: number;
|
||||||
|
teamAddressCount?: number;
|
||||||
|
teamAdoptionCount?: number;
|
||||||
|
},
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新授权统计
|
||||||
|
*/
|
||||||
|
updateAuthorizationStats(
|
||||||
|
userId: bigint,
|
||||||
|
data: {
|
||||||
|
provinceAdoptionCount?: number;
|
||||||
|
cityAdoptionCount?: number;
|
||||||
|
},
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户状态
|
||||||
|
*/
|
||||||
|
updateStatus(userId: bigint, status: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 KYC 状态
|
||||||
|
*/
|
||||||
|
updateKycStatus(userId: bigint, kycStatus: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新在线状态
|
||||||
|
*/
|
||||||
|
updateOnlineStatus(userId: bigint, isOnline: boolean): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新在线状态
|
||||||
|
*/
|
||||||
|
batchUpdateOnlineStatus(userIds: bigint[], isOnline: boolean): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户总数
|
||||||
|
*/
|
||||||
|
count(filters?: UserQueryFilters): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否存在
|
||||||
|
*/
|
||||||
|
exists(userId: bigint): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './kafka.module';
|
||||||
|
export * from './user-event-consumer.service';
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../persistence/prisma/prisma.service';
|
||||||
|
import { UserQueryRepositoryImpl } from '../persistence/repositories/user-query.repository.impl';
|
||||||
|
import { USER_QUERY_REPOSITORY } from '../../domain/repositories/user-query.repository';
|
||||||
|
import { UserEventConsumerService } from './user-event-consumer.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
providers: [
|
||||||
|
PrismaService,
|
||||||
|
{
|
||||||
|
provide: USER_QUERY_REPOSITORY,
|
||||||
|
useClass: UserQueryRepositoryImpl,
|
||||||
|
},
|
||||||
|
UserEventConsumerService,
|
||||||
|
],
|
||||||
|
exports: [UserEventConsumerService, USER_QUERY_REPOSITORY],
|
||||||
|
})
|
||||||
|
export class KafkaModule {}
|
||||||
|
|
@ -0,0 +1,407 @@
|
||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
|
||||||
|
import { PrismaService } from '../persistence/prisma/prisma.service';
|
||||||
|
import { IUserQueryRepository, USER_QUERY_REPOSITORY } from '../../domain/repositories/user-query.repository';
|
||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户事件 Payload 类型定义
|
||||||
|
*/
|
||||||
|
interface UserAccountCreatedPayload {
|
||||||
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
referralCode: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
initialDeviceId: string;
|
||||||
|
inviterSequence: string | null;
|
||||||
|
registeredAt: string;
|
||||||
|
_outbox?: {
|
||||||
|
id: string;
|
||||||
|
aggregateId: string;
|
||||||
|
eventType: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserAccountAutoCreatedPayload {
|
||||||
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
referralCode: string;
|
||||||
|
initialDeviceId: string;
|
||||||
|
inviterSequence: string | null;
|
||||||
|
registeredAt: string;
|
||||||
|
_outbox?: {
|
||||||
|
id: string;
|
||||||
|
aggregateId: string;
|
||||||
|
eventType: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserProfileUpdatedPayload {
|
||||||
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
nickname: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
_outbox?: {
|
||||||
|
id: string;
|
||||||
|
aggregateId: string;
|
||||||
|
eventType: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KYCVerifiedPayload {
|
||||||
|
userId: string;
|
||||||
|
verifiedAt: string;
|
||||||
|
_outbox?: {
|
||||||
|
id: string;
|
||||||
|
aggregateId: string;
|
||||||
|
eventType: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KYCRejectedPayload {
|
||||||
|
userId: string;
|
||||||
|
reason: string;
|
||||||
|
_outbox?: {
|
||||||
|
id: string;
|
||||||
|
aggregateId: string;
|
||||||
|
eventType: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserAccountFrozenPayload {
|
||||||
|
userId: string;
|
||||||
|
reason: string;
|
||||||
|
_outbox?: {
|
||||||
|
id: string;
|
||||||
|
aggregateId: string;
|
||||||
|
eventType: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserAccountDeactivatedPayload {
|
||||||
|
userId: string;
|
||||||
|
deactivatedAt: string;
|
||||||
|
_outbox?: {
|
||||||
|
id: string;
|
||||||
|
aggregateId: string;
|
||||||
|
eventType: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户事件消费者服务
|
||||||
|
*
|
||||||
|
* 消费 identity-service 发布的用户相关事件,同步更新本地 UserQueryView
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UserEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(UserEventConsumerService.name);
|
||||||
|
private kafka: Kafka;
|
||||||
|
private consumer: Consumer;
|
||||||
|
private isRunning = false;
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
private readonly topics: string[];
|
||||||
|
private readonly consumerGroup: string;
|
||||||
|
private readonly ackTopic: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@Inject(USER_QUERY_REPOSITORY)
|
||||||
|
private readonly userQueryRepository: IUserQueryRepository,
|
||||||
|
) {
|
||||||
|
const brokers = (this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092')).split(',');
|
||||||
|
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID', 'admin-service');
|
||||||
|
this.consumerGroup = this.configService.get<string>('KAFKA_CONSUMER_GROUP', 'admin-service-user-sync');
|
||||||
|
this.ackTopic = 'identity.events.ack';
|
||||||
|
|
||||||
|
// 订阅的主题
|
||||||
|
this.topics = ['identity.events'];
|
||||||
|
|
||||||
|
this.kafka = new Kafka({
|
||||||
|
clientId,
|
||||||
|
brokers,
|
||||||
|
logLevel: logLevel.WARN,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.consumer = this.kafka.consumer({ groupId: this.consumerGroup });
|
||||||
|
|
||||||
|
this.logger.log(`[UserEventConsumer] Configured with topics: ${this.topics.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.isRunning) {
|
||||||
|
this.logger.warn('[UserEventConsumer] Already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.log('[UserEventConsumer] Connecting to Kafka...');
|
||||||
|
await this.consumer.connect();
|
||||||
|
|
||||||
|
for (const topic of this.topics) {
|
||||||
|
await this.consumer.subscribe({ topic, fromBeginning: false });
|
||||||
|
this.logger.log(`[UserEventConsumer] Subscribed to topic: ${topic}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.consumer.run({
|
||||||
|
eachMessage: async (payload: EachMessagePayload) => {
|
||||||
|
await this.handleMessage(payload);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
this.logger.log('[UserEventConsumer] Started successfully');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('[UserEventConsumer] Failed to start:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.isRunning) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.consumer.disconnect();
|
||||||
|
this.isRunning = false;
|
||||||
|
this.logger.log('[UserEventConsumer] Stopped');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('[UserEventConsumer] Failed to stop:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMessage(payload: EachMessagePayload): Promise<void> {
|
||||||
|
const { topic, partition, message } = payload;
|
||||||
|
|
||||||
|
if (!message.value) {
|
||||||
|
this.logger.warn(`[UserEventConsumer] Empty message from ${topic}:${partition}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventData = JSON.parse(message.value.toString());
|
||||||
|
const eventType = eventData._outbox?.eventType || eventData.eventType;
|
||||||
|
const eventId = eventData._outbox?.id || message.key?.toString();
|
||||||
|
|
||||||
|
this.logger.debug(`[UserEventConsumer] Received event: ${eventType} (${eventId})`);
|
||||||
|
|
||||||
|
// 幂等性检查
|
||||||
|
if (eventId && await this.isEventProcessed(eventId)) {
|
||||||
|
this.logger.debug(`[UserEventConsumer] Event ${eventId} already processed, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理事件
|
||||||
|
await this.processEvent(eventType, eventData);
|
||||||
|
|
||||||
|
// 记录已处理事件
|
||||||
|
if (eventId) {
|
||||||
|
await this.markEventProcessed(eventId, eventType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送确认消息 (B方案)
|
||||||
|
if (eventData._outbox?.id) {
|
||||||
|
await this.sendAck(eventData._outbox.id, eventType);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[UserEventConsumer] ✓ Processed event: ${eventType}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[UserEventConsumer] Failed to process message:`, error);
|
||||||
|
// 不抛出错误,避免阻塞消费
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processEvent(eventType: string, payload: unknown): Promise<void> {
|
||||||
|
switch (eventType) {
|
||||||
|
case 'UserAccountCreated':
|
||||||
|
await this.handleUserAccountCreated(payload as UserAccountCreatedPayload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'UserAccountAutoCreated':
|
||||||
|
await this.handleUserAccountAutoCreated(payload as UserAccountAutoCreatedPayload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'UserProfileUpdated':
|
||||||
|
await this.handleUserProfileUpdated(payload as UserProfileUpdatedPayload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'KYCVerified':
|
||||||
|
await this.handleKYCVerified(payload as KYCVerifiedPayload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'KYCRejected':
|
||||||
|
await this.handleKYCRejected(payload as KYCRejectedPayload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'UserAccountFrozen':
|
||||||
|
await this.handleUserAccountFrozen(payload as UserAccountFrozenPayload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'UserAccountDeactivated':
|
||||||
|
await this.handleUserAccountDeactivated(payload as UserAccountDeactivatedPayload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
this.logger.debug(`[UserEventConsumer] Unknown event type: ${eventType}, skipping`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Event Handlers ====================
|
||||||
|
|
||||||
|
private async handleUserAccountCreated(payload: UserAccountCreatedPayload): Promise<void> {
|
||||||
|
const phoneNumberMasked = payload.phoneNumber
|
||||||
|
? this.maskPhoneNumber(payload.phoneNumber)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await this.userQueryRepository.upsert({
|
||||||
|
userId: BigInt(payload.userId),
|
||||||
|
accountSequence: payload.accountSequence,
|
||||||
|
phoneNumberMasked,
|
||||||
|
inviterSequence: payload.inviterSequence,
|
||||||
|
registeredAt: new Date(payload.registeredAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`[UserEventConsumer] Created user: ${payload.accountSequence}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUserAccountAutoCreated(payload: UserAccountAutoCreatedPayload): Promise<void> {
|
||||||
|
await this.userQueryRepository.upsert({
|
||||||
|
userId: BigInt(payload.userId),
|
||||||
|
accountSequence: payload.accountSequence,
|
||||||
|
inviterSequence: payload.inviterSequence,
|
||||||
|
registeredAt: new Date(payload.registeredAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`[UserEventConsumer] Auto-created user: ${payload.accountSequence}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUserProfileUpdated(payload: UserProfileUpdatedPayload): Promise<void> {
|
||||||
|
const userId = BigInt(payload.userId);
|
||||||
|
|
||||||
|
// 检查用户是否存在
|
||||||
|
const exists = await this.userQueryRepository.exists(userId);
|
||||||
|
if (!exists) {
|
||||||
|
this.logger.warn(`[UserEventConsumer] User ${userId} not found, skipping profile update`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userQueryRepository.updateProfile(userId, {
|
||||||
|
nickname: payload.nickname,
|
||||||
|
avatarUrl: payload.avatarUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`[UserEventConsumer] Updated profile for user: ${payload.accountSequence}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleKYCVerified(payload: KYCVerifiedPayload): Promise<void> {
|
||||||
|
const userId = BigInt(payload.userId);
|
||||||
|
|
||||||
|
const exists = await this.userQueryRepository.exists(userId);
|
||||||
|
if (!exists) {
|
||||||
|
this.logger.warn(`[UserEventConsumer] User ${userId} not found, skipping KYC update`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userQueryRepository.updateKycStatus(userId, 'VERIFIED');
|
||||||
|
this.logger.log(`[UserEventConsumer] KYC verified for user: ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleKYCRejected(payload: KYCRejectedPayload): Promise<void> {
|
||||||
|
const userId = BigInt(payload.userId);
|
||||||
|
|
||||||
|
const exists = await this.userQueryRepository.exists(userId);
|
||||||
|
if (!exists) {
|
||||||
|
this.logger.warn(`[UserEventConsumer] User ${userId} not found, skipping KYC update`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userQueryRepository.updateKycStatus(userId, 'REJECTED');
|
||||||
|
this.logger.log(`[UserEventConsumer] KYC rejected for user: ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUserAccountFrozen(payload: UserAccountFrozenPayload): Promise<void> {
|
||||||
|
const userId = BigInt(payload.userId);
|
||||||
|
|
||||||
|
const exists = await this.userQueryRepository.exists(userId);
|
||||||
|
if (!exists) {
|
||||||
|
this.logger.warn(`[UserEventConsumer] User ${userId} not found, skipping status update`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userQueryRepository.updateStatus(userId, 'FROZEN');
|
||||||
|
this.logger.log(`[UserEventConsumer] User frozen: ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUserAccountDeactivated(payload: UserAccountDeactivatedPayload): Promise<void> {
|
||||||
|
const userId = BigInt(payload.userId);
|
||||||
|
|
||||||
|
const exists = await this.userQueryRepository.exists(userId);
|
||||||
|
if (!exists) {
|
||||||
|
this.logger.warn(`[UserEventConsumer] User ${userId} not found, skipping status update`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userQueryRepository.updateStatus(userId, 'DEACTIVATED');
|
||||||
|
this.logger.log(`[UserEventConsumer] User deactivated: ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Helper Methods ====================
|
||||||
|
|
||||||
|
private maskPhoneNumber(phone: string): string {
|
||||||
|
if (phone.length < 7) return phone;
|
||||||
|
return phone.slice(0, 3) + '****' + phone.slice(-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isEventProcessed(eventId: string): Promise<boolean> {
|
||||||
|
const count = await this.prisma.processedEvent.count({
|
||||||
|
where: { eventId },
|
||||||
|
});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markEventProcessed(eventId: string, eventType: string): Promise<void> {
|
||||||
|
await this.prisma.processedEvent.create({
|
||||||
|
data: {
|
||||||
|
eventId,
|
||||||
|
eventType,
|
||||||
|
processedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendAck(outboxId: string, eventType: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const producer = this.kafka.producer();
|
||||||
|
await producer.connect();
|
||||||
|
await producer.send({
|
||||||
|
topic: this.ackTopic,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
key: outboxId,
|
||||||
|
value: JSON.stringify({
|
||||||
|
outboxId,
|
||||||
|
eventType,
|
||||||
|
consumerId: this.consumerGroup,
|
||||||
|
confirmedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await producer.disconnect();
|
||||||
|
|
||||||
|
this.logger.debug(`[UserEventConsumer] Sent ACK for outbox event ${outboxId}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[UserEventConsumer] Failed to send ACK:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,317 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
IUserQueryRepository,
|
||||||
|
UserQueryItem,
|
||||||
|
UserQueryFilters,
|
||||||
|
UserQueryPagination,
|
||||||
|
UserQuerySort,
|
||||||
|
UserQueryResult,
|
||||||
|
} from '../../../domain/repositories/user-query.repository';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserQueryRepositoryImpl implements IUserQueryRepository {
|
||||||
|
private readonly logger = new Logger(UserQueryRepositoryImpl.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findMany(
|
||||||
|
filters: UserQueryFilters,
|
||||||
|
pagination: UserQueryPagination,
|
||||||
|
sort?: UserQuerySort,
|
||||||
|
): Promise<UserQueryResult> {
|
||||||
|
const where = this.buildWhereClause(filters);
|
||||||
|
const orderBy = this.buildOrderBy(sort);
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.userQueryView.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
skip: (pagination.page - 1) * pagination.pageSize,
|
||||||
|
take: pagination.pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.userQueryView.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map(this.mapToQueryItem),
|
||||||
|
total,
|
||||||
|
page: pagination.page,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
totalPages: Math.ceil(total / pagination.pageSize),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(userId: bigint): Promise<UserQueryItem | null> {
|
||||||
|
const item = await this.prisma.userQueryView.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return item ? this.mapToQueryItem(item) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByAccountSequence(accountSequence: string): Promise<UserQueryItem | null> {
|
||||||
|
const item = await this.prisma.userQueryView.findUnique({
|
||||||
|
where: { accountSequence },
|
||||||
|
});
|
||||||
|
|
||||||
|
return item ? this.mapToQueryItem(item) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(data: {
|
||||||
|
userId: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
phoneNumberMasked?: string | null;
|
||||||
|
inviterSequence?: string | null;
|
||||||
|
kycStatus?: string;
|
||||||
|
status?: string;
|
||||||
|
registeredAt: Date;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.prisma.userQueryView.upsert({
|
||||||
|
where: { userId: data.userId },
|
||||||
|
create: {
|
||||||
|
userId: data.userId,
|
||||||
|
accountSequence: data.accountSequence,
|
||||||
|
nickname: data.nickname ?? null,
|
||||||
|
avatarUrl: data.avatarUrl ?? null,
|
||||||
|
phoneNumberMasked: data.phoneNumberMasked ?? null,
|
||||||
|
inviterSequence: data.inviterSequence ?? null,
|
||||||
|
kycStatus: data.kycStatus ?? 'NOT_VERIFIED',
|
||||||
|
status: data.status ?? 'ACTIVE',
|
||||||
|
registeredAt: data.registeredAt,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
accountSequence: data.accountSequence,
|
||||||
|
nickname: data.nickname !== undefined ? data.nickname : undefined,
|
||||||
|
avatarUrl: data.avatarUrl !== undefined ? data.avatarUrl : undefined,
|
||||||
|
phoneNumberMasked: data.phoneNumberMasked !== undefined ? data.phoneNumberMasked : undefined,
|
||||||
|
inviterSequence: data.inviterSequence !== undefined ? data.inviterSequence : undefined,
|
||||||
|
kycStatus: data.kycStatus !== undefined ? data.kycStatus : undefined,
|
||||||
|
status: data.status !== undefined ? data.status : undefined,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`[UserQueryView] Upserted user ${data.accountSequence}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfile(
|
||||||
|
userId: bigint,
|
||||||
|
data: {
|
||||||
|
nickname?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await this.prisma.userQueryView.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`[UserQueryView] Updated profile for user ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAdoptionStats(
|
||||||
|
userId: bigint,
|
||||||
|
data: {
|
||||||
|
personalAdoptionCount?: number;
|
||||||
|
teamAddressCount?: number;
|
||||||
|
teamAdoptionCount?: number;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await this.prisma.userQueryView.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`[UserQueryView] Updated adoption stats for user ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAuthorizationStats(
|
||||||
|
userId: bigint,
|
||||||
|
data: {
|
||||||
|
provinceAdoptionCount?: number;
|
||||||
|
cityAdoptionCount?: number;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await this.prisma.userQueryView.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`[UserQueryView] Updated authorization stats for user ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(userId: bigint, status: string): Promise<void> {
|
||||||
|
await this.prisma.userQueryView.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`[UserQueryView] Updated status for user ${userId} to ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateKycStatus(userId: bigint, kycStatus: string): Promise<void> {
|
||||||
|
await this.prisma.userQueryView.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
kycStatus,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`[UserQueryView] Updated KYC status for user ${userId} to ${kycStatus}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOnlineStatus(userId: bigint, isOnline: boolean): Promise<void> {
|
||||||
|
await this.prisma.userQueryView.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
isOnline,
|
||||||
|
lastActiveAt: isOnline ? new Date() : undefined,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchUpdateOnlineStatus(userIds: bigint[], isOnline: boolean): Promise<void> {
|
||||||
|
await this.prisma.userQueryView.updateMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
data: {
|
||||||
|
isOnline,
|
||||||
|
lastActiveAt: isOnline ? new Date() : undefined,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`[UserQueryView] Batch updated online status for ${userIds.length} users`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(filters?: UserQueryFilters): Promise<number> {
|
||||||
|
const where = filters ? this.buildWhereClause(filters) : {};
|
||||||
|
return this.prisma.userQueryView.count({ where });
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(userId: bigint): Promise<boolean> {
|
||||||
|
const count = await this.prisma.userQueryView.count({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Private Methods ====================
|
||||||
|
|
||||||
|
private buildWhereClause(filters: UserQueryFilters): Prisma.UserQueryViewWhereInput {
|
||||||
|
const where: Prisma.UserQueryViewWhereInput = {};
|
||||||
|
|
||||||
|
// 关键词搜索 (账号序列号/昵称)
|
||||||
|
if (filters.keyword) {
|
||||||
|
where.OR = [
|
||||||
|
{ accountSequence: { contains: filters.keyword, mode: 'insensitive' } },
|
||||||
|
{ nickname: { contains: filters.keyword, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态筛选
|
||||||
|
if (filters.status) {
|
||||||
|
where.status = filters.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// KYC状态筛选
|
||||||
|
if (filters.kycStatus) {
|
||||||
|
where.kycStatus = filters.kycStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否有推荐人
|
||||||
|
if (filters.hasInviter !== undefined) {
|
||||||
|
where.inviterSequence = filters.hasInviter ? { not: null } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 认种数范围
|
||||||
|
if (filters.minAdoptions !== undefined || filters.maxAdoptions !== undefined) {
|
||||||
|
where.personalAdoptionCount = {};
|
||||||
|
if (filters.minAdoptions !== undefined) {
|
||||||
|
where.personalAdoptionCount.gte = filters.minAdoptions;
|
||||||
|
}
|
||||||
|
if (filters.maxAdoptions !== undefined) {
|
||||||
|
where.personalAdoptionCount.lte = filters.maxAdoptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册时间范围
|
||||||
|
if (filters.registeredAfter || filters.registeredBefore) {
|
||||||
|
where.registeredAt = {};
|
||||||
|
if (filters.registeredAfter) {
|
||||||
|
where.registeredAt.gte = filters.registeredAfter;
|
||||||
|
}
|
||||||
|
if (filters.registeredBefore) {
|
||||||
|
where.registeredAt.lte = filters.registeredBefore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildOrderBy(sort?: UserQuerySort): Prisma.UserQueryViewOrderByWithRelationInput {
|
||||||
|
if (!sort) {
|
||||||
|
return { registeredAt: 'desc' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { [sort.field]: sort.order };
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToQueryItem(item: {
|
||||||
|
userId: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
nickname: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
phoneNumberMasked: string | null;
|
||||||
|
inviterSequence: string | null;
|
||||||
|
kycStatus: string;
|
||||||
|
personalAdoptionCount: number;
|
||||||
|
teamAddressCount: number;
|
||||||
|
teamAdoptionCount: number;
|
||||||
|
provinceAdoptionCount: number;
|
||||||
|
cityAdoptionCount: number;
|
||||||
|
leaderboardRank: number | null;
|
||||||
|
status: string;
|
||||||
|
isOnline: boolean;
|
||||||
|
registeredAt: Date;
|
||||||
|
lastActiveAt: Date | null;
|
||||||
|
}): UserQueryItem {
|
||||||
|
return {
|
||||||
|
userId: item.userId,
|
||||||
|
accountSequence: item.accountSequence,
|
||||||
|
nickname: item.nickname,
|
||||||
|
avatarUrl: item.avatarUrl,
|
||||||
|
phoneNumberMasked: item.phoneNumberMasked,
|
||||||
|
inviterSequence: item.inviterSequence,
|
||||||
|
kycStatus: item.kycStatus,
|
||||||
|
personalAdoptionCount: item.personalAdoptionCount,
|
||||||
|
teamAddressCount: item.teamAddressCount,
|
||||||
|
teamAdoptionCount: item.teamAdoptionCount,
|
||||||
|
provinceAdoptionCount: item.provinceAdoptionCount,
|
||||||
|
cityAdoptionCount: item.cityAdoptionCount,
|
||||||
|
leaderboardRank: item.leaderboardRank,
|
||||||
|
status: item.status,
|
||||||
|
isOnline: item.isOnline,
|
||||||
|
registeredAt: item.registeredAt,
|
||||||
|
lastActiveAt: item.lastActiveAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ import { BlockchainWalletHandler } from '../event-handlers/blockchain-wallet.han
|
||||||
import { MpcKeygenCompletedHandler } from '../event-handlers/mpc-keygen-completed.handler';
|
import { MpcKeygenCompletedHandler } from '../event-handlers/mpc-keygen-completed.handler';
|
||||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
import { generateIdentity } from '@/shared/utils';
|
import { generateIdentity } from '@/shared/utils';
|
||||||
import { MpcKeygenRequestedEvent, AccountRecoveredEvent, AccountRecoveryFailedEvent, MnemonicRevokedEvent, AccountUnfrozenEvent, KeyRotationRequestedEvent } from '@/domain/events';
|
import { MpcKeygenRequestedEvent, AccountRecoveredEvent, AccountRecoveryFailedEvent, MnemonicRevokedEvent, AccountUnfrozenEvent, KeyRotationRequestedEvent, UserProfileUpdatedEvent } from '@/domain/events';
|
||||||
import {
|
import {
|
||||||
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
||||||
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
|
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
|
||||||
|
|
@ -480,6 +480,16 @@ export class UserApplicationService {
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.userRepository.save(account);
|
await this.userRepository.save(account);
|
||||||
|
|
||||||
|
// 发布用户资料更新事件
|
||||||
|
const event = new UserProfileUpdatedEvent({
|
||||||
|
userId: account.id.value.toString(),
|
||||||
|
accountSequence: account.accountSequence.value,
|
||||||
|
nickname: account.nickname,
|
||||||
|
avatarUrl: account.avatarUrl,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
await this.eventPublisher.publishAll([event]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitKYC(command: SubmitKYCCommand): Promise<void> {
|
async submitKYC(command: SubmitKYCCommand): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,28 @@ export class UserAccountDeactivatedEvent extends DomainEvent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户资料更新事件
|
||||||
|
* 当用户更新昵称或头像时发布
|
||||||
|
*/
|
||||||
|
export class UserProfileUpdatedEvent extends DomainEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly payload: {
|
||||||
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
nickname: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
updatedAt: Date;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
get eventType(): string {
|
||||||
|
return 'UserProfileUpdated';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MPC 密钥生成请求事件
|
* MPC 密钥生成请求事件
|
||||||
* 用户创建账户后发布此事件,触发 MPC 服务生成钱包地址
|
* 用户创建账户后发布此事件,触发 MPC 服务生成钱包地址
|
||||||
|
|
|
||||||
|
|
@ -1,158 +1,130 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Modal, toast } from '@/components/common';
|
import { Modal, toast, Button } from '@/components/common';
|
||||||
import { PageContainer } from '@/components/layout';
|
import { PageContainer } from '@/components/layout';
|
||||||
import { cn } from '@/utils/helpers';
|
import { cn } from '@/utils/helpers';
|
||||||
import { formatNumber, formatRanking } from '@/utils/formatters';
|
import { formatNumber, formatRanking } from '@/utils/formatters';
|
||||||
|
import { useUsers, useUserDetail } from '@/hooks';
|
||||||
|
import type { UserListItem } from '@/services/userService';
|
||||||
import styles from './users.module.scss';
|
import styles from './users.module.scss';
|
||||||
|
|
||||||
/**
|
// 骨架屏组件
|
||||||
* 用户数据类型
|
const TableRowSkeleton = () => (
|
||||||
*/
|
<div className={styles.users__tableRow}>
|
||||||
interface UserItem {
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
accountId: string;
|
<div key={i} className={styles.users__tableCellSkeleton}>
|
||||||
avatar: string;
|
<div className={styles.users__skeleton} />
|
||||||
nickname: string;
|
</div>
|
||||||
personalAdoptions: number;
|
))}
|
||||||
teamAddresses: number;
|
</div>
|
||||||
teamAdoptions: number;
|
);
|
||||||
provincialAdoptions: { count: number; percentage: number };
|
|
||||||
cityAdoptions: { count: number; percentage: number };
|
|
||||||
referrerId: string;
|
|
||||||
ranking: number | null;
|
|
||||||
status: 'online' | 'offline' | 'busy';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// 空数据提示
|
||||||
* 模拟用户数据
|
const EmptyData = ({ message }: { message: string }) => (
|
||||||
*/
|
<div className={styles.users__empty}>
|
||||||
const mockUsers: UserItem[] = [
|
<span>{message}</span>
|
||||||
{
|
</div>
|
||||||
accountId: '1001',
|
);
|
||||||
avatar: '/images/Data@2x.png',
|
|
||||||
nickname: '春风',
|
// 错误提示
|
||||||
personalAdoptions: 150,
|
const ErrorMessage = ({ message, onRetry }: { message: string; onRetry?: () => void }) => (
|
||||||
teamAddresses: 3200,
|
<div className={styles.users__error}>
|
||||||
teamAdoptions: 8500,
|
<span>{message}</span>
|
||||||
provincialAdoptions: { count: 2125, percentage: 25 },
|
{onRetry && (
|
||||||
cityAdoptions: { count: 850, percentage: 10 },
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||||
referrerId: '988',
|
重试
|
||||||
ranking: 1,
|
</Button>
|
||||||
status: 'busy',
|
)}
|
||||||
},
|
</div>
|
||||||
{
|
);
|
||||||
accountId: '1002',
|
|
||||||
avatar: '/images/Data1@2x.png',
|
|
||||||
nickname: '夏雨',
|
|
||||||
personalAdoptions: 120,
|
|
||||||
teamAddresses: 2800,
|
|
||||||
teamAdoptions: 7200,
|
|
||||||
provincialAdoptions: { count: 1800, percentage: 25 },
|
|
||||||
cityAdoptions: { count: 1080, percentage: 15 },
|
|
||||||
referrerId: '1001',
|
|
||||||
ranking: 31,
|
|
||||||
status: 'online',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accountId: '1003',
|
|
||||||
avatar: '/images/Data1@2x.png',
|
|
||||||
nickname: '秋叶',
|
|
||||||
personalAdoptions: 95,
|
|
||||||
teamAddresses: 1500,
|
|
||||||
teamAdoptions: 4100,
|
|
||||||
provincialAdoptions: { count: 1025, percentage: 25 },
|
|
||||||
cityAdoptions: { count: 410, percentage: 10 },
|
|
||||||
referrerId: '1002',
|
|
||||||
ranking: null,
|
|
||||||
status: 'offline',
|
|
||||||
},
|
|
||||||
...Array.from({ length: 47 }, (_, i) => ({
|
|
||||||
accountId: String(1004 + i),
|
|
||||||
avatar: '/images/Data@2x.png',
|
|
||||||
nickname: `用户${i + 4}`,
|
|
||||||
personalAdoptions: Math.floor(Math.random() * 100) + 50,
|
|
||||||
teamAddresses: Math.floor(Math.random() * 2000) + 500,
|
|
||||||
teamAdoptions: Math.floor(Math.random() * 5000) + 1000,
|
|
||||||
provincialAdoptions: {
|
|
||||||
count: Math.floor(Math.random() * 1000) + 200,
|
|
||||||
percentage: Math.floor(Math.random() * 30) + 10,
|
|
||||||
},
|
|
||||||
cityAdoptions: {
|
|
||||||
count: Math.floor(Math.random() * 500) + 100,
|
|
||||||
percentage: Math.floor(Math.random() * 20) + 5,
|
|
||||||
},
|
|
||||||
referrerId: String(Math.floor(Math.random() * 1000) + 1),
|
|
||||||
ranking: Math.random() > 0.3 ? Math.floor(Math.random() * 100) + 1 : null,
|
|
||||||
status: (['online', 'offline', 'busy'] as const)[Math.floor(Math.random() * 3)],
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户管理页面
|
* 用户管理页面
|
||||||
* 基于 UIPro Figma 设计实现
|
* 接入 admin-service 真实 API
|
||||||
*/
|
*/
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [selectedRows, setSelectedRows] = useState<string[]>([]);
|
const [selectedRows, setSelectedRows] = useState<string[]>([]);
|
||||||
const [detailModal, setDetailModal] = useState<UserItem | null>(null);
|
const [detailUserId, setDetailUserId] = useState<string | null>(null);
|
||||||
const [pagination, setPagination] = useState({
|
const [pagination, setPagination] = useState({
|
||||||
current: 1,
|
current: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 过滤后的数据
|
// 使用 React Query hooks 获取用户列表
|
||||||
const filteredData = useMemo(() => {
|
const {
|
||||||
if (!keyword) return mockUsers;
|
data: usersData,
|
||||||
return mockUsers.filter(
|
isLoading,
|
||||||
(user) =>
|
error,
|
||||||
user.accountId.includes(keyword) ||
|
refetch,
|
||||||
user.nickname.toLowerCase().includes(keyword.toLowerCase())
|
} = useUsers({
|
||||||
);
|
keyword: keyword || undefined,
|
||||||
}, [keyword]);
|
page: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
sortBy: 'registeredAt',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
});
|
||||||
|
|
||||||
// 分页数据
|
// 获取用户详情
|
||||||
const paginatedData = useMemo(() => {
|
const {
|
||||||
const start = (pagination.current - 1) * pagination.pageSize;
|
data: userDetail,
|
||||||
return filteredData.slice(start, start + pagination.pageSize);
|
isLoading: detailLoading,
|
||||||
}, [filteredData, pagination]);
|
} = useUserDetail(detailUserId || '');
|
||||||
|
|
||||||
// 总页数
|
const users = usersData?.items ?? [];
|
||||||
const totalPages = Math.ceil(filteredData.length / pagination.pageSize);
|
const total = usersData?.total ?? 0;
|
||||||
|
const totalPages = usersData?.totalPages ?? 1;
|
||||||
|
|
||||||
// 全选处理
|
// 全选处理
|
||||||
const handleSelectAll = (checked: boolean) => {
|
const handleSelectAll = useCallback((checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setSelectedRows(paginatedData.map((user) => user.accountId));
|
setSelectedRows(users.map((user) => user.accountId));
|
||||||
} else {
|
} else {
|
||||||
setSelectedRows([]);
|
setSelectedRows([]);
|
||||||
}
|
}
|
||||||
};
|
}, [users]);
|
||||||
|
|
||||||
// 单选处理
|
// 单选处理
|
||||||
const handleSelectRow = (accountId: string, checked: boolean) => {
|
const handleSelectRow = useCallback((accountId: string, checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setSelectedRows((prev) => [...prev, accountId]);
|
setSelectedRows((prev) => [...prev, accountId]);
|
||||||
} else {
|
} else {
|
||||||
setSelectedRows((prev) => prev.filter((id) => id !== accountId));
|
setSelectedRows((prev) => prev.filter((id) => id !== accountId));
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
// 搜索处理
|
||||||
|
const handleSearch = useCallback((value: string) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 导出 Excel
|
// 导出 Excel
|
||||||
const handleExport = () => {
|
const handleExport = useCallback(() => {
|
||||||
toast.success('导出功能开发中');
|
toast.success('导出功能开发中');
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// 批量编辑
|
// 批量编辑
|
||||||
const handleBatchEdit = () => {
|
const handleBatchEdit = useCallback(() => {
|
||||||
if (selectedRows.length === 0) {
|
if (selectedRows.length === 0) {
|
||||||
toast.warning('请先选择用户');
|
toast.warning('请先选择用户');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(`已选择 ${selectedRows.length} 位用户`);
|
toast.success(`已选择 ${selectedRows.length} 位用户`);
|
||||||
};
|
}, [selectedRows.length]);
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
const handleViewDetail = useCallback((user: UserListItem) => {
|
||||||
|
setDetailUserId(user.accountId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 关闭详情弹窗
|
||||||
|
const handleCloseDetail = useCallback(() => {
|
||||||
|
setDetailUserId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 生成分页按钮
|
// 生成分页按钮
|
||||||
const renderPaginationButtons = () => {
|
const renderPaginationButtons = () => {
|
||||||
|
|
@ -276,6 +248,13 @@ export default function UsersPage() {
|
||||||
return buttons;
|
return buttons;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取状态显示
|
||||||
|
const getStatusClass = (user: UserListItem) => {
|
||||||
|
if (user.isOnline) return 'online';
|
||||||
|
if (user.status === 'frozen') return 'busy';
|
||||||
|
return 'offline';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer title="用户管理">
|
<PageContainer title="用户管理">
|
||||||
<div className={styles.users}>
|
<div className={styles.users}>
|
||||||
|
|
@ -308,7 +287,7 @@ export default function UsersPage() {
|
||||||
placeholder="搜索账户ID、昵称"
|
placeholder="搜索账户ID、昵称"
|
||||||
type="text"
|
type="text"
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -342,8 +321,8 @@ export default function UsersPage() {
|
||||||
</select>
|
</select>
|
||||||
<select className={styles.users__paginationSelect}>
|
<select className={styles.users__paginationSelect}>
|
||||||
<option value="">用户状态</option>
|
<option value="">用户状态</option>
|
||||||
<option value="online">在线</option>
|
<option value="active">正常</option>
|
||||||
<option value="offline">离线</option>
|
<option value="frozen">冻结</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -358,7 +337,7 @@ export default function UsersPage() {
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className={styles.users__checkbox}
|
className={styles.users__checkbox}
|
||||||
checked={selectedRows.length === paginatedData.length && paginatedData.length > 0}
|
checked={selectedRows.length === users.length && users.length > 0}
|
||||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -399,112 +378,129 @@ export default function UsersPage() {
|
||||||
|
|
||||||
{/* 表格内容 */}
|
{/* 表格内容 */}
|
||||||
<div className={styles.users__tableBody}>
|
<div className={styles.users__tableBody}>
|
||||||
{paginatedData.map((user) => (
|
{isLoading ? (
|
||||||
<div
|
// 加载状态显示骨架屏
|
||||||
key={user.accountId}
|
Array.from({ length: pagination.pageSize }).map((_, i) => (
|
||||||
className={cn(
|
<TableRowSkeleton key={i} />
|
||||||
styles.users__tableRow,
|
))
|
||||||
selectedRows.includes(user.accountId) && styles['users__tableRow--selected']
|
) : error ? (
|
||||||
)}
|
// 错误状态
|
||||||
>
|
<ErrorMessage
|
||||||
{/* 复选框 */}
|
message="加载用户数据失败"
|
||||||
<div className={cn(styles.users__tableCell, styles['users__tableCell--checkbox'])}>
|
onRetry={() => refetch()}
|
||||||
<input
|
/>
|
||||||
type="checkbox"
|
) : users.length === 0 ? (
|
||||||
className={styles.users__checkbox}
|
// 空数据状态
|
||||||
checked={selectedRows.includes(user.accountId)}
|
<EmptyData message="暂无用户数据" />
|
||||||
onChange={(e) => handleSelectRow(user.accountId, e.target.checked)}
|
) : (
|
||||||
/>
|
// 正常显示数据
|
||||||
</div>
|
users.map((user) => (
|
||||||
|
|
||||||
{/* 账户序号 */}
|
|
||||||
<div className={cn(styles.users__tableCell, styles['users__tableCell--id'])}>
|
|
||||||
{user.accountId}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 头像 */}
|
|
||||||
<div className={cn(styles.users__tableCell, styles['users__tableCell--avatar'])}>
|
|
||||||
<div
|
|
||||||
className={styles.users__avatar}
|
|
||||||
style={{ backgroundImage: `url(${user.avatar})` }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
styles.users__avatarStatus,
|
|
||||||
styles[`users__avatarStatus--${user.status}`]
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 昵称 */}
|
|
||||||
<div className={cn(styles.users__tableCell, styles['users__tableCell--nickname'])}>
|
|
||||||
{user.nickname}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 账户认种量 */}
|
|
||||||
<div className={cn(styles.users__tableCell, styles['users__tableCell--adoptions'])}>
|
|
||||||
{formatNumber(user.personalAdoptions)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 团队总注册地址量 */}
|
|
||||||
<div className={cn(styles.users__tableCell, styles['users__tableCell--teamAddress'])}>
|
|
||||||
{formatNumber(user.teamAddresses)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 团队总认种量 */}
|
|
||||||
<div className={cn(styles.users__tableCell, styles['users__tableCell--teamTotal'])}>
|
|
||||||
{formatNumber(user.teamAdoptions)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 团队本省认种量及占比 */}
|
|
||||||
<div className={cn(styles.users__tableCell, styles['users__tableCell--province'])}>
|
|
||||||
<span>{formatNumber(user.provincialAdoptions.count)}</span>
|
|
||||||
<span className={styles.users__percentage}>
|
|
||||||
({user.provincialAdoptions.percentage}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 团队本市认种量及占比 */}
|
|
||||||
<div className={cn(styles.users__tableCell, styles['users__tableCell--city'])}>
|
|
||||||
<span>{formatNumber(user.cityAdoptions.count)}</span>
|
|
||||||
<span className={styles.users__percentage}>
|
|
||||||
({user.cityAdoptions.percentage}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 推荐人序列号 */}
|
|
||||||
<div className={cn(styles.users__tableCell, styles['users__tableCell--referrer'])}>
|
|
||||||
{user.referrerId}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 龙虎榜排名 */}
|
|
||||||
<div
|
<div
|
||||||
|
key={user.accountId}
|
||||||
className={cn(
|
className={cn(
|
||||||
styles.users__tableCell,
|
styles.users__tableRow,
|
||||||
styles['users__tableCell--ranking'],
|
selectedRows.includes(user.accountId) && styles['users__tableRow--selected']
|
||||||
user.ranking && user.ranking <= 10
|
|
||||||
? styles['users__tableCell--gold']
|
|
||||||
: styles['users__tableCell--normal']
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{user.ranking ? formatRanking(user.ranking) : '-'}
|
{/* 复选框 */}
|
||||||
</div>
|
<div className={cn(styles.users__tableCell, styles['users__tableCell--checkbox'])}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className={styles.users__checkbox}
|
||||||
|
checked={selectedRows.includes(user.accountId)}
|
||||||
|
onChange={(e) => handleSelectRow(user.accountId, e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 操作 */}
|
{/* 账户序号 */}
|
||||||
<div className={cn(styles.users__tableCell, styles['users__tableCell--actions'])}>
|
<div className={cn(styles.users__tableCell, styles['users__tableCell--id'])}>
|
||||||
<button
|
{user.accountSequence || user.accountId}
|
||||||
className={styles.users__rowAction}
|
</div>
|
||||||
onClick={() => setDetailModal(user)}
|
|
||||||
|
{/* 头像 */}
|
||||||
|
<div className={cn(styles.users__tableCell, styles['users__tableCell--avatar'])}>
|
||||||
|
<div
|
||||||
|
className={styles.users__avatar}
|
||||||
|
style={{ backgroundImage: `url(${user.avatar || '/images/Data@2x.png'})` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
styles.users__avatarStatus,
|
||||||
|
styles[`users__avatarStatus--${getStatusClass(user)}`]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 昵称 */}
|
||||||
|
<div className={cn(styles.users__tableCell, styles['users__tableCell--nickname'])}>
|
||||||
|
{user.nickname || '-'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 账户认种量 */}
|
||||||
|
<div className={cn(styles.users__tableCell, styles['users__tableCell--adoptions'])}>
|
||||||
|
{formatNumber(user.personalAdoptions)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 团队总注册地址量 */}
|
||||||
|
<div className={cn(styles.users__tableCell, styles['users__tableCell--teamAddress'])}>
|
||||||
|
{formatNumber(user.teamAddresses)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 团队总认种量 */}
|
||||||
|
<div className={cn(styles.users__tableCell, styles['users__tableCell--teamTotal'])}>
|
||||||
|
{formatNumber(user.teamAdoptions)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 团队本省认种量及占比 */}
|
||||||
|
<div className={cn(styles.users__tableCell, styles['users__tableCell--province'])}>
|
||||||
|
<span>{formatNumber(user.provincialAdoptions.count)}</span>
|
||||||
|
<span className={styles.users__percentage}>
|
||||||
|
({user.provincialAdoptions.percentage}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 团队本市认种量及占比 */}
|
||||||
|
<div className={cn(styles.users__tableCell, styles['users__tableCell--city'])}>
|
||||||
|
<span>{formatNumber(user.cityAdoptions.count)}</span>
|
||||||
|
<span className={styles.users__percentage}>
|
||||||
|
({user.cityAdoptions.percentage}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 推荐人序列号 */}
|
||||||
|
<div className={cn(styles.users__tableCell, styles['users__tableCell--referrer'])}>
|
||||||
|
{user.referrerId || '-'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 龙虎榜排名 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
styles.users__tableCell,
|
||||||
|
styles['users__tableCell--ranking'],
|
||||||
|
user.ranking && user.ranking <= 10
|
||||||
|
? styles['users__tableCell--gold']
|
||||||
|
: styles['users__tableCell--normal']
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
查看详情
|
{user.ranking ? formatRanking(user.ranking) : '-'}
|
||||||
</button>
|
</div>
|
||||||
<button className={styles.users__rowAction}>
|
|
||||||
编辑
|
{/* 操作 */}
|
||||||
</button>
|
<div className={cn(styles.users__tableCell, styles['users__tableCell--actions'])}>
|
||||||
|
<button
|
||||||
|
className={styles.users__rowAction}
|
||||||
|
onClick={() => handleViewDetail(user)}
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</button>
|
||||||
|
<button className={styles.users__rowAction}>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -524,7 +520,7 @@ export default function UsersPage() {
|
||||||
<option value={50}>50条</option>
|
<option value={50}>50条</option>
|
||||||
</select>
|
</select>
|
||||||
<span className={styles.users__paginationTotal}>
|
<span className={styles.users__paginationTotal}>
|
||||||
共 <span>{filteredData.length}</span> 条记录
|
共 <span>{total}</span> 条记录
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.users__paginationList}>{renderPaginationButtons()}</div>
|
<div className={styles.users__paginationList}>{renderPaginationButtons()}</div>
|
||||||
|
|
@ -533,49 +529,61 @@ export default function UsersPage() {
|
||||||
|
|
||||||
{/* 用户详情弹窗 */}
|
{/* 用户详情弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
visible={!!detailModal}
|
visible={!!detailUserId}
|
||||||
title="用户详情"
|
title="用户详情"
|
||||||
onClose={() => setDetailModal(null)}
|
onClose={handleCloseDetail}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={600}
|
width={600}
|
||||||
>
|
>
|
||||||
{detailModal && (
|
{detailLoading ? (
|
||||||
|
<div className={styles.userDetail__loading}>加载中...</div>
|
||||||
|
) : userDetail ? (
|
||||||
<div className={styles.userDetail}>
|
<div className={styles.userDetail}>
|
||||||
<div className={styles.userDetail__header}>
|
<div className={styles.userDetail__header}>
|
||||||
<div
|
<div
|
||||||
className={styles.users__avatar}
|
className={styles.users__avatar}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${detailModal.avatar})`,
|
backgroundImage: `url(${userDetail.avatar || '/images/Data@2x.png'})`,
|
||||||
width: 64,
|
width: 64,
|
||||||
height: 64,
|
height: 64,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className={styles.userDetail__info}>
|
<div className={styles.userDetail__info}>
|
||||||
<h3>{detailModal.nickname}</h3>
|
<h3>{userDetail.nickname || '未设置昵称'}</h3>
|
||||||
<p>账户ID: {detailModal.accountId}</p>
|
<p>账户序号: {userDetail.accountSequence}</p>
|
||||||
|
<p>手机号: {userDetail.phoneNumberMasked || '未绑定'}</p>
|
||||||
|
<p>KYC状态: {userDetail.kycStatus}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.userDetail__stats}>
|
<div className={styles.userDetail__stats}>
|
||||||
<div className={styles.userDetail__statItem}>
|
<div className={styles.userDetail__statItem}>
|
||||||
<span className={styles.userDetail__statLabel}>个人认种量</span>
|
<span className={styles.userDetail__statLabel}>个人认种量</span>
|
||||||
<span className={styles.userDetail__statValue}>
|
<span className={styles.userDetail__statValue}>
|
||||||
{formatNumber(detailModal.personalAdoptions)}
|
{formatNumber(userDetail.personalAdoptions)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.userDetail__statItem}>
|
<div className={styles.userDetail__statItem}>
|
||||||
<span className={styles.userDetail__statLabel}>团队认种量</span>
|
<span className={styles.userDetail__statLabel}>团队认种量</span>
|
||||||
<span className={styles.userDetail__statValue}>
|
<span className={styles.userDetail__statValue}>
|
||||||
{formatNumber(detailModal.teamAdoptions)}
|
{formatNumber(userDetail.teamAdoptions)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.userDetail__statItem}>
|
<div className={styles.userDetail__statItem}>
|
||||||
<span className={styles.userDetail__statLabel}>龙虎榜排名</span>
|
<span className={styles.userDetail__statLabel}>龙虎榜排名</span>
|
||||||
<span className={styles.userDetail__statValue}>
|
<span className={styles.userDetail__statValue}>
|
||||||
{formatRanking(detailModal.ranking)}
|
{formatRanking(userDetail.ranking)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.userDetail__meta}>
|
||||||
|
<p>注册时间: {new Date(userDetail.registeredAt).toLocaleString()}</p>
|
||||||
|
{userDetail.lastActiveAt && (
|
||||||
|
<p>最后活跃: {new Date(userDetail.lastActiveAt).toLocaleString()}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.userDetail__empty}>未找到用户信息</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
// Hooks 统一导出
|
// Hooks 统一导出
|
||||||
|
|
||||||
export * from './useDashboard';
|
export * from './useDashboard';
|
||||||
|
export * from './useUsers';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* 用户管理 Hooks
|
||||||
|
* 使用 React Query 进行数据获取和缓存管理
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { userService, type UserListParams } from '@/services/userService';
|
||||||
|
|
||||||
|
/** Query Keys */
|
||||||
|
export const userKeys = {
|
||||||
|
all: ['users'] as const,
|
||||||
|
list: (params: UserListParams) => [...userKeys.all, 'list', params] as const,
|
||||||
|
detail: (id: string) => [...userKeys.all, 'detail', id] as const,
|
||||||
|
stats: () => [...userKeys.all, 'stats'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户列表
|
||||||
|
*/
|
||||||
|
export function useUsers(params: UserListParams = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: userKeys.list(params),
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await userService.getUsers(params);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
staleTime: 30 * 1000, // 30秒后标记为过期
|
||||||
|
gcTime: 5 * 60 * 1000, // 5分钟后垃圾回收
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户详情
|
||||||
|
*/
|
||||||
|
export function useUserDetail(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: userKeys.detail(id),
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await userService.getUserDetail(id);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!id, // 只有在 id 存在时才查询
|
||||||
|
staleTime: 60 * 1000, // 1分钟后标记为过期
|
||||||
|
gcTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户统计
|
||||||
|
*/
|
||||||
|
export function useUserStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: userKeys.stats(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await userService.getUserStats();
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
staleTime: 60 * 1000, // 1分钟后标记为过期
|
||||||
|
gcTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -10,14 +10,15 @@ export const API_ENDPOINTS = {
|
||||||
REGISTER: '/auth/register',
|
REGISTER: '/auth/register',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 用户管理
|
// 用户管理 (admin-service)
|
||||||
USERS: {
|
USERS: {
|
||||||
LIST: '/users',
|
LIST: '/admin/users',
|
||||||
DETAIL: (id: string) => `/users/${id}`,
|
DETAIL: (id: string) => `/admin/users/${id}`,
|
||||||
UPDATE: (id: string) => `/users/${id}`,
|
STATS: '/admin/users/stats/summary',
|
||||||
DELETE: (id: string) => `/users/${id}`,
|
UPDATE: (id: string) => `/admin/users/${id}`,
|
||||||
EXPORT: '/users/export',
|
DELETE: (id: string) => `/admin/users/${id}`,
|
||||||
BATCH_UPDATE: '/users/batch',
|
EXPORT: '/admin/users/export',
|
||||||
|
BATCH_UPDATE: '/admin/users/batch',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 龙虎榜
|
// 龙虎榜
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* 用户管理服务
|
||||||
|
* 负责用户数据的API调用
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiClient from '@/infrastructure/api/client';
|
||||||
|
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
|
||||||
|
import type { ApiResponse } from '@/types';
|
||||||
|
|
||||||
|
/** 用户列表项 */
|
||||||
|
export interface UserListItem {
|
||||||
|
accountId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
avatar: string | null;
|
||||||
|
nickname: string | null;
|
||||||
|
personalAdoptions: number;
|
||||||
|
teamAddresses: number;
|
||||||
|
teamAdoptions: number;
|
||||||
|
provincialAdoptions: {
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
};
|
||||||
|
cityAdoptions: {
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
};
|
||||||
|
referrerId: string | null;
|
||||||
|
ranking: number | null;
|
||||||
|
status: 'active' | 'frozen' | 'deactivated';
|
||||||
|
isOnline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户详情 */
|
||||||
|
export interface UserDetail extends UserListItem {
|
||||||
|
phoneNumberMasked: string | null;
|
||||||
|
kycStatus: string;
|
||||||
|
registeredAt: string;
|
||||||
|
lastActiveAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户列表响应 */
|
||||||
|
export interface UserListResponse {
|
||||||
|
items: UserListItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户统计 */
|
||||||
|
export interface UserStats {
|
||||||
|
totalUsers: number;
|
||||||
|
activeUsers: number;
|
||||||
|
frozenUsers: number;
|
||||||
|
verifiedUsers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户列表查询参数 */
|
||||||
|
export interface UserListParams {
|
||||||
|
keyword?: string;
|
||||||
|
status?: string;
|
||||||
|
kycStatus?: string;
|
||||||
|
hasInviter?: boolean;
|
||||||
|
minAdoptions?: number;
|
||||||
|
maxAdoptions?: number;
|
||||||
|
registeredAfter?: string;
|
||||||
|
registeredBefore?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户管理服务
|
||||||
|
*/
|
||||||
|
export const userService = {
|
||||||
|
/**
|
||||||
|
* 获取用户列表
|
||||||
|
*/
|
||||||
|
async getUsers(params: UserListParams = {}): Promise<ApiResponse<UserListResponse>> {
|
||||||
|
return apiClient.get(API_ENDPOINTS.USERS.LIST, { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户详情
|
||||||
|
*/
|
||||||
|
async getUserDetail(id: string): Promise<ApiResponse<UserDetail>> {
|
||||||
|
return apiClient.get(API_ENDPOINTS.USERS.DETAIL(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户统计
|
||||||
|
*/
|
||||||
|
async getUserStats(): Promise<ApiResponse<UserStats>> {
|
||||||
|
return apiClient.get(API_ENDPOINTS.USERS.STATS);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出用户数据
|
||||||
|
*/
|
||||||
|
async exportUsers(params: UserListParams = {}): Promise<Blob> {
|
||||||
|
const response = await apiClient.get(API_ENDPOINTS.USERS.EXPORT, {
|
||||||
|
params,
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return response.data as unknown as Blob;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default userService;
|
||||||
Loading…
Reference in New Issue