diff --git a/backend/services/authorization-service/src/api/dto/request/self-apply-authorization.dto.ts b/backend/services/authorization-service/src/api/dto/request/self-apply-authorization.dto.ts index d8b7dc61..9ebba9e9 100644 --- a/backend/services/authorization-service/src/api/dto/request/self-apply-authorization.dto.ts +++ b/backend/services/authorization-service/src/api/dto/request/self-apply-authorization.dto.ts @@ -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 } diff --git a/backend/services/authorization-service/src/application/services/authorization-application.service.ts b/backend/services/authorization-service/src/application/services/authorization-application.service.ts index a48d34ed..18cd0867 100644 --- a/backend/services/authorization-service/src/application/services/authorization-application.service.ts +++ b/backend/services/authorization-service/src/application/services/authorization-application.service.ts @@ -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 { + 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.COMMUNITY]: '社区', diff --git a/backend/services/identity-service/src/api/controllers/user-account.controller.ts b/backend/services/identity-service/src/api/controllers/user-account.controller.ts index 0eeecbbe..72a7c881 100644 --- a/backend/services/identity-service/src/api/controllers/user-account.controller.ts +++ b/backend/services/identity-service/src/api/controllers/user-account.controller.ts @@ -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: '用户注册(手机号)' }) diff --git a/backend/services/identity-service/src/api/dto/index.ts b/backend/services/identity-service/src/api/dto/index.ts index f67cd548..20f9c0ba 100644 --- a/backend/services/identity-service/src/api/dto/index.ts +++ b/backend/services/identity-service/src/api/dto/index.ts @@ -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 { diff --git a/backend/services/identity-service/src/api/dto/request/verify-sms-code.dto.ts b/backend/services/identity-service/src/api/dto/request/verify-sms-code.dto.ts index 78988f76..34a46856 100644 --- a/backend/services/identity-service/src/api/dto/request/verify-sms-code.dto.ts +++ b/backend/services/identity-service/src/api/dto/request/verify-sms-code.dto.ts @@ -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; diff --git a/backend/services/identity-service/src/application/commands/index.ts b/backend/services/identity-service/src/application/commands/index.ts index 43805157..b312f303 100644 --- a/backend/services/identity-service/src/application/commands/index.ts +++ b/backend/services/identity-service/src/application/commands/index.ts @@ -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', ) {} } diff --git a/backend/services/identity-service/src/application/services/user-application.service.ts b/backend/services/identity-service/src/application/services/user-application.service.ts index f4c393a8..409885ea 100644 --- a/backend/services/identity-service/src/application/services/user-application.service.ts +++ b/backend/services/identity-service/src/application/services/user-application.service.ts @@ -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 { + 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 { + 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)}`, + ); + } } diff --git a/backend/services/wallet-service/prisma/migrations/20241222000000_add_withdrawal_fee_config/migration.sql b/backend/services/wallet-service/prisma/migrations/20241222000000_add_withdrawal_fee_config/migration.sql new file mode 100644 index 00000000..5a5a16fc --- /dev/null +++ b/backend/services/wallet-service/prisma/migrations/20241222000000_add_withdrawal_fee_config/migration.sql @@ -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"); diff --git a/backend/services/wallet-service/prisma/schema.prisma b/backend/services/wallet-service/prisma/schema.prisma index 5fa83ae5..55f06456 100644 --- a/backend/services/wallet-service/prisma/schema.prisma +++ b/backend/services/wallet-service/prisma/schema.prisma @@ -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 事件不会被重复处理 diff --git a/backend/services/wallet-service/src/api/controllers/wallet.controller.ts b/backend/services/wallet-service/src/api/controllers/wallet.controller.ts index 17c40e56..ff1616fa 100644 --- a/backend/services/wallet-service/src/api/controllers/wallet.controller.ts +++ b/backend/services/wallet-service/src/api/controllers/wallet.controller.ts @@ -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 { + 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 { + 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, + }; + } } diff --git a/backend/services/wallet-service/src/api/dto/response/fee-config.dto.ts b/backend/services/wallet-service/src/api/dto/response/fee-config.dto.ts new file mode 100644 index 00000000..585d9996 --- /dev/null +++ b/backend/services/wallet-service/src/api/dto/response/fee-config.dto.ts @@ -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 +} diff --git a/backend/services/wallet-service/src/api/dto/response/index.ts b/backend/services/wallet-service/src/api/dto/response/index.ts index eb701fea..62ef5789 100644 --- a/backend/services/wallet-service/src/api/dto/response/index.ts +++ b/backend/services/wallet-service/src/api/dto/response/index.ts @@ -1,3 +1,4 @@ export * from './wallet.dto'; export * from './ledger.dto'; export * from './withdrawal.dto'; +export * from './fee-config.dto'; diff --git a/backend/services/wallet-service/src/application/services/wallet-application.service.ts b/backend/services/wallet-service/src/application/services/wallet-application.service.ts index e6ab159b..c9a4ef0b 100644 --- a/backend/services/wallet-service/src/application/services/wallet-application.service.ts +++ b/backend/services/wallet-service/src/application/services/wallet-application.service.ts @@ -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, diff --git a/backend/services/wallet-service/src/domain/aggregates/withdrawal-order.aggregate.ts b/backend/services/wallet-service/src/domain/aggregates/withdrawal-order.aggregate.ts index b7b294f6..3ef9af74 100644 --- a/backend/services/wallet-service/src/domain/aggregates/withdrawal-order.aggregate.ts +++ b/backend/services/wallet-service/src/domain/aggregates/withdrawal-order.aggregate.ts @@ -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; } diff --git a/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts b/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts index 0ed62351..5f455d77 100644 --- a/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts @@ -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 {} diff --git a/backend/services/wallet-service/src/infrastructure/persistence/repositories/fee-config.repository.impl.ts b/backend/services/wallet-service/src/infrastructure/persistence/repositories/fee-config.repository.impl.ts new file mode 100644 index 00000000..707a6ccb --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/persistence/repositories/fee-config.repository.impl.ts @@ -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 { + 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, + }; + } +} diff --git a/backend/services/wallet-service/src/infrastructure/persistence/repositories/index.ts b/backend/services/wallet-service/src/infrastructure/persistence/repositories/index.ts index 0a2d7931..78036509 100644 --- a/backend/services/wallet-service/src/infrastructure/persistence/repositories/index.ts +++ b/backend/services/wallet-service/src/infrastructure/persistence/repositories/index.ts @@ -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'; diff --git a/frontend/mobile-app/lib/core/services/account_service.dart b/frontend/mobile-app/lib/core/services/account_service.dart index 51c24b1f..2ea4243f 100644 --- a/frontend/mobile-app/lib/core/services/account_service.dart +++ b/frontend/mobile-app/lib/core/services/account_service.dart @@ -571,6 +571,74 @@ class AccountService { } } + /// 发送重置密码短信验证码 (POST /user/send-reset-password-sms) + /// + /// 用于找回密码功能 + Future 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 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) /// /// 当钱包生成失败或超时时,用户可手动触发重试 diff --git a/frontend/mobile-app/lib/core/services/authorization_service.dart b/frontend/mobile-app/lib/core/services/authorization_service.dart index 62a2984d..ecf9bd7a 100644 --- a/frontend/mobile-app/lib/core/services/authorization_service.dart +++ b/frontend/mobile-app/lib/core/services/authorization_service.dart @@ -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 json) { + return TeamChainAuthorizationHolder( + accountSequence: json['accountSequence'] ?? '', + nickname: json['nickname'] ?? '', + ); + } +} + /// 用户授权状态响应 class UserAuthorizationStatusResponse { final bool hasPlanted; final int plantedCount; final List 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 json) { @@ -362,6 +386,12 @@ class UserAuthorizationStatusResponse { existingAuthorizations: (json['existingAuthorizations'] as List?) ?.map((e) => e.toString()) .toList() ?? [], + teamChainCityTeamHolder: json['teamChainCityTeamHolder'] != null + ? TeamChainAuthorizationHolder.fromJson(json['teamChainCityTeamHolder']) + : null, + teamChainProvinceTeamHolder: json['teamChainProvinceTeamHolder'] != null + ? TeamChainAuthorizationHolder.fromJson(json['teamChainProvinceTeamHolder']) + : null, ); } } diff --git a/frontend/mobile-app/lib/core/services/wallet_service.dart b/frontend/mobile-app/lib/core/services/wallet_service.dart index 83da73f0..8eed7c7e 100644 --- a/frontend/mobile-app/lib/core/services/wallet_service.dart +++ b/frontend/mobile-app/lib/core/services/wallet_service.dart @@ -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 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 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; + final data = responseData['data'] as Map? ?? 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: '默认配置 (请求失败)', + ); + } + } } /// 提取响应 diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/forgot_password_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/forgot_password_page.dart new file mode 100644 index 00000000..6892400b --- /dev/null +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/forgot_password_page.dart @@ -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 createState() => _ForgotPasswordPageState(); +} + +class _ForgotPasswordPageState extends ConsumerState { + 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 _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 _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(Colors.white), + ), + ) + : Text( + '重置密码', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w700, + color: canSubmit ? Colors.white : const Color(0xFFB8956A), + ), + ), + ), + ); + } +} diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart index c39d4bb6..339872a6 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart @@ -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, diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/phone_login_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/phone_login_page.dart index d803bfb2..5d776335 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/phone_login_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/phone_login_page.dart @@ -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 { 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 { ), Expanded( child: Text( - '恢复账号', + '登录账号', style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w600, @@ -346,6 +349,27 @@ class _PhoneLoginPageState extends ConsumerState { ); } + /// 构建找回密码链接 + 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( diff --git a/frontend/mobile-app/lib/features/authorization/presentation/pages/authorization_apply_page.dart b/frontend/mobile-app/lib/features/authorization/presentation/pages/authorization_apply_page.dart index 08395e66..82b25b8c 100644 --- a/frontend/mobile-app/lib/features/authorization/presentation/pages/authorization_apply_page.dart +++ b/frontend/mobile-app/lib/features/authorization/presentation/pages/authorization_apply_page.dart @@ -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 _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, + ), + ), ), ], ), diff --git a/frontend/mobile-app/lib/features/mining/presentation/pages/mining_page.dart b/frontend/mobile-app/lib/features/mining/presentation/pages/mining_page.dart index 8961272a..93693c6a 100644 --- a/frontend/mobile-app/lib/features/mining/presentation/pages/mining_page.dart +++ b/frontend/mobile-app/lib/features/mining/presentation/pages/mining_page.dart @@ -575,7 +575,7 @@ class _MiningPageState extends ConsumerState { ), SizedBox(height: 8), Text( - '视频流功能开发中...', + '待开启...', style: TextStyle( fontSize: 14, fontFamily: 'Inter', diff --git a/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_confirm_page.dart b/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_confirm_page.dart index 0e791bf5..6dbff26a 100644 --- a/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_confirm_page.dart +++ b/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_confirm_page.dart @@ -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 { /// 用户手机号(脱敏显示) String? _maskedPhoneNumber; - /// 手续费率 - final double _feeRate = 0.001; // 0.1% + /// 手续费配置 + FeeConfig? _feeConfig; @override void initState() { super.initState(); - _loadUserPhone(); + _loadInitData(); + } + + /// 加载初始数据(手机号和手续费配置) + Future _loadInitData() async { + await Future.wait([ + _loadUserPhone(), + _loadFeeConfig(), + ]); + } + + /// 加载手续费配置 + Future _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 { /// 计算手续费 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; } /// 获取网络名称 diff --git a/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_usdt_page.dart b/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_usdt_page.dart index 43139b86..2f705204 100644 --- a/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_usdt_page.dart +++ b/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_usdt_page.dart @@ -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 { /// 钱包是否已就绪 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 { _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 { /// 计算手续费 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; } /// 验证并提交 diff --git a/frontend/mobile-app/lib/routes/app_router.dart b/frontend/mobile-app/lib/routes/app_router.dart index d6e278b1..15673094 100644 --- a/frontend/mobile-app/lib/routes/app_router.dart +++ b/frontend/mobile-app/lib/routes/app_router.dart @@ -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((ref) { }, ), + // Forgot Password (找回密码) + GoRoute( + path: RoutePaths.forgotPassword, + name: RouteNames.forgotPassword, + builder: (context, state) { + return const ForgotPasswordPage(); + }, + ), + // SMS Verify (短信验证码) GoRoute( path: RoutePaths.smsVerify, diff --git a/frontend/mobile-app/lib/routes/route_names.dart b/frontend/mobile-app/lib/routes/route_names.dart index ff036a8c..8635ef93 100644 --- a/frontend/mobile-app/lib/routes/route_names.dart +++ b/frontend/mobile-app/lib/routes/route_names.dart @@ -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'; diff --git a/frontend/mobile-app/lib/routes/route_paths.dart b/frontend/mobile-app/lib/routes/route_paths.dart index 4a7554bc..ba3af4ef 100644 --- a/frontend/mobile-app/lib/routes/route_paths.dart +++ b/frontend/mobile-app/lib/routes/route_paths.dart @@ -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';