feat(authorization/reward): 实现火柴人排名本月收益显示功能

- reward-service: 添加批量查询月度收益内部接口
  - 新增 InternalController 提供 /internal/monthly-earnings/batch 端点
  - 在 RewardApplicationService 添加 batchGetMonthlyEarnings 方法
  - 支持按账户序列号、月份、权益类型批量查询可结算收益
  - 统计分享权益(SHARE_RIGHT)、省团队权益(PROVINCE_TEAM_RIGHT)、市团队权益(CITY_TEAM_RIGHT)

- authorization-service: 集成 reward-service 获取月度收益
  - 新增 RewardServiceClient 用于调用 reward-service 内部接口
  - 修改 getStickmanRanking 方法,调用 reward-service 获取月度收益
  - 省团队查询省团队权益+分享权益,市团队查询市团队权益+分享权益
  - monthlyEarnings 字段现在显示真实的月度可结算收益

🤖 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-23 05:04:55 -08:00
parent b052afa065
commit 2f5ca85106
7 changed files with 293 additions and 7 deletions

View File

@ -20,7 +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, IdentityServiceClient } from '@/infrastructure/external'
import { ReferralServiceClient, IdentityServiceClient, RewardServiceClient } from '@/infrastructure/external'
// Application
import { AuthorizationApplicationService, REFERRAL_REPOSITORY, TEAM_STATISTICS_REPOSITORY } from '@/application/services'
@ -89,6 +89,7 @@ const MockReferralRepository = {
// External Service Clients (replaces mock)
ReferralServiceClient,
IdentityServiceClient,
RewardServiceClient,
{
provide: TEAM_STATISTICS_REPOSITORY,
useExisting: ReferralServiceClient,

View File

@ -23,7 +23,7 @@ import {
TeamStatistics,
} from '@/domain/services'
import { EventPublisherService } from '@/infrastructure/kafka'
import { ReferralServiceClient, IdentityServiceClient } from '@/infrastructure/external'
import { ReferralServiceClient, IdentityServiceClient, RewardServiceClient } from '@/infrastructure/external'
import { ApplicationError, NotFoundError } from '@/shared/exceptions'
import {
ApplyCommunityAuthCommand,
@ -70,6 +70,7 @@ export class AuthorizationApplicationService {
private readonly eventPublisher: EventPublisherService,
private readonly referralServiceClient: ReferralServiceClient,
private readonly identityServiceClient: IdentityServiceClient,
private readonly rewardServiceClient: RewardServiceClient,
) {}
/**
@ -687,14 +688,31 @@ export class AuthorizationApplicationService {
const accountSequences = assessments.map(a => a.userId.accountSequence)
const userInfoMap = await this.identityServiceClient.batchGetUserInfoBySequence(accountSequences)
// 批量获取月度收益(从 reward-service
// 根据角色类型确定要查询的权益类型
const rightTypes = roleType === RoleType.AUTH_PROVINCE_COMPANY
? ['PROVINCE_TEAM_RIGHT', 'SHARE_RIGHT'] // 省团队:省团队权益 + 分享权益
: ['CITY_TEAM_RIGHT', 'SHARE_RIGHT'] // 市团队:市团队权益 + 分享权益
const monthlyEarningsMap = await this.rewardServiceClient.batchGetMonthlyEarnings(
accountSequences,
month,
rightTypes,
)
this.logger.debug(
`[getStickmanRanking] 获取到 ${monthlyEarningsMap.size} 条月度收益记录`,
)
const rankings: StickmanRankingDTO[] = []
const finalTarget = LadderTargetRule.getFinalTarget(roleType)
for (const assessment of assessments) {
const userInfo = userInfoMap.get(assessment.userId.accountSequence)
const earnings = monthlyEarningsMap.get(assessment.userId.accountSequence)
this.logger.debug(
`[getStickmanRanking] 处理评估记录: userId=${assessment.userId.value}, ` +
`regionCode=${assessment.regionCode.value}, cumulativeCompleted=${assessment.cumulativeCompleted}`,
`regionCode=${assessment.regionCode.value}, cumulativeCompleted=${assessment.cumulativeCompleted}, ` +
`monthlyEarnings=${earnings?.monthlyTotalUsdt || 0}`,
)
rankings.push({
id: assessment.authorizationId.value,
@ -705,7 +723,7 @@ export class AuthorizationApplicationService {
nickname: userInfo?.nickname || `用户${assessment.userId.accountSequence.slice(-4)}`,
avatarUrl: userInfo?.avatarUrl,
completedCount: assessment.cumulativeCompleted,
monthlyEarnings: 0, // TODO: 从奖励服务获取本月可结算收益
monthlyEarnings: earnings?.monthlyTotalUsdt || 0, // 从奖励服务获取的月度可结算收益
isCurrentUser: currentUserId ? assessment.userId.value === currentUserId : false,
ranking: assessment.rankingInRegion || 0,
isFirstPlace: assessment.isFirstPlace,
@ -714,8 +732,8 @@ export class AuthorizationApplicationService {
finalTarget,
progressPercentage: (assessment.cumulativeCompleted / finalTarget) * 100,
exceedRatio: assessment.exceedRatio,
monthlyRewardUsdt: 0, // TODO: 从奖励服务获取
monthlyRewardRwad: 0,
monthlyRewardUsdt: earnings?.monthlyTotalUsdt || 0, // 与 monthlyEarnings 相同
monthlyRewardRwad: 0, // 算力奖励暂未实现
})
}

View File

@ -1,2 +1,3 @@
export * from './referral-service.client';
export * from './identity-service.client';
export * from './reward-service.client';

View File

@ -0,0 +1,102 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
/**
*
*/
export interface MonthlyEarningsInfo {
accountSequence: string;
monthlySettleableUsdt: number;
monthlyShareUsdt: number;
monthlyProvinceTeamUsdt: number;
monthlyCityTeamUsdt: number;
monthlyTotalUsdt: number;
}
/**
* Reward Service HTTP
* reward-service
*/
@Injectable()
export class RewardServiceClient implements OnModuleInit {
private readonly logger = new Logger(RewardServiceClient.name);
private httpClient: AxiosInstance;
private readonly baseUrl: string;
private readonly enabled: boolean;
constructor(private readonly configService: ConfigService) {
this.baseUrl = this.configService.get<string>('REWARD_SERVICE_URL') || 'http://rwa-reward-service:3000';
this.enabled = this.configService.get<boolean>('REWARD_SERVICE_ENABLED') !== false;
}
onModuleInit() {
this.httpClient = axios.create({
baseURL: this.baseUrl,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
this.logger.log(`[INIT] RewardServiceClient initialized: ${this.baseUrl}, enabled: ${this.enabled}`);
}
/**
*
*
* @param accountSequences
* @param month YYYY-MM
* @param rightTypes
*/
async batchGetMonthlyEarnings(
accountSequences: string[],
month: string,
rightTypes?: string[],
): Promise<Map<string, MonthlyEarningsInfo>> {
const result = new Map<string, MonthlyEarningsInfo>();
if (!this.enabled || accountSequences.length === 0) {
return result;
}
try {
this.logger.debug(`[HTTP] POST /internal/monthly-earnings/batch - ${accountSequences.length} accounts, month=${month}`);
const response = await this.httpClient.post<MonthlyEarningsInfo[]>(
`/api/v1/internal/monthly-earnings/batch`,
{
accountSequences,
month,
rightTypes,
},
);
// 处理响应
const data = response.data;
if (data && Array.isArray(data)) {
for (const item of data) {
result.set(item.accountSequence, item);
}
}
this.logger.debug(`[HTTP] Got ${result.size} monthly earnings records`);
} catch (error) {
this.logger.error(`[HTTP] Failed to batch get monthly earnings:`, error);
}
return result;
}
/**
*
*/
async getMonthlyEarnings(
accountSequence: string,
month: string,
rightTypes?: string[],
): Promise<MonthlyEarningsInfo | null> {
const result = await this.batchGetMonthlyEarnings([accountSequence], month, rightTypes);
return result.get(accountSequence) || null;
}
}

View File

@ -5,6 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { HealthController } from './controllers/health.controller';
import { RewardController } from './controllers/reward.controller';
import { SettlementController } from './controllers/settlement.controller';
import { InternalController } from './controllers/internal.controller';
import { JwtStrategy } from '../shared/strategies/jwt.strategy';
import { ApplicationModule } from '../application/application.module';
@ -23,7 +24,7 @@ import { ApplicationModule } from '../application/application.module';
}),
ApplicationModule,
],
controllers: [HealthController, RewardController, SettlementController],
controllers: [HealthController, RewardController, SettlementController, InternalController],
providers: [JwtStrategy],
})
export class ApiModule {}

View File

@ -0,0 +1,65 @@
import { Controller, Post, Body, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { RewardApplicationService } from '../../application/services/reward-application.service';
/**
* DTO
*/
class BatchMonthlyEarningsRequest {
accountSequences: string[];
month: string; // 格式: YYYY-MM
rightTypes?: string[]; // 可选,过滤权益类型
}
/**
*
*/
interface MonthlyEarningsItem {
accountSequence: string;
monthlySettleableUsdt: number;
monthlyShareUsdt: number; // 分享权益收益
monthlyProvinceTeamUsdt: number; // 省团队权益收益
monthlyCityTeamUsdt: number; // 市团队权益收益
monthlyTotalUsdt: number; // 月度总收益
}
/**
* API
* JWT
*/
@ApiTags('Internal')
@Controller('internal')
export class InternalController {
private readonly logger = new Logger(InternalController.name);
constructor(private readonly rewardService: RewardApplicationService) {}
@Post('monthly-earnings/batch')
@ApiOperation({ summary: '批量查询月度可结算收益(内部接口)' })
@ApiResponse({ status: 200, description: '成功' })
async batchGetMonthlyEarnings(
@Body() request: BatchMonthlyEarningsRequest,
): Promise<MonthlyEarningsItem[]> {
const { accountSequences, month, rightTypes } = request;
if (!accountSequences || accountSequences.length === 0) {
return [];
}
this.logger.debug(
`[batchGetMonthlyEarnings] 查询 ${accountSequences.length} 个账户的月度收益, month=${month}`,
);
const result = await this.rewardService.batchGetMonthlyEarnings(
accountSequences,
month,
rightTypes,
);
this.logger.debug(
`[batchGetMonthlyEarnings] 返回 ${result.length} 条记录`,
);
return result;
}
}

View File

@ -495,4 +495,102 @@ export class RewardApplicationService {
`[OUTBOX] Published ${outboxEvents.length} reward.summary.updated events to outbox`,
);
}
/**
* authorization-service
*
* SETTLEABLE
* - SHARE_RIGHT
* - PROVINCE_TEAM_RIGHT
* - CITY_TEAM_RIGHT
*
* @param accountSequences
* @param month YYYY-MM
* @param rightTypes
*/
async batchGetMonthlyEarnings(
accountSequences: string[],
month: string,
rightTypes?: string[],
): Promise<{
accountSequence: string;
monthlySettleableUsdt: number;
monthlyShareUsdt: number;
monthlyProvinceTeamUsdt: number;
monthlyCityTeamUsdt: number;
monthlyTotalUsdt: number;
}[]> {
if (accountSequences.length === 0) {
return [];
}
// 解析月份,获取该月的起止时间
const [year, monthNum] = month.split('-').map(Number);
const startDate = new Date(year, monthNum - 1, 1); // 月份从0开始
const endDate = new Date(year, monthNum, 1); // 下个月的第一天
this.logger.debug(
`[batchGetMonthlyEarnings] month=${month}, startDate=${startDate.toISOString()}, endDate=${endDate.toISOString()}`,
);
const results: {
accountSequence: string;
monthlySettleableUsdt: number;
monthlyShareUsdt: number;
monthlyProvinceTeamUsdt: number;
monthlyCityTeamUsdt: number;
monthlyTotalUsdt: number;
}[] = [];
// 批量查询每个账户的月度收益
for (const accountSequence of accountSequences) {
let monthlySettleableUsdt = 0;
let monthlyShareUsdt = 0;
let monthlyProvinceTeamUsdt = 0;
let monthlyCityTeamUsdt = 0;
// 查询可结算状态的收益记录
// 使用 claimedAt 作为进入可结算状态的时间(用户认种后权益进入可结算)
const entries = await this.rewardLedgerEntryRepository.findByAccountSequence(
accountSequence,
{
status: RewardStatus.SETTLEABLE,
startDate,
endDate,
},
);
for (const entry of entries) {
const rightType = entry.rewardSource.rightType;
const amount = entry.usdtAmount.amount;
// 如果指定了权益类型过滤,则只统计指定类型
if (rightTypes && rightTypes.length > 0 && !rightTypes.includes(rightType)) {
continue;
}
monthlySettleableUsdt += amount;
// 按权益类型分类统计
if (rightType === RightType.SHARE_RIGHT) {
monthlyShareUsdt += amount;
} else if (rightType === RightType.PROVINCE_TEAM_RIGHT) {
monthlyProvinceTeamUsdt += amount;
} else if (rightType === RightType.CITY_TEAM_RIGHT) {
monthlyCityTeamUsdt += amount;
}
}
results.push({
accountSequence,
monthlySettleableUsdt,
monthlyShareUsdt,
monthlyProvinceTeamUsdt,
monthlyCityTeamUsdt,
monthlyTotalUsdt: monthlySettleableUsdt,
});
}
return results;
}
}