feat(authorization): 优化市/省团队自助申请逻辑

- 团队链区域唯一性:同一直推链上只阻止申请已被占用的城市/省份,非全部阻止
- 市/省团队互斥:同一用户只能拥有市团队或省团队之一
- 前端优化:显示已占用区域数量提示,选择时验证区域可用性

🤖 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-22 22:52:39 -08:00
parent 30f3487bd3
commit 428ac91737
4 changed files with 191 additions and 65 deletions

View File

@ -95,14 +95,21 @@ export class SelfApplyAuthorizationResponseDto {
} }
/** /**
* *
*
*/ */
export class TeamChainAuthorizationHolderDto { export class TeamChainOccupiedRegionDto {
@ApiProperty({ description: '持有者账号序号' }) @ApiProperty({ description: '持有者账号序号' })
accountSequence: string accountSequence: string
@ApiProperty({ description: '持有者昵称' }) @ApiProperty({ description: '持有者昵称' })
nickname: string nickname: string
@ApiProperty({ description: '区域代码(城市代码或省份代码)' })
regionCode: string
@ApiProperty({ description: '区域名称(城市名称或省份名称)' })
regionName: string
} }
/** /**
@ -118,9 +125,9 @@ export class UserAuthorizationStatusResponseDto {
@ApiProperty({ description: '已拥有的授权类型列表' }) @ApiProperty({ description: '已拥有的授权类型列表' })
existingAuthorizations: string[] existingAuthorizations: string[]
@ApiPropertyOptional({ description: '团队链中市团队授权持有者(如有)' }) @ApiProperty({ description: '团队链中已被占用的市团队区域列表' })
teamChainCityTeamHolder?: TeamChainAuthorizationHolderDto teamChainOccupiedCities: TeamChainOccupiedRegionDto[]
@ApiPropertyOptional({ description: '团队链中省团队授权持有者(如有)' }) @ApiProperty({ description: '团队链中已被占用的省团队区域列表' })
teamChainProvinceTeamHolder?: TeamChainAuthorizationHolderDto teamChainOccupiedProvinces: TeamChainOccupiedRegionDto[]
} }

View File

@ -45,7 +45,7 @@ import {
SelfApplyAuthorizationResponseDto, SelfApplyAuthorizationResponseDto,
UserAuthorizationStatusResponseDto, UserAuthorizationStatusResponseDto,
SelfApplyAuthorizationType, SelfApplyAuthorizationType,
TeamChainAuthorizationHolderDto, TeamChainOccupiedRegionDto,
} from '@/api/dto/request/self-apply-authorization.dto' } from '@/api/dto/request/self-apply-authorization.dto'
import { AuthorizationDTO, StickmanRankingDTO, CommunityHierarchyDTO } from '@/application/dto' import { AuthorizationDTO, StickmanRankingDTO, CommunityHierarchyDTO } from '@/application/dto'
@ -2786,12 +2786,12 @@ export class AuthorizationApplicationService {
.filter(auth => auth.status !== AuthorizationStatus.REVOKED) .filter(auth => auth.status !== AuthorizationStatus.REVOKED)
.map(auth => this.mapRoleTypeToDisplayName(auth.roleType)) .map(auth => this.mapRoleTypeToDisplayName(auth.roleType))
// 3. 检查团队链中是否有市团队/省团队授权持有者 // 3. 查找团队链中已被占用的城市和省份
const teamChainCityTeamHolder = await this.findTeamChainAuthorizationHolder( const teamChainOccupiedCities = await this.findTeamChainOccupiedRegions(
accountSequence, accountSequence,
RoleType.AUTH_CITY_COMPANY, RoleType.AUTH_CITY_COMPANY,
) )
const teamChainProvinceTeamHolder = await this.findTeamChainAuthorizationHolder( const teamChainOccupiedProvinces = await this.findTeamChainOccupiedRegions(
accountSequence, accountSequence,
RoleType.AUTH_PROVINCE_COMPANY, RoleType.AUTH_PROVINCE_COMPANY,
) )
@ -2800,8 +2800,8 @@ export class AuthorizationApplicationService {
hasPlanted, hasPlanted,
plantedCount, plantedCount,
existingAuthorizations, existingAuthorizations,
teamChainCityTeamHolder, teamChainOccupiedCities,
teamChainProvinceTeamHolder, teamChainOccupiedProvinces,
} }
} }
@ -2910,7 +2910,16 @@ export class AuthorizationApplicationService {
private async processCityTeamApplication( private async processCityTeamApplication(
command: SelfApplyAuthorizationCommand, command: SelfApplyAuthorizationCommand,
): Promise<SelfApplyAuthorizationResponseDto> { ): Promise<SelfApplyAuthorizationResponseDto> {
// 检查是否已有该市的授权市公司 // 检查用户是否已拥有省团队授权(市团队和省团队互斥,只能二选一)
const userAuthorizations = await this.authorizationRepository.findByAccountSequence(command.accountSequence)
const hasProvinceTeam = userAuthorizations.some(
auth => auth.roleType === RoleType.AUTH_PROVINCE_COMPANY && auth.status !== AuthorizationStatus.REVOKED,
)
if (hasProvinceTeam) {
throw new ApplicationError('您已拥有省团队授权,市团队和省团队只能二选一')
}
// 检查是否已有该市的授权市公司(全局唯一性)
const existingList = await this.authorizationRepository.findActiveByRoleTypeAndRegion( const existingList = await this.authorizationRepository.findActiveByRoleTypeAndRegion(
RoleType.AUTH_CITY_COMPANY, RoleType.AUTH_CITY_COMPANY,
RegionCode.create(command.cityCode!), RegionCode.create(command.cityCode!),
@ -2919,6 +2928,16 @@ export class AuthorizationApplicationService {
throw new ApplicationError('该市已有授权市公司') throw new ApplicationError('该市已有授权市公司')
} }
// 检查团队链中是否已有人申请该城市(团队链唯一性)
const occupiedCities = await this.findTeamChainOccupiedRegions(
command.accountSequence,
RoleType.AUTH_CITY_COMPANY,
)
const isCityOccupiedInChain = occupiedCities.some(r => r.regionCode === command.cityCode)
if (isCityOccupiedInChain) {
throw new ApplicationError(`您的团队链中已有人申请了「${command.cityName}」的市团队授权`)
}
// 创建授权市公司授权(自助申请直接生效,状态为 AUTHORIZED // 创建授权市公司授权(自助申请直接生效,状态为 AUTHORIZED
const userId = UserId.create(command.userId, command.accountSequence) const userId = UserId.create(command.userId, command.accountSequence)
const authorization = AuthorizationRole.createSelfAppliedAuthCityCompany({ const authorization = AuthorizationRole.createSelfAppliedAuthCityCompany({
@ -2952,7 +2971,16 @@ export class AuthorizationApplicationService {
private async processProvinceTeamApplication( private async processProvinceTeamApplication(
command: SelfApplyAuthorizationCommand, command: SelfApplyAuthorizationCommand,
): Promise<SelfApplyAuthorizationResponseDto> { ): Promise<SelfApplyAuthorizationResponseDto> {
// 检查是否已有该省的授权省公司 // 检查用户是否已拥有市团队授权(市团队和省团队互斥,只能二选一)
const userAuthorizations = await this.authorizationRepository.findByAccountSequence(command.accountSequence)
const hasCityTeam = userAuthorizations.some(
auth => auth.roleType === RoleType.AUTH_CITY_COMPANY && auth.status !== AuthorizationStatus.REVOKED,
)
if (hasCityTeam) {
throw new ApplicationError('您已拥有市团队授权,市团队和省团队只能二选一')
}
// 检查是否已有该省的授权省公司(全局唯一性)
const existingList = await this.authorizationRepository.findActiveByRoleTypeAndRegion( const existingList = await this.authorizationRepository.findActiveByRoleTypeAndRegion(
RoleType.AUTH_PROVINCE_COMPANY, RoleType.AUTH_PROVINCE_COMPANY,
RegionCode.create(command.provinceCode!), RegionCode.create(command.provinceCode!),
@ -2961,6 +2989,16 @@ export class AuthorizationApplicationService {
throw new ApplicationError('该省已有授权省公司') throw new ApplicationError('该省已有授权省公司')
} }
// 检查团队链中是否已有人申请该省份(团队链唯一性)
const occupiedProvinces = await this.findTeamChainOccupiedRegions(
command.accountSequence,
RoleType.AUTH_PROVINCE_COMPANY,
)
const isProvinceOccupiedInChain = occupiedProvinces.some(r => r.regionCode === command.provinceCode)
if (isProvinceOccupiedInChain) {
throw new ApplicationError(`您的团队链中已有人申请了「${command.provinceName}」的省团队授权`)
}
// 创建授权省公司授权(自助申请直接生效,状态为 AUTHORIZED // 创建授权省公司授权(自助申请直接生效,状态为 AUTHORIZED
const userId = UserId.create(command.userId, command.accountSequence) const userId = UserId.create(command.userId, command.accountSequence)
const authorization = AuthorizationRole.createSelfAppliedAuthProvinceCompany({ const authorization = AuthorizationRole.createSelfAppliedAuthProvinceCompany({
@ -2989,41 +3027,47 @@ export class AuthorizationApplicationService {
} }
/** /**
* *
* * /
*
* @returns
*/ */
private async findTeamChainAuthorizationHolder( private async findTeamChainOccupiedRegions(
accountSequence: string, accountSequence: string,
roleType: RoleType.AUTH_CITY_COMPANY | RoleType.AUTH_PROVINCE_COMPANY, roleType: RoleType.AUTH_CITY_COMPANY | RoleType.AUTH_PROVINCE_COMPANY,
): Promise<TeamChainAuthorizationHolderDto | undefined> { ): Promise<TeamChainOccupiedRegionDto[]> {
const occupiedRegions: TeamChainOccupiedRegionDto[] = []
try { try {
// 获取用户的祖先链(推荐链) // 获取用户的祖先链(推荐链)
const ancestorChain = await this.referralServiceClient.getReferralChain(accountSequence) const ancestorChain = await this.referralServiceClient.getReferralChain(accountSequence)
if (!ancestorChain || ancestorChain.length === 0) { if (!ancestorChain || ancestorChain.length === 0) {
return undefined return occupiedRegions
} }
// 遍历祖先链,查找持有该授权的用户 // 遍历祖先链,收集所有持有该类型授权的区域
for (const ancestorAccountSeq of ancestorChain) { for (const ancestorAccountSeq of ancestorChain) {
const authorizations = await this.authorizationRepository.findByAccountSequence(ancestorAccountSeq) const authorizations = await this.authorizationRepository.findByAccountSequence(ancestorAccountSeq)
const matchingAuth = authorizations.find( const matchingAuths = authorizations.filter(
auth => auth.roleType === roleType && auth.status !== AuthorizationStatus.REVOKED, auth => auth.roleType === roleType && auth.status !== AuthorizationStatus.REVOKED,
) )
if (matchingAuth) { for (const auth of matchingAuths) {
// 获取用户昵称 // 获取用户昵称
const userInfo = await this.identityServiceClient.getUserInfo(matchingAuth.userId.value) const userInfo = await this.identityServiceClient.getUserInfo(auth.userId.value)
return { occupiedRegions.push({
accountSequence: ancestorAccountSeq, accountSequence: ancestorAccountSeq,
nickname: userInfo?.nickname || `用户${ancestorAccountSeq}`, nickname: userInfo?.nickname || `用户${ancestorAccountSeq}`,
} regionCode: auth.regionCode?.value || '',
regionName: auth.regionName || '',
})
} }
} }
return undefined return occupiedRegions
} catch (error) { } catch (error) {
this.logger.error(`[findTeamChainAuthorizationHolder] Error: ${error}`) this.logger.error(`[findTeamChainOccupiedRegions] Error: ${error}`)
return undefined return occupiedRegions
} }
} }

View File

@ -343,20 +343,26 @@ extension SelfApplyAuthorizationTypeExtension on SelfApplyAuthorizationType {
} }
} }
/// ///
class TeamChainAuthorizationHolder { class TeamChainOccupiedRegion {
final String accountSequence; final String accountSequence;
final String nickname; final String nickname;
final String regionCode;
final String regionName;
TeamChainAuthorizationHolder({ TeamChainOccupiedRegion({
required this.accountSequence, required this.accountSequence,
required this.nickname, required this.nickname,
required this.regionCode,
required this.regionName,
}); });
factory TeamChainAuthorizationHolder.fromJson(Map<String, dynamic> json) { factory TeamChainOccupiedRegion.fromJson(Map<String, dynamic> json) {
return TeamChainAuthorizationHolder( return TeamChainOccupiedRegion(
accountSequence: json['accountSequence'] ?? '', accountSequence: json['accountSequence'] ?? '',
nickname: json['nickname'] ?? '', nickname: json['nickname'] ?? '',
regionCode: json['regionCode'] ?? '',
regionName: json['regionName'] ?? '',
); );
} }
} }
@ -366,17 +372,17 @@ class UserAuthorizationStatusResponse {
final bool hasPlanted; final bool hasPlanted;
final int plantedCount; final int plantedCount;
final List<String> existingAuthorizations; final List<String> existingAuthorizations;
/// ///
final TeamChainAuthorizationHolder? teamChainCityTeamHolder; final List<TeamChainOccupiedRegion> teamChainOccupiedCities;
/// ///
final TeamChainAuthorizationHolder? teamChainProvinceTeamHolder; final List<TeamChainOccupiedRegion> teamChainOccupiedProvinces;
UserAuthorizationStatusResponse({ UserAuthorizationStatusResponse({
required this.hasPlanted, required this.hasPlanted,
required this.plantedCount, required this.plantedCount,
required this.existingAuthorizations, required this.existingAuthorizations,
this.teamChainCityTeamHolder, this.teamChainOccupiedCities = const [],
this.teamChainProvinceTeamHolder, this.teamChainOccupiedProvinces = const [],
}); });
factory UserAuthorizationStatusResponse.fromJson(Map<String, dynamic> json) { factory UserAuthorizationStatusResponse.fromJson(Map<String, dynamic> json) {
@ -386,14 +392,42 @@ class UserAuthorizationStatusResponse {
existingAuthorizations: (json['existingAuthorizations'] as List<dynamic>?) existingAuthorizations: (json['existingAuthorizations'] as List<dynamic>?)
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toList() ?? [], .toList() ?? [],
teamChainCityTeamHolder: json['teamChainCityTeamHolder'] != null teamChainOccupiedCities: (json['teamChainOccupiedCities'] as List<dynamic>?)
? TeamChainAuthorizationHolder.fromJson(json['teamChainCityTeamHolder']) ?.map((e) => TeamChainOccupiedRegion.fromJson(e as Map<String, dynamic>))
: null, .toList() ?? [],
teamChainProvinceTeamHolder: json['teamChainProvinceTeamHolder'] != null teamChainOccupiedProvinces: (json['teamChainOccupiedProvinces'] as List<dynamic>?)
? TeamChainAuthorizationHolder.fromJson(json['teamChainProvinceTeamHolder']) ?.map((e) => TeamChainOccupiedRegion.fromJson(e as Map<String, dynamic>))
: null, .toList() ?? [],
); );
} }
///
bool isCityOccupied(String cityCode) {
return teamChainOccupiedCities.any((r) => r.regionCode == cityCode);
}
///
bool isProvinceOccupied(String provinceCode) {
return teamChainOccupiedProvinces.any((r) => r.regionCode == provinceCode);
}
///
TeamChainOccupiedRegion? getCityOccupier(String cityCode) {
try {
return teamChainOccupiedCities.firstWhere((r) => r.regionCode == cityCode);
} catch (_) {
return null;
}
}
///
TeamChainOccupiedRegion? getProvinceOccupier(String provinceCode) {
try {
return teamChainOccupiedProvinces.firstWhere((r) => r.regionCode == provinceCode);
} catch (_) {
return null;
}
}
} }
/// ///

View File

@ -80,11 +80,8 @@ class _AuthorizationApplyPageState
/// ///
List<String> _existingAuthorizations = []; List<String> _existingAuthorizations = [];
/// ///
TeamChainAuthorizationHolder? _teamChainCityTeamHolder; UserAuthorizationStatusResponse? _authorizationStatus;
///
TeamChainAuthorizationHolder? _teamChainProvinceTeamHolder;
/// ///
String? _savedProvinceName; String? _savedProvinceName;
@ -138,8 +135,7 @@ class _AuthorizationApplyPageState
_hasPlanted = status.hasPlanted; _hasPlanted = status.hasPlanted;
_plantedCount = status.plantedCount; _plantedCount = status.plantedCount;
_existingAuthorizations = status.existingAuthorizations; _existingAuthorizations = status.existingAuthorizations;
_teamChainCityTeamHolder = status.teamChainCityTeamHolder; _authorizationStatus = status;
_teamChainProvinceTeamHolder = status.teamChainProvinceTeamHolder;
_isLoading = false; _isLoading = false;
}); });
} }
@ -436,6 +432,17 @@ class _AuthorizationApplyPageState
); );
return; return;
} }
//
if (_authorizationStatus != null && _authorizationStatus!.isCityOccupied(tempCityCode!)) {
final occupier = _authorizationStatus!.getCityOccupier(tempCityCode!);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('该城市已被团队中的 ${occupier?.nickname ?? "其他用户"} 申请,请选择其他城市'),
backgroundColor: Colors.red,
),
);
return;
}
} else if (type == AuthorizationType.provinceTeam) { } else if (type == AuthorizationType.provinceTeam) {
if (tempProvinceCode == null || tempProvinceName == null) { if (tempProvinceCode == null || tempProvinceName == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -446,6 +453,17 @@ class _AuthorizationApplyPageState
); );
return; return;
} }
//
if (_authorizationStatus != null && _authorizationStatus!.isProvinceOccupied(tempProvinceCode!)) {
final occupier = _authorizationStatus!.getProvinceOccupier(tempProvinceCode!);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('该省份已被团队中的 ${occupier?.nickname ?? "其他用户"} 申请,请选择其他省份'),
backgroundColor: Colors.red,
),
);
return;
}
} }
Navigator.of(dialogContext).pop({ Navigator.of(dialogContext).pop({
@ -855,17 +873,27 @@ class _AuthorizationApplyPageState
final isSelected = _selectedType == type; final isSelected = _selectedType == type;
final isAlreadyHas = _existingAuthorizations.contains(type.displayName); final isAlreadyHas = _existingAuthorizations.contains(type.displayName);
// //
TeamChainAuthorizationHolder? teamChainHolder; int occupiedCount = 0;
if (type == AuthorizationType.cityTeam) { if (type == AuthorizationType.cityTeam && _authorizationStatus != null) {
teamChainHolder = _teamChainCityTeamHolder; occupiedCount = _authorizationStatus!.teamChainOccupiedCities.length;
} else if (type == AuthorizationType.provinceTeam) { } else if (type == AuthorizationType.provinceTeam && _authorizationStatus != null) {
teamChainHolder = _teamChainProvinceTeamHolder; occupiedCount = _authorizationStatus!.teamChainOccupiedProvinces.length;
} }
final isTeamChainBlocked = teamChainHolder != null;
// //
final isDisabled = isAlreadyHas || isTeamChainBlocked; bool isMutuallyExcluded = false;
String mutuallyExcludedReason = '';
if (type == AuthorizationType.cityTeam && _existingAuthorizations.contains('省团队')) {
isMutuallyExcluded = true;
mutuallyExcludedReason = '已拥有省团队,市团队和省团队只能二选一';
} else if (type == AuthorizationType.provinceTeam && _existingAuthorizations.contains('市团队')) {
isMutuallyExcluded = true;
mutuallyExcludedReason = '已拥有市团队,市团队和省团队只能二选一';
}
//
final isDisabled = isAlreadyHas || isMutuallyExcluded;
return GestureDetector( return GestureDetector(
onTap: isDisabled onTap: isDisabled
@ -947,11 +975,23 @@ class _AuthorizationApplyPageState
: const Color(0xFF745D43), : const Color(0xFF745D43),
), ),
), ),
// //
if (isTeamChainBlocked) ...[ if (occupiedCount > 0 && !isDisabled) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'团队中已有人拥有此权益:${teamChainHolder!.nickname}${teamChainHolder.accountSequence}', '团队链中已有 $occupiedCount${type == AuthorizationType.cityTeam ? '城市' : '省份'}被申请,请选择其他区域',
style: const TextStyle(
fontSize: 12,
fontFamily: 'Inter',
color: Color(0xFFFF9800),
),
),
],
//
if (isMutuallyExcluded) ...[
const SizedBox(height: 8),
Text(
mutuallyExcludedReason,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontFamily: 'Inter', fontFamily: 'Inter',
@ -979,7 +1019,8 @@ class _AuthorizationApplyPageState
), ),
), ),
) )
else if (isTeamChainBlocked) //
else if (isMutuallyExcluded)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(