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:
hailin 2025-12-22 19:00:02 -08:00
parent 06df38b918
commit cb0f10af34
30 changed files with 1391 additions and 58 deletions

View File

@ -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
}

View File

@ -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]: '社区',

View File

@ -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: '用户注册(手机号)' })

View File

@ -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 {

View File

@ -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;

View File

@ -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',
) {}
}

View File

@ -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)}`,
);
}
}

View File

@ -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");

View File

@ -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 事件不会被重复处理

View File

@ -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,
};
}
}

View File

@ -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
}

View File

@ -1,3 +1,4 @@
export * from './wallet.dto';
export * from './ledger.dto';
export * from './withdrawal.dto';
export * from './fee-config.dto';

View File

@ -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,

View File

@ -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; }

View File

@ -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 {}

View File

@ -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,
};
}
}

View File

@ -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';

View File

@ -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)
///
///

View File

@ -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,
);
}
}

View File

@ -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: '默认配置 (请求失败)',
);
}
}
}
///

View File

@ -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),
),
),
),
);
}
}

View File

@ -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,

View File

@ -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(

View File

@ -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,
),
),
),
],
),

View File

@ -575,7 +575,7 @@ class _MiningPageState extends ConsumerState<MiningPage> {
),
SizedBox(height: 8),
Text(
'视频流功能开发中...',
'待开启...',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',

View File

@ -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;
}
///

View File

@ -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;
}
///

View File

@ -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,

View File

@ -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';

View File

@ -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';