From 27a4bbfbefa0da0c8ac38165161a861e5a042245 Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 23 Dec 2025 22:42:19 -0800 Subject: [PATCH] =?UTF-8?q?feat(authorization):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=81=AB=E6=9F=B4=E4=BA=BA=E6=8E=92=E5=90=8D=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E6=9F=A5=E7=9C=8B=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - identity-service: 新增内部API获取用户详情(手机号、邮箱、KYC等) - referral-service: 新增内部API获取用户团队统计(直推人数、伞下用户数、认种数量) - authorization-service: - 新增用户公开资料和私密资料API - 聚合identity-service和referral-service数据 - 省团队以上权限可查看私密信息(脱敏处理) 前端: - 新增UserProfileDialog弹窗组件,支持查看用户详情 - stickman_race_widget: 排名列表项可点击查看用户详情 - authorization_service: 新增getUserProfile/getUserPrivateProfile方法 用户详情包括: - 基本信息: 用户ID、昵称、头像、注册时间、所在地区 - 团队数据: 推荐人、直推人数、伞下用户数、个人/团队认种数 - 授权信息: 授权类型、权益激活状态 - 联系信息(特权用户可见): 手机号、邮箱、真实姓名(脱敏) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../controllers/authorization.controller.ts | 33 ++ .../src/api/dto/response/index.ts | 1 + .../api/dto/response/user-profile.response.ts | 89 ++++ .../authorization-application.service.ts | 226 ++++++++ .../external/identity-service.client.ts | 43 ++ .../external/referral-service.client.ts | 46 ++ .../api/controllers/internal.controller.ts | 49 ++ .../internal-referral-chain.controller.ts | 68 +++ .../core/services/authorization_service.dart | 188 +++++++ .../widgets/stickman_race_widget.dart | 58 ++- .../widgets/user_profile_dialog.dart | 488 ++++++++++++++++++ 11 files changed, 1276 insertions(+), 13 deletions(-) create mode 100644 backend/services/authorization-service/src/api/dto/response/user-profile.response.ts create mode 100644 frontend/mobile-app/lib/features/authorization/presentation/widgets/user_profile_dialog.dart diff --git a/backend/services/authorization-service/src/api/controllers/authorization.controller.ts b/backend/services/authorization-service/src/api/controllers/authorization.controller.ts index c9f38861..37e6574a 100644 --- a/backend/services/authorization-service/src/api/controllers/authorization.controller.ts +++ b/backend/services/authorization-service/src/api/controllers/authorization.controller.ts @@ -44,6 +44,8 @@ import { ApplyAuthorizationResponse, StickmanRankingResponse, CommunityHierarchyResponse, + UserProfilePublicResponse, + UserProfilePrivateResponse, } from '@/api/dto/response' import { CurrentUser } from '@/shared/decorators' import { JwtAuthGuard } from '@/shared/guards' @@ -215,4 +217,35 @@ export class AuthorizationController { ) return await this.applicationService.selfApplyAuthorization(command) } + + // ============================================ + // 用户资料相关接口 + // ============================================ + + @Get('users/:accountSequence/profile') + @ApiOperation({ + summary: '获取用户公开资料', + description: '获取指定用户的公开资料,包括昵称、头像、注册时间、推荐人、直推人数、伞下用户数、认种数量、授权列表等', + }) + @ApiParam({ name: 'accountSequence', description: '目标用户的账户序列号' }) + @ApiResponse({ status: 200, type: UserProfilePublicResponse }) + async getUserProfile( + @Param('accountSequence') accountSequence: string, + ): Promise { + return await this.applicationService.getUserPublicProfile(accountSequence) + } + + @Get('users/:accountSequence/profile/private') + @ApiOperation({ + summary: '获取用户私密资料', + description: '获取指定用户的私密资料(需要省团队以上权限),包括脱敏的手机号、邮箱、真实姓名等', + }) + @ApiParam({ name: 'accountSequence', description: '目标用户的账户序列号' }) + @ApiResponse({ status: 200, type: UserProfilePrivateResponse }) + async getUserPrivateProfile( + @CurrentUser() user: { userId: string; accountSequence: string }, + @Param('accountSequence') accountSequence: string, + ): Promise { + return await this.applicationService.getUserPrivateProfile(user.accountSequence, accountSequence) + } } diff --git a/backend/services/authorization-service/src/api/dto/response/index.ts b/backend/services/authorization-service/src/api/dto/response/index.ts index 9154a88b..5dfb23a6 100644 --- a/backend/services/authorization-service/src/api/dto/response/index.ts +++ b/backend/services/authorization-service/src/api/dto/response/index.ts @@ -1,2 +1,3 @@ export * from './authorization.response' export * from './community-hierarchy.response' +export * from './user-profile.response' diff --git a/backend/services/authorization-service/src/api/dto/response/user-profile.response.ts b/backend/services/authorization-service/src/api/dto/response/user-profile.response.ts new file mode 100644 index 00000000..5962ad08 --- /dev/null +++ b/backend/services/authorization-service/src/api/dto/response/user-profile.response.ts @@ -0,0 +1,89 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { RoleType } from '@/domain/enums' + +/** + * 用户授权信息 + */ +export class UserAuthorizationInfo { + @ApiProperty({ description: '授权ID' }) + authorizationId: string + + @ApiProperty({ description: '角色类型', enum: RoleType }) + roleType: RoleType + + @ApiProperty({ description: '区域代码' }) + regionCode: string + + @ApiProperty({ description: '区域名称' }) + regionName: string + + @ApiProperty({ description: '显示标题' }) + displayTitle: string + + @ApiProperty({ description: '权益是否激活' }) + benefitActive: boolean +} + +/** + * 用户公开资料响应(所有人可见) + */ +export class UserProfilePublicResponse { + @ApiProperty({ description: '用户ID' }) + userId: string + + @ApiProperty({ description: '账户序列号' }) + accountSequence: string + + @ApiProperty({ description: '昵称' }) + nickname: string + + @ApiPropertyOptional({ description: '头像URL' }) + avatarUrl?: string + + @ApiProperty({ description: '注册时间' }) + registeredAt: string + + @ApiPropertyOptional({ description: '推荐人序列号' }) + inviterSequence?: string + + @ApiPropertyOptional({ description: '推荐人昵称' }) + inviterNickname?: string + + @ApiProperty({ description: '直推人数' }) + directReferralCount: number + + @ApiProperty({ description: '伞下用户数(团队总人数)' }) + umbrellaUserCount: number + + @ApiPropertyOptional({ description: '所在省份' }) + province?: string + + @ApiPropertyOptional({ description: '所在城市' }) + city?: string + + @ApiProperty({ description: '个人认种数量' }) + personalPlantingCount: number + + @ApiProperty({ description: '团队认种数量' }) + teamPlantingCount: number + + @ApiProperty({ description: '授权列表', type: [UserAuthorizationInfo] }) + authorizations: UserAuthorizationInfo[] +} + +/** + * 用户私密资料响应(仅特权用户可见,继承公开资料) + */ +export class UserProfilePrivateResponse extends UserProfilePublicResponse { + @ApiPropertyOptional({ description: '手机号(脱敏)' }) + phoneNumber?: string + + @ApiPropertyOptional({ description: '邮箱(脱敏)' }) + email?: string + + @ApiPropertyOptional({ description: '真实姓名(脱敏)' }) + realName?: string + + @ApiProperty({ description: 'KYC状态' }) + kycStatus: string +} diff --git a/backend/services/authorization-service/src/application/services/authorization-application.service.ts b/backend/services/authorization-service/src/application/services/authorization-application.service.ts index 6f119600..7159e147 100644 --- a/backend/services/authorization-service/src/application/services/authorization-application.service.ts +++ b/backend/services/authorization-service/src/application/services/authorization-application.service.ts @@ -3201,4 +3201,230 @@ export class AuthorizationApplicationService { } return mapping[roleType] || roleType } + + // ============================================ + // 用户资料相关方法 + // ============================================ + + /** + * 获取用户公开资料 + * 任何登录用户都可以查看,不包含敏感信息 + * + * @param targetAccountSequence 目标用户的 accountSequence + */ + async getUserPublicProfile(targetAccountSequence: string): Promise<{ + userId: string + accountSequence: string + nickname: string + avatarUrl?: string + registeredAt: string + inviterSequence?: string + inviterNickname?: string + directReferralCount: number + umbrellaUserCount: number + province?: string + city?: string + personalPlantingCount: number + teamPlantingCount: number + authorizations: Array<{ + authorizationId: string + roleType: RoleType + regionCode: string + regionName: string + displayTitle: string + benefitActive: boolean + }> + }> { + this.logger.debug(`[getUserPublicProfile] targetAccountSequence=${targetAccountSequence}`) + + // 1. 获取基本用户信息 + const userInfo = await this.identityServiceClient.getUserInfoBySequence(targetAccountSequence) + if (!userInfo) { + throw new NotFoundError('用户不存在') + } + + // 2. 获取推荐关系统计 + const profileStats = await this.referralServiceClient.getUserProfileStats(targetAccountSequence) + + // 3. 获取推荐人信息 + let inviterNickname: string | undefined + const referralChain = await this.referralServiceClient.getReferralChain(targetAccountSequence) + if (referralChain.length > 0) { + const inviterInfo = await this.identityServiceClient.getUserInfoBySequence(referralChain[0]) + inviterNickname = inviterInfo?.nickname + } + + // 4. 获取用户的授权列表 + const authorizations = await this.authorizationRepository.findByAccountSequence(targetAccountSequence) + const activeAuthorizations = authorizations.filter( + (auth) => auth.status !== AuthorizationStatus.REVOKED, + ) + + // 5. 获取省市信息(从授权中提取,优先取省团队/市团队授权的区域) + let province: string | undefined + let city: string | undefined + for (const auth of activeAuthorizations) { + if (auth.roleType === RoleType.AUTH_PROVINCE_COMPANY || auth.roleType === RoleType.PROVINCE_COMPANY) { + province = auth.regionName + } + if (auth.roleType === RoleType.AUTH_CITY_COMPANY || auth.roleType === RoleType.CITY_COMPANY) { + city = auth.regionName + } + } + + // 6. 从用户详情获取注册时间和推荐人序列号 + const userDetail = await this.identityServiceClient.getUserDetailBySequence(targetAccountSequence) + + return { + userId: userInfo.userId, + accountSequence: userInfo.accountSequence, + nickname: userInfo.nickname, + avatarUrl: userInfo.avatarUrl, + registeredAt: userDetail?.registeredAt || new Date().toISOString(), + inviterSequence: userDetail?.inviterSequence, + inviterNickname, + directReferralCount: profileStats.directReferralCount, + umbrellaUserCount: profileStats.umbrellaUserCount, + province, + city, + personalPlantingCount: profileStats.personalPlantingCount, + teamPlantingCount: profileStats.teamPlantingCount, + authorizations: activeAuthorizations.map((auth) => ({ + authorizationId: auth.authorizationId.value, + roleType: auth.roleType, + regionCode: auth.regionCode.value, + regionName: auth.regionName, + displayTitle: auth.displayTitle, + benefitActive: auth.benefitActive, + })), + } + } + + /** + * 获取用户私密资料(包含敏感信息) + * 仅特权用户可以查看(省团队以上级别) + * + * @param requestAccountSequence 请求者的 accountSequence + * @param targetAccountSequence 目标用户的 accountSequence + */ + async getUserPrivateProfile( + requestAccountSequence: string, + targetAccountSequence: string, + ): Promise<{ + userId: string + accountSequence: string + nickname: string + avatarUrl?: string + phoneNumber?: string + email?: string + realName?: string + kycStatus: string + registeredAt: string + inviterSequence?: string + inviterNickname?: string + directReferralCount: number + umbrellaUserCount: number + province?: string + city?: string + personalPlantingCount: number + teamPlantingCount: number + authorizations: Array<{ + authorizationId: string + roleType: RoleType + regionCode: string + regionName: string + displayTitle: string + benefitActive: boolean + }> + }> { + this.logger.debug( + `[getUserPrivateProfile] requestAccountSequence=${requestAccountSequence}, targetAccountSequence=${targetAccountSequence}`, + ) + + // 1. 验证请求者是否有权限查看私密资料 + const hasPrivilege = await this.checkPrivateProfileAccess(requestAccountSequence, targetAccountSequence) + if (!hasPrivilege) { + throw new ApplicationError('您没有权限查看该用户的详细信息') + } + + // 2. 获取公开资料 + const publicProfile = await this.getUserPublicProfile(targetAccountSequence) + + // 3. 获取敏感信息 + const userDetail = await this.identityServiceClient.getUserDetailBySequence(targetAccountSequence) + + // 4. 脱敏处理手机号和邮箱 + const maskedPhoneNumber = userDetail?.phoneNumber + ? this.maskPhoneNumber(userDetail.phoneNumber) + : undefined + const maskedEmail = userDetail?.email + ? this.maskEmail(userDetail.email) + : undefined + const maskedRealName = userDetail?.realName + ? this.maskRealName(userDetail.realName) + : undefined + + return { + ...publicProfile, + phoneNumber: maskedPhoneNumber, + email: maskedEmail, + realName: maskedRealName, + kycStatus: userDetail?.kycStatus || 'NOT_VERIFIED', + } + } + + /** + * 检查用户是否有权限查看私密资料 + * 规则: + * - 省区域公司(PROVINCE_COMPANY)可以查看 + * - 省团队(AUTH_PROVINCE_COMPANY)可以查看 + * - 市区域公司(CITY_COMPANY)可以查看 + * - 其他角色不能查看 + */ + private async checkPrivateProfileAccess( + requestAccountSequence: string, + _targetAccountSequence: string, + ): Promise { + // 获取请求者的授权 + const requestorAuthorizations = await this.authorizationRepository.findByAccountSequence(requestAccountSequence) + + // 检查是否有高级权限 + const privilegedRoleTypes = [ + RoleType.PROVINCE_COMPANY, + RoleType.AUTH_PROVINCE_COMPANY, + RoleType.CITY_COMPANY, + ] + + return requestorAuthorizations.some( + (auth) => + auth.status === AuthorizationStatus.AUTHORIZED && + privilegedRoleTypes.includes(auth.roleType), + ) + } + + /** + * 手机号脱敏:138****1234 + */ + private maskPhoneNumber(phone: string): string { + if (phone.length < 7) return phone + return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4) + } + + /** + * 邮箱脱敏:t***@example.com + */ + private maskEmail(email: string): string { + const atIndex = email.indexOf('@') + if (atIndex < 2) return email + return email.substring(0, 1) + '***' + email.substring(atIndex) + } + + /** + * 姓名脱敏:张*明 + */ + private maskRealName(name: string): string { + if (name.length <= 1) return name + if (name.length === 2) return name[0] + '*' + return name[0] + '*'.repeat(name.length - 2) + name[name.length - 1] + } } diff --git a/backend/services/authorization-service/src/infrastructure/external/identity-service.client.ts b/backend/services/authorization-service/src/infrastructure/external/identity-service.client.ts index 79e44c49..60438965 100644 --- a/backend/services/authorization-service/src/infrastructure/external/identity-service.client.ts +++ b/backend/services/authorization-service/src/infrastructure/external/identity-service.client.ts @@ -12,6 +12,22 @@ export interface UserInfo { avatarUrl?: string; } +/** + * 用户详情接口(包含敏感信息) + */ +export interface UserDetailInfo { + userId: string; + accountSequence: string; + nickname: string; + avatarUrl?: string; + phoneNumber?: string; // 敏感信息 + email?: string; // 敏感信息 + registeredAt: string; + inviterSequence?: string; // 推荐人序列号 + kycStatus: string; + realName?: string; // 敏感信息 +} + /** * Identity Service HTTP 客户端 * 用于从 identity-service 获取用户信息 @@ -99,4 +115,31 @@ export class IdentityServiceClient implements OnModuleInit { return null; } + + /** + * 获取用户详情(包含敏感信息,按 accountSequence) + */ + async getUserDetailBySequence(accountSequence: string): Promise { + if (!this.enabled) { + return null; + } + + try { + this.logger.debug(`[HTTP] GET /internal/users/${accountSequence}/detail`); + + const response = await this.httpClient.get<{ success: boolean; data: UserDetailInfo } | UserDetailInfo>( + `/api/v1/internal/users/${accountSequence}/detail`, + ); + + // 处理可能的两种响应格式 + const data = (response.data as any)?.data || response.data; + if (data) { + return data as UserDetailInfo; + } + } catch (error) { + this.logger.error(`[HTTP] Failed to get user detail for ${accountSequence}:`, error); + } + + return null; + } } diff --git a/backend/services/authorization-service/src/infrastructure/external/referral-service.client.ts b/backend/services/authorization-service/src/infrastructure/external/referral-service.client.ts index 5674903b..d814785f 100644 --- a/backend/services/authorization-service/src/infrastructure/external/referral-service.client.ts +++ b/backend/services/authorization-service/src/infrastructure/external/referral-service.client.ts @@ -35,6 +35,17 @@ interface TeamMembersResponse { teamMembers: string[]; } +/** + * 用户资料统计接口 + */ +export interface UserProfileStatsResponse { + accountSequence: string; + directReferralCount: number; + umbrellaUserCount: number; + personalPlantingCount: number; + teamPlantingCount: number; +} + /** * 适配器类:将 referral-service 返回的数据转换为 authorization-service 需要的格式 */ @@ -245,4 +256,39 @@ export class ReferralServiceClient implements ITeamStatisticsRepository, OnModul return []; } } + + /** + * 获取用户资料统计(用于用户详情页) + */ + async getUserProfileStats(accountSequence: string): Promise { + const emptyStats: UserProfileStatsResponse = { + accountSequence, + directReferralCount: 0, + umbrellaUserCount: 0, + personalPlantingCount: 0, + teamPlantingCount: 0, + }; + + if (!this.enabled) { + this.logger.debug('[DISABLED] Referral service integration is disabled'); + return emptyStats; + } + + try { + this.logger.debug(`[HTTP] GET /internal/referral-chain/${accountSequence}/profile-stats`); + + const response = await this.httpClient.get( + `/api/v1/internal/referral-chain/${accountSequence}/profile-stats`, + ); + + if (!response.data) { + return emptyStats; + } + + return response.data; + } catch (error) { + this.logger.error(`[HTTP] Failed to get profile stats for accountSequence ${accountSequence}:`, error); + return emptyStats; + } + } } diff --git a/backend/services/identity-service/src/api/controllers/internal.controller.ts b/backend/services/identity-service/src/api/controllers/internal.controller.ts index 0e6d354e..df1eca24 100644 --- a/backend/services/identity-service/src/api/controllers/internal.controller.ts +++ b/backend/services/identity-service/src/api/controllers/internal.controller.ts @@ -25,6 +25,22 @@ interface UserBasicInfo { avatarUrl?: string; } +/** + * 用户详情信息响应(包含敏感信息,仅内部调用) + */ +interface UserDetailInfo { + userId: string; + accountSequence: string; + nickname: string; + avatarUrl?: string; + phoneNumber?: string; // 敏感信息 + email?: string; // 敏感信息 + registeredAt: string; + inviterSequence?: string; // 推荐人序列号 + kycStatus: string; + realName?: string; // 敏感信息 +} + /** * 内部服务调用控制器 * 用于微服务间的内部通信,不需要JWT认证 @@ -94,4 +110,37 @@ export class InternalController { return null; } } + + @Public() + @Get('users/:accountSequence/detail') + @ApiOperation({ summary: '获取用户详情(内部调用,包含敏感信息)' }) + @ApiResponse({ status: 200, description: '返回用户详情信息' }) + async getUserDetailBySequence(@Param('accountSequence') accountSequence: string): Promise { + this.logger.debug(`[getUserDetailBySequence] 查询用户详情: ${accountSequence}`); + + try { + const sequence = AccountSequence.create(accountSequence); + const user = await this.userRepository.findByAccountSequence(sequence); + + if (!user) { + return null; + } + + return { + userId: user.userId.value.toString(), + accountSequence: user.accountSequence.value, + nickname: user.nickname, + avatarUrl: user.avatarUrl || undefined, + phoneNumber: user.phoneNumber?.value || undefined, + email: user.email || undefined, + registeredAt: user.registeredAt.toISOString(), + inviterSequence: user.inviterSequence?.value || undefined, + kycStatus: user.kycStatus, + realName: user.realName || undefined, + }; + } catch (error) { + this.logger.error(`[getUserDetailBySequence] 查询失败:`, error); + return null; + } + } } diff --git a/backend/services/referral-service/src/api/controllers/internal-referral-chain.controller.ts b/backend/services/referral-service/src/api/controllers/internal-referral-chain.controller.ts index 3338bfcc..ad01d312 100644 --- a/backend/services/referral-service/src/api/controllers/internal-referral-chain.controller.ts +++ b/backend/services/referral-service/src/api/controllers/internal-referral-chain.controller.ts @@ -3,6 +3,8 @@ import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { REFERRAL_RELATIONSHIP_REPOSITORY, IReferralRelationshipRepository, + TEAM_STATISTICS_REPOSITORY, + ITeamStatisticsRepository, } from '../../domain'; /** @@ -17,6 +19,8 @@ export class InternalReferralChainController { constructor( @Inject(REFERRAL_RELATIONSHIP_REPOSITORY) private readonly referralRepo: IReferralRelationshipRepository, + @Inject(TEAM_STATISTICS_REPOSITORY) + private readonly teamStatsRepo: ITeamStatisticsRepository, ) {} /** @@ -182,4 +186,68 @@ export class InternalReferralChainController { teamMembers, }; } + + /** + * 获取用户的完整团队统计(内部API) + * 用于用户详情页展示 + */ + @Get(':accountSequence/profile-stats') + @ApiOperation({ summary: '获取用户团队统计(内部API)' }) + @ApiParam({ name: 'accountSequence', description: '账户序列号' }) + @ApiResponse({ + status: 200, + description: '用户团队统计', + schema: { + type: 'object', + properties: { + accountSequence: { type: 'string' }, + directReferralCount: { type: 'number', description: '直推人数' }, + umbrellaUserCount: { type: 'number', description: '伞下用户数(团队总人数)' }, + personalPlantingCount: { type: 'number', description: '个人认种数量' }, + teamPlantingCount: { type: 'number', description: '团队认种数量' }, + }, + }, + }) + async getProfileStats(@Param('accountSequence') accountSequence: string) { + this.logger.debug(`[INTERNAL] getProfileStats: accountSequence=${accountSequence}`); + + const relationship = await this.referralRepo.findByAccountSequence(accountSequence); + + if (!relationship) { + return { + accountSequence: accountSequence, + directReferralCount: 0, + umbrellaUserCount: 0, + personalPlantingCount: 0, + teamPlantingCount: 0, + }; + } + + // 获取直推人数 + const directReferrals = await this.referralRepo.findDirectReferrals(relationship.userId); + const directReferralCount = directReferrals.length; + + // 获取伞下用户数(递归获取所有下级) + let umbrellaUserCount = 0; + const queue = [...directReferrals]; + while (queue.length > 0) { + umbrellaUserCount++; + const current = queue.shift()!; + const subReferrals = await this.referralRepo.findDirectReferrals(current.userId); + queue.push(...subReferrals); + } + + // 获取团队统计 + const teamStats = await this.teamStatsRepo.findByUserId(relationship.userId); + const personalPlantingCount = teamStats?.personalPlantingCount ?? 0; + const teamPlantingCount = teamStats?.teamPlantingCount ?? 0; + + return { + accountSequence: accountSequence, + directReferralCount, + umbrellaUserCount, + personalPlantingCount, + teamPlantingCount, + }; + } } diff --git a/frontend/mobile-app/lib/core/services/authorization_service.dart b/frontend/mobile-app/lib/core/services/authorization_service.dart index 073e71db..a932fe96 100644 --- a/frontend/mobile-app/lib/core/services/authorization_service.dart +++ b/frontend/mobile-app/lib/core/services/authorization_service.dart @@ -460,33 +460,147 @@ class SelfApplyAuthorizationResponse { /// 火柴人排名响应 class StickmanRankingResponse { final String id; + final String userId; + final String authorizationId; final String nickname; final String? avatarUrl; final int completedCount; final double monthlyEarnings; final bool isCurrentUser; + final String? accountSequence; StickmanRankingResponse({ required this.id, + required this.userId, + required this.authorizationId, required this.nickname, this.avatarUrl, required this.completedCount, required this.monthlyEarnings, required this.isCurrentUser, + this.accountSequence, }); factory StickmanRankingResponse.fromJson(Map json) { return StickmanRankingResponse( id: json['id']?.toString() ?? '', + userId: json['userId']?.toString() ?? '', + authorizationId: json['authorizationId']?.toString() ?? '', nickname: json['nickname'] ?? '', avatarUrl: json['avatarUrl'], completedCount: json['completedCount'] ?? 0, monthlyEarnings: (json['monthlyEarnings'] ?? 0).toDouble(), isCurrentUser: json['isCurrentUser'] ?? false, + accountSequence: json['accountSequence']?.toString(), ); } } +/// 用户授权简要信息 +class UserAuthorizationInfo { + final String authorizationId; + final RoleType roleType; + final String regionCode; + final String regionName; + final String displayTitle; + final bool benefitActive; + + UserAuthorizationInfo({ + required this.authorizationId, + required this.roleType, + required this.regionCode, + required this.regionName, + required this.displayTitle, + required this.benefitActive, + }); + + factory UserAuthorizationInfo.fromJson(Map json) { + return UserAuthorizationInfo( + authorizationId: json['authorizationId'] ?? '', + roleType: RoleTypeExtension.fromString(json['roleType']) ?? RoleType.community, + regionCode: json['regionCode'] ?? '', + regionName: json['regionName'] ?? '', + displayTitle: json['displayTitle'] ?? '', + benefitActive: json['benefitActive'] ?? false, + ); + } +} + +/// 用户公开资料响应 +class UserProfileResponse { + final String userId; + final String accountSequence; + final String nickname; + final String? avatarUrl; + final String registeredAt; + final String? inviterSequence; + final String? inviterNickname; + final int directReferralCount; + final int umbrellaUserCount; + final String? province; + final String? city; + final int personalPlantingCount; + final int teamPlantingCount; + final List authorizations; + // 私密字段(仅特权用户可见) + final String? phoneNumber; + final String? email; + final String? realName; + final String? kycStatus; + + UserProfileResponse({ + required this.userId, + required this.accountSequence, + required this.nickname, + this.avatarUrl, + required this.registeredAt, + this.inviterSequence, + this.inviterNickname, + required this.directReferralCount, + required this.umbrellaUserCount, + this.province, + this.city, + required this.personalPlantingCount, + required this.teamPlantingCount, + required this.authorizations, + this.phoneNumber, + this.email, + this.realName, + this.kycStatus, + }); + + factory UserProfileResponse.fromJson(Map json) { + return UserProfileResponse( + userId: json['userId']?.toString() ?? '', + accountSequence: json['accountSequence']?.toString() ?? '', + nickname: json['nickname'] ?? '', + avatarUrl: json['avatarUrl'], + registeredAt: json['registeredAt'] ?? '', + inviterSequence: json['inviterSequence'], + inviterNickname: json['inviterNickname'], + directReferralCount: json['directReferralCount'] ?? 0, + umbrellaUserCount: json['umbrellaUserCount'] ?? 0, + province: json['province'], + city: json['city'], + personalPlantingCount: json['personalPlantingCount'] ?? 0, + teamPlantingCount: json['teamPlantingCount'] ?? 0, + authorizations: (json['authorizations'] as List?) + ?.map((e) => UserAuthorizationInfo.fromJson(e as Map)) + .toList() ?? [], + phoneNumber: json['phoneNumber'], + email: json['email'], + realName: json['realName'], + kycStatus: json['kycStatus'], + ); + } + + /// 注册时间(解析后) + DateTime get registeredAtDateTime => DateTime.tryParse(registeredAt) ?? DateTime.now(); + + /// 是否有私密信息 + bool get hasPrivateInfo => phoneNumber != null || email != null || realName != null; +} + /// 授权服务 /// /// 处理用户授权相关功能: @@ -730,4 +844,78 @@ class AuthorizationService { rethrow; } } + + /// 获取用户公开资料 + /// + /// [accountSequence] 目标用户的账户序列号 + /// 返回用户的公开资料,包括昵称、头像、注册时间、推荐人、直推人数等 + Future getUserProfile(String accountSequence) async { + try { + debugPrint('获取用户资料: accountSequence=$accountSequence'); + final response = await _apiClient.get( + '/authorizations/users/$accountSequence/profile', + ); + + if (response.statusCode == 200) { + final responseData = response.data; + Map? data; + if (responseData is Map) { + if (responseData.containsKey('data')) { + data = responseData['data'] as Map?; + } else { + data = responseData; + } + } + + if (data != null) { + final profile = UserProfileResponse.fromJson(data); + debugPrint('用户资料获取成功: ${profile.nickname}'); + return profile; + } + throw Exception('用户资料数据格式错误'); + } + + throw Exception('获取用户资料失败'); + } catch (e) { + debugPrint('获取用户资料失败: $e'); + rethrow; + } + } + + /// 获取用户私密资料(需要省团队以上权限) + /// + /// [accountSequence] 目标用户的账户序列号 + /// 返回用户的私密资料,包括脱敏的手机号、邮箱、真实姓名等 + Future getUserPrivateProfile(String accountSequence) async { + try { + debugPrint('获取用户私密资料: accountSequence=$accountSequence'); + final response = await _apiClient.get( + '/authorizations/users/$accountSequence/profile/private', + ); + + if (response.statusCode == 200) { + final responseData = response.data; + Map? data; + if (responseData is Map) { + if (responseData.containsKey('data')) { + data = responseData['data'] as Map?; + } else { + data = responseData; + } + } + + if (data != null) { + final profile = UserProfileResponse.fromJson(data); + debugPrint('用户私密资料获取成功: ${profile.nickname}'); + return profile; + } + throw Exception('用户私密资料数据格式错误'); + } + + throw Exception('获取用户私密资料失败'); + } catch (e) { + debugPrint('获取用户私密资料失败: $e'); + rethrow; + } + } } diff --git a/frontend/mobile-app/lib/features/authorization/presentation/widgets/stickman_race_widget.dart b/frontend/mobile-app/lib/features/authorization/presentation/widgets/stickman_race_widget.dart index 77a328f9..94c847ec 100644 --- a/frontend/mobile-app/lib/features/authorization/presentation/widgets/stickman_race_widget.dart +++ b/frontend/mobile-app/lib/features/authorization/presentation/widgets/stickman_race_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:lottie/lottie.dart'; +import 'user_profile_dialog.dart'; /// 火柴人排名数据模型 class StickmanRankingData { @@ -10,6 +11,7 @@ class StickmanRankingData { final int targetCount; // 目标数量 (省: 50000, 市: 10000) final double monthlyEarnings; // 本月可结算收益 final bool isCurrentUser; // 是否是当前用户 + final String? accountSequence; // 账户序列号(用于查看详情) StickmanRankingData({ required this.id, @@ -19,6 +21,7 @@ class StickmanRankingData { required this.targetCount, required this.monthlyEarnings, this.isCurrentUser = false, + this.accountSequence, }); /// 完成进度 (0.0 - 1.0) @@ -406,19 +409,21 @@ class _StickmanRaceWidgetState extends State /// 构建排名项 Widget _buildRankingItem(int rank, StickmanRankingData data) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: data.isCurrentUser - ? const Color(0xFFD4AF37).withValues(alpha: 0.1) - : Colors.white.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(8), - border: data.isCurrentUser - ? Border.all(color: const Color(0xFFD4AF37), width: 1) - : null, - ), - child: Row( + return GestureDetector( + onTap: () => _showUserProfile(data), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: data.isCurrentUser + ? const Color(0xFFD4AF37).withValues(alpha: 0.1) + : Colors.white.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + border: data.isCurrentUser + ? Border.all(color: const Color(0xFFD4AF37), width: 1) + : null, + ), + child: Row( children: [ // 排名 Container( @@ -536,8 +541,35 @@ class _StickmanRaceWidgetState extends State ], ), ), + // 点击查看详情图标 + const SizedBox(width: 8), + const Icon( + Icons.chevron_right, + color: Color(0xFF8B5A2B), + size: 20, + ), ], ), + ), + ); + } + + /// 显示用户资料弹窗 + void _showUserProfile(StickmanRankingData data) { + if (data.accountSequence == null || data.accountSequence!.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('暂无该用户的详细信息'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + UserProfileDialog.show( + context, + accountSequence: data.accountSequence!, + nickname: data.nickname, ); } diff --git a/frontend/mobile-app/lib/features/authorization/presentation/widgets/user_profile_dialog.dart b/frontend/mobile-app/lib/features/authorization/presentation/widgets/user_profile_dialog.dart new file mode 100644 index 00000000..068757de --- /dev/null +++ b/frontend/mobile-app/lib/features/authorization/presentation/widgets/user_profile_dialog.dart @@ -0,0 +1,488 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/services/authorization_service.dart'; +import '../../../../core/di/injection_container.dart'; + +/// 用户资料详情弹窗 +class UserProfileDialog extends ConsumerStatefulWidget { + /// 目标用户的账户序列号 + final String accountSequence; + + /// 用户昵称(用于显示加载中标题) + final String? nickname; + + const UserProfileDialog({ + super.key, + required this.accountSequence, + this.nickname, + }); + + /// 显示用户资料弹窗 + static Future show( + BuildContext context, { + required String accountSequence, + String? nickname, + }) { + return showDialog( + context: context, + builder: (context) => UserProfileDialog( + accountSequence: accountSequence, + nickname: nickname, + ), + ); + } + + @override + ConsumerState createState() => _UserProfileDialogState(); +} + +class _UserProfileDialogState extends ConsumerState { + UserProfileResponse? _profile; + bool _isLoading = true; + String? _error; + bool _hasPrivilege = false; + + @override + void initState() { + super.initState(); + _loadProfile(); + } + + Future _loadProfile() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final authService = ref.read(authorizationServiceProvider); + + // 先尝试获取私密资料(如果有权限) + try { + final privateProfile = await authService.getUserPrivateProfile(widget.accountSequence); + if (mounted) { + setState(() { + _profile = privateProfile; + _hasPrivilege = true; + _isLoading = false; + }); + } + return; + } catch (e) { + // 没有权限,继续获取公开资料 + debugPrint('无权限获取私密资料,尝试获取公开资料'); + } + + // 获取公开资料 + final profile = await authService.getUserProfile(widget.accountSequence); + if (mounted) { + setState(() { + _profile = profile; + _hasPrivilege = false; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString().replaceAll('Exception: ', ''); + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), + child: Container( + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600), + decoration: BoxDecoration( + color: const Color(0xFFF5F0E6), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(), + Flexible( + child: _isLoading + ? _buildLoading() + : _error != null + ? _buildError() + : _buildContent(), + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFFD4AF37), Color(0xFFB8860B)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Row( + children: [ + const Icon(Icons.person, color: Colors.white, size: 24), + const SizedBox(width: 12), + Expanded( + child: Text( + widget.nickname ?? '用户详情', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ); + } + + Widget _buildLoading() { + return const Center( + child: Padding( + padding: EdgeInsets.all(40), + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFFD4AF37)), + ), + ), + ); + } + + Widget _buildError() { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 48, + ), + const SizedBox(height: 16), + Text( + _error ?? '加载失败', + style: const TextStyle( + fontSize: 14, + color: Color(0xFF5D4037), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + TextButton( + onPressed: _loadProfile, + child: const Text('重试'), + ), + ], + ), + ), + ); + } + + Widget _buildContent() { + final profile = _profile!; + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 用户头像和基本信息 + _buildUserHeader(profile), + const SizedBox(height: 20), + + // 基本信息卡片 + _buildInfoCard( + title: '基本信息', + children: [ + _buildInfoRow('用户ID', profile.accountSequence), + _buildInfoRow('注册时间', _formatDate(profile.registeredAtDateTime)), + if (profile.inviterNickname != null) + _buildInfoRow('推荐人', profile.inviterNickname!), + if (profile.province != null || profile.city != null) + _buildInfoRow('所在地区', [profile.province, profile.city].whereType().join(' ')), + ], + ), + const SizedBox(height: 16), + + // 团队数据卡片 + _buildInfoCard( + title: '团队数据', + children: [ + _buildInfoRow('直推人数', '${profile.directReferralCount} 人'), + _buildInfoRow('伞下用户', '${profile.umbrellaUserCount} 人'), + _buildInfoRow('个人认种', '${profile.personalPlantingCount} 棵'), + _buildInfoRow('团队认种', '${profile.teamPlantingCount} 棵'), + ], + ), + const SizedBox(height: 16), + + // 授权信息 + if (profile.authorizations.isNotEmpty) ...[ + _buildInfoCard( + title: '授权信息', + children: profile.authorizations.map((auth) { + return _buildAuthorizationRow(auth); + }).toList(), + ), + const SizedBox(height: 16), + ], + + // 私密信息(仅特权用户可见) + if (_hasPrivilege && profile.hasPrivateInfo) + _buildInfoCard( + title: '联系信息', + icon: Icons.lock, + children: [ + if (profile.phoneNumber != null) + _buildInfoRow('手机号', profile.phoneNumber!), + if (profile.email != null) + _buildInfoRow('邮箱', profile.email!), + if (profile.realName != null) + _buildInfoRow('真实姓名', profile.realName!), + if (profile.kycStatus != null) + _buildInfoRow('KYC状态', _formatKycStatus(profile.kycStatus!)), + ], + ), + ], + ), + ); + } + + Widget _buildUserHeader(UserProfileResponse profile) { + return Row( + children: [ + // 头像 + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: const Color(0xFFD4AF37), + width: 2, + ), + ), + child: ClipOval( + child: profile.avatarUrl != null + ? Image.network( + profile.avatarUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => _buildDefaultAvatar(), + ) + : _buildDefaultAvatar(), + ), + ), + const SizedBox(width: 16), + // 昵称和标签 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.nickname, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 4, + children: profile.authorizations.map((auth) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: auth.benefitActive + ? const Color(0xFFD4AF37).withValues(alpha: 0.2) + : Colors.grey.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + auth.displayTitle, + style: TextStyle( + fontSize: 11, + color: auth.benefitActive + ? const Color(0xFFD4AF37) + : Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + ); + }).toList(), + ), + ], + ), + ), + ], + ); + } + + Widget _buildDefaultAvatar() { + return Container( + color: const Color(0xFFD4AF37).withValues(alpha: 0.3), + child: const Icon( + Icons.person, + size: 32, + color: Color(0xFFD4AF37), + ), + ); + } + + Widget _buildInfoCard({ + required String title, + required List children, + IconData? icon, + }) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFD4AF37).withValues(alpha: 0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (icon != null) ...[ + Icon(icon, size: 16, color: const Color(0xFFD4AF37)), + const SizedBox(width: 6), + ], + Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + ], + ), + const SizedBox(height: 12), + ...children, + ], + ), + ); + } + + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF8B7355), + ), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF5D4037), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } + + Widget _buildAuthorizationRow(UserAuthorizationInfo auth) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: auth.benefitActive ? Colors.green : Colors.grey, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + auth.displayTitle, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF5D4037), + ), + ), + ), + Text( + auth.benefitActive ? '权益激活' : '权益未激活', + style: TextStyle( + fontSize: 12, + color: auth.benefitActive ? Colors.green : Colors.grey, + ), + ), + ], + ), + ); + } + + String _formatDate(DateTime date) { + return DateFormat('yyyy-MM-dd').format(date); + } + + String _formatKycStatus(String status) { + switch (status.toUpperCase()) { + case 'VERIFIED': + return '已认证'; + case 'PENDING': + return '审核中'; + case 'REJECTED': + return '已拒绝'; + default: + return '未认证'; + } + } +}