fix(authorization): 修复团队升级竞态条件,改用事件链模式
问题:authorization-service 和 referral-service 并行消费 TreePlanted 事件, 导致升级检查时统计数据尚未更新完成。 解决方案: - referral-service: 批量更新团队统计后发布 TeamStatisticsUpdatedEvent - authorization-service: 监听该事件触发升级检查,替代原有的即时检查 - TeamStatistics 聚合添加 accountSequence 字段用于事件发布 🤖 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
ca95c1decf
commit
ed1f863919
|
|
@ -39,6 +39,24 @@ interface PlantingEventMessage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团队统计更新事件消息格式
|
||||||
|
* 来自 referral-service 的 TeamStatisticsUpdatedEvent
|
||||||
|
*/
|
||||||
|
interface TeamStatisticsUpdatedMessage {
|
||||||
|
eventId?: string
|
||||||
|
eventName?: string
|
||||||
|
occurredAt?: string
|
||||||
|
data?: {
|
||||||
|
userId: string
|
||||||
|
accountSequence: string
|
||||||
|
totalTeamCount: number
|
||||||
|
directReferralCount: number
|
||||||
|
leaderboardScore: number
|
||||||
|
updateReason: 'planting_added' | 'planting_removed' | 'member_joined' | 'recalculation'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class EventConsumerController {
|
export class EventConsumerController {
|
||||||
private readonly logger = new Logger(EventConsumerController.name)
|
private readonly logger = new Logger(EventConsumerController.name)
|
||||||
|
|
@ -119,6 +137,60 @@ export class EventConsumerController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听团队统计更新事件 - 用于检查自动升级条件
|
||||||
|
* 当 referral-service 更新用户团队统计后发布此事件
|
||||||
|
* 这确保了在统计数据已更新后再进行升级检查,避免竞态条件
|
||||||
|
*/
|
||||||
|
@EventPattern('referral.team-statistics.events')
|
||||||
|
async handleTeamStatisticsUpdated(
|
||||||
|
@Payload() message: TeamStatisticsUpdatedMessage,
|
||||||
|
@Ctx() context: KafkaContext,
|
||||||
|
) {
|
||||||
|
const eventName = message.eventName || 'unknown'
|
||||||
|
const eventId = message.eventId || 'unknown'
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.log(`[KAFKA] Received team statistics event: ${eventName}, eventId=${eventId}`)
|
||||||
|
this.logger.debug(`[KAFKA] Event payload: ${JSON.stringify(message)}`)
|
||||||
|
|
||||||
|
if (eventName === 'referral.team_statistics.updated' && message.data) {
|
||||||
|
const { accountSequence, totalTeamCount } = message.data
|
||||||
|
await this.checkUserAutoUpgrade(accountSequence, totalTeamCount)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('[KAFKA] Failed to handle team statistics event:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查特定用户的自动升级条件
|
||||||
|
* 当该用户的团队统计更新后被调用
|
||||||
|
*/
|
||||||
|
private async checkUserAutoUpgrade(accountSequence: string, totalTeamCount: number): Promise<void> {
|
||||||
|
this.logger.debug(`[TEAM-AUTO-UPGRADE] Checking upgrade for ${accountSequence}, totalTeamCount=${totalTeamCount}`)
|
||||||
|
|
||||||
|
// 获取该用户的所有授权
|
||||||
|
const authProvince = await this.authorizationRepository.findByAccountSequenceAndRoleType(
|
||||||
|
accountSequence,
|
||||||
|
RoleType.AUTH_PROVINCE_COMPANY,
|
||||||
|
)
|
||||||
|
const authCity = await this.authorizationRepository.findByAccountSequenceAndRoleType(
|
||||||
|
accountSequence,
|
||||||
|
RoleType.AUTH_CITY_COMPANY,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 检查省团队升级
|
||||||
|
if (authProvince && authProvince.benefitActive && authProvince.status === AuthorizationStatus.AUTHORIZED) {
|
||||||
|
await this.checkAuthProvinceUpgrade(authProvince)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查市团队升级
|
||||||
|
if (authCity && authCity.benefitActive && authCity.status === AuthorizationStatus.AUTHORIZED) {
|
||||||
|
await this.checkAuthCityUpgrade(authCity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理认种事件
|
* 处理认种事件
|
||||||
* 1. 检查用户是否有待激活的授权(初始考核)
|
* 1. 检查用户是否有待激活的授权(初始考核)
|
||||||
|
|
@ -182,9 +254,9 @@ export class EventConsumerController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 检查所有已激活市/省团队的自动升级条件
|
// 注意:自动升级检查已移至 handleTeamStatisticsUpdated
|
||||||
// 业务规则:市/省团队本人伞下认种数达到阈值时,团队本人获得区域授权
|
// 通过监听 referral.team-statistics.events 事件来触发升级检查
|
||||||
await this.checkAllTeamAutoUpgrade()
|
// 这样确保在 referral-service 完成统计更新后再进行检查,避免竞态条件
|
||||||
|
|
||||||
this.logger.log(`[PLANTING] Completed processing tree planted event for user ${userId}`)
|
this.logger.log(`[PLANTING] Completed processing tree planted event for user ${userId}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -293,43 +365,6 @@ export class EventConsumerController {
|
||||||
private static readonly PROVINCE_UPGRADE_THRESHOLD = 50000 // 5万棵
|
private static readonly PROVINCE_UPGRADE_THRESHOLD = 50000 // 5万棵
|
||||||
private static readonly CITY_UPGRADE_THRESHOLD = 10000 // 1万棵
|
private static readonly CITY_UPGRADE_THRESHOLD = 10000 // 1万棵
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查所有已激活市/省团队的自动升级条件
|
|
||||||
* 业务规则:
|
|
||||||
* - 已激活权益的省团队(AUTH_PROVINCE_COMPANY)用户,如果伞下认种数达到5万棵,自动升级为省区域(PROVINCE_COMPANY)
|
|
||||||
* - 已激活权益的市团队(AUTH_CITY_COMPANY)用户,如果伞下认种数达到1万棵,自动升级为市区域(CITY_COMPANY)
|
|
||||||
* 注意:伞下认种数不包括团队本人的认种
|
|
||||||
*/
|
|
||||||
private async checkAllTeamAutoUpgrade(): Promise<void> {
|
|
||||||
this.logger.debug('[TEAM-AUTO-UPGRADE] Starting check for all active team authorizations')
|
|
||||||
|
|
||||||
// 并行检查省团队和市团队
|
|
||||||
await Promise.all([
|
|
||||||
this.checkAllAuthProvinceUpgrade(),
|
|
||||||
this.checkAllAuthCityUpgrade(),
|
|
||||||
])
|
|
||||||
|
|
||||||
this.logger.debug('[TEAM-AUTO-UPGRADE] Completed check for all active team authorizations')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查所有已激活省团队的自动升级条件
|
|
||||||
*/
|
|
||||||
private async checkAllAuthProvinceUpgrade(): Promise<void> {
|
|
||||||
// 1. 获取所有权益已激活的省团队授权
|
|
||||||
const activeAuthProvinces = await this.authorizationRepository.findAllActiveAuthProvinceCompanies()
|
|
||||||
if (activeAuthProvinces.length === 0) {
|
|
||||||
this.logger.debug('[TEAM-AUTO-UPGRADE] No active auth province companies found')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`[TEAM-AUTO-UPGRADE] Found ${activeAuthProvinces.length} active auth province companies`)
|
|
||||||
|
|
||||||
// 2. 逐个检查是否达到升级阈值
|
|
||||||
for (const authProvince of activeAuthProvinces) {
|
|
||||||
await this.checkAuthProvinceUpgrade(authProvince)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查单个省团队是否可以升级为省区域
|
* 检查单个省团队是否可以升级为省区域
|
||||||
|
|
@ -403,25 +438,6 @@ export class EventConsumerController {
|
||||||
this.logger.log(`[TEAM-AUTO-UPGRADE] Successfully auto upgraded auth province ${accountSequence} to province company: ${provinceName}`)
|
this.logger.log(`[TEAM-AUTO-UPGRADE] Successfully auto upgraded auth province ${accountSequence} to province company: ${provinceName}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查所有已激活市团队的自动升级条件
|
|
||||||
*/
|
|
||||||
private async checkAllAuthCityUpgrade(): Promise<void> {
|
|
||||||
// 1. 获取所有权益已激活的市团队授权
|
|
||||||
const activeAuthCities = await this.authorizationRepository.findAllActiveAuthCityCompanies()
|
|
||||||
if (activeAuthCities.length === 0) {
|
|
||||||
this.logger.debug('[TEAM-AUTO-UPGRADE] No active auth city companies found')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`[TEAM-AUTO-UPGRADE] Found ${activeAuthCities.length} active auth city companies`)
|
|
||||||
|
|
||||||
// 2. 逐个检查是否达到升级阈值
|
|
||||||
for (const authCity of activeAuthCities) {
|
|
||||||
await this.checkAuthCityUpgrade(authCity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查单个市团队是否可以升级为市区域
|
* 检查单个市团队是否可以升级为市区域
|
||||||
* 业务规则:市团队本人伞下认种数(不含自己)达到1万棵时自动升级
|
* 业务规则:市团队本人伞下认种数(不含自己)达到1万棵时自动升级
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ export class ReferralService {
|
||||||
const saved = await this.referralRepo.save(relationship);
|
const saved = await this.referralRepo.save(relationship);
|
||||||
|
|
||||||
// 创建团队统计记录
|
// 创建团队统计记录
|
||||||
await this.teamStatsRepo.create(command.userId);
|
await this.teamStatsRepo.create(command.userId, command.accountSequence);
|
||||||
|
|
||||||
// 如果有推荐人,更新推荐人的直推计数
|
// 如果有推荐人,更新推荐人的直推计数
|
||||||
if (referrerId) {
|
if (referrerId) {
|
||||||
|
|
@ -101,7 +101,10 @@ export class ReferralService {
|
||||||
// 如果推荐人没有统计记录(历史用户),先创建
|
// 如果推荐人没有统计记录(历史用户),先创建
|
||||||
if (!referrerStats) {
|
if (!referrerStats) {
|
||||||
this.logger.warn(`Creating missing team statistics for referrer ${referrerId}`);
|
this.logger.warn(`Creating missing team statistics for referrer ${referrerId}`);
|
||||||
referrerStats = await this.teamStatsRepo.create(referrerId);
|
// 获取推荐人的 accountSequence
|
||||||
|
const referrerRelationship = await this.referralRepo.findByUserId(referrerId);
|
||||||
|
const referrerAccountSequence = referrerRelationship?.accountSequence ?? '';
|
||||||
|
referrerStats = await this.teamStatsRepo.create(referrerId, referrerAccountSequence);
|
||||||
}
|
}
|
||||||
referrerStats.addDirectReferral(command.userId);
|
referrerStats.addDirectReferral(command.userId);
|
||||||
await this.teamStatsRepo.save(referrerStats);
|
await this.teamStatsRepo.save(referrerStats);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
TEAM_STATISTICS_REPOSITORY,
|
TEAM_STATISTICS_REPOSITORY,
|
||||||
ITeamStatisticsRepository,
|
ITeamStatisticsRepository,
|
||||||
ReferralChainService,
|
ReferralChainService,
|
||||||
|
TeamStatisticsUpdatedEvent,
|
||||||
} from '../../domain';
|
} from '../../domain';
|
||||||
import { EventPublisherService } from '../../infrastructure';
|
import { EventPublisherService } from '../../infrastructure';
|
||||||
import { UpdateTeamStatisticsCommand } from '../commands';
|
import { UpdateTeamStatisticsCommand } from '../commands';
|
||||||
|
|
@ -78,6 +79,26 @@ export class TeamStatisticsService {
|
||||||
// 批量更新
|
// 批量更新
|
||||||
await this.teamStatsRepo.batchUpdateTeamCounts(updates);
|
await this.teamStatsRepo.batchUpdateTeamCounts(updates);
|
||||||
|
|
||||||
|
// 批量更新后,为所有更新的祖先发布 TeamStatisticsUpdatedEvent
|
||||||
|
// 这是为了让 authorization-service 可以在统计数据更新后进行升级检查
|
||||||
|
const updatedStats = await this.teamStatsRepo.findByUserIds(ancestors);
|
||||||
|
const events = updatedStats.map(
|
||||||
|
(stats) =>
|
||||||
|
new TeamStatisticsUpdatedEvent(
|
||||||
|
stats.userId,
|
||||||
|
stats.accountSequence,
|
||||||
|
stats.teamPlantingCount, // totalTeamCount 即团队认种数
|
||||||
|
stats.directReferralCount,
|
||||||
|
stats.leaderboardScore,
|
||||||
|
'planting_added',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
await this.eventPublisher.publishDomainEvents(events);
|
||||||
|
this.logger.log(`Published ${events.length} TeamStatisticsUpdatedEvent for ancestors`);
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Updated team statistics for ${ancestors.length} ancestors of accountSequence ${command.accountSequence}`,
|
`Updated team statistics for ${ancestors.length} ancestors of accountSequence ${command.accountSequence}`,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export interface DirectReferralStats {
|
||||||
export interface TeamStatisticsProps {
|
export interface TeamStatisticsProps {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
userId: bigint;
|
userId: bigint;
|
||||||
|
accountSequence: string; // 账户序列号,用于事件发布
|
||||||
directReferralCount: number;
|
directReferralCount: number;
|
||||||
totalTeamCount: number;
|
totalTeamCount: number;
|
||||||
personalPlantingCount: number;
|
personalPlantingCount: number;
|
||||||
|
|
@ -36,6 +37,7 @@ export class TeamStatistics {
|
||||||
private constructor(
|
private constructor(
|
||||||
private readonly _id: bigint,
|
private readonly _id: bigint,
|
||||||
private readonly _userId: UserId,
|
private readonly _userId: UserId,
|
||||||
|
private readonly _accountSequence: string, // 账户序列号
|
||||||
private _directReferralCount: number,
|
private _directReferralCount: number,
|
||||||
private _totalTeamCount: number,
|
private _totalTeamCount: number,
|
||||||
private _personalPlantingCount: number,
|
private _personalPlantingCount: number,
|
||||||
|
|
@ -55,6 +57,9 @@ export class TeamStatistics {
|
||||||
get userId(): bigint {
|
get userId(): bigint {
|
||||||
return this._userId.value;
|
return this._userId.value;
|
||||||
}
|
}
|
||||||
|
get accountSequence(): string {
|
||||||
|
return this._accountSequence;
|
||||||
|
}
|
||||||
get directReferralCount(): number {
|
get directReferralCount(): number {
|
||||||
return this._directReferralCount;
|
return this._directReferralCount;
|
||||||
}
|
}
|
||||||
|
|
@ -92,11 +97,12 @@ export class TeamStatistics {
|
||||||
/**
|
/**
|
||||||
* 创建新的团队统计记录 (推荐关系创建时)
|
* 创建新的团队统计记录 (推荐关系创建时)
|
||||||
*/
|
*/
|
||||||
static create(userId: bigint): TeamStatistics {
|
static create(userId: bigint, accountSequence: string): TeamStatistics {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return new TeamStatistics(
|
return new TeamStatistics(
|
||||||
0n,
|
0n,
|
||||||
UserId.create(userId),
|
UserId.create(userId),
|
||||||
|
accountSequence,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
|
|
@ -124,6 +130,7 @@ export class TeamStatistics {
|
||||||
return new TeamStatistics(
|
return new TeamStatistics(
|
||||||
props.id,
|
props.id,
|
||||||
UserId.create(props.userId),
|
UserId.create(props.userId),
|
||||||
|
props.accountSequence,
|
||||||
props.directReferralCount,
|
props.directReferralCount,
|
||||||
props.totalTeamCount,
|
props.totalTeamCount,
|
||||||
props.personalPlantingCount,
|
props.personalPlantingCount,
|
||||||
|
|
@ -181,6 +188,7 @@ export class TeamStatistics {
|
||||||
this._domainEvents.push(
|
this._domainEvents.push(
|
||||||
new TeamStatisticsUpdatedEvent(
|
new TeamStatisticsUpdatedEvent(
|
||||||
this._userId.value,
|
this._userId.value,
|
||||||
|
this._accountSequence,
|
||||||
this._totalTeamCount,
|
this._totalTeamCount,
|
||||||
this._directReferralCount,
|
this._directReferralCount,
|
||||||
this._leaderboardScore.score,
|
this._leaderboardScore.score,
|
||||||
|
|
@ -207,6 +215,7 @@ export class TeamStatistics {
|
||||||
this._domainEvents.push(
|
this._domainEvents.push(
|
||||||
new TeamStatisticsUpdatedEvent(
|
new TeamStatisticsUpdatedEvent(
|
||||||
this._userId.value,
|
this._userId.value,
|
||||||
|
this._accountSequence,
|
||||||
this._teamPlantingCount,
|
this._teamPlantingCount,
|
||||||
this._directReferralCount,
|
this._directReferralCount,
|
||||||
this._leaderboardScore.score,
|
this._leaderboardScore.score,
|
||||||
|
|
@ -252,6 +261,7 @@ export class TeamStatistics {
|
||||||
return {
|
return {
|
||||||
id: this._id,
|
id: this._id,
|
||||||
userId: this._userId.value,
|
userId: this._userId.value,
|
||||||
|
accountSequence: this._accountSequence,
|
||||||
directReferralCount: this._directReferralCount,
|
directReferralCount: this._directReferralCount,
|
||||||
totalTeamCount: this._totalTeamCount,
|
totalTeamCount: this._totalTeamCount,
|
||||||
personalPlantingCount: this._personalPlantingCount,
|
personalPlantingCount: this._personalPlantingCount,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { DomainEvent } from './domain-event.base';
|
||||||
export class TeamStatisticsUpdatedEvent extends DomainEvent {
|
export class TeamStatisticsUpdatedEvent extends DomainEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly userId: bigint,
|
public readonly userId: bigint,
|
||||||
|
public readonly accountSequence: string,
|
||||||
public readonly totalTeamCount: number,
|
public readonly totalTeamCount: number,
|
||||||
public readonly directReferralCount: number,
|
public readonly directReferralCount: number,
|
||||||
public readonly leaderboardScore: number,
|
public readonly leaderboardScore: number,
|
||||||
|
|
@ -25,6 +26,7 @@ export class TeamStatisticsUpdatedEvent extends DomainEvent {
|
||||||
occurredAt: this.occurredAt.toISOString(),
|
occurredAt: this.occurredAt.toISOString(),
|
||||||
data: {
|
data: {
|
||||||
userId: this.userId.toString(),
|
userId: this.userId.toString(),
|
||||||
|
accountSequence: this.accountSequence,
|
||||||
totalTeamCount: this.totalTeamCount,
|
totalTeamCount: this.totalTeamCount,
|
||||||
directReferralCount: this.directReferralCount,
|
directReferralCount: this.directReferralCount,
|
||||||
leaderboardScore: this.leaderboardScore,
|
leaderboardScore: this.leaderboardScore,
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export interface ITeamStatisticsRepository {
|
||||||
/**
|
/**
|
||||||
* 创建初始团队统计记录
|
* 创建初始团队统计记录
|
||||||
*/
|
*/
|
||||||
create(userId: bigint): Promise<TeamStatistics>;
|
create(userId: bigint, accountSequence: string): Promise<TeamStatistics>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TEAM_STATISTICS_REPOSITORY = Symbol('ITeamStatisticsRepository');
|
export const TEAM_STATISTICS_REPOSITORY = Symbol('ITeamStatisticsRepository');
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,11 @@ export class TeamStatisticsRepository implements ITeamStatisticsRepository {
|
||||||
async findByUserId(userId: bigint): Promise<TeamStatistics | null> {
|
async findByUserId(userId: bigint): Promise<TeamStatistics | null> {
|
||||||
const record = await this.prisma.teamStatistics.findUnique({
|
const record = await this.prisma.teamStatistics.findUnique({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
referralRelationship: {
|
||||||
|
select: { accountSequence: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!record) return null;
|
if (!record) return null;
|
||||||
|
|
@ -98,12 +103,19 @@ export class TeamStatisticsRepository implements ITeamStatisticsRepository {
|
||||||
select: { referralId: true, teamPlantingCount: true },
|
select: { referralId: true, teamPlantingCount: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return TeamStatistics.reconstitute(this.mapToProps(record, directReferrals));
|
return TeamStatistics.reconstitute(
|
||||||
|
this.mapToProps(record, directReferrals, record.referralRelationship?.accountSequence ?? ''),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUserIds(userIds: bigint[]): Promise<TeamStatistics[]> {
|
async findByUserIds(userIds: bigint[]): Promise<TeamStatistics[]> {
|
||||||
const records = await this.prisma.teamStatistics.findMany({
|
const records = await this.prisma.teamStatistics.findMany({
|
||||||
where: { userId: { in: userIds } },
|
where: { userId: { in: userIds } },
|
||||||
|
include: {
|
||||||
|
referralRelationship: {
|
||||||
|
select: { accountSequence: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result: TeamStatistics[] = [];
|
const result: TeamStatistics[] = [];
|
||||||
|
|
@ -112,7 +124,9 @@ export class TeamStatisticsRepository implements ITeamStatisticsRepository {
|
||||||
where: { referrerId: record.userId },
|
where: { referrerId: record.userId },
|
||||||
select: { referralId: true, teamPlantingCount: true },
|
select: { referralId: true, teamPlantingCount: true },
|
||||||
});
|
});
|
||||||
result.push(TeamStatistics.reconstitute(this.mapToProps(record, directReferrals)));
|
result.push(TeamStatistics.reconstitute(
|
||||||
|
this.mapToProps(record, directReferrals, record.referralRelationship?.accountSequence ?? ''),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -240,7 +254,7 @@ export class TeamStatisticsRepository implements ITeamStatisticsRepository {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(userId: bigint): Promise<TeamStatistics> {
|
async create(userId: bigint, accountSequence: string): Promise<TeamStatistics> {
|
||||||
const created = await this.prisma.teamStatistics.create({
|
const created = await this.prisma.teamStatistics.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
|
|
@ -255,7 +269,7 @@ export class TeamStatisticsRepository implements ITeamStatisticsRepository {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return TeamStatistics.reconstitute(this.mapToProps(created, []));
|
return TeamStatistics.reconstitute(this.mapToProps(created, [], accountSequence));
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapToProps(
|
private mapToProps(
|
||||||
|
|
@ -277,6 +291,7 @@ export class TeamStatisticsRepository implements ITeamStatisticsRepository {
|
||||||
referralId: bigint;
|
referralId: bigint;
|
||||||
teamPlantingCount: number;
|
teamPlantingCount: number;
|
||||||
}>,
|
}>,
|
||||||
|
accountSequence: string,
|
||||||
): TeamStatisticsProps {
|
): TeamStatisticsProps {
|
||||||
const directReferralStats: DirectReferralStats[] = directReferrals.map((dr) => ({
|
const directReferralStats: DirectReferralStats[] = directReferrals.map((dr) => ({
|
||||||
referralId: dr.referralId,
|
referralId: dr.referralId,
|
||||||
|
|
@ -286,6 +301,7 @@ export class TeamStatisticsRepository implements ITeamStatisticsRepository {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
userId: record.userId,
|
userId: record.userId,
|
||||||
|
accountSequence,
|
||||||
directReferralCount: record.directReferralCount,
|
directReferralCount: record.directReferralCount,
|
||||||
totalTeamCount: record.totalTeamCount,
|
totalTeamCount: record.totalTeamCount,
|
||||||
personalPlantingCount: record.selfPlantingCount,
|
personalPlantingCount: record.selfPlantingCount,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { TeamStatisticsUpdatedEvent } from '../../../src/domain/events';
|
||||||
describe('TeamStatistics Aggregate', () => {
|
describe('TeamStatistics Aggregate', () => {
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('should create empty team statistics', () => {
|
it('should create empty team statistics', () => {
|
||||||
const stats = TeamStatistics.create(100n);
|
const stats = TeamStatistics.create(100n, 'D25010100001');
|
||||||
|
|
||||||
expect(stats.userId).toBe(100n);
|
expect(stats.userId).toBe(100n);
|
||||||
expect(stats.directReferralCount).toBe(0);
|
expect(stats.directReferralCount).toBe(0);
|
||||||
|
|
@ -20,6 +20,7 @@ describe('TeamStatistics Aggregate', () => {
|
||||||
const props = {
|
const props = {
|
||||||
id: 1n,
|
id: 1n,
|
||||||
userId: 100n,
|
userId: 100n,
|
||||||
|
accountSequence: 'D25010100001',
|
||||||
directReferralCount: 5,
|
directReferralCount: 5,
|
||||||
totalTeamCount: 100,
|
totalTeamCount: 100,
|
||||||
personalPlantingCount: 10,
|
personalPlantingCount: 10,
|
||||||
|
|
@ -48,7 +49,7 @@ describe('TeamStatistics Aggregate', () => {
|
||||||
|
|
||||||
describe('addDirectReferral', () => {
|
describe('addDirectReferral', () => {
|
||||||
it('should increment direct referral count', () => {
|
it('should increment direct referral count', () => {
|
||||||
const stats = TeamStatistics.create(100n);
|
const stats = TeamStatistics.create(100n, 'D25010100001');
|
||||||
|
|
||||||
stats.addDirectReferral(200n);
|
stats.addDirectReferral(200n);
|
||||||
expect(stats.directReferralCount).toBe(1);
|
expect(stats.directReferralCount).toBe(1);
|
||||||
|
|
@ -60,7 +61,7 @@ describe('TeamStatistics Aggregate', () => {
|
||||||
|
|
||||||
describe('addPersonalPlanting', () => {
|
describe('addPersonalPlanting', () => {
|
||||||
it('should add personal planting count', () => {
|
it('should add personal planting count', () => {
|
||||||
const stats = TeamStatistics.create(100n);
|
const stats = TeamStatistics.create(100n, 'D25010100001');
|
||||||
|
|
||||||
stats.addPersonalPlanting(10, '110000', '110100');
|
stats.addPersonalPlanting(10, '110000', '110100');
|
||||||
|
|
||||||
|
|
@ -69,7 +70,7 @@ describe('TeamStatistics Aggregate', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit TeamStatisticsUpdatedEvent', () => {
|
it('should emit TeamStatisticsUpdatedEvent', () => {
|
||||||
const stats = TeamStatistics.create(100n);
|
const stats = TeamStatistics.create(100n, 'D25010100001');
|
||||||
stats.addPersonalPlanting(10, '110000', '110100');
|
stats.addPersonalPlanting(10, '110000', '110100');
|
||||||
|
|
||||||
expect(stats.domainEvents.length).toBe(1);
|
expect(stats.domainEvents.length).toBe(1);
|
||||||
|
|
@ -77,7 +78,7 @@ describe('TeamStatistics Aggregate', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update province/city distribution', () => {
|
it('should update province/city distribution', () => {
|
||||||
const stats = TeamStatistics.create(100n);
|
const stats = TeamStatistics.create(100n, 'D25010100001');
|
||||||
|
|
||||||
stats.addPersonalPlanting(10, '110000', '110100');
|
stats.addPersonalPlanting(10, '110000', '110100');
|
||||||
stats.addPersonalPlanting(5, '110000', '110100');
|
stats.addPersonalPlanting(5, '110000', '110100');
|
||||||
|
|
@ -88,7 +89,7 @@ describe('TeamStatistics Aggregate', () => {
|
||||||
|
|
||||||
describe('addTeamPlanting', () => {
|
describe('addTeamPlanting', () => {
|
||||||
it('should add team planting count', () => {
|
it('should add team planting count', () => {
|
||||||
const stats = TeamStatistics.create(100n);
|
const stats = TeamStatistics.create(100n, 'D25010100001');
|
||||||
|
|
||||||
stats.addTeamPlanting(20, '110000', '110100', 200n);
|
stats.addTeamPlanting(20, '110000', '110100', 200n);
|
||||||
|
|
||||||
|
|
@ -97,7 +98,7 @@ describe('TeamStatistics Aggregate', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should track direct referral team count', () => {
|
it('should track direct referral team count', () => {
|
||||||
const stats = TeamStatistics.create(100n);
|
const stats = TeamStatistics.create(100n, 'D25010100001');
|
||||||
stats.addDirectReferral(200n);
|
stats.addDirectReferral(200n);
|
||||||
stats.addDirectReferral(300n);
|
stats.addDirectReferral(300n);
|
||||||
|
|
||||||
|
|
@ -110,7 +111,7 @@ describe('TeamStatistics Aggregate', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should recalculate leaderboard score', () => {
|
it('should recalculate leaderboard score', () => {
|
||||||
const stats = TeamStatistics.create(100n);
|
const stats = TeamStatistics.create(100n, 'D25010100001');
|
||||||
stats.addDirectReferral(200n);
|
stats.addDirectReferral(200n);
|
||||||
stats.addDirectReferral(300n);
|
stats.addDirectReferral(300n);
|
||||||
|
|
||||||
|
|
@ -124,7 +125,7 @@ describe('TeamStatistics Aggregate', () => {
|
||||||
|
|
||||||
describe('getDirectReferralStats', () => {
|
describe('getDirectReferralStats', () => {
|
||||||
it('should return copy of direct referral stats', () => {
|
it('should return copy of direct referral stats', () => {
|
||||||
const stats = TeamStatistics.create(100n);
|
const stats = TeamStatistics.create(100n, 'D25010100001');
|
||||||
stats.addDirectReferral(200n);
|
stats.addDirectReferral(200n);
|
||||||
stats.addTeamPlanting(30, '110000', '110100', 200n);
|
stats.addTeamPlanting(30, '110000', '110100', 200n);
|
||||||
|
|
||||||
|
|
@ -138,7 +139,7 @@ describe('TeamStatistics Aggregate', () => {
|
||||||
|
|
||||||
describe('clearDomainEvents', () => {
|
describe('clearDomainEvents', () => {
|
||||||
it('should clear domain events', () => {
|
it('should clear domain events', () => {
|
||||||
const stats = TeamStatistics.create(100n);
|
const stats = TeamStatistics.create(100n, 'D25010100001');
|
||||||
stats.addPersonalPlanting(10, '110000', '110100');
|
stats.addPersonalPlanting(10, '110000', '110100');
|
||||||
expect(stats.domainEvents.length).toBe(1);
|
expect(stats.domainEvents.length).toBe(1);
|
||||||
|
|
||||||
|
|
@ -149,7 +150,7 @@ describe('TeamStatistics Aggregate', () => {
|
||||||
|
|
||||||
describe('toPersistence', () => {
|
describe('toPersistence', () => {
|
||||||
it('should convert to persistence format', () => {
|
it('should convert to persistence format', () => {
|
||||||
const stats = TeamStatistics.create(100n);
|
const stats = TeamStatistics.create(100n, 'D25010100001');
|
||||||
stats.addDirectReferral(200n);
|
stats.addDirectReferral(200n);
|
||||||
stats.addTeamPlanting(30, '110000', '110100', 200n);
|
stats.addTeamPlanting(30, '110000', '110100', 200n);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ describe('TeamStatisticsRepository (Integration)', () => {
|
||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('should create new team statistics', async () => {
|
it('should create new team statistics', async () => {
|
||||||
const stats = await repository.create(100n);
|
const stats = await repository.create(100n, 'D25010100001');
|
||||||
|
|
||||||
expect(stats).toBeDefined();
|
expect(stats).toBeDefined();
|
||||||
expect(stats.userId).toBe(100n);
|
expect(stats.userId).toBe(100n);
|
||||||
|
|
@ -42,7 +42,7 @@ describe('TeamStatisticsRepository (Integration)', () => {
|
||||||
|
|
||||||
describe('save', () => {
|
describe('save', () => {
|
||||||
it('should save team statistics', async () => {
|
it('should save team statistics', async () => {
|
||||||
const stats = TeamStatistics.create(100n);
|
const stats = TeamStatistics.create(100n, 'D25010100001');
|
||||||
stats.addPersonalPlanting(10, '110000', '110100');
|
stats.addPersonalPlanting(10, '110000', '110100');
|
||||||
|
|
||||||
const saved = await repository.save(stats);
|
const saved = await repository.save(stats);
|
||||||
|
|
@ -54,10 +54,10 @@ describe('TeamStatisticsRepository (Integration)', () => {
|
||||||
|
|
||||||
it('should update existing statistics', async () => {
|
it('should update existing statistics', async () => {
|
||||||
// Create initial
|
// Create initial
|
||||||
const stats = await repository.create(100n);
|
const stats = await repository.create(100n, 'D25010100001');
|
||||||
|
|
||||||
// Create new instance and add planting
|
// Create new instance and add planting
|
||||||
const updated = TeamStatistics.create(100n);
|
const updated = TeamStatistics.create(100n, 'D25010100001');
|
||||||
updated.addPersonalPlanting(20, '110000', '110100');
|
updated.addPersonalPlanting(20, '110000', '110100');
|
||||||
|
|
||||||
const saved = await repository.save(updated);
|
const saved = await repository.save(updated);
|
||||||
|
|
@ -67,7 +67,7 @@ describe('TeamStatisticsRepository (Integration)', () => {
|
||||||
|
|
||||||
describe('findByUserId', () => {
|
describe('findByUserId', () => {
|
||||||
it('should find statistics by user ID', async () => {
|
it('should find statistics by user ID', async () => {
|
||||||
await repository.create(100n);
|
await repository.create(100n, 'D25010100001');
|
||||||
|
|
||||||
const found = await repository.findByUserId(100n);
|
const found = await repository.findByUserId(100n);
|
||||||
|
|
||||||
|
|
@ -83,9 +83,9 @@ describe('TeamStatisticsRepository (Integration)', () => {
|
||||||
|
|
||||||
describe('findByUserIds', () => {
|
describe('findByUserIds', () => {
|
||||||
it('should find statistics for multiple users', async () => {
|
it('should find statistics for multiple users', async () => {
|
||||||
await repository.create(100n);
|
await repository.create(100n, 'D25010100001');
|
||||||
await repository.create(101n);
|
await repository.create(101n, 'D25010100002');
|
||||||
await repository.create(102n);
|
await repository.create(102n, 'D25010100003');
|
||||||
|
|
||||||
const found = await repository.findByUserIds([100n, 101n]);
|
const found = await repository.findByUserIds([100n, 101n]);
|
||||||
|
|
||||||
|
|
@ -98,15 +98,15 @@ describe('TeamStatisticsRepository (Integration)', () => {
|
||||||
describe('getLeaderboard', () => {
|
describe('getLeaderboard', () => {
|
||||||
it('should return leaderboard sorted by score', async () => {
|
it('should return leaderboard sorted by score', async () => {
|
||||||
// Create users with different scores
|
// Create users with different scores
|
||||||
const stats1 = TeamStatistics.create(100n);
|
const stats1 = TeamStatistics.create(100n, 'D25010100001');
|
||||||
stats1.addPersonalPlanting(50, '110000', '110100');
|
stats1.addPersonalPlanting(50, '110000', '110100');
|
||||||
await repository.save(stats1);
|
await repository.save(stats1);
|
||||||
|
|
||||||
const stats2 = TeamStatistics.create(101n);
|
const stats2 = TeamStatistics.create(101n, 'D25010100002');
|
||||||
stats2.addPersonalPlanting(100, '110000', '110100');
|
stats2.addPersonalPlanting(100, '110000', '110100');
|
||||||
await repository.save(stats2);
|
await repository.save(stats2);
|
||||||
|
|
||||||
const stats3 = TeamStatistics.create(102n);
|
const stats3 = TeamStatistics.create(102n, 'D25010100003');
|
||||||
stats3.addPersonalPlanting(30, '110000', '110100');
|
stats3.addPersonalPlanting(30, '110000', '110100');
|
||||||
await repository.save(stats3);
|
await repository.save(stats3);
|
||||||
|
|
||||||
|
|
@ -122,7 +122,7 @@ describe('TeamStatisticsRepository (Integration)', () => {
|
||||||
|
|
||||||
it('should respect limit and offset', async () => {
|
it('should respect limit and offset', async () => {
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
const stats = TeamStatistics.create(BigInt(100 + i));
|
const stats = TeamStatistics.create(BigInt(100 + i), `D2501010000${i}`);
|
||||||
stats.addPersonalPlanting(10 + i, '110000', '110100');
|
stats.addPersonalPlanting(10 + i, '110000', '110100');
|
||||||
await repository.save(stats);
|
await repository.save(stats);
|
||||||
}
|
}
|
||||||
|
|
@ -136,11 +136,11 @@ describe('TeamStatisticsRepository (Integration)', () => {
|
||||||
|
|
||||||
describe('getUserRank', () => {
|
describe('getUserRank', () => {
|
||||||
it('should return correct rank', async () => {
|
it('should return correct rank', async () => {
|
||||||
const stats1 = TeamStatistics.create(100n);
|
const stats1 = TeamStatistics.create(100n, 'D25010100001');
|
||||||
stats1.addPersonalPlanting(100, '110000', '110100');
|
stats1.addPersonalPlanting(100, '110000', '110100');
|
||||||
await repository.save(stats1);
|
await repository.save(stats1);
|
||||||
|
|
||||||
const stats2 = TeamStatistics.create(101n);
|
const stats2 = TeamStatistics.create(101n, 'D25010100002');
|
||||||
stats2.addPersonalPlanting(50, '110000', '110100');
|
stats2.addPersonalPlanting(50, '110000', '110100');
|
||||||
await repository.save(stats2);
|
await repository.save(stats2);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue