diff --git a/backend/services/authorization-service/src/app.module.ts b/backend/services/authorization-service/src/app.module.ts index 3668c429..7f5a277f 100644 --- a/backend/services/authorization-service/src/app.module.ts +++ b/backend/services/authorization-service/src/app.module.ts @@ -20,6 +20,7 @@ import { import { RedisModule } from '@/infrastructure/redis/redis.module' import { KafkaModule } from '@/infrastructure/kafka/kafka.module' import { EventConsumerController } from '@/infrastructure/kafka/event-consumer.controller' +import { ReferralServiceClient } from '@/infrastructure/external/referral-service.client' // Application import { AuthorizationApplicationService, REFERRAL_REPOSITORY, TEAM_STATISTICS_REPOSITORY } from '@/application/services' @@ -41,26 +42,6 @@ const MockReferralRepository = { }, } -const MockTeamStatisticsRepository = { - provide: TEAM_STATISTICS_REPOSITORY, - useValue: { - findByUserId: async () => ({ - userId: '', - accountSequence: BigInt(0), - totalTeamPlantingCount: 0, - getProvinceTeamCount: () => 0, - getCityTeamCount: () => 0, - }), - findByAccountSequence: async () => ({ - userId: '', - accountSequence: BigInt(0), - totalTeamPlantingCount: 0, - getProvinceTeamCount: () => 0, - getCityTeamCount: () => 0, - }), - }, -} - @Module({ imports: [ ConfigModule.forRoot({ @@ -93,7 +74,13 @@ const MockTeamStatisticsRepository = { useClass: MonthlyAssessmentRepositoryImpl, }, MockReferralRepository, - MockTeamStatisticsRepository, + + // External Service Clients (replaces mock) + ReferralServiceClient, + { + provide: TEAM_STATISTICS_REPOSITORY, + useExisting: ReferralServiceClient, + }, // Application Services AuthorizationApplicationService, diff --git a/backend/services/authorization-service/src/infrastructure/external/index.ts b/backend/services/authorization-service/src/infrastructure/external/index.ts new file mode 100644 index 00000000..1842d2b4 --- /dev/null +++ b/backend/services/authorization-service/src/infrastructure/external/index.ts @@ -0,0 +1 @@ +export * from './referral-service.client'; 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 new file mode 100644 index 00000000..e138d4e7 --- /dev/null +++ b/backend/services/authorization-service/src/infrastructure/external/referral-service.client.ts @@ -0,0 +1,162 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios, { AxiosInstance } from 'axios'; +import { + ITeamStatisticsRepository, + TeamStatistics, +} from '../../domain/services/assessment-calculator.service'; + +/** + * 团队统计数据接口(来自 referral-service) + */ +interface ReferralTeamStatsResponse { + userId: string; + accountSequence: string; + totalTeamPlantingCount: number; + provinceCityDistribution: Record> | null; +} + +/** + * 适配器类:将 referral-service 返回的数据转换为 authorization-service 需要的格式 + */ +class TeamStatisticsAdapter implements TeamStatistics { + constructor( + public readonly userId: string, + public readonly accountSequence: bigint, + public readonly totalTeamPlantingCount: number, + private readonly provinceCityDistribution: Record> | null, + ) {} + + getProvinceTeamCount(provinceCode: string): number { + if (!this.provinceCityDistribution || !this.provinceCityDistribution[provinceCode]) { + return 0; + } + // 计算该省所有城市的总和 + const provinceCities = this.provinceCityDistribution[provinceCode]; + return Object.values(provinceCities).reduce((sum, count) => sum + count, 0); + } + + getCityTeamCount(cityCode: string): number { + if (!this.provinceCityDistribution) { + return 0; + } + // 遍历所有省份找到该城市 + for (const provinceCode of Object.keys(this.provinceCityDistribution)) { + const provinceCities = this.provinceCityDistribution[provinceCode]; + if (provinceCities && cityCode in provinceCities) { + return provinceCities[cityCode]; + } + } + return 0; + } +} + +/** + * Referral Service HTTP 客户端 + * 用于从 referral-service 获取团队统计数据 + */ +@Injectable() +export class ReferralServiceClient implements ITeamStatisticsRepository, OnModuleInit { + private readonly logger = new Logger(ReferralServiceClient.name); + private httpClient: AxiosInstance; + private readonly baseUrl: string; + private readonly enabled: boolean; + + constructor(private readonly configService: ConfigService) { + this.baseUrl = this.configService.get('REFERRAL_SERVICE_URL') || 'http://referral-service:3004'; + this.enabled = this.configService.get('REFERRAL_SERVICE_ENABLED') !== false; + } + + onModuleInit() { + this.httpClient = axios.create({ + baseURL: this.baseUrl, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, + }); + + this.logger.log(`[INIT] ReferralServiceClient initialized: ${this.baseUrl}, enabled: ${this.enabled}`); + } + + /** + * 根据 userId 查询团队统计 + */ + async findByUserId(userId: string): Promise { + if (!this.enabled) { + this.logger.debug('[DISABLED] Referral service integration is disabled'); + return this.createEmptyStats(userId, BigInt(0)); + } + + try { + this.logger.debug(`[HTTP] GET /internal/team-statistics/user/${userId}`); + + const response = await this.httpClient.get( + `/api/v1/internal/team-statistics/user/${userId}`, + ); + + if (!response.data) { + this.logger.debug(`[HTTP] No stats found for userId: ${userId}`); + return this.createEmptyStats(userId, BigInt(0)); + } + + const data = response.data; + this.logger.debug(`[HTTP] Got stats for userId ${userId}: totalTeamPlantingCount=${data.totalTeamPlantingCount}`); + + return new TeamStatisticsAdapter( + data.userId, + BigInt(data.accountSequence || 0), + data.totalTeamPlantingCount, + data.provinceCityDistribution, + ); + } catch (error) { + this.logger.error(`[HTTP] Failed to get stats for userId ${userId}:`, error); + // 返回空数据而不是抛出错误,避免影响主流程 + return this.createEmptyStats(userId, BigInt(0)); + } + } + + /** + * 根据 accountSequence 查询团队统计 + */ + async findByAccountSequence(accountSequence: bigint): Promise { + if (!this.enabled) { + this.logger.debug('[DISABLED] Referral service integration is disabled'); + return this.createEmptyStats('', accountSequence); + } + + try { + this.logger.debug(`[HTTP] GET /internal/team-statistics/account/${accountSequence}`); + + const response = await this.httpClient.get( + `/api/v1/internal/team-statistics/account/${accountSequence}`, + ); + + if (!response.data) { + this.logger.debug(`[HTTP] No stats found for accountSequence: ${accountSequence}`); + return this.createEmptyStats('', accountSequence); + } + + const data = response.data; + this.logger.debug(`[HTTP] Got stats for accountSequence ${accountSequence}: totalTeamPlantingCount=${data.totalTeamPlantingCount}`); + + return new TeamStatisticsAdapter( + data.userId, + BigInt(data.accountSequence || accountSequence.toString()), + data.totalTeamPlantingCount, + data.provinceCityDistribution, + ); + } catch (error) { + this.logger.error(`[HTTP] Failed to get stats for accountSequence ${accountSequence}:`, error); + // 返回空数据而不是抛出错误,避免影响主流程 + return this.createEmptyStats('', accountSequence); + } + } + + /** + * 创建空的统计数据 + */ + private createEmptyStats(userId: string, accountSequence: bigint): TeamStatistics { + return new TeamStatisticsAdapter(userId, accountSequence, 0, null); + } +} diff --git a/backend/services/docker-compose.yml b/backend/services/docker-compose.yml index c72968f5..b40ce8fe 100644 --- a/backend/services/docker-compose.yml +++ b/backend/services/docker-compose.yml @@ -472,6 +472,9 @@ services: - KAFKA_BROKERS=kafka:29092 - KAFKA_CLIENT_ID=authorization-service - KAFKA_GROUP_ID=authorization-service-group + # Referral Service - 用于获取团队统计数据 + - REFERRAL_SERVICE_URL=http://rwa-referral-service:3004 + - REFERRAL_SERVICE_ENABLED=true depends_on: postgres: condition: service_healthy @@ -479,6 +482,8 @@ services: condition: service_healthy kafka: condition: service_started + referral-service: + condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3009/api/v1/health"] interval: 30s diff --git a/backend/services/referral-service/src/api/controllers/index.ts b/backend/services/referral-service/src/api/controllers/index.ts index 5be60f3a..73ce4ef2 100644 --- a/backend/services/referral-service/src/api/controllers/index.ts +++ b/backend/services/referral-service/src/api/controllers/index.ts @@ -1,3 +1,4 @@ export * from './referral.controller'; export * from './team-statistics.controller'; +export * from './internal-team-statistics.controller'; export * from './health.controller'; diff --git a/backend/services/referral-service/src/api/controllers/internal-team-statistics.controller.ts b/backend/services/referral-service/src/api/controllers/internal-team-statistics.controller.ts new file mode 100644 index 00000000..a89a4404 --- /dev/null +++ b/backend/services/referral-service/src/api/controllers/internal-team-statistics.controller.ts @@ -0,0 +1,116 @@ +import { Controller, Get, Param, Logger, Inject } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { + TEAM_STATISTICS_REPOSITORY, + ITeamStatisticsRepository, +} from '../../domain'; + +/** + * 内部团队统计API - 供其他微服务调用 + * 无需JWT认证(服务间内部通信) + */ +@ApiTags('Internal Team Statistics API') +@Controller('internal/team-statistics') +export class InternalTeamStatisticsController { + private readonly logger = new Logger(InternalTeamStatisticsController.name); + + constructor( + @Inject(TEAM_STATISTICS_REPOSITORY) + private readonly teamStatsRepo: ITeamStatisticsRepository, + ) {} + + @Get('user/:userId') + @ApiOperation({ summary: '根据 userId 获取团队统计(内部API)' }) + @ApiParam({ name: 'userId', description: '用户ID (bigint)' }) + @ApiResponse({ + status: 200, + description: '团队统计数据', + schema: { + type: 'object', + properties: { + userId: { type: 'string' }, + accountSequence: { type: 'string' }, + totalTeamPlantingCount: { type: 'number' }, + provinceCityDistribution: { type: 'object' }, + }, + }, + }) + async getStatsByUserId(@Param('userId') userId: string) { + this.logger.debug(`[INTERNAL] getStatsByUserId: ${userId}`); + + try { + const stats = await this.teamStatsRepo.findByUserId(BigInt(userId)); + + if (!stats) { + this.logger.debug(`[INTERNAL] No stats found for userId: ${userId}`); + return null; + } + + const distribution = stats.provinceCityDistribution; + + return { + userId: stats.userId.toString(), + accountSequence: '0', // userId 查询时无法获取 accountSequence + totalTeamPlantingCount: stats.teamPlantingCount, // 使用 teamPlantingCount 作为团队总量 + provinceCityDistribution: distribution.toJson(), + }; + } catch (error) { + this.logger.error(`[INTERNAL] Error getting stats for userId ${userId}:`, error); + throw error; + } + } + + @Get('account/:accountSequence') + @ApiOperation({ summary: '根据 accountSequence 获取团队统计(内部API)' }) + @ApiParam({ name: 'accountSequence', description: '账户序列号' }) + @ApiResponse({ + status: 200, + description: '团队统计数据', + schema: { + type: 'object', + properties: { + userId: { type: 'string' }, + accountSequence: { type: 'string' }, + totalTeamPlantingCount: { type: 'number' }, + provinceCityDistribution: { type: 'object' }, + }, + }, + }) + async getStatsByAccountSequence(@Param('accountSequence') accountSequence: string) { + this.logger.debug(`[INTERNAL] getStatsByAccountSequence: ${accountSequence}`); + + try { + // 需要先通过 accountSequence 查找 userId + // 这里需要扩展 repository 方法 + const stats = await this.findByAccountSequence(BigInt(accountSequence)); + + if (!stats) { + this.logger.debug(`[INTERNAL] No stats found for accountSequence: ${accountSequence}`); + return null; + } + + const distribution = stats.provinceCityDistribution; + + return { + userId: stats.userId.toString(), + accountSequence: accountSequence, + totalTeamPlantingCount: stats.teamPlantingCount, + provinceCityDistribution: distribution.toJson(), + }; + } catch (error) { + this.logger.error(`[INTERNAL] Error getting stats for accountSequence ${accountSequence}:`, error); + throw error; + } + } + + /** + * 通过 accountSequence 查找团队统计 + * 需要先查询 referral_relationships 获取 userId,再查询 team_statistics + */ + private async findByAccountSequence(accountSequence: bigint) { + // 使用 repository 的 findByUserId,但这里需要 accountSequence 到 userId 的映射 + // 由于当前架构 accountSequence 和 userId 不一定相等,需要通过 referral_relationships 表查询 + // 暂时尝试用 accountSequence 作为 userId 查询 + return this.teamStatsRepo.findByUserId(accountSequence); + } +} diff --git a/backend/services/referral-service/src/modules/api.module.ts b/backend/services/referral-service/src/modules/api.module.ts index a8fa828d..b04e3569 100644 --- a/backend/services/referral-service/src/modules/api.module.ts +++ b/backend/services/referral-service/src/modules/api.module.ts @@ -1,18 +1,21 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ApplicationModule } from './application.module'; +import { InfrastructureModule } from './infrastructure.module'; import { ReferralController, TeamStatisticsController, + InternalTeamStatisticsController, HealthController, } from '../api'; import { InternalReferralController } from '../api/controllers/referral.controller'; @Module({ - imports: [ConfigModule, ApplicationModule], + imports: [ConfigModule, ApplicationModule, InfrastructureModule], controllers: [ ReferralController, TeamStatisticsController, + InternalTeamStatisticsController, HealthController, InternalReferralController, ],