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

View File

@ -45,7 +45,7 @@ import {
SelfApplyAuthorizationResponseDto,
UserAuthorizationStatusResponseDto,
SelfApplyAuthorizationType,
TeamChainAuthorizationHolderDto,
TeamChainOccupiedRegionDto,
} from '@/api/dto/request/self-apply-authorization.dto'
import { AuthorizationDTO, StickmanRankingDTO, CommunityHierarchyDTO } from '@/application/dto'
@ -2786,12 +2786,12 @@ export class AuthorizationApplicationService {
.filter(auth => auth.status !== AuthorizationStatus.REVOKED)
.map(auth => this.mapRoleTypeToDisplayName(auth.roleType))
// 3. 检查团队链中是否有市团队/省团队授权持有者
const teamChainCityTeamHolder = await this.findTeamChainAuthorizationHolder(
// 3. 查找团队链中已被占用的城市和省份
const teamChainOccupiedCities = await this.findTeamChainOccupiedRegions(
accountSequence,
RoleType.AUTH_CITY_COMPANY,
)
const teamChainProvinceTeamHolder = await this.findTeamChainAuthorizationHolder(
const teamChainOccupiedProvinces = await this.findTeamChainOccupiedRegions(
accountSequence,
RoleType.AUTH_PROVINCE_COMPANY,
)
@ -2800,8 +2800,8 @@ export class AuthorizationApplicationService {
hasPlanted,
plantedCount,
existingAuthorizations,
teamChainCityTeamHolder,
teamChainProvinceTeamHolder,
teamChainOccupiedCities,
teamChainOccupiedProvinces,
}
}
@ -2910,7 +2910,16 @@ export class AuthorizationApplicationService {
private async processCityTeamApplication(
command: SelfApplyAuthorizationCommand,
): 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(
RoleType.AUTH_CITY_COMPANY,
RegionCode.create(command.cityCode!),
@ -2919,6 +2928,16 @@ export class AuthorizationApplicationService {
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
const userId = UserId.create(command.userId, command.accountSequence)
const authorization = AuthorizationRole.createSelfAppliedAuthCityCompany({
@ -2952,7 +2971,16 @@ export class AuthorizationApplicationService {
private async processProvinceTeamApplication(
command: SelfApplyAuthorizationCommand,
): 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(
RoleType.AUTH_PROVINCE_COMPANY,
RegionCode.create(command.provinceCode!),
@ -2961,6 +2989,16 @@ export class AuthorizationApplicationService {
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
const userId = UserId.create(command.userId, command.accountSequence)
const authorization = AuthorizationRole.createSelfAppliedAuthProvinceCompany({
@ -2989,41 +3027,47 @@ export class AuthorizationApplicationService {
}
/**
*
*
*
* /
*
* @returns
*/
private async findTeamChainAuthorizationHolder(
private async findTeamChainOccupiedRegions(
accountSequence: string,
roleType: RoleType.AUTH_CITY_COMPANY | RoleType.AUTH_PROVINCE_COMPANY,
): Promise<TeamChainAuthorizationHolderDto | undefined> {
): Promise<TeamChainOccupiedRegionDto[]> {
const occupiedRegions: TeamChainOccupiedRegionDto[] = []
try {
// 获取用户的祖先链(推荐链)
const ancestorChain = await this.referralServiceClient.getReferralChain(accountSequence)
if (!ancestorChain || ancestorChain.length === 0) {
return undefined
return occupiedRegions
}
// 遍历祖先链,查找持有该授权的用户
// 遍历祖先链,收集所有持有该类型授权的区域
for (const ancestorAccountSeq of ancestorChain) {
const authorizations = await this.authorizationRepository.findByAccountSequence(ancestorAccountSeq)
const matchingAuth = authorizations.find(
const matchingAuths = authorizations.filter(
auth => auth.roleType === roleType && auth.status !== AuthorizationStatus.REVOKED,
)
if (matchingAuth) {
for (const auth of matchingAuths) {
// 获取用户昵称
const userInfo = await this.identityServiceClient.getUserInfo(matchingAuth.userId.value)
return {
const userInfo = await this.identityServiceClient.getUserInfo(auth.userId.value)
occupiedRegions.push({
accountSequence: ancestorAccountSeq,
nickname: userInfo?.nickname || `用户${ancestorAccountSeq}`,
}
regionCode: auth.regionCode?.value || '',
regionName: auth.regionName || '',
})
}
}
return undefined
return occupiedRegions
} catch (error) {
this.logger.error(`[findTeamChainAuthorizationHolder] Error: ${error}`)
return undefined
this.logger.error(`[findTeamChainOccupiedRegions] Error: ${error}`)
return occupiedRegions
}
}

View File

@ -343,20 +343,26 @@ extension SelfApplyAuthorizationTypeExtension on SelfApplyAuthorizationType {
}
}
///
class TeamChainAuthorizationHolder {
///
class TeamChainOccupiedRegion {
final String accountSequence;
final String nickname;
final String regionCode;
final String regionName;
TeamChainAuthorizationHolder({
TeamChainOccupiedRegion({
required this.accountSequence,
required this.nickname,
required this.regionCode,
required this.regionName,
});
factory TeamChainAuthorizationHolder.fromJson(Map<String, dynamic> json) {
return TeamChainAuthorizationHolder(
factory TeamChainOccupiedRegion.fromJson(Map<String, dynamic> json) {
return TeamChainOccupiedRegion(
accountSequence: json['accountSequence'] ?? '',
nickname: json['nickname'] ?? '',
regionCode: json['regionCode'] ?? '',
regionName: json['regionName'] ?? '',
);
}
}
@ -366,17 +372,17 @@ class UserAuthorizationStatusResponse {
final bool hasPlanted;
final int plantedCount;
final List<String> existingAuthorizations;
///
final TeamChainAuthorizationHolder? teamChainCityTeamHolder;
///
final TeamChainAuthorizationHolder? teamChainProvinceTeamHolder;
///
final List<TeamChainOccupiedRegion> teamChainOccupiedCities;
///
final List<TeamChainOccupiedRegion> teamChainOccupiedProvinces;
UserAuthorizationStatusResponse({
required this.hasPlanted,
required this.plantedCount,
required this.existingAuthorizations,
this.teamChainCityTeamHolder,
this.teamChainProvinceTeamHolder,
this.teamChainOccupiedCities = const [],
this.teamChainOccupiedProvinces = const [],
});
factory UserAuthorizationStatusResponse.fromJson(Map<String, dynamic> json) {
@ -386,14 +392,42 @@ class UserAuthorizationStatusResponse {
existingAuthorizations: (json['existingAuthorizations'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ?? [],
teamChainCityTeamHolder: json['teamChainCityTeamHolder'] != null
? TeamChainAuthorizationHolder.fromJson(json['teamChainCityTeamHolder'])
: null,
teamChainProvinceTeamHolder: json['teamChainProvinceTeamHolder'] != null
? TeamChainAuthorizationHolder.fromJson(json['teamChainProvinceTeamHolder'])
: null,
teamChainOccupiedCities: (json['teamChainOccupiedCities'] as List<dynamic>?)
?.map((e) => TeamChainOccupiedRegion.fromJson(e as Map<String, dynamic>))
.toList() ?? [],
teamChainOccupiedProvinces: (json['teamChainOccupiedProvinces'] as List<dynamic>?)
?.map((e) => TeamChainOccupiedRegion.fromJson(e as Map<String, dynamic>))
.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 = [];
///
TeamChainAuthorizationHolder? _teamChainCityTeamHolder;
///
TeamChainAuthorizationHolder? _teamChainProvinceTeamHolder;
///
UserAuthorizationStatusResponse? _authorizationStatus;
///
String? _savedProvinceName;
@ -138,8 +135,7 @@ class _AuthorizationApplyPageState
_hasPlanted = status.hasPlanted;
_plantedCount = status.plantedCount;
_existingAuthorizations = status.existingAuthorizations;
_teamChainCityTeamHolder = status.teamChainCityTeamHolder;
_teamChainProvinceTeamHolder = status.teamChainProvinceTeamHolder;
_authorizationStatus = status;
_isLoading = false;
});
}
@ -436,6 +432,17 @@ class _AuthorizationApplyPageState
);
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) {
if (tempProvinceCode == null || tempProvinceName == null) {
ScaffoldMessenger.of(context).showSnackBar(
@ -446,6 +453,17 @@ class _AuthorizationApplyPageState
);
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({
@ -855,17 +873,27 @@ class _AuthorizationApplyPageState
final isSelected = _selectedType == type;
final isAlreadyHas = _existingAuthorizations.contains(type.displayName);
//
TeamChainAuthorizationHolder? teamChainHolder;
if (type == AuthorizationType.cityTeam) {
teamChainHolder = _teamChainCityTeamHolder;
} else if (type == AuthorizationType.provinceTeam) {
teamChainHolder = _teamChainProvinceTeamHolder;
//
int occupiedCount = 0;
if (type == AuthorizationType.cityTeam && _authorizationStatus != null) {
occupiedCount = _authorizationStatus!.teamChainOccupiedCities.length;
} else if (type == AuthorizationType.provinceTeam && _authorizationStatus != null) {
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(
onTap: isDisabled
@ -947,11 +975,23 @@ class _AuthorizationApplyPageState
: const Color(0xFF745D43),
),
),
//
if (isTeamChainBlocked) ...[
//
if (occupiedCount > 0 && !isDisabled) ...[
const SizedBox(height: 8),
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(
fontSize: 12,
fontFamily: 'Inter',
@ -979,7 +1019,8 @@ class _AuthorizationApplyPageState
),
),
)
else if (isTeamChainBlocked)
//
else if (isMutuallyExcluded)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(