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 // 新用户
|
||||
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 { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository';
|
||||
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({
|
||||
imports: [
|
||||
|
|
@ -46,6 +51,7 @@ import { AdminNotificationController, MobileNotificationController } from './api
|
|||
DownloadController,
|
||||
AdminNotificationController,
|
||||
MobileNotificationController,
|
||||
UserController,
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
|
|
@ -72,6 +78,12 @@ import { AdminNotificationController, MobileNotificationController } from './api
|
|||
provide: NOTIFICATION_REPOSITORY,
|
||||
useClass: NotificationRepositoryImpl,
|
||||
},
|
||||
// User Management
|
||||
{
|
||||
provide: USER_QUERY_REPOSITORY,
|
||||
useClass: UserQueryRepositoryImpl,
|
||||
},
|
||||
UserEventConsumerService,
|
||||
],
|
||||
})
|
||||
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 { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
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 {
|
||||
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
||||
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
|
||||
|
|
@ -480,6 +480,16 @@ export class UserApplicationService {
|
|||
});
|
||||
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -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 服务生成钱包地址
|
||||
|
|
|
|||
|
|
@ -1,158 +1,130 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Modal, toast } from '@/components/common';
|
||||
import { Modal, toast, Button } from '@/components/common';
|
||||
import { PageContainer } from '@/components/layout';
|
||||
import { cn } from '@/utils/helpers';
|
||||
import { formatNumber, formatRanking } from '@/utils/formatters';
|
||||
import { useUsers, useUserDetail } from '@/hooks';
|
||||
import type { UserListItem } from '@/services/userService';
|
||||
import styles from './users.module.scss';
|
||||
|
||||
/**
|
||||
* 用户数据类型
|
||||
*/
|
||||
interface UserItem {
|
||||
accountId: string;
|
||||
avatar: string;
|
||||
nickname: string;
|
||||
personalAdoptions: number;
|
||||
teamAddresses: number;
|
||||
teamAdoptions: number;
|
||||
provincialAdoptions: { count: number; percentage: number };
|
||||
cityAdoptions: { count: number; percentage: number };
|
||||
referrerId: string;
|
||||
ranking: number | null;
|
||||
status: 'online' | 'offline' | 'busy';
|
||||
}
|
||||
// 骨架屏组件
|
||||
const TableRowSkeleton = () => (
|
||||
<div className={styles.users__tableRow}>
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className={styles.users__tableCellSkeleton}>
|
||||
<div className={styles.users__skeleton} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 模拟用户数据
|
||||
*/
|
||||
const mockUsers: UserItem[] = [
|
||||
{
|
||||
accountId: '1001',
|
||||
avatar: '/images/Data@2x.png',
|
||||
nickname: '春风',
|
||||
personalAdoptions: 150,
|
||||
teamAddresses: 3200,
|
||||
teamAdoptions: 8500,
|
||||
provincialAdoptions: { count: 2125, percentage: 25 },
|
||||
cityAdoptions: { count: 850, percentage: 10 },
|
||||
referrerId: '988',
|
||||
ranking: 1,
|
||||
status: 'busy',
|
||||
},
|
||||
{
|
||||
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)],
|
||||
})),
|
||||
];
|
||||
// 空数据提示
|
||||
const EmptyData = ({ message }: { message: string }) => (
|
||||
<div className={styles.users__empty}>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 错误提示
|
||||
const ErrorMessage = ({ message, onRetry }: { message: string; onRetry?: () => void }) => (
|
||||
<div className={styles.users__error}>
|
||||
<span>{message}</span>
|
||||
{onRetry && (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 用户管理页面
|
||||
* 基于 UIPro Figma 设计实现
|
||||
* 接入 admin-service 真实 API
|
||||
*/
|
||||
export default function UsersPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedRows, setSelectedRows] = useState<string[]>([]);
|
||||
const [detailModal, setDetailModal] = useState<UserItem | null>(null);
|
||||
const [detailUserId, setDetailUserId] = useState<string | null>(null);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
// 过滤后的数据
|
||||
const filteredData = useMemo(() => {
|
||||
if (!keyword) return mockUsers;
|
||||
return mockUsers.filter(
|
||||
(user) =>
|
||||
user.accountId.includes(keyword) ||
|
||||
user.nickname.toLowerCase().includes(keyword.toLowerCase())
|
||||
);
|
||||
}, [keyword]);
|
||||
// 使用 React Query hooks 获取用户列表
|
||||
const {
|
||||
data: usersData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useUsers({
|
||||
keyword: keyword || undefined,
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy: 'registeredAt',
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
|
||||
// 分页数据
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (pagination.current - 1) * pagination.pageSize;
|
||||
return filteredData.slice(start, start + pagination.pageSize);
|
||||
}, [filteredData, pagination]);
|
||||
// 获取用户详情
|
||||
const {
|
||||
data: userDetail,
|
||||
isLoading: detailLoading,
|
||||
} = useUserDetail(detailUserId || '');
|
||||
|
||||
// 总页数
|
||||
const totalPages = Math.ceil(filteredData.length / pagination.pageSize);
|
||||
const users = usersData?.items ?? [];
|
||||
const total = usersData?.total ?? 0;
|
||||
const totalPages = usersData?.totalPages ?? 1;
|
||||
|
||||
// 全选处理
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
const handleSelectAll = useCallback((checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedRows(paginatedData.map((user) => user.accountId));
|
||||
setSelectedRows(users.map((user) => user.accountId));
|
||||
} else {
|
||||
setSelectedRows([]);
|
||||
}
|
||||
};
|
||||
}, [users]);
|
||||
|
||||
// 单选处理
|
||||
const handleSelectRow = (accountId: string, checked: boolean) => {
|
||||
const handleSelectRow = useCallback((accountId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedRows((prev) => [...prev, accountId]);
|
||||
} else {
|
||||
setSelectedRows((prev) => prev.filter((id) => id !== accountId));
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = useCallback((value: string) => {
|
||||
setKeyword(value);
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
}, []);
|
||||
|
||||
// 导出 Excel
|
||||
const handleExport = () => {
|
||||
const handleExport = useCallback(() => {
|
||||
toast.success('导出功能开发中');
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 批量编辑
|
||||
const handleBatchEdit = () => {
|
||||
const handleBatchEdit = useCallback(() => {
|
||||
if (selectedRows.length === 0) {
|
||||
toast.warning('请先选择用户');
|
||||
return;
|
||||
}
|
||||
toast.success(`已选择 ${selectedRows.length} 位用户`);
|
||||
};
|
||||
}, [selectedRows.length]);
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = useCallback((user: UserListItem) => {
|
||||
setDetailUserId(user.accountId);
|
||||
}, []);
|
||||
|
||||
// 关闭详情弹窗
|
||||
const handleCloseDetail = useCallback(() => {
|
||||
setDetailUserId(null);
|
||||
}, []);
|
||||
|
||||
// 生成分页按钮
|
||||
const renderPaginationButtons = () => {
|
||||
|
|
@ -276,6 +248,13 @@ export default function UsersPage() {
|
|||
return buttons;
|
||||
};
|
||||
|
||||
// 获取状态显示
|
||||
const getStatusClass = (user: UserListItem) => {
|
||||
if (user.isOnline) return 'online';
|
||||
if (user.status === 'frozen') return 'busy';
|
||||
return 'offline';
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer title="用户管理">
|
||||
<div className={styles.users}>
|
||||
|
|
@ -308,7 +287,7 @@ export default function UsersPage() {
|
|||
placeholder="搜索账户ID、昵称"
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -342,8 +321,8 @@ export default function UsersPage() {
|
|||
</select>
|
||||
<select className={styles.users__paginationSelect}>
|
||||
<option value="">用户状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
<option value="active">正常</option>
|
||||
<option value="frozen">冻结</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -358,7 +337,7 @@ export default function UsersPage() {
|
|||
<input
|
||||
type="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)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -399,112 +378,129 @@ export default function UsersPage() {
|
|||
|
||||
{/* 表格内容 */}
|
||||
<div className={styles.users__tableBody}>
|
||||
{paginatedData.map((user) => (
|
||||
<div
|
||||
key={user.accountId}
|
||||
className={cn(
|
||||
styles.users__tableRow,
|
||||
selectedRows.includes(user.accountId) && styles['users__tableRow--selected']
|
||||
)}
|
||||
>
|
||||
{/* 复选框 */}
|
||||
<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--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>
|
||||
|
||||
{/* 龙虎榜排名 */}
|
||||
{isLoading ? (
|
||||
// 加载状态显示骨架屏
|
||||
Array.from({ length: pagination.pageSize }).map((_, i) => (
|
||||
<TableRowSkeleton key={i} />
|
||||
))
|
||||
) : error ? (
|
||||
// 错误状态
|
||||
<ErrorMessage
|
||||
message="加载用户数据失败"
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : users.length === 0 ? (
|
||||
// 空数据状态
|
||||
<EmptyData message="暂无用户数据" />
|
||||
) : (
|
||||
// 正常显示数据
|
||||
users.map((user) => (
|
||||
<div
|
||||
key={user.accountId}
|
||||
className={cn(
|
||||
styles.users__tableCell,
|
||||
styles['users__tableCell--ranking'],
|
||||
user.ranking && user.ranking <= 10
|
||||
? styles['users__tableCell--gold']
|
||||
: styles['users__tableCell--normal']
|
||||
styles.users__tableRow,
|
||||
selectedRows.includes(user.accountId) && styles['users__tableRow--selected']
|
||||
)}
|
||||
>
|
||||
{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'])}>
|
||||
<button
|
||||
className={styles.users__rowAction}
|
||||
onClick={() => setDetailModal(user)}
|
||||
{/* 账户序号 */}
|
||||
<div className={cn(styles.users__tableCell, styles['users__tableCell--id'])}>
|
||||
{user.accountSequence || user.accountId}
|
||||
</div>
|
||||
|
||||
{/* 头像 */}
|
||||
<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']
|
||||
)}
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
<button className={styles.users__rowAction}>
|
||||
编辑
|
||||
</button>
|
||||
{user.ranking ? formatRanking(user.ranking) : '-'}
|
||||
</div>
|
||||
|
||||
{/* 操作 */}
|
||||
<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>
|
||||
|
||||
|
|
@ -524,7 +520,7 @@ export default function UsersPage() {
|
|||
<option value={50}>50条</option>
|
||||
</select>
|
||||
<span className={styles.users__paginationTotal}>
|
||||
共 <span>{filteredData.length}</span> 条记录
|
||||
共 <span>{total}</span> 条记录
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.users__paginationList}>{renderPaginationButtons()}</div>
|
||||
|
|
@ -533,49 +529,61 @@ export default function UsersPage() {
|
|||
|
||||
{/* 用户详情弹窗 */}
|
||||
<Modal
|
||||
visible={!!detailModal}
|
||||
visible={!!detailUserId}
|
||||
title="用户详情"
|
||||
onClose={() => setDetailModal(null)}
|
||||
onClose={handleCloseDetail}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
{detailModal && (
|
||||
{detailLoading ? (
|
||||
<div className={styles.userDetail__loading}>加载中...</div>
|
||||
) : userDetail ? (
|
||||
<div className={styles.userDetail}>
|
||||
<div className={styles.userDetail__header}>
|
||||
<div
|
||||
className={styles.users__avatar}
|
||||
style={{
|
||||
backgroundImage: `url(${detailModal.avatar})`,
|
||||
backgroundImage: `url(${userDetail.avatar || '/images/Data@2x.png'})`,
|
||||
width: 64,
|
||||
height: 64,
|
||||
}}
|
||||
/>
|
||||
<div className={styles.userDetail__info}>
|
||||
<h3>{detailModal.nickname}</h3>
|
||||
<p>账户ID: {detailModal.accountId}</p>
|
||||
<h3>{userDetail.nickname || '未设置昵称'}</h3>
|
||||
<p>账户序号: {userDetail.accountSequence}</p>
|
||||
<p>手机号: {userDetail.phoneNumberMasked || '未绑定'}</p>
|
||||
<p>KYC状态: {userDetail.kycStatus}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.userDetail__stats}>
|
||||
<div className={styles.userDetail__statItem}>
|
||||
<span className={styles.userDetail__statLabel}>个人认种量</span>
|
||||
<span className={styles.userDetail__statValue}>
|
||||
{formatNumber(detailModal.personalAdoptions)}
|
||||
{formatNumber(userDetail.personalAdoptions)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.userDetail__statItem}>
|
||||
<span className={styles.userDetail__statLabel}>团队认种量</span>
|
||||
<span className={styles.userDetail__statValue}>
|
||||
{formatNumber(detailModal.teamAdoptions)}
|
||||
{formatNumber(userDetail.teamAdoptions)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.userDetail__statItem}>
|
||||
<span className={styles.userDetail__statLabel}>龙虎榜排名</span>
|
||||
<span className={styles.userDetail__statValue}>
|
||||
{formatRanking(detailModal.ranking)}
|
||||
{formatRanking(userDetail.ranking)}
|
||||
</span>
|
||||
</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 className={styles.userDetail__empty}>未找到用户信息</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// Hooks 统一导出
|
||||
|
||||
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',
|
||||
},
|
||||
|
||||
// 用户管理
|
||||
// 用户管理 (admin-service)
|
||||
USERS: {
|
||||
LIST: '/users',
|
||||
DETAIL: (id: string) => `/users/${id}`,
|
||||
UPDATE: (id: string) => `/users/${id}`,
|
||||
DELETE: (id: string) => `/users/${id}`,
|
||||
EXPORT: '/users/export',
|
||||
BATCH_UPDATE: '/users/batch',
|
||||
LIST: '/admin/users',
|
||||
DETAIL: (id: string) => `/admin/users/${id}`,
|
||||
STATS: '/admin/users/stats/summary',
|
||||
UPDATE: (id: string) => `/admin/users/${id}`,
|
||||
DELETE: (id: string) => `/admin/users/${id}`,
|
||||
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