feat(authorization): add community hierarchy API

- Add internal referral-chain API in referral-service for getting ancestor path and team members
- Extend ReferralServiceClient to call referral-chain API
- Add findActiveCommunityByAccountSequences repository method
- Add getCommunityHierarchy application service method
- Add GET /authorizations/my/community-hierarchy endpoint
- Update frontend with CommunityHierarchy model and getMyCommunityHierarchy method

API returns:
- myCommunity: user's own community authorization (if any)
- parentCommunity: nearest parent community (defaults to 总部社区 if none)
- childCommunities: nearest child communities in user's team

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-10 18:18:49 -08:00
parent c1d30b0a65
commit 9cf8b5305b
15 changed files with 584 additions and 1 deletions

View File

@ -38,6 +38,7 @@ import {
AuthorizationResponse,
ApplyAuthorizationResponse,
StickmanRankingResponse,
CommunityHierarchyResponse,
} from '@/api/dto/response'
import { CurrentUser } from '@/shared/decorators'
import { JwtAuthGuard } from '@/shared/guards'
@ -97,6 +98,15 @@ export class AuthorizationController {
return await this.applicationService.getUserAuthorizations(user.accountSequence)
}
@Get('my/community-hierarchy')
@ApiOperation({ summary: '获取我的社区层级(上级社区和下级社区)' })
@ApiResponse({ status: 200, type: CommunityHierarchyResponse })
async getMyCommunityHierarchy(
@CurrentUser() user: { userId: string; accountSequence: number },
): Promise<CommunityHierarchyResponse> {
return await this.applicationService.getCommunityHierarchy(user.accountSequence)
}
@Get(':id')
@ApiOperation({ summary: '获取授权详情' })
@ApiParam({ name: 'id', description: '授权ID' })

View File

@ -0,0 +1,52 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
/**
*
*/
export class CommunityInfo {
@ApiProperty({ description: '授权ID' })
authorizationId: string
@ApiProperty({ description: '账户序列号' })
accountSequence: number
@ApiProperty({ description: '社区名称' })
communityName: string
@ApiPropertyOptional({ description: '用户ID' })
userId?: string
@ApiProperty({ description: '是否为总部社区' })
isHeadquarters: boolean
}
/**
*
*/
export class CommunityHierarchyResponse {
@ApiPropertyOptional({ description: '我的社区授权(如果有)', type: CommunityInfo })
myCommunity: CommunityInfo | null
@ApiProperty({ description: '上级社区(最近的,如果没有则为总部社区)', type: CommunityInfo })
parentCommunity: CommunityInfo
@ApiProperty({ description: '下级社区列表最近的按accountSequence排序', type: [CommunityInfo] })
childCommunities: CommunityInfo[]
@ApiProperty({ description: '是否有上级社区(非总部)' })
hasParentCommunity: boolean
@ApiProperty({ description: '下级社区数量' })
childCommunityCount: number
}
/**
*
*/
export const HEADQUARTERS_COMMUNITY: CommunityInfo = {
authorizationId: 'headquarters',
accountSequence: 0,
communityName: '总部社区',
userId: undefined,
isHeadquarters: true,
}

View File

@ -1 +1,2 @@
export * from './authorization.response'
export * from './community-hierarchy.response'

View File

@ -0,0 +1,26 @@
/**
*
*/
export interface CommunityInfoDTO {
authorizationId: string
accountSequence: number
communityName: string
userId?: string
isHeadquarters: boolean
}
/**
* DTO
*/
export interface CommunityHierarchyDTO {
/** 我的社区授权(如果有) */
myCommunity: CommunityInfoDTO | null
/** 上级社区(最近的,如果没有则为总部社区) */
parentCommunity: CommunityInfoDTO
/** 下级社区列表(最近的) */
childCommunities: CommunityInfoDTO[]
/** 是否有上级社区(非总部) */
hasParentCommunity: boolean
/** 下级社区数量 */
childCommunityCount: number
}

View File

@ -1 +1,2 @@
export * from './authorization.dto'
export * from './community-hierarchy.dto'

View File

@ -22,6 +22,7 @@ import {
TeamStatistics,
} from '@/domain/services'
import { EventPublisherService } from '@/infrastructure/kafka'
import { ReferralServiceClient } from '@/infrastructure/external'
import { ApplicationError, NotFoundError } from '@/shared/exceptions'
import {
ApplyCommunityAuthCommand,
@ -37,7 +38,7 @@ import {
GrantMonthlyBypassCommand,
ExemptLocalPercentageCheckCommand,
} from '@/application/commands'
import { AuthorizationDTO, StickmanRankingDTO } from '@/application/dto'
import { AuthorizationDTO, StickmanRankingDTO, CommunityHierarchyDTO } from '@/application/dto'
export const REFERRAL_REPOSITORY = Symbol('IReferralRepository')
export const TEAM_STATISTICS_REPOSITORY = Symbol('ITeamStatisticsRepository')
@ -57,6 +58,7 @@ export class AuthorizationApplicationService {
@Inject(TEAM_STATISTICS_REPOSITORY)
private readonly statsRepository: ITeamStatisticsRepository,
private readonly eventPublisher: EventPublisherService,
private readonly referralServiceClient: ReferralServiceClient,
) {}
/**
@ -493,4 +495,130 @@ export class AuthorizationApplicationService {
updatedAt: auth.updatedAt,
}
}
/**
*
* - myCommunity: 我的社区授权
* - parentCommunity: 上级社区沿
* - childCommunities: 下级社区
*/
async getCommunityHierarchy(accountSequence: number): Promise<CommunityHierarchyDTO> {
this.logger.debug(`[getCommunityHierarchy] accountSequence=${accountSequence}`)
// 1. 查询我的社区授权
const myCommunity = await this.authorizationRepository.findByAccountSequenceAndRoleType(
BigInt(accountSequence),
RoleType.COMMUNITY,
)
// 2. 获取我的祖先链(推荐链)
const ancestorAccountSequences = await this.referralServiceClient.getReferralChain(BigInt(accountSequence))
this.logger.debug(`[getCommunityHierarchy] ancestorPath: ${ancestorAccountSequences.join(',')}`)
// 3. 查找上级社区(在祖先链中找最近的有社区授权的用户)
let parentCommunityAuth: AuthorizationRole | null = null
if (ancestorAccountSequences.length > 0) {
const ancestorCommunities = await this.authorizationRepository.findActiveCommunityByAccountSequences(
ancestorAccountSequences.map((seq) => BigInt(seq)),
)
// 找最近的ancestorAccountSequences 是从直接推荐人到根节点的顺序)
if (ancestorCommunities.length > 0) {
// 按祖先链顺序找第一个匹配的
for (const ancestorSeq of ancestorAccountSequences) {
const found = ancestorCommunities.find(
(auth) => Number(auth.userId.accountSequence) === ancestorSeq,
)
if (found) {
parentCommunityAuth = found
break
}
}
}
}
// 4. 获取我的团队成员
const teamMemberAccountSequences = await this.referralServiceClient.getTeamMembers(BigInt(accountSequence))
this.logger.debug(`[getCommunityHierarchy] teamMembers count: ${teamMemberAccountSequences.length}`)
// 5. 查找下级社区(在团队成员中找最近的有社区授权的用户)
// "最近" 的定义:直接下级优先,然后是下级的下级,以此类推
// 由于 getTeamMembers 返回的是广度优先遍历结果,可以直接使用顺序
let childCommunityAuths: AuthorizationRole[] = []
if (teamMemberAccountSequences.length > 0) {
const teamCommunities = await this.authorizationRepository.findActiveCommunityByAccountSequences(
teamMemberAccountSequences.map((seq) => BigInt(seq)),
)
// 只保留"最近的"下级社区
// 如果一个社区的上级不在我的直接团队成员中,或者其上级就是我,则它是"最近的"
// 简化实现:返回所有团队中的社区,前端可以根据需要过滤
// 但按用户要求"只计算最近的那个",这里需要做过滤
// 算法:如果某个社区 A 的祖先中有另一个社区 B 也在团队中,则 A 不是最近的
const communityAccountSeqs = new Set(teamCommunities.map((c) => Number(c.userId.accountSequence)))
for (const comm of teamCommunities) {
// 获取这个社区成员的祖先链
const commAncestors = await this.referralServiceClient.getReferralChain(comm.userId.accountSequence)
// 检查这个社区是否有"更近"的祖先社区
let hasCloserAncestorCommunity = false
for (const ancestorSeq of commAncestors) {
// 如果祖先是我,停止检查
if (ancestorSeq === accountSequence) {
break
}
// 如果祖先也是社区且在我的团队中,则当前社区不是最近的
if (communityAccountSeqs.has(ancestorSeq)) {
hasCloserAncestorCommunity = true
break
}
}
if (!hasCloserAncestorCommunity) {
childCommunityAuths.push(comm)
}
}
}
// 6. 构建响应
const HEADQUARTERS_COMMUNITY = {
authorizationId: 'headquarters',
accountSequence: 0,
communityName: '总部社区',
userId: undefined,
isHeadquarters: true,
}
return {
myCommunity: myCommunity && myCommunity.status === AuthorizationStatus.AUTHORIZED
? {
authorizationId: myCommunity.authorizationId.value,
accountSequence: Number(myCommunity.userId.accountSequence),
communityName: myCommunity.displayTitle,
userId: myCommunity.userId.value,
isHeadquarters: false,
}
: null,
parentCommunity: parentCommunityAuth
? {
authorizationId: parentCommunityAuth.authorizationId.value,
accountSequence: Number(parentCommunityAuth.userId.accountSequence),
communityName: parentCommunityAuth.displayTitle,
userId: parentCommunityAuth.userId.value,
isHeadquarters: false,
}
: HEADQUARTERS_COMMUNITY,
childCommunities: childCommunityAuths.map((auth) => ({
authorizationId: auth.authorizationId.value,
accountSequence: Number(auth.userId.accountSequence),
communityName: auth.displayTitle,
userId: auth.userId.value,
isHeadquarters: false,
})),
hasParentCommunity: parentCommunityAuth !== null,
childCommunityCount: childCommunityAuths.length,
}
}
}

View File

@ -24,4 +24,8 @@ export interface IAuthorizationRoleRepository {
findPendingByUserId(userId: UserId): Promise<AuthorizationRole[]>
findByStatus(status: AuthorizationStatus): Promise<AuthorizationRole[]>
delete(authorizationId: AuthorizationId): Promise<void>
/**
* accountSequence
*/
findActiveCommunityByAccountSequences(accountSequences: bigint[]): Promise<AuthorizationRole[]>
}

View File

@ -16,6 +16,24 @@ interface ReferralTeamStatsResponse {
provinceCityDistribution: Record<string, Record<string, number>> | null;
}
/**
*
*/
interface ReferralChainResponse {
accountSequence: number;
userId: string | null;
ancestorPath: string[];
referrerId: string | null;
}
/**
*
*/
interface TeamMembersResponse {
accountSequence: number;
teamMembers: number[];
}
/**
* referral-service authorization-service
*/
@ -159,4 +177,61 @@ export class ReferralServiceClient implements ITeamStatisticsRepository, OnModul
private createEmptyStats(userId: string, accountSequence: bigint): TeamStatistics {
return new TeamStatisticsAdapter(userId, accountSequence, 0, null);
}
/**
*
* accountSequence
*/
async getReferralChain(accountSequence: bigint): Promise<number[]> {
if (!this.enabled) {
this.logger.debug('[DISABLED] Referral service integration is disabled');
return [];
}
try {
this.logger.debug(`[HTTP] GET /internal/referral-chain/${accountSequence}`);
const response = await this.httpClient.get<ReferralChainResponse>(
`/api/v1/internal/referral-chain/${accountSequence}`,
);
if (!response.data || !response.data.ancestorPath) {
return [];
}
// ancestorPath 存储的是 userId (bigint string),我们需要映射到 accountSequence
// 由于 referral-service 中 userId = BigInt(accountSequence),可以直接转换
return response.data.ancestorPath.map((id) => Number(id));
} catch (error) {
this.logger.error(`[HTTP] Failed to get referral chain for accountSequence ${accountSequence}:`, error);
return [];
}
}
/**
* accountSequence
*/
async getTeamMembers(accountSequence: bigint): Promise<number[]> {
if (!this.enabled) {
this.logger.debug('[DISABLED] Referral service integration is disabled');
return [];
}
try {
this.logger.debug(`[HTTP] GET /internal/referral-chain/${accountSequence}/team-members`);
const response = await this.httpClient.get<TeamMembersResponse>(
`/api/v1/internal/referral-chain/${accountSequence}/team-members`,
);
if (!response.data || !response.data.teamMembers) {
return [];
}
return response.data.teamMembers;
} catch (error) {
this.logger.error(`[HTTP] Failed to get team members for accountSequence ${accountSequence}:`, error);
return [];
}
}
}

View File

@ -176,6 +176,25 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
})
}
async findActiveCommunityByAccountSequences(
accountSequences: bigint[],
): Promise<AuthorizationRole[]> {
if (accountSequences.length === 0) {
return []
}
const records = await this.prisma.authorizationRole.findMany({
where: {
accountSequence: { in: accountSequences },
roleType: RoleType.COMMUNITY,
status: AuthorizationStatus.AUTHORIZED,
benefitActive: true,
},
orderBy: { accountSequence: 'asc' },
})
return records.map((record) => this.toDomain(record))
}
private toDomain(record: any): AuthorizationRole {
const props: AuthorizationRoleProps = {
authorizationId: AuthorizationId.create(record.id),

View File

@ -29,6 +29,7 @@ describe('Domain Services Integration Tests', () => {
delete: jest.fn(),
findByAccountSequenceAndRoleType: jest.fn(),
findByAccountSequence: jest.fn(),
findActiveCommunityByAccountSequences: jest.fn(),
}
const mockMonthlyAssessmentRepository: jest.Mocked<IMonthlyAssessmentRepository> = {

View File

@ -1,4 +1,5 @@
export * from './referral.controller';
export * from './team-statistics.controller';
export * from './internal-team-statistics.controller';
export * from './internal-referral-chain.controller';
export * from './health.controller';

View File

@ -0,0 +1,163 @@
import { Controller, Get, Param, Logger, Inject, NotFoundException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
import {
REFERRAL_RELATIONSHIP_REPOSITORY,
IReferralRelationshipRepository,
} from '../../domain';
/**
* API -
* JWT认证
*/
@ApiTags('Internal Referral Chain API')
@Controller('internal/referral-chain')
export class InternalReferralChainController {
private readonly logger = new Logger(InternalReferralChainController.name);
constructor(
@Inject(REFERRAL_RELATIONSHIP_REPOSITORY)
private readonly referralRepo: IReferralRelationshipRepository,
) {}
/**
*
*/
@Get(':accountSequence')
@ApiOperation({ summary: '获取用户推荐链内部API' })
@ApiParam({ name: 'accountSequence', description: '账户序列号' })
@ApiResponse({
status: 200,
description: '推荐链数据',
schema: {
type: 'object',
properties: {
accountSequence: { type: 'number' },
userId: { type: 'string' },
ancestorPath: {
type: 'array',
items: { type: 'string' },
description: '祖先链从直接推荐人到根节点的accountSequence列表',
},
referrerId: { type: 'string', nullable: true },
},
},
})
async getReferralChain(@Param('accountSequence') accountSequence: string) {
this.logger.debug(`[INTERNAL] getReferralChain: accountSequence=${accountSequence}`);
const relationship = await this.referralRepo.findByAccountSequence(Number(accountSequence));
if (!relationship) {
this.logger.debug(`[INTERNAL] No referral found for accountSequence: ${accountSequence}`);
// 返回空的祖先链而不是抛出错误
return {
accountSequence: Number(accountSequence),
userId: null,
ancestorPath: [],
referrerId: null,
};
}
// ancestorPath 存储的是 userId (bigint),需要转换为字符串
const ancestorPath = relationship.referralChain.map((id) => id.toString());
return {
accountSequence: relationship.accountSequence,
userId: relationship.userId.toString(),
ancestorPath,
referrerId: relationship.referrerId?.toString() ?? null,
};
}
/**
*
*/
@Get('batch/:accountSequences')
@ApiOperation({ summary: '批量获取用户推荐链内部API' })
@ApiParam({ name: 'accountSequences', description: '账户序列号列表,逗号分隔' })
@ApiResponse({
status: 200,
description: '批量推荐链数据',
})
async getBatchReferralChains(@Param('accountSequences') accountSequences: string) {
const sequences = accountSequences.split(',').map((s) => Number(s.trim()));
this.logger.debug(`[INTERNAL] getBatchReferralChains: ${sequences.length} accounts`);
const results: Record<number, { userId: string | null; ancestorPath: string[]; referrerId: string | null }> = {};
for (const seq of sequences) {
const relationship = await this.referralRepo.findByAccountSequence(seq);
if (relationship) {
results[seq] = {
userId: relationship.userId.toString(),
ancestorPath: relationship.referralChain.map((id) => id.toString()),
referrerId: relationship.referrerId?.toString() ?? null,
};
} else {
results[seq] = {
userId: null,
ancestorPath: [],
referrerId: null,
};
}
}
return results;
}
/**
* accountSequence列表
*
*/
@Get(':accountSequence/team-members')
@ApiOperation({ summary: '获取团队成员accountSequence列表内部API' })
@ApiParam({ name: 'accountSequence', description: '账户序列号' })
@ApiResponse({
status: 200,
description: '团队成员列表',
schema: {
type: 'object',
properties: {
accountSequence: { type: 'number' },
teamMembers: {
type: 'array',
items: { type: 'number' },
description: '团队成员accountSequence列表直接和间接下级',
},
},
},
})
async getTeamMembers(@Param('accountSequence') accountSequence: string) {
this.logger.debug(`[INTERNAL] getTeamMembers: accountSequence=${accountSequence}`);
const relationship = await this.referralRepo.findByAccountSequence(Number(accountSequence));
if (!relationship) {
return {
accountSequence: Number(accountSequence),
teamMembers: [],
};
}
// 获取所有直推用户
const directReferrals = await this.referralRepo.findDirectReferrals(relationship.userId);
// 递归获取所有下级成员的accountSequence
const teamMembers: number[] = [];
const queue = [...directReferrals];
while (queue.length > 0) {
const current = queue.shift()!;
teamMembers.push(current.accountSequence);
// 获取当前用户的直推
const subReferrals = await this.referralRepo.findDirectReferrals(current.userId);
queue.push(...subReferrals);
}
return {
accountSequence: Number(accountSequence),
teamMembers,
};
}
}

View File

@ -6,6 +6,7 @@ import {
ReferralController,
TeamStatisticsController,
InternalTeamStatisticsController,
InternalReferralChainController,
HealthController,
} from '../api';
import { InternalReferralController } from '../api/controllers/referral.controller';
@ -16,6 +17,7 @@ import { InternalReferralController } from '../api/controllers/referral.controll
ReferralController,
TeamStatisticsController,
InternalTeamStatisticsController,
InternalReferralChainController,
HealthController,
InternalReferralController,
],

View File

@ -78,6 +78,7 @@ class ApiEndpoints {
// Authorization (-> Authorization Service)
static const String authorizations = '/authorizations';
static const String myAuthorizations = '$authorizations/my'; //
static const String myCommunityHierarchy = '$authorizations/my/community-hierarchy'; //
// Telemetry (-> Reporting Service)
static const String telemetry = '/telemetry';

View File

@ -208,6 +208,69 @@ class UserAuthorizationSummary {
}
}
///
class CommunityInfo {
final String authorizationId;
final int accountSequence;
final String communityName;
final String? userId;
final bool isHeadquarters;
CommunityInfo({
required this.authorizationId,
required this.accountSequence,
required this.communityName,
this.userId,
required this.isHeadquarters,
});
factory CommunityInfo.fromJson(Map<String, dynamic> json) {
return CommunityInfo(
authorizationId: json['authorizationId'] ?? '',
accountSequence: json['accountSequence'] ?? 0,
communityName: json['communityName'] ?? '',
userId: json['userId'],
isHeadquarters: json['isHeadquarters'] ?? false,
);
}
}
///
class CommunityHierarchy {
///
final CommunityInfo? myCommunity;
///
final CommunityInfo parentCommunity;
///
final List<CommunityInfo> childCommunities;
///
final bool hasParentCommunity;
///
final int childCommunityCount;
CommunityHierarchy({
this.myCommunity,
required this.parentCommunity,
required this.childCommunities,
required this.hasParentCommunity,
required this.childCommunityCount,
});
factory CommunityHierarchy.fromJson(Map<String, dynamic> json) {
return CommunityHierarchy(
myCommunity: json['myCommunity'] != null
? CommunityInfo.fromJson(json['myCommunity'])
: null,
parentCommunity: CommunityInfo.fromJson(json['parentCommunity']),
childCommunities: (json['childCommunities'] as List<dynamic>?)
?.map((e) => CommunityInfo.fromJson(e as Map<String, dynamic>))
.toList() ?? [],
hasParentCommunity: json['hasParentCommunity'] ?? false,
childCommunityCount: json['childCommunityCount'] ?? 0,
);
}
}
///
///
/// :
@ -261,4 +324,40 @@ class AuthorizationService {
final authorizations = await getMyAuthorizations();
return UserAuthorizationSummary.fromList(authorizations);
}
///
///
///
/// GET /authorizations/my/community-hierarchy
Future<CommunityHierarchy> getMyCommunityHierarchy() async {
try {
debugPrint('获取社区层级...');
final response = await _apiClient.get(ApiEndpoints.myCommunityHierarchy);
if (response.statusCode == 200) {
final responseData = response.data;
// API : {"success": true, "data": {...}}
Map<String, dynamic>? data;
if (responseData is Map<String, dynamic>) {
if (responseData.containsKey('data')) {
data = responseData['data'] as Map<String, dynamic>?;
} else {
data = responseData;
}
}
if (data != null) {
final hierarchy = CommunityHierarchy.fromJson(data);
debugPrint('社区层级获取成功: 上级=${hierarchy.parentCommunity.communityName}, 下级数量=${hierarchy.childCommunityCount}');
return hierarchy;
}
throw Exception('社区层级数据格式错误');
}
throw Exception('获取社区层级失败');
} catch (e) {
debugPrint('获取社区层级失败: $e');
rethrow;
}
}
}