feat: 多项业务功能增强
- 动态提取手续费配置:支持固定/百分比两种费率类型,默认2绿积分/笔 - 找回密码功能:新增手机号+短信验证码重置密码流程 - 授权申请优化:自助申请时验证团队链授权状态 - UI文案调整:登录账号、监控页待开启等 🤖 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
06df38b918
commit
cb0f10af34
|
|
@ -94,6 +94,17 @@ export class SelfApplyAuthorizationResponseDto {
|
|||
benefitsActivated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队链中已有授权的持有者信息
|
||||
*/
|
||||
export class TeamChainAuthorizationHolderDto {
|
||||
@ApiProperty({ description: '持有者账号序号' })
|
||||
accountSequence: string
|
||||
|
||||
@ApiProperty({ description: '持有者昵称' })
|
||||
nickname: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户授权状态响应 DTO
|
||||
*/
|
||||
|
|
@ -106,4 +117,10 @@ export class UserAuthorizationStatusResponseDto {
|
|||
|
||||
@ApiProperty({ description: '已拥有的授权类型列表' })
|
||||
existingAuthorizations: string[]
|
||||
|
||||
@ApiPropertyOptional({ description: '团队链中市团队授权持有者(如有)' })
|
||||
teamChainCityTeamHolder?: TeamChainAuthorizationHolderDto
|
||||
|
||||
@ApiPropertyOptional({ description: '团队链中省团队授权持有者(如有)' })
|
||||
teamChainProvinceTeamHolder?: TeamChainAuthorizationHolderDto
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import {
|
|||
SelfApplyAuthorizationResponseDto,
|
||||
UserAuthorizationStatusResponseDto,
|
||||
SelfApplyAuthorizationType,
|
||||
TeamChainAuthorizationHolderDto,
|
||||
} from '@/api/dto/request/self-apply-authorization.dto'
|
||||
import { AuthorizationDTO, StickmanRankingDTO, CommunityHierarchyDTO } from '@/application/dto'
|
||||
|
||||
|
|
@ -2785,10 +2786,22 @@ export class AuthorizationApplicationService {
|
|||
.filter(auth => auth.status !== AuthorizationStatus.REVOKED)
|
||||
.map(auth => this.mapRoleTypeToDisplayName(auth.roleType))
|
||||
|
||||
// 3. 检查团队链中是否有市团队/省团队授权持有者
|
||||
const teamChainCityTeamHolder = await this.findTeamChainAuthorizationHolder(
|
||||
accountSequence,
|
||||
RoleType.AUTH_CITY_COMPANY,
|
||||
)
|
||||
const teamChainProvinceTeamHolder = await this.findTeamChainAuthorizationHolder(
|
||||
accountSequence,
|
||||
RoleType.AUTH_PROVINCE_COMPANY,
|
||||
)
|
||||
|
||||
return {
|
||||
hasPlanted,
|
||||
plantedCount,
|
||||
existingAuthorizations,
|
||||
teamChainCityTeamHolder,
|
||||
teamChainProvinceTeamHolder,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2976,8 +2989,44 @@ export class AuthorizationApplicationService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 将 RoleType 映射为显示名称
|
||||
* 查找团队链中指定类型的授权持有者
|
||||
* 遍历用户的祖先链(推荐链),查找是否有人持有指定类型的授权
|
||||
*/
|
||||
private async findTeamChainAuthorizationHolder(
|
||||
accountSequence: string,
|
||||
roleType: RoleType.AUTH_CITY_COMPANY | RoleType.AUTH_PROVINCE_COMPANY,
|
||||
): Promise<TeamChainAuthorizationHolderDto | undefined> {
|
||||
try {
|
||||
// 获取用户的祖先链(推荐链)
|
||||
const ancestorChain = await this.referralServiceClient.getReferralChain(accountSequence)
|
||||
if (!ancestorChain || ancestorChain.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 遍历祖先链,查找持有该授权的用户
|
||||
for (const ancestorAccountSeq of ancestorChain) {
|
||||
const authorizations = await this.authorizationRepository.findByAccountSequence(ancestorAccountSeq)
|
||||
const matchingAuth = authorizations.find(
|
||||
auth => auth.roleType === roleType && auth.status !== AuthorizationStatus.REVOKED,
|
||||
)
|
||||
|
||||
if (matchingAuth) {
|
||||
// 获取用户昵称
|
||||
const userInfo = await this.identityServiceClient.getUserInfo(matchingAuth.userId.value)
|
||||
return {
|
||||
accountSequence: ancestorAccountSeq,
|
||||
nickname: userInfo?.nickname || `用户${ancestorAccountSeq}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
} catch (error) {
|
||||
this.logger.error(`[findTeamChainAuthorizationHolder] Error: ${error}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private mapRoleTypeToDisplayName(roleType: RoleType): string {
|
||||
const mapping: Record<RoleType, string> = {
|
||||
[RoleType.COMMUNITY]: '社区',
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ import {
|
|||
VerifySmsCodeDto,
|
||||
SetPasswordDto,
|
||||
LoginWithPasswordDto,
|
||||
ResetPasswordDto,
|
||||
} from '@/api/dto';
|
||||
|
||||
@ApiTags('User')
|
||||
|
|
@ -189,12 +190,40 @@ export class UserAccountController {
|
|||
new VerifySmsCodeCommand(
|
||||
dto.phoneNumber,
|
||||
dto.smsCode,
|
||||
dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
||||
dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER' | 'RESET_PASSWORD',
|
||||
),
|
||||
);
|
||||
return { message: '验证成功' };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('send-reset-password-sms')
|
||||
@ApiOperation({
|
||||
summary: '发送重置密码短信验证码',
|
||||
description: '向已注册的手机号发送重置密码验证码',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '验证码已发送' })
|
||||
async sendResetPasswordSms(@Body() body: { phoneNumber: string }) {
|
||||
await this.userService.sendResetPasswordSmsCode(body.phoneNumber);
|
||||
return { message: '验证码已发送' };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('reset-password')
|
||||
@ApiOperation({
|
||||
summary: '重置密码',
|
||||
description: '通过手机号+短信验证码重置登录密码',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '密码重置成功' })
|
||||
async resetPassword(@Body() dto: ResetPasswordDto) {
|
||||
await this.userService.resetPassword(
|
||||
dto.phoneNumber,
|
||||
dto.smsCode,
|
||||
dto.newPassword,
|
||||
);
|
||||
return { message: '密码重置成功' };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '用户注册(手机号)' })
|
||||
|
|
|
|||
|
|
@ -33,9 +33,26 @@ export class SendSmsCodeDto {
|
|||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'] })
|
||||
@IsEnum(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'])
|
||||
type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER';
|
||||
@ApiProperty({ enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER', 'RESET_PASSWORD'] })
|
||||
@IsEnum(['REGISTER', 'LOGIN', 'BIND', 'RECOVER', 'RESET_PASSWORD'])
|
||||
type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER' | 'RESET_PASSWORD';
|
||||
}
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@ApiProperty({ example: '13800138000', description: '手机号' })
|
||||
@IsString()
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ example: '123456', description: '短信验证码' })
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: '验证码格式错误' })
|
||||
smsCode: string;
|
||||
|
||||
@ApiProperty({ example: 'newPassword123', description: '新密码(至少6位)' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '新密码不能为空' })
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export class RegisterDto {
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ export class VerifySmsCodeDto {
|
|||
@ApiProperty({
|
||||
example: 'REGISTER',
|
||||
description: '验证码类型',
|
||||
enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'],
|
||||
enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER', 'RESET_PASSWORD'],
|
||||
})
|
||||
@IsString()
|
||||
@IsIn(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'], {
|
||||
@IsIn(['REGISTER', 'LOGIN', 'BIND', 'RECOVER', 'RESET_PASSWORD'], {
|
||||
message: '无效的验证码类型',
|
||||
})
|
||||
type: string;
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export class RemoveDeviceCommand {
|
|||
export class SendSmsCodeCommand {
|
||||
constructor(
|
||||
public readonly phoneNumber: string,
|
||||
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
||||
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER' | 'RESET_PASSWORD',
|
||||
) {}
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ export class VerifySmsCodeCommand {
|
|||
constructor(
|
||||
public readonly phoneNumber: string,
|
||||
public readonly smsCode: string,
|
||||
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
||||
public readonly type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER' | 'RESET_PASSWORD',
|
||||
) {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2124,4 +2124,116 @@ export class UserApplicationService {
|
|||
if (phone.length < 7) return phone;
|
||||
return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4);
|
||||
}
|
||||
|
||||
// ============ 找回密码相关 ============
|
||||
|
||||
/**
|
||||
* 发送重置密码短信验证码
|
||||
*
|
||||
* @param phoneNumber 手机号
|
||||
*/
|
||||
async sendResetPasswordSmsCode(phoneNumber: string): Promise<void> {
|
||||
this.logger.log(
|
||||
`[RESET_PASSWORD] Sending SMS code to: ${this.maskPhoneNumber(phoneNumber)}`,
|
||||
);
|
||||
|
||||
const phone = PhoneNumber.create(phoneNumber);
|
||||
|
||||
// 检查手机号是否已注册
|
||||
const account = await this.userRepository.findByPhoneNumber(phone);
|
||||
if (!account) {
|
||||
this.logger.warn(
|
||||
`[RESET_PASSWORD] Phone not registered: ${this.maskPhoneNumber(phoneNumber)}`,
|
||||
);
|
||||
throw new ApplicationError('该手机号未注册');
|
||||
}
|
||||
|
||||
// 检查账户状态
|
||||
if (!account.isActive) {
|
||||
this.logger.warn(
|
||||
`[RESET_PASSWORD] Account inactive for phone: ${this.maskPhoneNumber(phoneNumber)}`,
|
||||
);
|
||||
throw new ApplicationError('账户已冻结或注销');
|
||||
}
|
||||
|
||||
// 生成并发送验证码
|
||||
const code = this.generateSmsCode();
|
||||
const cacheKey = `sms:reset_password:${phone.value}`;
|
||||
|
||||
await this.smsService.sendVerificationCode(phone.value, code);
|
||||
await this.redisService.set(cacheKey, code, 300); // 5分钟有效
|
||||
|
||||
this.logger.log(
|
||||
`[RESET_PASSWORD] SMS code sent successfully to: ${this.maskPhoneNumber(phoneNumber)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*
|
||||
* @param phoneNumber 手机号
|
||||
* @param smsCode 短信验证码
|
||||
* @param newPassword 新密码
|
||||
*/
|
||||
async resetPassword(
|
||||
phoneNumber: string,
|
||||
smsCode: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
`[RESET_PASSWORD] Resetting password for: ${this.maskPhoneNumber(phoneNumber)}`,
|
||||
);
|
||||
|
||||
const phone = PhoneNumber.create(phoneNumber);
|
||||
|
||||
// 1. 验证短信验证码
|
||||
const cacheKey = `sms:reset_password:${phone.value}`;
|
||||
const cachedCode = await this.redisService.get(cacheKey);
|
||||
|
||||
if (!cachedCode || cachedCode !== smsCode) {
|
||||
this.logger.warn(
|
||||
`[RESET_PASSWORD] Invalid SMS code for: ${this.maskPhoneNumber(phoneNumber)}`,
|
||||
);
|
||||
throw new ApplicationError('验证码错误或已过期');
|
||||
}
|
||||
|
||||
// 2. 查找用户
|
||||
const account = await this.userRepository.findByPhoneNumber(phone);
|
||||
if (!account) {
|
||||
this.logger.warn(
|
||||
`[RESET_PASSWORD] User not found for phone: ${this.maskPhoneNumber(phoneNumber)}`,
|
||||
);
|
||||
throw new ApplicationError('用户不存在');
|
||||
}
|
||||
|
||||
// 3. 检查账户状态
|
||||
if (!account.isActive) {
|
||||
this.logger.warn(
|
||||
`[RESET_PASSWORD] Account inactive for phone: ${this.maskPhoneNumber(phoneNumber)}`,
|
||||
);
|
||||
throw new ApplicationError('账户已冻结或注销');
|
||||
}
|
||||
|
||||
// 4. 验证密码长度
|
||||
if (newPassword.length < 6) {
|
||||
throw new ApplicationError('密码至少需要6位');
|
||||
}
|
||||
|
||||
// 5. 更新密码
|
||||
const bcrypt = await import('bcrypt');
|
||||
const saltRounds = 10;
|
||||
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
await this.prisma.userAccount.update({
|
||||
where: { userId: account.userId.value },
|
||||
data: { passwordHash },
|
||||
});
|
||||
|
||||
// 6. 删除验证码
|
||||
await this.redisService.delete(cacheKey);
|
||||
|
||||
this.logger.log(
|
||||
`[RESET_PASSWORD] Password reset successfully for: ${this.maskPhoneNumber(phoneNumber)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
-- CreateTable: 提取手续费配置表
|
||||
CREATE TABLE "withdrawal_fee_configs" (
|
||||
"config_id" BIGSERIAL NOT NULL,
|
||||
"fee_type" VARCHAR(20) NOT NULL,
|
||||
"fee_value" DECIMAL(20,8) NOT NULL,
|
||||
"min_fee" DECIMAL(20,8) NOT NULL DEFAULT 0,
|
||||
"max_fee" DECIMAL(20,8) NOT NULL DEFAULT 0,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"memo" VARCHAR(200),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "withdrawal_fee_configs_pkey" PRIMARY KEY ("config_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "withdrawal_fee_configs_is_active_idx" ON "withdrawal_fee_configs"("is_active");
|
||||
|
|
@ -240,6 +240,37 @@ model PendingReward {
|
|||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 提取手续费配置表
|
||||
// ============================================
|
||||
model WithdrawalFeeConfig {
|
||||
id BigInt @id @default(autoincrement()) @map("config_id")
|
||||
|
||||
// 费率类型: FIXED(固定金额) / PERCENTAGE(百分比)
|
||||
feeType String @map("fee_type") @db.VarChar(20)
|
||||
|
||||
// 费率值: 固定金额时为具体数值,百分比时为小数 (如 0.001 = 0.1%)
|
||||
feeValue Decimal @map("fee_value") @db.Decimal(20, 8)
|
||||
|
||||
// 最小手续费 (百分比模式下生效)
|
||||
minFee Decimal @default(0) @map("min_fee") @db.Decimal(20, 8)
|
||||
|
||||
// 最大手续费 (百分比模式下生效,0表示不限制)
|
||||
maxFee Decimal @default(0) @map("max_fee") @db.Decimal(20, 8)
|
||||
|
||||
// 是否启用
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
// 备注
|
||||
memo String? @map("memo") @db.VarChar(200)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("withdrawal_fee_configs")
|
||||
@@index([isActive])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 已处理事件表 (幂等性检查)
|
||||
// 用于确保 Kafka 事件不会被重复处理
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { Controller, Get, Post, Body, UseGuards, Headers, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { Controller, Get, Post, Body, Query, UseGuards, Headers, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { WalletApplicationService } from '@/application/services';
|
||||
import { GetMyWalletQuery } from '@/application/queries';
|
||||
import { ClaimRewardsCommand, SettleRewardsCommand, RequestWithdrawalCommand } from '@/application/commands';
|
||||
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
import { SettleRewardsDTO, RequestWithdrawalDTO } from '@/api/dto/request';
|
||||
import { WalletResponseDTO, WithdrawalResponseDTO, WithdrawalListItemDTO } from '@/api/dto/response';
|
||||
import { WalletResponseDTO, WithdrawalResponseDTO, WithdrawalListItemDTO, WithdrawalFeeConfigResponseDTO, CalculateFeeResponseDTO } from '@/api/dto/response';
|
||||
import { IdentityClientService } from '@/infrastructure/external/identity';
|
||||
import { FeeConfigRepositoryImpl } from '@/infrastructure/persistence/repositories';
|
||||
|
||||
@ApiTags('Wallet')
|
||||
@Controller('wallet')
|
||||
|
|
@ -17,6 +18,7 @@ export class WalletController {
|
|||
constructor(
|
||||
private readonly walletService: WalletApplicationService,
|
||||
private readonly identityClient: IdentityClientService,
|
||||
private readonly feeConfigRepo: FeeConfigRepositoryImpl,
|
||||
) {}
|
||||
|
||||
@Get('my-wallet')
|
||||
|
|
@ -184,4 +186,35 @@ export class WalletController {
|
|||
}>> {
|
||||
return this.walletService.getExpiredRewards(user.accountSequence);
|
||||
}
|
||||
|
||||
@Get('fee-config')
|
||||
@ApiOperation({ summary: '查询提取手续费配置', description: '获取当前生效的提取手续费配置' })
|
||||
@ApiResponse({ status: 200, type: WithdrawalFeeConfigResponseDTO })
|
||||
async getFeeConfig(): Promise<WithdrawalFeeConfigResponseDTO> {
|
||||
return this.feeConfigRepo.getActiveConfig();
|
||||
}
|
||||
|
||||
@Get('calculate-fee')
|
||||
@ApiOperation({ summary: '计算提取手续费', description: '根据提取金额计算手续费' })
|
||||
@ApiQuery({ name: 'amount', type: Number, description: '提取金额' })
|
||||
@ApiResponse({ status: 200, type: CalculateFeeResponseDTO })
|
||||
async calculateFee(
|
||||
@Query('amount') amountStr: string,
|
||||
): Promise<CalculateFeeResponseDTO> {
|
||||
const amount = parseFloat(amountStr);
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
throw new HttpException('无效的金额', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const { fee, feeType, feeValue } = await this.feeConfigRepo.calculateFee(amount);
|
||||
|
||||
return {
|
||||
amount,
|
||||
fee,
|
||||
totalRequired: amount + fee,
|
||||
receiverGets: amount, // 接收方收到完整金额
|
||||
feeType,
|
||||
feeValue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { ApiProperty } from '@nestjs/swagger'
|
||||
|
||||
/**
|
||||
* 手续费类型
|
||||
*/
|
||||
export enum FeeType {
|
||||
FIXED = 'FIXED', // 固定金额
|
||||
PERCENTAGE = 'PERCENTAGE', // 百分比
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取手续费配置响应 DTO
|
||||
*/
|
||||
export class WithdrawalFeeConfigResponseDTO {
|
||||
@ApiProperty({ description: '费率类型', enum: FeeType, example: FeeType.FIXED })
|
||||
feeType: FeeType
|
||||
|
||||
@ApiProperty({ description: '费率值 (固定金额或百分比)', example: 2 })
|
||||
feeValue: number
|
||||
|
||||
@ApiProperty({ description: '最小手续费 (百分比模式)', example: 0 })
|
||||
minFee: number
|
||||
|
||||
@ApiProperty({ description: '最大手续费 (百分比模式,0表示不限)', example: 0 })
|
||||
maxFee: number
|
||||
|
||||
@ApiProperty({ description: '备注', example: '默认手续费配置', required: false })
|
||||
memo?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算手续费请求 DTO
|
||||
*/
|
||||
export class CalculateFeeRequestDTO {
|
||||
@ApiProperty({ description: '提取金额', example: 1000 })
|
||||
amount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算手续费响应 DTO
|
||||
*/
|
||||
export class CalculateFeeResponseDTO {
|
||||
@ApiProperty({ description: '提取金额', example: 1000 })
|
||||
amount: number
|
||||
|
||||
@ApiProperty({ description: '手续费', example: 2 })
|
||||
fee: number
|
||||
|
||||
@ApiProperty({ description: '发送方总支出 (金额 + 手续费)', example: 1002 })
|
||||
totalRequired: number
|
||||
|
||||
@ApiProperty({ description: '接收方收到金额', example: 1000 })
|
||||
receiverGets: number
|
||||
|
||||
@ApiProperty({ description: '费率类型', enum: FeeType })
|
||||
feeType: FeeType
|
||||
|
||||
@ApiProperty({ description: '费率值', example: 2 })
|
||||
feeValue: number
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './wallet.dto';
|
||||
export * from './ledger.dto';
|
||||
export * from './withdrawal.dto';
|
||||
export * from './fee-config.dto';
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import { DuplicateTransactionError, WalletNotFoundError, OptimisticLockError } f
|
|||
import { WalletCacheService } from '@/infrastructure/redis';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka';
|
||||
import { WithdrawalRequestedEvent } from '@/domain/events';
|
||||
import { FeeConfigRepositoryImpl } from '@/infrastructure/persistence/repositories';
|
||||
import { FeeType } from '@/api/dto/response';
|
||||
|
||||
export interface WalletDTO {
|
||||
walletId: string;
|
||||
|
|
@ -89,6 +91,7 @@ export class WalletApplicationService {
|
|||
private readonly walletCacheService: WalletCacheService,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly feeConfigRepo: FeeConfigRepositoryImpl,
|
||||
) {}
|
||||
|
||||
// =============== Commands ===============
|
||||
|
|
@ -1252,11 +1255,6 @@ export class WalletApplicationService {
|
|||
|
||||
// =============== Withdrawal ===============
|
||||
|
||||
/**
|
||||
* 提现手续费率 (0.1%)
|
||||
*/
|
||||
private readonly WITHDRAWAL_FEE_RATE = 0.001;
|
||||
|
||||
/**
|
||||
* 最小提现金额
|
||||
*/
|
||||
|
|
@ -1266,11 +1264,12 @@ export class WalletApplicationService {
|
|||
* 请求提现
|
||||
*
|
||||
* 流程:
|
||||
* 1. 验证余额是否足够 (金额 + 手续费)
|
||||
* 2. 创建提现订单
|
||||
* 3. 冻结用户余额
|
||||
* 4. 记录流水
|
||||
* 5. 发布事件通知 blockchain-service
|
||||
* 1. 获取动态手续费配置
|
||||
* 2. 验证余额是否足够 (金额 + 手续费)
|
||||
* 3. 创建提现订单
|
||||
* 4. 冻结用户余额
|
||||
* 5. 记录流水
|
||||
* 6. 发布事件通知 blockchain-service
|
||||
*/
|
||||
async requestWithdrawal(command: RequestWithdrawalCommand): Promise<{
|
||||
orderNo: string;
|
||||
|
|
@ -1281,12 +1280,17 @@ export class WalletApplicationService {
|
|||
}> {
|
||||
const userId = BigInt(command.userId);
|
||||
const amount = Money.USDT(command.amount);
|
||||
// 计算手续费 = 金额 * 费率
|
||||
const feeAmount = command.amount * this.WITHDRAWAL_FEE_RATE;
|
||||
|
||||
// 从配置获取动态手续费
|
||||
const { fee: feeAmount, feeType, feeValue } = await this.feeConfigRepo.calculateFee(command.amount);
|
||||
const fee = Money.USDT(feeAmount);
|
||||
const totalRequired = amount.add(fee);
|
||||
|
||||
this.logger.log(`Processing withdrawal request for user ${userId}: ${command.amount} USDT to ${command.toAddress}, fee: ${feeAmount}`);
|
||||
const feeDescription = feeType === FeeType.FIXED
|
||||
? `固定 ${feeValue} 绿积分`
|
||||
: `${(feeValue * 100).toFixed(2)}%`;
|
||||
|
||||
this.logger.log(`Processing withdrawal request for user ${userId}: ${command.amount} USDT to ${command.toAddress}, fee: ${feeAmount} (${feeDescription})`);
|
||||
|
||||
// 验证最小提现金额
|
||||
if (command.amount < this.MIN_WITHDRAWAL_AMOUNT) {
|
||||
|
|
@ -1305,7 +1309,7 @@ export class WalletApplicationService {
|
|||
// 验证余额是否足够
|
||||
if (wallet.balances.usdt.available.lessThan(totalRequired)) {
|
||||
throw new BadRequestException(
|
||||
`余额不足: 需要 ${totalRequired.value} USDT (金额 ${command.amount} + 手续费 ${feeAmount.toFixed(2)}), 当前可用 ${wallet.balances.usdt.available.value} USDT`,
|
||||
`余额不足: 需要 ${totalRequired.value} 绿积分 (金额 ${command.amount} + 手续费 ${feeAmount.toFixed(2)}), 当前可用 ${wallet.balances.usdt.available.value} 绿积分`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1335,7 +1339,7 @@ export class WalletApplicationService {
|
|||
amount: Money.signed(-totalRequired.value, 'USDT'),
|
||||
balanceAfter: wallet.balances.usdt.available,
|
||||
refOrderId: savedOrder.orderNo,
|
||||
memo: `Withdrawal freeze: ${command.amount} USDT + ${feeAmount.toFixed(2)} USDT fee (${this.WITHDRAWAL_FEE_RATE * 100}%)`,
|
||||
memo: `提取冻结: ${command.amount} 绿积分 + ${feeAmount.toFixed(2)} 绿积分手续费 (${feeDescription})`,
|
||||
});
|
||||
await this.ledgerRepo.save(freezeEntry);
|
||||
|
||||
|
|
@ -1347,7 +1351,7 @@ export class WalletApplicationService {
|
|||
walletId: wallet.walletId.toString(),
|
||||
amount: command.amount.toString(),
|
||||
fee: feeAmount.toString(),
|
||||
netAmount: (command.amount - feeAmount).toString(),
|
||||
netAmount: command.amount.toString(), // 接收方收到完整金额,手续费由发送方额外承担
|
||||
assetType: 'USDT',
|
||||
chainType: command.chainType,
|
||||
toAddress: command.toAddress,
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export class WithdrawalOrder {
|
|||
get userId(): UserId { return this._userId; }
|
||||
get amount(): Money { return this._amount; }
|
||||
get fee(): Money { return this._fee; }
|
||||
get netAmount(): Money { return Money.USDT(this._amount.value - this._fee.value); }
|
||||
get netAmount(): Money { return this._amount; } // 接收方收到完整金额,手续费由发送方额外承担
|
||||
get chainType(): ChainType { return this._chainType; }
|
||||
get toAddress(): string { return this._toAddress; }
|
||||
get txHash(): string | null { return this._txHash; }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
SettlementOrderRepositoryImpl,
|
||||
WithdrawalOrderRepositoryImpl,
|
||||
PendingRewardRepositoryImpl,
|
||||
FeeConfigRepositoryImpl,
|
||||
} from './persistence/repositories';
|
||||
import {
|
||||
WALLET_ACCOUNT_REPOSITORY,
|
||||
|
|
@ -45,12 +46,13 @@ const repositories = [
|
|||
provide: PENDING_REWARD_REPOSITORY,
|
||||
useClass: PendingRewardRepositoryImpl,
|
||||
},
|
||||
FeeConfigRepositoryImpl,
|
||||
];
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [RedisModule, KafkaModule, IdentityModule],
|
||||
providers: [PrismaService, ...repositories],
|
||||
exports: [PrismaService, RedisModule, KafkaModule, IdentityModule, ...repositories],
|
||||
exports: [PrismaService, RedisModule, KafkaModule, IdentityModule, FeeConfigRepositoryImpl, ...repositories],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
import { FeeType, WithdrawalFeeConfigResponseDTO } from '@/api/dto/response';
|
||||
|
||||
/**
|
||||
* 默认手续费配置 (当数据库无配置时使用)
|
||||
*/
|
||||
const DEFAULT_FEE_CONFIG: WithdrawalFeeConfigResponseDTO = {
|
||||
feeType: FeeType.FIXED,
|
||||
feeValue: 2, // 固定 2 绿积分
|
||||
minFee: 0,
|
||||
maxFee: 0,
|
||||
memo: '默认配置:固定 2 绿积分/笔',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FeeConfigRepositoryImpl {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 获取当前生效的手续费配置
|
||||
* 如果没有配置,返回默认配置 (固定 2 绿积分/笔)
|
||||
*/
|
||||
async getActiveConfig(): Promise<WithdrawalFeeConfigResponseDTO> {
|
||||
const config = await this.prisma.withdrawalFeeConfig.findFirst({
|
||||
where: { isActive: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
return DEFAULT_FEE_CONFIG;
|
||||
}
|
||||
|
||||
return {
|
||||
feeType: config.feeType as FeeType,
|
||||
feeValue: Number(config.feeValue),
|
||||
minFee: Number(config.minFee),
|
||||
maxFee: Number(config.maxFee),
|
||||
memo: config.memo ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算手续费
|
||||
* @param amount 提取金额
|
||||
* @returns 手续费金额
|
||||
*/
|
||||
async calculateFee(amount: number): Promise<{
|
||||
fee: number;
|
||||
feeType: FeeType;
|
||||
feeValue: number;
|
||||
}> {
|
||||
const config = await this.getActiveConfig();
|
||||
let fee: number;
|
||||
|
||||
if (config.feeType === FeeType.FIXED) {
|
||||
// 固定金额
|
||||
fee = config.feeValue;
|
||||
} else {
|
||||
// 百分比
|
||||
fee = amount * config.feeValue;
|
||||
|
||||
// 应用最小/最大限制
|
||||
if (config.minFee > 0 && fee < config.minFee) {
|
||||
fee = config.minFee;
|
||||
}
|
||||
if (config.maxFee > 0 && fee > config.maxFee) {
|
||||
fee = config.maxFee;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fee,
|
||||
feeType: config.feeType,
|
||||
feeValue: config.feeValue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -4,3 +4,4 @@ export * from './deposit-order.repository.impl';
|
|||
export * from './settlement-order.repository.impl';
|
||||
export * from './withdrawal-order.repository.impl';
|
||||
export * from './pending-reward.repository.impl';
|
||||
export * from './fee-config.repository.impl';
|
||||
|
|
|
|||
|
|
@ -571,6 +571,74 @@ class AccountService {
|
|||
}
|
||||
}
|
||||
|
||||
/// 发送重置密码短信验证码 (POST /user/send-reset-password-sms)
|
||||
///
|
||||
/// 用于找回密码功能
|
||||
Future<void> sendResetPasswordSmsCode(String phoneNumber) async {
|
||||
debugPrint('$_tag sendResetPasswordSmsCode() - 发送重置密码验证码');
|
||||
debugPrint('$_tag sendResetPasswordSmsCode() - 手机号: $phoneNumber');
|
||||
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
'/user/send-reset-password-sms',
|
||||
data: {'phoneNumber': phoneNumber},
|
||||
);
|
||||
|
||||
debugPrint('$_tag sendResetPasswordSmsCode() - API 响应状态码: ${response.statusCode}');
|
||||
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw ApiException('发送验证码失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
debugPrint('$_tag sendResetPasswordSmsCode() - 验证码发送成功');
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('$_tag sendResetPasswordSmsCode() - API 异常: $e');
|
||||
rethrow;
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('$_tag sendResetPasswordSmsCode() - 未知异常: $e');
|
||||
debugPrint('$_tag sendResetPasswordSmsCode() - 堆栈: $stackTrace');
|
||||
throw ApiException('发送验证码失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置密码 (POST /user/reset-password)
|
||||
///
|
||||
/// 用于找回密码功能,通过手机号+验证码重置密码
|
||||
Future<void> resetPassword({
|
||||
required String phoneNumber,
|
||||
required String smsCode,
|
||||
required String newPassword,
|
||||
}) async {
|
||||
debugPrint('$_tag resetPassword() - 开始重置密码');
|
||||
debugPrint('$_tag resetPassword() - 手机号: $phoneNumber');
|
||||
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
'/user/reset-password',
|
||||
data: {
|
||||
'phoneNumber': phoneNumber,
|
||||
'smsCode': smsCode,
|
||||
'newPassword': newPassword,
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('$_tag resetPassword() - API 响应状态码: ${response.statusCode}');
|
||||
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw ApiException('重置密码失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
debugPrint('$_tag resetPassword() - 密码重置成功');
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('$_tag resetPassword() - API 异常: $e');
|
||||
rethrow;
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('$_tag resetPassword() - 未知异常: $e');
|
||||
debugPrint('$_tag resetPassword() - 堆栈: $stackTrace');
|
||||
throw ApiException('重置密码失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动重试钱包生成 (POST /user/wallet/retry)
|
||||
///
|
||||
/// 当钱包生成失败或超时时,用户可手动触发重试
|
||||
|
|
|
|||
|
|
@ -343,16 +343,40 @@ extension SelfApplyAuthorizationTypeExtension on SelfApplyAuthorizationType {
|
|||
}
|
||||
}
|
||||
|
||||
/// 团队链中授权持有者信息
|
||||
class TeamChainAuthorizationHolder {
|
||||
final String accountSequence;
|
||||
final String nickname;
|
||||
|
||||
TeamChainAuthorizationHolder({
|
||||
required this.accountSequence,
|
||||
required this.nickname,
|
||||
});
|
||||
|
||||
factory TeamChainAuthorizationHolder.fromJson(Map<String, dynamic> json) {
|
||||
return TeamChainAuthorizationHolder(
|
||||
accountSequence: json['accountSequence'] ?? '',
|
||||
nickname: json['nickname'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户授权状态响应
|
||||
class UserAuthorizationStatusResponse {
|
||||
final bool hasPlanted;
|
||||
final int plantedCount;
|
||||
final List<String> existingAuthorizations;
|
||||
/// 团队链中市团队授权持有者(如有)
|
||||
final TeamChainAuthorizationHolder? teamChainCityTeamHolder;
|
||||
/// 团队链中省团队授权持有者(如有)
|
||||
final TeamChainAuthorizationHolder? teamChainProvinceTeamHolder;
|
||||
|
||||
UserAuthorizationStatusResponse({
|
||||
required this.hasPlanted,
|
||||
required this.plantedCount,
|
||||
required this.existingAuthorizations,
|
||||
this.teamChainCityTeamHolder,
|
||||
this.teamChainProvinceTeamHolder,
|
||||
});
|
||||
|
||||
factory UserAuthorizationStatusResponse.fromJson(Map<String, dynamic> json) {
|
||||
|
|
@ -362,6 +386,12 @@ 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,60 @@ class ClaimRewardsResponse {
|
|||
}
|
||||
}
|
||||
|
||||
/// 手续费类型
|
||||
enum FeeType {
|
||||
fixed, // 固定金额
|
||||
percentage, // 百分比
|
||||
}
|
||||
|
||||
/// 手续费配置
|
||||
class FeeConfig {
|
||||
final FeeType feeType;
|
||||
final double feeValue;
|
||||
final double minFee;
|
||||
final double maxFee;
|
||||
final String? memo;
|
||||
|
||||
FeeConfig({
|
||||
required this.feeType,
|
||||
required this.feeValue,
|
||||
required this.minFee,
|
||||
required this.maxFee,
|
||||
this.memo,
|
||||
});
|
||||
|
||||
factory FeeConfig.fromJson(Map<String, dynamic> json) {
|
||||
return FeeConfig(
|
||||
feeType: json['feeType'] == 'FIXED' ? FeeType.fixed : FeeType.percentage,
|
||||
feeValue: (json['feeValue'] ?? 2).toDouble(),
|
||||
minFee: (json['minFee'] ?? 0).toDouble(),
|
||||
maxFee: (json['maxFee'] ?? 0).toDouble(),
|
||||
memo: json['memo'],
|
||||
);
|
||||
}
|
||||
|
||||
/// 计算手续费
|
||||
double calculateFee(double amount) {
|
||||
if (feeType == FeeType.fixed) {
|
||||
return feeValue;
|
||||
} else {
|
||||
double fee = amount * feeValue;
|
||||
if (minFee > 0 && fee < minFee) fee = minFee;
|
||||
if (maxFee > 0 && fee > maxFee) fee = maxFee;
|
||||
return fee;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取费率描述
|
||||
String get description {
|
||||
if (feeType == FeeType.fixed) {
|
||||
return '固定 ${feeValue.toStringAsFixed(0)} 绿积分/笔';
|
||||
} else {
|
||||
return '${(feeValue * 100).toStringAsFixed(2)}%';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 钱包服务
|
||||
///
|
||||
/// 提供钱包余额查询、奖励领取等功能
|
||||
|
|
@ -518,6 +572,53 @@ class WalletService {
|
|||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// =============== 手续费配置相关 API ===============
|
||||
|
||||
/// 获取提取手续费配置
|
||||
///
|
||||
/// 调用 GET /wallet/fee-config (wallet-service)
|
||||
Future<FeeConfig> getFeeConfig() async {
|
||||
try {
|
||||
debugPrint('[WalletService] ========== 获取手续费配置 ==========');
|
||||
debugPrint('[WalletService] 请求: GET /wallet/fee-config');
|
||||
|
||||
final response = await _apiClient.get('/wallet/fee-config');
|
||||
|
||||
debugPrint('[WalletService] 响应状态码: ${response.statusCode}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final responseData = response.data as Map<String, dynamic>;
|
||||
final data = responseData['data'] as Map<String, dynamic>? ?? responseData;
|
||||
final result = FeeConfig.fromJson(data);
|
||||
debugPrint('[WalletService] 获取成功: ${result.description}');
|
||||
debugPrint('[WalletService] ================================');
|
||||
return result;
|
||||
}
|
||||
|
||||
// 如果请求失败,返回默认配置
|
||||
debugPrint('[WalletService] 获取失败,使用默认配置');
|
||||
return FeeConfig(
|
||||
feeType: FeeType.fixed,
|
||||
feeValue: 2,
|
||||
minFee: 0,
|
||||
maxFee: 0,
|
||||
memo: '默认配置',
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[WalletService] !!!!!!!!!! 获取手续费配置异常 !!!!!!!!!!');
|
||||
debugPrint('[WalletService] 错误: $e');
|
||||
debugPrint('[WalletService] 堆栈: $stackTrace');
|
||||
// 返回默认配置,不抛出异常
|
||||
return FeeConfig(
|
||||
feeType: FeeType.fixed,
|
||||
feeValue: 2,
|
||||
minFee: 0,
|
||||
maxFee: 0,
|
||||
memo: '默认配置 (请求失败)',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取响应
|
||||
|
|
|
|||
|
|
@ -0,0 +1,556 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
|
||||
/// 找回密码页面
|
||||
/// 通过手机号+短信验证码重置密码
|
||||
class ForgotPasswordPage extends ConsumerStatefulWidget {
|
||||
const ForgotPasswordPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
|
||||
}
|
||||
|
||||
class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
final TextEditingController _smsCodeController = TextEditingController();
|
||||
final TextEditingController _newPasswordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController = TextEditingController();
|
||||
|
||||
bool _isSubmitting = false;
|
||||
bool _isSendingSms = false;
|
||||
int _countdown = 0;
|
||||
String? _errorMessage;
|
||||
bool _obscureNewPassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_smsCodeController.dispose();
|
||||
_newPasswordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 验证手机号格式
|
||||
bool _isValidPhoneNumber(String phone) {
|
||||
final regex = RegExp(r'^1[3-9]\d{9}$');
|
||||
return regex.hasMatch(phone);
|
||||
}
|
||||
|
||||
/// 发送短信验证码
|
||||
Future<void> _sendSmsCode() async {
|
||||
final phone = _phoneController.text.trim();
|
||||
|
||||
if (phone.isEmpty) {
|
||||
setState(() => _errorMessage = '请输入手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isValidPhoneNumber(phone)) {
|
||||
setState(() => _errorMessage = '请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSendingSms = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
await accountService.sendResetPasswordSmsCode(phone);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSendingSms = false;
|
||||
_countdown = 60;
|
||||
});
|
||||
_startCountdown();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('验证码已发送'),
|
||||
backgroundColor: Color(0xFF4CAF50),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[ForgotPasswordPage] 发送验证码失败: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSendingSms = false;
|
||||
_errorMessage = '发送验证码失败,请稍后重试';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 开始倒计时
|
||||
void _startCountdown() {
|
||||
Future.doWhile(() async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (!mounted || _countdown <= 0) return false;
|
||||
setState(() {
|
||||
_countdown--;
|
||||
});
|
||||
return _countdown > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/// 验证输入
|
||||
String? _validateInputs() {
|
||||
final phone = _phoneController.text.trim();
|
||||
final smsCode = _smsCodeController.text.trim();
|
||||
final newPassword = _newPasswordController.text;
|
||||
final confirmPassword = _confirmPasswordController.text;
|
||||
|
||||
if (phone.isEmpty) return '请输入手机号';
|
||||
if (!_isValidPhoneNumber(phone)) return '请输入正确的手机号';
|
||||
if (smsCode.isEmpty) return '请输入验证码';
|
||||
if (smsCode.length != 6) return '验证码为6位数字';
|
||||
if (newPassword.isEmpty) return '请输入新密码';
|
||||
if (newPassword.length < 6) return '密码至少6位';
|
||||
if (confirmPassword.isEmpty) return '请确认新密码';
|
||||
if (newPassword != confirmPassword) return '两次输入的密码不一致';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 重置密码
|
||||
Future<void> _resetPassword() async {
|
||||
final validationError = _validateInputs();
|
||||
if (validationError != null) {
|
||||
setState(() => _errorMessage = validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final phone = _phoneController.text.trim();
|
||||
final smsCode = _smsCodeController.text.trim();
|
||||
final newPassword = _newPasswordController.text;
|
||||
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
await accountService.resetPassword(
|
||||
phoneNumber: phone,
|
||||
smsCode: smsCode,
|
||||
newPassword: newPassword,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('密码重置成功,请重新登录'),
|
||||
backgroundColor: Color(0xFF4CAF50),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
context.pop();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[ForgotPasswordPage] 重置密码失败: $e');
|
||||
setState(() {
|
||||
_errorMessage = '重置密码失败: $e';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFFFF5E6),
|
||||
Color(0xFFEAE0C8),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildAppBar(),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(height: 32.h),
|
||||
_buildPhoneInput(),
|
||||
SizedBox(height: 16.h),
|
||||
_buildSmsCodeInput(),
|
||||
SizedBox(height: 16.h),
|
||||
_buildNewPasswordInput(),
|
||||
SizedBox(height: 16.h),
|
||||
_buildConfirmPasswordInput(),
|
||||
if (_errorMessage != null) ...[
|
||||
SizedBox(height: 16.h),
|
||||
_buildErrorMessage(),
|
||||
],
|
||||
SizedBox(height: 32.h),
|
||||
_buildSubmitButton(),
|
||||
SizedBox(height: 32.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar() {
|
||||
return Container(
|
||||
height: 56.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios, color: Color(0xFF5D4037)),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'找回密码',
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF5D4037),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 48.w),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhoneInput() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'手机号',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF5D4037),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.h),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFFD4A574), width: 1),
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(11),
|
||||
],
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF5D4037),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入手机号',
|
||||
hintStyle: TextStyle(fontSize: 16.sp, color: const Color(0xFFB8956A)),
|
||||
border: InputBorder.none,
|
||||
isDense: false,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_errorMessage != null) setState(() => _errorMessage = null);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSmsCodeInput() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'验证码',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF5D4037),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.h),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFFD4A574), width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _smsCodeController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
],
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF5D4037),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入验证码',
|
||||
hintStyle: TextStyle(fontSize: 16.sp, color: const Color(0xFFB8956A)),
|
||||
border: InputBorder.none,
|
||||
isDense: false,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_errorMessage != null) setState(() => _errorMessage = null);
|
||||
},
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: (_isSendingSms || _countdown > 0) ? null : _sendSmsCode,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
|
||||
decoration: BoxDecoration(
|
||||
color: (_isSendingSms || _countdown > 0)
|
||||
? const Color(0xFFD4C4B0)
|
||||
: const Color(0xFF8B5A2B),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Text(
|
||||
_isSendingSms
|
||||
? '发送中...'
|
||||
: _countdown > 0
|
||||
? '${_countdown}s'
|
||||
: '获取验证码',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNewPasswordInput() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'新密码',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF5D4037),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.h),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFFD4A574), width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _newPasswordController,
|
||||
obscureText: _obscureNewPassword,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF5D4037),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入新密码(至少6位)',
|
||||
hintStyle: TextStyle(fontSize: 16.sp, color: const Color(0xFFB8956A)),
|
||||
border: InputBorder.none,
|
||||
isDense: false,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_errorMessage != null) setState(() => _errorMessage = null);
|
||||
},
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _obscureNewPassword = !_obscureNewPassword),
|
||||
child: Icon(
|
||||
_obscureNewPassword ? Icons.visibility_off : Icons.visibility,
|
||||
color: const Color(0xFF8B6F47),
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfirmPasswordInput() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'确认新密码',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF5D4037),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.h),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFFD4A574), width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _obscureConfirmPassword,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF5D4037),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '请再次输入新密码',
|
||||
hintStyle: TextStyle(fontSize: 16.sp, color: const Color(0xFFB8956A)),
|
||||
border: InputBorder.none,
|
||||
isDense: false,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_errorMessage != null) setState(() => _errorMessage = null);
|
||||
},
|
||||
onSubmitted: (_) => _resetPassword(),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
|
||||
child: Icon(
|
||||
_obscureConfirmPassword ? Icons.visibility_off : Icons.visibility,
|
||||
color: const Color(0xFF8B6F47),
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorMessage() {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(color: Colors.red.withOpacity(0.3), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Colors.red, size: 16.sp),
|
||||
SizedBox(width: 8.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(fontSize: 14.sp, color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubmitButton() {
|
||||
final canSubmit = !_isSubmitting &&
|
||||
_phoneController.text.trim().isNotEmpty &&
|
||||
_smsCodeController.text.trim().isNotEmpty &&
|
||||
_newPasswordController.text.isNotEmpty &&
|
||||
_confirmPasswordController.text.isNotEmpty;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50.h,
|
||||
child: ElevatedButton(
|
||||
onPressed: canSubmit ? _resetPassword : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: canSubmit ? const Color(0xFF8B5A2B) : const Color(0xFFD4C4B0),
|
||||
disabledBackgroundColor: const Color(0xFFD4C4B0),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.r)),
|
||||
elevation: 0,
|
||||
),
|
||||
child: _isSubmitting
|
||||
? SizedBox(
|
||||
width: 20.w,
|
||||
height: 20.h,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'重置密码',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: canSubmit ? Colors.white : const Color(0xFFB8956A),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -690,10 +690,10 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
|
|||
],
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
// 恢复账号入口(手机号+密码登录)
|
||||
// 登录账号入口(手机号+密码登录)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
debugPrint('[GuidePage] 跳转到恢复账号页面');
|
||||
debugPrint('[GuidePage] 跳转到登录账号页面');
|
||||
context.push(RoutePaths.phoneLogin);
|
||||
},
|
||||
child: Container(
|
||||
|
|
@ -717,7 +717,7 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
|
|||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
'恢复账号',
|
||||
'登录账号',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import '../../../../routes/route_paths.dart';
|
|||
import '../providers/auth_provider.dart';
|
||||
|
||||
/// 手机号+密码登录页面
|
||||
/// 用于"恢复账号"功能
|
||||
/// 用于"登录账号"功能
|
||||
class PhoneLoginPage extends ConsumerStatefulWidget {
|
||||
const PhoneLoginPage({super.key});
|
||||
|
||||
|
|
@ -159,12 +159,15 @@ class _PhoneLoginPageState extends ConsumerState<PhoneLoginPage> {
|
|||
SizedBox(height: 16.h),
|
||||
// 密码输入框
|
||||
_buildPasswordInput(),
|
||||
SizedBox(height: 12.h),
|
||||
// 找回密码
|
||||
_buildForgotPassword(),
|
||||
// 错误提示
|
||||
if (_errorMessage != null) ...[
|
||||
SizedBox(height: 16.h),
|
||||
_buildErrorMessage(),
|
||||
],
|
||||
SizedBox(height: 32.h),
|
||||
SizedBox(height: 24.h),
|
||||
// 登录按钮
|
||||
_buildLoginButton(),
|
||||
SizedBox(height: 24.h),
|
||||
|
|
@ -199,7 +202,7 @@ class _PhoneLoginPageState extends ConsumerState<PhoneLoginPage> {
|
|||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'恢复账号',
|
||||
'登录账号',
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
|
@ -346,6 +349,27 @@ class _PhoneLoginPageState extends ConsumerState<PhoneLoginPage> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 构建找回密码链接
|
||||
Widget _buildForgotPassword() {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
debugPrint('[PhoneLoginPage] 跳转到找回密码页面');
|
||||
context.push(RoutePaths.forgotPassword);
|
||||
},
|
||||
child: Text(
|
||||
'找回密码',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF8B5A2B),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建错误提示
|
||||
Widget _buildErrorMessage() {
|
||||
return Container(
|
||||
|
|
|
|||
|
|
@ -30,11 +30,11 @@ extension AuthorizationTypeExtension on AuthorizationType {
|
|||
String get description {
|
||||
switch (this) {
|
||||
case AuthorizationType.community:
|
||||
return '社区权益 - 576 CNY/棵';
|
||||
return '社区权益 - 576 绿积分/棵';
|
||||
case AuthorizationType.cityTeam:
|
||||
return '市团队权益 - 288 CNY/棵';
|
||||
return '市团队权益 - 288 绿积分/棵';
|
||||
case AuthorizationType.provinceTeam:
|
||||
return '省团队权益 - 144 CNY/棵';
|
||||
return '省团队权益 - 144 绿积分/棵';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +80,12 @@ class _AuthorizationApplyPageState
|
|||
/// 用户已有的授权
|
||||
List<String> _existingAuthorizations = [];
|
||||
|
||||
/// 团队链中市团队授权持有者
|
||||
TeamChainAuthorizationHolder? _teamChainCityTeamHolder;
|
||||
|
||||
/// 团队链中省团队授权持有者
|
||||
TeamChainAuthorizationHolder? _teamChainProvinceTeamHolder;
|
||||
|
||||
/// 保存的省市信息(来自认种时选择)
|
||||
String? _savedProvinceName;
|
||||
String? _savedProvinceCode;
|
||||
|
|
@ -132,6 +138,8 @@ class _AuthorizationApplyPageState
|
|||
_hasPlanted = status.hasPlanted;
|
||||
_plantedCount = status.plantedCount;
|
||||
_existingAuthorizations = status.existingAuthorizations;
|
||||
_teamChainCityTeamHolder = status.teamChainCityTeamHolder;
|
||||
_teamChainProvinceTeamHolder = status.teamChainProvinceTeamHolder;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
|
@ -847,8 +855,20 @@ 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;
|
||||
}
|
||||
final isTeamChainBlocked = teamChainHolder != null;
|
||||
|
||||
// 综合判断是否禁用
|
||||
final isDisabled = isAlreadyHas || isTeamChainBlocked;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isAlreadyHas
|
||||
onTap: isDisabled
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
|
|
@ -860,7 +880,7 @@ class _AuthorizationApplyPageState
|
|||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isAlreadyHas
|
||||
color: isDisabled
|
||||
? const Color(0xFFE0E0E0)
|
||||
: isSelected
|
||||
? const Color(0xFFD4AF37).withValues(alpha: 0.1)
|
||||
|
|
@ -882,7 +902,7 @@ class _AuthorizationApplyPageState
|
|||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isAlreadyHas
|
||||
color: isDisabled
|
||||
? const Color(0xFF9E9E9E)
|
||||
: isSelected
|
||||
? const Color(0xFFD4AF37)
|
||||
|
|
@ -911,7 +931,7 @@ class _AuthorizationApplyPageState
|
|||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isAlreadyHas
|
||||
color: isDisabled
|
||||
? const Color(0xFF9E9E9E)
|
||||
: const Color(0xFF5D4037),
|
||||
),
|
||||
|
|
@ -922,11 +942,23 @@ class _AuthorizationApplyPageState
|
|||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
color: isAlreadyHas
|
||||
color: isDisabled
|
||||
? const Color(0xFFBDBDBD)
|
||||
: const Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
// 显示团队链授权持有者信息
|
||||
if (isTeamChainBlocked) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'团队中已有人拥有此权益:${teamChainHolder!.nickname}(${teamChainHolder.accountSequence})',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFFFF9800),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -946,6 +978,22 @@ class _AuthorizationApplyPageState
|
|||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (isTeamChainBlocked)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF9800),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'不可申请',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter',
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -575,7 +575,7 @@ class _MiningPageState extends ConsumerState<MiningPage> {
|
|||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'视频流功能开发中...',
|
||||
'待开启...',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:go_router/go_router.dart';
|
||||
import 'withdraw_usdt_page.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/wallet_service.dart';
|
||||
|
||||
/// 提取确认页面
|
||||
/// 显示提取详情并进行谷歌验证器验证
|
||||
|
|
@ -50,13 +51,36 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
|
|||
/// 用户手机号(脱敏显示)
|
||||
String? _maskedPhoneNumber;
|
||||
|
||||
/// 手续费率
|
||||
final double _feeRate = 0.001; // 0.1%
|
||||
/// 手续费配置
|
||||
FeeConfig? _feeConfig;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadUserPhone();
|
||||
_loadInitData();
|
||||
}
|
||||
|
||||
/// 加载初始数据(手机号和手续费配置)
|
||||
Future<void> _loadInitData() async {
|
||||
await Future.wait([
|
||||
_loadUserPhone(),
|
||||
_loadFeeConfig(),
|
||||
]);
|
||||
}
|
||||
|
||||
/// 加载手续费配置
|
||||
Future<void> _loadFeeConfig() async {
|
||||
try {
|
||||
final walletService = ref.read(walletServiceProvider);
|
||||
final feeConfig = await walletService.getFeeConfig();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_feeConfig = feeConfig;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[WithdrawConfirmPage] 加载手续费配置失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载用户手机号
|
||||
|
|
@ -156,12 +180,16 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
|
|||
|
||||
/// 计算手续费
|
||||
double _calculateFee() {
|
||||
return widget.params.amount * _feeRate;
|
||||
if (_feeConfig != null) {
|
||||
return _feeConfig!.calculateFee(widget.params.amount);
|
||||
}
|
||||
// 默认固定 2 绿积分
|
||||
return 2;
|
||||
}
|
||||
|
||||
/// 计算实际到账
|
||||
/// 计算实际到账(接收方收到完整金额,手续费由发送方额外承担)
|
||||
double _calculateActualAmount() {
|
||||
return widget.params.amount - _calculateFee();
|
||||
return widget.params.amount;
|
||||
}
|
||||
|
||||
/// 获取网络名称
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
|||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/wallet_service.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
|
||||
/// 提款网络枚举
|
||||
|
|
@ -52,8 +53,8 @@ class _WithdrawUsdtPageState extends ConsumerState<WithdrawUsdtPage> {
|
|||
/// 钱包是否已就绪
|
||||
bool _isWalletReady = false;
|
||||
|
||||
/// 手续费率
|
||||
final double _feeRate = 0.001; // 0.1%
|
||||
/// 手续费配置
|
||||
FeeConfig? _feeConfig;
|
||||
|
||||
/// 最小提款金额
|
||||
final double _minAmount = 100.0;
|
||||
|
|
@ -103,18 +104,28 @@ class _WithdrawUsdtPageState extends ConsumerState<WithdrawUsdtPage> {
|
|||
_isWalletReady = false;
|
||||
}
|
||||
|
||||
// 如果钱包已就绪,查询余额
|
||||
// 如果钱包已就绪,查询余额和手续费配置
|
||||
if (_isWalletReady) {
|
||||
try {
|
||||
final walletService = ref.read(walletServiceProvider);
|
||||
final wallet = await walletService.getMyWallet();
|
||||
|
||||
// 并行加载余额和手续费配置
|
||||
final results = await Future.wait([
|
||||
walletService.getMyWallet(),
|
||||
walletService.getFeeConfig(),
|
||||
]);
|
||||
|
||||
final wallet = results[0] as WalletResponse;
|
||||
final feeConfig = results[1] as FeeConfig;
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_usdtBalance = wallet.balances.usdt.available;
|
||||
_feeConfig = feeConfig;
|
||||
_isLoading = false;
|
||||
});
|
||||
debugPrint('[WithdrawUsdtPage] USDT 余额: $_usdtBalance');
|
||||
debugPrint('[WithdrawUsdtPage] 手续费配置: ${feeConfig.description}');
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[WithdrawUsdtPage] 加载余额失败: $e');
|
||||
|
|
@ -165,13 +176,17 @@ class _WithdrawUsdtPageState extends ConsumerState<WithdrawUsdtPage> {
|
|||
/// 计算手续费
|
||||
double _calculateFee() {
|
||||
final amount = double.tryParse(_amountController.text) ?? 0;
|
||||
return amount * _feeRate;
|
||||
if (_feeConfig != null) {
|
||||
return _feeConfig!.calculateFee(amount);
|
||||
}
|
||||
// 默认固定 2 绿积分
|
||||
return 2;
|
||||
}
|
||||
|
||||
/// 计算实际到账
|
||||
/// 计算实际到账(接收方收到完整金额,手续费由发送方额外承担)
|
||||
double _calculateActualAmount() {
|
||||
final amount = double.tryParse(_amountController.text) ?? 0;
|
||||
return amount - _calculateFee();
|
||||
return amount;
|
||||
}
|
||||
|
||||
/// 验证并提交
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import '../features/auth/presentation/pages/wallet_created_page.dart';
|
|||
import '../features/auth/presentation/pages/import_mnemonic_page.dart';
|
||||
import '../features/auth/presentation/pages/phone_register_page.dart';
|
||||
import '../features/auth/presentation/pages/phone_login_page.dart';
|
||||
import '../features/auth/presentation/pages/forgot_password_page.dart';
|
||||
import '../features/auth/presentation/pages/sms_verify_page.dart';
|
||||
import '../features/auth/presentation/pages/set_password_page.dart';
|
||||
import '../features/home/presentation/pages/home_shell_page.dart';
|
||||
|
|
@ -155,6 +156,15 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
},
|
||||
),
|
||||
|
||||
// Forgot Password (找回密码)
|
||||
GoRoute(
|
||||
path: RoutePaths.forgotPassword,
|
||||
name: RouteNames.forgotPassword,
|
||||
builder: (context, state) {
|
||||
return const ForgotPasswordPage();
|
||||
},
|
||||
),
|
||||
|
||||
// SMS Verify (短信验证码)
|
||||
GoRoute(
|
||||
path: RoutePaths.smsVerify,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class RouteNames {
|
|||
static const importMnemonic = 'import-mnemonic';
|
||||
static const phoneRegister = 'phone-register';
|
||||
static const phoneLogin = 'phone-login';
|
||||
static const forgotPassword = 'forgot-password';
|
||||
static const smsVerify = 'sms-verify';
|
||||
static const setPassword = 'set-password';
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class RoutePaths {
|
|||
static const importMnemonic = '/auth/import-mnemonic';
|
||||
static const phoneRegister = '/auth/phone-register';
|
||||
static const phoneLogin = '/auth/phone-login';
|
||||
static const forgotPassword = '/auth/forgot-password';
|
||||
static const smsVerify = '/auth/sms-verify';
|
||||
static const setPassword = '/auth/set-password';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue