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
|
benefitsActivated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 团队链中已有授权的持有者信息
|
||||||
|
*/
|
||||||
|
export class TeamChainAuthorizationHolderDto {
|
||||||
|
@ApiProperty({ description: '持有者账号序号' })
|
||||||
|
accountSequence: string
|
||||||
|
|
||||||
|
@ApiProperty({ description: '持有者昵称' })
|
||||||
|
nickname: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户授权状态响应 DTO
|
* 用户授权状态响应 DTO
|
||||||
*/
|
*/
|
||||||
|
|
@ -106,4 +117,10 @@ export class UserAuthorizationStatusResponseDto {
|
||||||
|
|
||||||
@ApiProperty({ description: '已拥有的授权类型列表' })
|
@ApiProperty({ description: '已拥有的授权类型列表' })
|
||||||
existingAuthorizations: string[]
|
existingAuthorizations: string[]
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '团队链中市团队授权持有者(如有)' })
|
||||||
|
teamChainCityTeamHolder?: TeamChainAuthorizationHolderDto
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '团队链中省团队授权持有者(如有)' })
|
||||||
|
teamChainProvinceTeamHolder?: TeamChainAuthorizationHolderDto
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import {
|
||||||
SelfApplyAuthorizationResponseDto,
|
SelfApplyAuthorizationResponseDto,
|
||||||
UserAuthorizationStatusResponseDto,
|
UserAuthorizationStatusResponseDto,
|
||||||
SelfApplyAuthorizationType,
|
SelfApplyAuthorizationType,
|
||||||
|
TeamChainAuthorizationHolderDto,
|
||||||
} 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'
|
||||||
|
|
||||||
|
|
@ -2785,10 +2786,22 @@ 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. 检查团队链中是否有市团队/省团队授权持有者
|
||||||
|
const teamChainCityTeamHolder = await this.findTeamChainAuthorizationHolder(
|
||||||
|
accountSequence,
|
||||||
|
RoleType.AUTH_CITY_COMPANY,
|
||||||
|
)
|
||||||
|
const teamChainProvinceTeamHolder = await this.findTeamChainAuthorizationHolder(
|
||||||
|
accountSequence,
|
||||||
|
RoleType.AUTH_PROVINCE_COMPANY,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasPlanted,
|
hasPlanted,
|
||||||
plantedCount,
|
plantedCount,
|
||||||
existingAuthorizations,
|
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 {
|
private mapRoleTypeToDisplayName(roleType: RoleType): string {
|
||||||
const mapping: Record<RoleType, string> = {
|
const mapping: Record<RoleType, string> = {
|
||||||
[RoleType.COMMUNITY]: '社区',
|
[RoleType.COMMUNITY]: '社区',
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ import {
|
||||||
VerifySmsCodeDto,
|
VerifySmsCodeDto,
|
||||||
SetPasswordDto,
|
SetPasswordDto,
|
||||||
LoginWithPasswordDto,
|
LoginWithPasswordDto,
|
||||||
|
ResetPasswordDto,
|
||||||
} from '@/api/dto';
|
} from '@/api/dto';
|
||||||
|
|
||||||
@ApiTags('User')
|
@ApiTags('User')
|
||||||
|
|
@ -189,12 +190,40 @@ export class UserAccountController {
|
||||||
new VerifySmsCodeCommand(
|
new VerifySmsCodeCommand(
|
||||||
dto.phoneNumber,
|
dto.phoneNumber,
|
||||||
dto.smsCode,
|
dto.smsCode,
|
||||||
dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER',
|
dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER' | 'RESET_PASSWORD',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return { message: '验证成功' };
|
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()
|
@Public()
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@ApiOperation({ summary: '用户注册(手机号)' })
|
@ApiOperation({ summary: '用户注册(手机号)' })
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,26 @@ export class SendSmsCodeDto {
|
||||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' })
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'] })
|
@ApiProperty({ enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER', 'RESET_PASSWORD'] })
|
||||||
@IsEnum(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'])
|
@IsEnum(['REGISTER', 'LOGIN', 'BIND', 'RECOVER', 'RESET_PASSWORD'])
|
||||||
type: 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER';
|
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 {
|
export class RegisterDto {
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,10 @@ export class VerifySmsCodeDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'REGISTER',
|
example: 'REGISTER',
|
||||||
description: '验证码类型',
|
description: '验证码类型',
|
||||||
enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER'],
|
enum: ['REGISTER', 'LOGIN', 'BIND', 'RECOVER', 'RESET_PASSWORD'],
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['REGISTER', 'LOGIN', 'BIND', 'RECOVER'], {
|
@IsIn(['REGISTER', 'LOGIN', 'BIND', 'RECOVER', 'RESET_PASSWORD'], {
|
||||||
message: '无效的验证码类型',
|
message: '无效的验证码类型',
|
||||||
})
|
})
|
||||||
type: string;
|
type: string;
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ export class RemoveDeviceCommand {
|
||||||
export class SendSmsCodeCommand {
|
export class SendSmsCodeCommand {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly phoneNumber: string,
|
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(
|
constructor(
|
||||||
public readonly phoneNumber: string,
|
public readonly phoneNumber: string,
|
||||||
public readonly smsCode: 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;
|
if (phone.length < 7) return phone;
|
||||||
return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4);
|
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])
|
@@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 事件不会被重复处理
|
// 用于确保 Kafka 事件不会被重复处理
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { Controller, Get, Post, Body, UseGuards, Headers, HttpException, HttpStatus } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Query, UseGuards, Headers, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||||
import { WalletApplicationService } from '@/application/services';
|
import { WalletApplicationService } from '@/application/services';
|
||||||
import { GetMyWalletQuery } from '@/application/queries';
|
import { GetMyWalletQuery } from '@/application/queries';
|
||||||
import { ClaimRewardsCommand, SettleRewardsCommand, RequestWithdrawalCommand } from '@/application/commands';
|
import { ClaimRewardsCommand, SettleRewardsCommand, RequestWithdrawalCommand } from '@/application/commands';
|
||||||
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
|
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
|
||||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
import { SettleRewardsDTO, RequestWithdrawalDTO } from '@/api/dto/request';
|
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 { IdentityClientService } from '@/infrastructure/external/identity';
|
||||||
|
import { FeeConfigRepositoryImpl } from '@/infrastructure/persistence/repositories';
|
||||||
|
|
||||||
@ApiTags('Wallet')
|
@ApiTags('Wallet')
|
||||||
@Controller('wallet')
|
@Controller('wallet')
|
||||||
|
|
@ -17,6 +18,7 @@ export class WalletController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly walletService: WalletApplicationService,
|
private readonly walletService: WalletApplicationService,
|
||||||
private readonly identityClient: IdentityClientService,
|
private readonly identityClient: IdentityClientService,
|
||||||
|
private readonly feeConfigRepo: FeeConfigRepositoryImpl,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('my-wallet')
|
@Get('my-wallet')
|
||||||
|
|
@ -184,4 +186,35 @@ export class WalletController {
|
||||||
}>> {
|
}>> {
|
||||||
return this.walletService.getExpiredRewards(user.accountSequence);
|
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 './wallet.dto';
|
||||||
export * from './ledger.dto';
|
export * from './ledger.dto';
|
||||||
export * from './withdrawal.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 { WalletCacheService } from '@/infrastructure/redis';
|
||||||
import { EventPublisherService } from '@/infrastructure/kafka';
|
import { EventPublisherService } from '@/infrastructure/kafka';
|
||||||
import { WithdrawalRequestedEvent } from '@/domain/events';
|
import { WithdrawalRequestedEvent } from '@/domain/events';
|
||||||
|
import { FeeConfigRepositoryImpl } from '@/infrastructure/persistence/repositories';
|
||||||
|
import { FeeType } from '@/api/dto/response';
|
||||||
|
|
||||||
export interface WalletDTO {
|
export interface WalletDTO {
|
||||||
walletId: string;
|
walletId: string;
|
||||||
|
|
@ -89,6 +91,7 @@ export class WalletApplicationService {
|
||||||
private readonly walletCacheService: WalletCacheService,
|
private readonly walletCacheService: WalletCacheService,
|
||||||
private readonly eventPublisher: EventPublisherService,
|
private readonly eventPublisher: EventPublisherService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly feeConfigRepo: FeeConfigRepositoryImpl,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// =============== Commands ===============
|
// =============== Commands ===============
|
||||||
|
|
@ -1252,11 +1255,6 @@ export class WalletApplicationService {
|
||||||
|
|
||||||
// =============== Withdrawal ===============
|
// =============== Withdrawal ===============
|
||||||
|
|
||||||
/**
|
|
||||||
* 提现手续费率 (0.1%)
|
|
||||||
*/
|
|
||||||
private readonly WITHDRAWAL_FEE_RATE = 0.001;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 最小提现金额
|
* 最小提现金额
|
||||||
*/
|
*/
|
||||||
|
|
@ -1266,11 +1264,12 @@ export class WalletApplicationService {
|
||||||
* 请求提现
|
* 请求提现
|
||||||
*
|
*
|
||||||
* 流程:
|
* 流程:
|
||||||
* 1. 验证余额是否足够 (金额 + 手续费)
|
* 1. 获取动态手续费配置
|
||||||
* 2. 创建提现订单
|
* 2. 验证余额是否足够 (金额 + 手续费)
|
||||||
* 3. 冻结用户余额
|
* 3. 创建提现订单
|
||||||
* 4. 记录流水
|
* 4. 冻结用户余额
|
||||||
* 5. 发布事件通知 blockchain-service
|
* 5. 记录流水
|
||||||
|
* 6. 发布事件通知 blockchain-service
|
||||||
*/
|
*/
|
||||||
async requestWithdrawal(command: RequestWithdrawalCommand): Promise<{
|
async requestWithdrawal(command: RequestWithdrawalCommand): Promise<{
|
||||||
orderNo: string;
|
orderNo: string;
|
||||||
|
|
@ -1281,12 +1280,17 @@ export class WalletApplicationService {
|
||||||
}> {
|
}> {
|
||||||
const userId = BigInt(command.userId);
|
const userId = BigInt(command.userId);
|
||||||
const amount = Money.USDT(command.amount);
|
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 fee = Money.USDT(feeAmount);
|
||||||
const totalRequired = amount.add(fee);
|
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) {
|
if (command.amount < this.MIN_WITHDRAWAL_AMOUNT) {
|
||||||
|
|
@ -1305,7 +1309,7 @@ export class WalletApplicationService {
|
||||||
// 验证余额是否足够
|
// 验证余额是否足够
|
||||||
if (wallet.balances.usdt.available.lessThan(totalRequired)) {
|
if (wallet.balances.usdt.available.lessThan(totalRequired)) {
|
||||||
throw new BadRequestException(
|
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'),
|
amount: Money.signed(-totalRequired.value, 'USDT'),
|
||||||
balanceAfter: wallet.balances.usdt.available,
|
balanceAfter: wallet.balances.usdt.available,
|
||||||
refOrderId: savedOrder.orderNo,
|
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);
|
await this.ledgerRepo.save(freezeEntry);
|
||||||
|
|
||||||
|
|
@ -1347,7 +1351,7 @@ export class WalletApplicationService {
|
||||||
walletId: wallet.walletId.toString(),
|
walletId: wallet.walletId.toString(),
|
||||||
amount: command.amount.toString(),
|
amount: command.amount.toString(),
|
||||||
fee: feeAmount.toString(),
|
fee: feeAmount.toString(),
|
||||||
netAmount: (command.amount - feeAmount).toString(),
|
netAmount: command.amount.toString(), // 接收方收到完整金额,手续费由发送方额外承担
|
||||||
assetType: 'USDT',
|
assetType: 'USDT',
|
||||||
chainType: command.chainType,
|
chainType: command.chainType,
|
||||||
toAddress: command.toAddress,
|
toAddress: command.toAddress,
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ export class WithdrawalOrder {
|
||||||
get userId(): UserId { return this._userId; }
|
get userId(): UserId { return this._userId; }
|
||||||
get amount(): Money { return this._amount; }
|
get amount(): Money { return this._amount; }
|
||||||
get fee(): Money { return this._fee; }
|
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 chainType(): ChainType { return this._chainType; }
|
||||||
get toAddress(): string { return this._toAddress; }
|
get toAddress(): string { return this._toAddress; }
|
||||||
get txHash(): string | null { return this._txHash; }
|
get txHash(): string | null { return this._txHash; }
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
SettlementOrderRepositoryImpl,
|
SettlementOrderRepositoryImpl,
|
||||||
WithdrawalOrderRepositoryImpl,
|
WithdrawalOrderRepositoryImpl,
|
||||||
PendingRewardRepositoryImpl,
|
PendingRewardRepositoryImpl,
|
||||||
|
FeeConfigRepositoryImpl,
|
||||||
} from './persistence/repositories';
|
} from './persistence/repositories';
|
||||||
import {
|
import {
|
||||||
WALLET_ACCOUNT_REPOSITORY,
|
WALLET_ACCOUNT_REPOSITORY,
|
||||||
|
|
@ -45,12 +46,13 @@ const repositories = [
|
||||||
provide: PENDING_REWARD_REPOSITORY,
|
provide: PENDING_REWARD_REPOSITORY,
|
||||||
useClass: PendingRewardRepositoryImpl,
|
useClass: PendingRewardRepositoryImpl,
|
||||||
},
|
},
|
||||||
|
FeeConfigRepositoryImpl,
|
||||||
];
|
];
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RedisModule, KafkaModule, IdentityModule],
|
imports: [RedisModule, KafkaModule, IdentityModule],
|
||||||
providers: [PrismaService, ...repositories],
|
providers: [PrismaService, ...repositories],
|
||||||
exports: [PrismaService, RedisModule, KafkaModule, IdentityModule, ...repositories],
|
exports: [PrismaService, RedisModule, KafkaModule, IdentityModule, FeeConfigRepositoryImpl, ...repositories],
|
||||||
})
|
})
|
||||||
export class InfrastructureModule {}
|
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 './settlement-order.repository.impl';
|
||||||
export * from './withdrawal-order.repository.impl';
|
export * from './withdrawal-order.repository.impl';
|
||||||
export * from './pending-reward.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)
|
/// 手动重试钱包生成 (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 {
|
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 TeamChainAuthorizationHolder? teamChainProvinceTeamHolder;
|
||||||
|
|
||||||
UserAuthorizationStatusResponse({
|
UserAuthorizationStatusResponse({
|
||||||
required this.hasPlanted,
|
required this.hasPlanted,
|
||||||
required this.plantedCount,
|
required this.plantedCount,
|
||||||
required this.existingAuthorizations,
|
required this.existingAuthorizations,
|
||||||
|
this.teamChainCityTeamHolder,
|
||||||
|
this.teamChainProvinceTeamHolder,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory UserAuthorizationStatusResponse.fromJson(Map<String, dynamic> json) {
|
factory UserAuthorizationStatusResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
|
@ -362,6 +386,12 @@ 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
|
||||||
|
? 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;
|
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),
|
SizedBox(height: 24.h),
|
||||||
// 恢复账号入口(手机号+密码登录)
|
// 登录账号入口(手机号+密码登录)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
debugPrint('[GuidePage] 跳转到恢复账号页面');
|
debugPrint('[GuidePage] 跳转到登录账号页面');
|
||||||
context.push(RoutePaths.phoneLogin);
|
context.push(RoutePaths.phoneLogin);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -717,7 +717,7 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
|
||||||
),
|
),
|
||||||
SizedBox(width: 8.w),
|
SizedBox(width: 8.w),
|
||||||
Text(
|
Text(
|
||||||
'恢复账号',
|
'登录账号',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15.sp,
|
fontSize: 15.sp,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import '../../../../routes/route_paths.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
|
|
||||||
/// 手机号+密码登录页面
|
/// 手机号+密码登录页面
|
||||||
/// 用于"恢复账号"功能
|
/// 用于"登录账号"功能
|
||||||
class PhoneLoginPage extends ConsumerStatefulWidget {
|
class PhoneLoginPage extends ConsumerStatefulWidget {
|
||||||
const PhoneLoginPage({super.key});
|
const PhoneLoginPage({super.key});
|
||||||
|
|
||||||
|
|
@ -159,12 +159,15 @@ class _PhoneLoginPageState extends ConsumerState<PhoneLoginPage> {
|
||||||
SizedBox(height: 16.h),
|
SizedBox(height: 16.h),
|
||||||
// 密码输入框
|
// 密码输入框
|
||||||
_buildPasswordInput(),
|
_buildPasswordInput(),
|
||||||
|
SizedBox(height: 12.h),
|
||||||
|
// 找回密码
|
||||||
|
_buildForgotPassword(),
|
||||||
// 错误提示
|
// 错误提示
|
||||||
if (_errorMessage != null) ...[
|
if (_errorMessage != null) ...[
|
||||||
SizedBox(height: 16.h),
|
SizedBox(height: 16.h),
|
||||||
_buildErrorMessage(),
|
_buildErrorMessage(),
|
||||||
],
|
],
|
||||||
SizedBox(height: 32.h),
|
SizedBox(height: 24.h),
|
||||||
// 登录按钮
|
// 登录按钮
|
||||||
_buildLoginButton(),
|
_buildLoginButton(),
|
||||||
SizedBox(height: 24.h),
|
SizedBox(height: 24.h),
|
||||||
|
|
@ -199,7 +202,7 @@ class _PhoneLoginPageState extends ConsumerState<PhoneLoginPage> {
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'恢复账号',
|
'登录账号',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18.sp,
|
fontSize: 18.sp,
|
||||||
fontWeight: FontWeight.w600,
|
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() {
|
Widget _buildErrorMessage() {
|
||||||
return Container(
|
return Container(
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,11 @@ extension AuthorizationTypeExtension on AuthorizationType {
|
||||||
String get description {
|
String get description {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case AuthorizationType.community:
|
case AuthorizationType.community:
|
||||||
return '社区权益 - 576 CNY/棵';
|
return '社区权益 - 576 绿积分/棵';
|
||||||
case AuthorizationType.cityTeam:
|
case AuthorizationType.cityTeam:
|
||||||
return '市团队权益 - 288 CNY/棵';
|
return '市团队权益 - 288 绿积分/棵';
|
||||||
case AuthorizationType.provinceTeam:
|
case AuthorizationType.provinceTeam:
|
||||||
return '省团队权益 - 144 CNY/棵';
|
return '省团队权益 - 144 绿积分/棵';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -80,6 +80,12 @@ class _AuthorizationApplyPageState
|
||||||
/// 用户已有的授权
|
/// 用户已有的授权
|
||||||
List<String> _existingAuthorizations = [];
|
List<String> _existingAuthorizations = [];
|
||||||
|
|
||||||
|
/// 团队链中市团队授权持有者
|
||||||
|
TeamChainAuthorizationHolder? _teamChainCityTeamHolder;
|
||||||
|
|
||||||
|
/// 团队链中省团队授权持有者
|
||||||
|
TeamChainAuthorizationHolder? _teamChainProvinceTeamHolder;
|
||||||
|
|
||||||
/// 保存的省市信息(来自认种时选择)
|
/// 保存的省市信息(来自认种时选择)
|
||||||
String? _savedProvinceName;
|
String? _savedProvinceName;
|
||||||
String? _savedProvinceCode;
|
String? _savedProvinceCode;
|
||||||
|
|
@ -132,6 +138,8 @@ class _AuthorizationApplyPageState
|
||||||
_hasPlanted = status.hasPlanted;
|
_hasPlanted = status.hasPlanted;
|
||||||
_plantedCount = status.plantedCount;
|
_plantedCount = status.plantedCount;
|
||||||
_existingAuthorizations = status.existingAuthorizations;
|
_existingAuthorizations = status.existingAuthorizations;
|
||||||
|
_teamChainCityTeamHolder = status.teamChainCityTeamHolder;
|
||||||
|
_teamChainProvinceTeamHolder = status.teamChainProvinceTeamHolder;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -847,8 +855,20 @@ class _AuthorizationApplyPageState
|
||||||
final isSelected = _selectedType == type;
|
final isSelected = _selectedType == type;
|
||||||
final isAlreadyHas = _existingAuthorizations.contains(type.displayName);
|
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(
|
return GestureDetector(
|
||||||
onTap: isAlreadyHas
|
onTap: isDisabled
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -860,7 +880,7 @@ class _AuthorizationApplyPageState
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isAlreadyHas
|
color: isDisabled
|
||||||
? const Color(0xFFE0E0E0)
|
? const Color(0xFFE0E0E0)
|
||||||
: isSelected
|
: isSelected
|
||||||
? const Color(0xFFD4AF37).withValues(alpha: 0.1)
|
? const Color(0xFFD4AF37).withValues(alpha: 0.1)
|
||||||
|
|
@ -882,7 +902,7 @@ class _AuthorizationApplyPageState
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isAlreadyHas
|
color: isDisabled
|
||||||
? const Color(0xFF9E9E9E)
|
? const Color(0xFF9E9E9E)
|
||||||
: isSelected
|
: isSelected
|
||||||
? const Color(0xFFD4AF37)
|
? const Color(0xFFD4AF37)
|
||||||
|
|
@ -911,7 +931,7 @@ class _AuthorizationApplyPageState
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: isAlreadyHas
|
color: isDisabled
|
||||||
? const Color(0xFF9E9E9E)
|
? const Color(0xFF9E9E9E)
|
||||||
: const Color(0xFF5D4037),
|
: const Color(0xFF5D4037),
|
||||||
),
|
),
|
||||||
|
|
@ -922,11 +942,23 @@ class _AuthorizationApplyPageState
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
color: isAlreadyHas
|
color: isDisabled
|
||||||
? const Color(0xFFBDBDBD)
|
? const Color(0xFFBDBDBD)
|
||||||
: const Color(0xFF745D43),
|
: 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,
|
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),
|
SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'视频流功能开发中...',
|
'待开启...',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'withdraw_usdt_page.dart';
|
import 'withdraw_usdt_page.dart';
|
||||||
import '../../../../core/di/injection_container.dart';
|
import '../../../../core/di/injection_container.dart';
|
||||||
|
import '../../../../core/services/wallet_service.dart';
|
||||||
|
|
||||||
/// 提取确认页面
|
/// 提取确认页面
|
||||||
/// 显示提取详情并进行谷歌验证器验证
|
/// 显示提取详情并进行谷歌验证器验证
|
||||||
|
|
@ -50,13 +51,36 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
|
||||||
/// 用户手机号(脱敏显示)
|
/// 用户手机号(脱敏显示)
|
||||||
String? _maskedPhoneNumber;
|
String? _maskedPhoneNumber;
|
||||||
|
|
||||||
/// 手续费率
|
/// 手续费配置
|
||||||
final double _feeRate = 0.001; // 0.1%
|
FeeConfig? _feeConfig;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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() {
|
double _calculateFee() {
|
||||||
return widget.params.amount * _feeRate;
|
if (_feeConfig != null) {
|
||||||
|
return _feeConfig!.calculateFee(widget.params.amount);
|
||||||
|
}
|
||||||
|
// 默认固定 2 绿积分
|
||||||
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 计算实际到账
|
/// 计算实际到账(接收方收到完整金额,手续费由发送方额外承担)
|
||||||
double _calculateActualAmount() {
|
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:image_picker/image_picker.dart';
|
||||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
import '../../../../core/di/injection_container.dart';
|
import '../../../../core/di/injection_container.dart';
|
||||||
|
import '../../../../core/services/wallet_service.dart';
|
||||||
import '../../../../routes/route_paths.dart';
|
import '../../../../routes/route_paths.dart';
|
||||||
|
|
||||||
/// 提款网络枚举
|
/// 提款网络枚举
|
||||||
|
|
@ -52,8 +53,8 @@ class _WithdrawUsdtPageState extends ConsumerState<WithdrawUsdtPage> {
|
||||||
/// 钱包是否已就绪
|
/// 钱包是否已就绪
|
||||||
bool _isWalletReady = false;
|
bool _isWalletReady = false;
|
||||||
|
|
||||||
/// 手续费率
|
/// 手续费配置
|
||||||
final double _feeRate = 0.001; // 0.1%
|
FeeConfig? _feeConfig;
|
||||||
|
|
||||||
/// 最小提款金额
|
/// 最小提款金额
|
||||||
final double _minAmount = 100.0;
|
final double _minAmount = 100.0;
|
||||||
|
|
@ -103,18 +104,28 @@ class _WithdrawUsdtPageState extends ConsumerState<WithdrawUsdtPage> {
|
||||||
_isWalletReady = false;
|
_isWalletReady = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果钱包已就绪,查询余额
|
// 如果钱包已就绪,查询余额和手续费配置
|
||||||
if (_isWalletReady) {
|
if (_isWalletReady) {
|
||||||
try {
|
try {
|
||||||
final walletService = ref.read(walletServiceProvider);
|
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) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_usdtBalance = wallet.balances.usdt.available;
|
_usdtBalance = wallet.balances.usdt.available;
|
||||||
|
_feeConfig = feeConfig;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
debugPrint('[WithdrawUsdtPage] USDT 余额: $_usdtBalance');
|
debugPrint('[WithdrawUsdtPage] USDT 余额: $_usdtBalance');
|
||||||
|
debugPrint('[WithdrawUsdtPage] 手续费配置: ${feeConfig.description}');
|
||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('[WithdrawUsdtPage] 加载余额失败: $e');
|
debugPrint('[WithdrawUsdtPage] 加载余额失败: $e');
|
||||||
|
|
@ -165,13 +176,17 @@ class _WithdrawUsdtPageState extends ConsumerState<WithdrawUsdtPage> {
|
||||||
/// 计算手续费
|
/// 计算手续费
|
||||||
double _calculateFee() {
|
double _calculateFee() {
|
||||||
final amount = double.tryParse(_amountController.text) ?? 0;
|
final amount = double.tryParse(_amountController.text) ?? 0;
|
||||||
return amount * _feeRate;
|
if (_feeConfig != null) {
|
||||||
|
return _feeConfig!.calculateFee(amount);
|
||||||
|
}
|
||||||
|
// 默认固定 2 绿积分
|
||||||
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 计算实际到账
|
/// 计算实际到账(接收方收到完整金额,手续费由发送方额外承担)
|
||||||
double _calculateActualAmount() {
|
double _calculateActualAmount() {
|
||||||
final amount = double.tryParse(_amountController.text) ?? 0;
|
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/import_mnemonic_page.dart';
|
||||||
import '../features/auth/presentation/pages/phone_register_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/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/sms_verify_page.dart';
|
||||||
import '../features/auth/presentation/pages/set_password_page.dart';
|
import '../features/auth/presentation/pages/set_password_page.dart';
|
||||||
import '../features/home/presentation/pages/home_shell_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 (短信验证码)
|
// SMS Verify (短信验证码)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RoutePaths.smsVerify,
|
path: RoutePaths.smsVerify,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ class RouteNames {
|
||||||
static const importMnemonic = 'import-mnemonic';
|
static const importMnemonic = 'import-mnemonic';
|
||||||
static const phoneRegister = 'phone-register';
|
static const phoneRegister = 'phone-register';
|
||||||
static const phoneLogin = 'phone-login';
|
static const phoneLogin = 'phone-login';
|
||||||
|
static const forgotPassword = 'forgot-password';
|
||||||
static const smsVerify = 'sms-verify';
|
static const smsVerify = 'sms-verify';
|
||||||
static const setPassword = 'set-password';
|
static const setPassword = 'set-password';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ class RoutePaths {
|
||||||
static const importMnemonic = '/auth/import-mnemonic';
|
static const importMnemonic = '/auth/import-mnemonic';
|
||||||
static const phoneRegister = '/auth/phone-register';
|
static const phoneRegister = '/auth/phone-register';
|
||||||
static const phoneLogin = '/auth/phone-login';
|
static const phoneLogin = '/auth/phone-login';
|
||||||
|
static const forgotPassword = '/auth/forgot-password';
|
||||||
static const smsVerify = '/auth/sms-verify';
|
static const smsVerify = '/auth/sms-verify';
|
||||||
static const setPassword = '/auth/set-password';
|
static const setPassword = '/auth/set-password';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue