feat(auth-service,mining-app): 实现完整认证流程和CDC用户同步
auth-service: - 添加DTO验证装饰器(IsString, IsNotEmpty, Matches, MinLength) - 添加短信验证码登录(loginBySms)方法 - 修复CDC Consumer字段映射匹配1.0 user_accounts表 - 更新CDC topic为cdc.identity.public.user_accounts mining-app (Flutter): - 新增auth_remote_datasource实现真实API调用 - 新增登录页面(密码/短信切换)和注册页面 - 替换splash_page中的mock登录为真实状态检查 - 添加token自动注入拦截器到ApiClient - 配置生产环境API指向Kong网关 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9fca17e7ed
commit
4bb995f2c2
|
|
@ -675,7 +675,33 @@
|
|||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" diff frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" checkout -- frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mobile-app\\): 修复认种向导待办操作无法正确标记完成的问题\n\n问题:用户完成认种并签署合同后,ADOPTION_WIZARD待办操作没有被标记为完成,\n导致用户被卡在待办操作页面无法进入App。\n\n原因:原来的检查逻辑只检查是否有\"待签合同\",当用户已签署合同后,\npendingTasks为空,返回false,导致待办操作无法完成。\n\n修复方案:\n- 改为检查用户是否有已支付的认种订单(PAID/FUND_ALLOCATED状态)\n- 通过比较订单创建时间和待办操作创建时间来判断\n- 订单在待办操作之后创建 → 已完成\n- 订单在待办操作之前但相差不超过24小时 → 也认为已完成(兼容延迟)\n- 保留待签合同的备用检查逻辑\n\n影响范围:仅影响ADOPTION_WIZARD待办操作的完成检测\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")"
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mobile-app\\): 修复认种向导待办操作无法正确标记完成的问题\n\n问题:用户完成认种并签署合同后,ADOPTION_WIZARD待办操作没有被标记为完成,\n导致用户被卡在待办操作页面无法进入App。\n\n原因:原来的检查逻辑只检查是否有\"待签合同\",当用户已签署合同后,\npendingTasks为空,返回false,导致待办操作无法完成。\n\n修复方案:\n- 改为检查用户是否有已支付的认种订单(PAID/FUND_ALLOCATED状态)\n- 通过比较订单创建时间和待办操作创建时间来判断\n- 订单在待办操作之后创建 → 已完成\n- 订单在待办操作之前但相差不超过24小时 → 也认为已完成(兼容延迟)\n- 保留待签合同的备用检查逻辑\n\n影响范围:仅影响ADOPTION_WIZARD待办操作的完成检测\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(contribution-service\\): 添加算力管理微服务\n\n## 概述\n为榴莲生态2.0添加 contribution-service 微服务,负责算力计算、分配和快照管理。\n\n## 架构设计\n- 采用 DDD + Hexagonal Architecture \\(六边形架构\\)\n- 使用 NestJS 框架 + Prisma ORM\n- 通过 Kafka CDC \\(Debezium\\) 从 user-service 同步数据\n- 使用 accountSequence \\(而非 userId\\) 进行跨服务关联\n\n## 核心功能模块\n\n### 1. Domain Layer \\(领域层\\)\n- ContributionAccountAggregate: 算力账户聚合根\n- ContributionRecordAggregate: 算力记录聚合根\n- ContributionAmount: 算力金额值对象 \\(基于 Decimal.js\\)\n- DistributionRate: 分配比例值对象\n- ContributionSourceType: 算力来源类型枚举 \\(PERSONAL/TEAM_LEVEL/TEAM_BONUS\\)\n\n### 2. Application Layer \\(应用层\\)\n- ContributionCalculationService: 算力计算核心服务\n - 个人算力: 认种金额 × 10\n - 团队等级奖励: 基于直推有效认种人数\n - 团队极差奖励: 多级分销算法\n- SnapshotService: 每日算力快照服务\n- CDC Event Handlers: 处理用户、认种、引荐关系同步事件\n\n### 3. Infrastructure Layer \\(基础设施层\\)\n- Prisma Repositories: \n - ContributionAccountRepository\n - ContributionRecordRepository\n - SyncedDataRepository \\(同步数据\\)\n - OutboxRepository \\(发件箱模式\\)\n - SystemAccountRepository\n - UnallocatedContributionRepository\n- Kafka CDC Consumer: 消费 Debezium CDC 事件\n- Redis: 缓存支持\n- UnitOfWork: 事务管理\n\n### 4. API Layer \\(接口层\\)\n- ContributionController: 算力查询接口\n- SnapshotController: 快照管理接口\n- HealthController: 健康检查\n\n## 数据模型 \\(Prisma Schema\\)\n- ContributionAccount: 算力账户\n- ContributionRecord: 算力记录 \\(支持过期\\)\n- DailyContributionSnapshot: 每日快照\n- SyncedUser/SyncedAdoption/SyncedReferral: CDC 同步数据\n- OutboxEvent: 发件箱事件\n- SystemContributionAccount: 系统账户\n- UnallocatedContribution: 未分配算力\n\n## TypeScript 类型修复\n- 修复所有 Repository 接口与实现的类型不匹配\n- 修复 ContributionAmount.multiply\\(\\) 返回值类型\n- 修复 isZero getter vs method 问题\n- 修复 bigint vs string 类型转换\n- 统一使用 items/total 返回格式\n- 修复 Prisma schema 字段名映射 \\(unallocType, contributionBalance 等\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mining-ecosystem\\): 添加挖矿生态系统完整微服务与前端\n\n## 概述\n为榴莲生态2.0添加完整的挖矿系统,包含3个后端微服务、1个管理后台和1个用户端App。\n\n---\n\n## 后端微服务\n\n### 1. mining-service \\(挖矿服务\\) - Port 3021\n**核心功能:**\n- 积分股每日分配(基于算力快照)\n- 每分钟定时销毁(进入黑洞)\n- 价格计算:价格 = 积分股池 ÷ \\(100.02亿 - 黑洞 - 流通池\\)\n- 全局状态管理(黑洞量、流通池、价格)\n\n**关键文件:**\n- src/application/services/mining-distribution.service.ts - 挖矿分配核心逻辑\n- src/application/schedulers/mining.scheduler.ts - 定时任务调度\n- src/domain/services/mining-calculator.service.ts - 分配计算\n- src/infrastructure/persistence/repositories/black-hole.repository.ts - 黑洞管理\n\n### 2. trading-service \\(交易服务\\) - Port 3022\n**核心功能:**\n- 积分股买卖撮合\n- K线数据生成\n- 手续费处理(10%买入/卖出)\n- 流通池管理\n- 卖出倍数计算:倍数 = \\(100亿 - 销毁量\\) ÷ \\(200万 - 流通池量\\)\n\n**关键文件:**\n- src/domain/services/matching-engine.service.ts - 撮合引擎\n- src/application/services/order.service.ts - 订单处理\n- src/application/services/transfer.service.ts - 划转服务\n- src/domain/aggregates/order.aggregate.ts - 订单聚合根\n\n### 3. mining-admin-service \\(挖矿管理服务\\) - Port 3023\n**核心功能:**\n- 系统配置管理(分配参数、手续费率等)\n- 老用户数据初始化\n- 系统监控仪表盘\n- 审计日志\n\n**关键文件:**\n- src/application/services/config.service.ts - 配置管理\n- src/application/services/initialization.service.ts - 数据初始化\n- src/application/services/dashboard.service.ts - 仪表盘数据\n\n---\n\n## 前端应用\n\n### 1. mining-admin-web \\(管理后台\\) - Next.js 14\n**技术栈:**\n- Next.js 14 + React 18\n- TailwindCSS + Radix UI\n- React Query + Zustand\n- ECharts 图表\n\n**功能模块:**\n- 登录认证\n- 仪表盘(实时数据、价格走势)\n- 用户查询(算力详情、挖矿记录、交易订单)\n- 系统配置管理\n- 数据初始化任务\n- 审计日志查看\n\n### 2. mining-app \\(用户端App\\) - Flutter 3.x\n**技术栈:**\n- Flutter 3.x + Dart\n- Riverpod 状态管理\n- GoRouter 路由\n- Clean Architecture \\(3层\\)\n\n**功能模块:**\n- 首页资产总览\n- 实时收益显示(每秒更新)\n- 贡献值展示(个人/团队)\n- 积分股买卖交易\n- K线图与价格显示\n- 个人中心\n\n---\n\n## 架构文档\n- docs/mining-ecosystem-architecture.md - 系统架构总览\n - 服务职责与端口分配\n - 数据流向图\n - Kafka Topics 定义\n - 跨服务关联(account_sequence)\n - 配置参数说明\n - 开发顺序建议\n\n---\n\n## .gitignore 更新\n- 添加 Flutter/Dart 构建文件忽略\n- 添加 iOS/Android 构建产物忽略\n- 添加 Next.js 构建目录忽略\n- 添加 TypeScript 缓存文件忽略\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mining\\): 添加 2.0 挖矿系统独立部署管理脚本\n\n添加 deploy-mining.sh 脚本用于管理 2.0 挖矿生态系统,\n该系统与 1.0 完全隔离,可随时重置而不影响 1.0。\n\n## 功能\n\n### 服务管理\n- up/down/restart - 启动/停止/重启 2.0 服务\n- status - 查看服务状态\n- logs [service] - 查看日志\n- build - 构建服务\n\n### 数据库管理\n- db-create - 创建 2.0 数据库\n- db-migrate - 运行 Prisma 迁移\n- db-reset - 删除并重建数据库(危险操作)\n- db-status - 查看数据库状态\n\n### CDC 同步管理\n- sync-reset - 重置 CDC 消费者偏移量到开始位置\n- sync-status - 查看 CDC 消费者组状态\n\n### 完整重置\n- full-reset - 完整系统重置\n 1. 停止所有 2.0 服务\n 2. 删除所有 2.0 数据库\n 3. 重建数据库\n 4. 运行迁移\n 5. 重置 CDC 偏移量\n 6. 重启服务(从 1.0 重新同步)\n\n### 健康监控\n- health - 检查所有组件健康状态\n- stats - 显示系统统计信息\n\n## 2.0 服务\n- contribution-service \\(3020\\)\n- mining-service \\(3021\\)\n- trading-service \\(3022\\)\n- mining-admin-service \\(3023\\)\n\n## 2.0 数据库\n- rwa_contribution\n- rwa_mining\n- rwa_trading\n- rwa_mining_admin\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(npx prisma format:*)",
|
||||
"Bash(while read svc)",
|
||||
"Bash(do echo \"=== $svc ===\")",
|
||||
"Bash(for svc in admin-service auth-service authorization-service backup-service blockchain-service contribution-service identity-service leaderboard-service mining-admin-service mining-service mpc-service planting-service presence-service referral-service reporting-service reward-service trading-service wallet-service)",
|
||||
"Bash(ssh ceshi@14.215.128.96 \"curl -s -o /dev/null -w ''%{http_code}'' https://madmin.szaiai.com/ --connect-timeout 10\")",
|
||||
"Bash(curl -s -o /dev/null -w '%{http_code}' https://madmin.szaiai.com/ --connect-timeout 15)",
|
||||
"Bash(ssh ceshi@14.215.128.96 \"docker network ls | grep rwa\")",
|
||||
"Bash(ssh ceshi@14.215.128.96 \"cd /home/ceshi/rwadurian/backend/services && git pull && ./deploy-mining.sh rebuild mining-admin-service --no-cache\")",
|
||||
"Bash(dir /s /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\mining-admin-service\\\\src\\\\*.ts\")",
|
||||
"Bash(DATABASE_URL=\"postgresql://user:pass@localhost:5432/db\" npx prisma migrate:*)",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"ls -la /etc/nginx/sites-enabled/ && cat /etc/nginx/sites-available/rwaapi.szaiai.com 2>/dev/null | head -100\")",
|
||||
"Bash(ssh ceshi@14.215.128.96 \"cd /home/ceshi/rwadurian/frontend/mining-admin-web && git pull && cat .env.production\")",
|
||||
"Bash(ssh ceshi@14.215.128.96 \"cd /home/ceshi/rwadurian/frontend/mining-admin-web && docker compose down && docker compose build --no-cache && docker compose up -d\")",
|
||||
"Bash(ssh ceshi@14.215.128.96 \"docker ps | grep -E ''mining-admin|rwa-mining''\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"cd /home/ceshi/rwadurian && git pull && grep -A10 ''mining-admin-service'' backend/api-gateway/kong.yml | head -15\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"cd /home/ceshi/rwadurian/backend/api-gateway && docker compose exec kong kong reload 2>/dev/null || docker exec kong kong reload\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"curl -s http://192.168.1.111:3023/health 2>/dev/null || echo ''Service not reachable''\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"curl -s http://192.168.1.111:3023/auth/login -X POST -H ''Content-Type: application/json'' -d ''{\"\"username\"\":\"\"admin\"\",\"\"password\"\":\"\"test\"\"}''\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"cd /home/ceshi/rwadurian && git pull && docker exec kong kong reload\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"docker ps | grep -i kong\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"curl -s http://localhost:8000/api/v2/mining-admin/auth/login -X POST -H ''Content-Type: application/json'' -d ''{\"\"username\"\":\"\"admin\"\",\"\"password\"\":\"\"admin123\"\"}''\")",
|
||||
"Bash(ssh ceshi@103.39.231.231 \"curl -s http://localhost:8000/api/v2/mining-admin/auth/profile -H ''Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI3ODRlNTA0MS1hYTM2LTQ0ZTctYTM1NS0yY2I2ZjYwYmY1YmIiLCJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6IlNVUEVSX0FETUlOIiwiaWF0IjoxNzY4MTIyMjc3LCJleHAiOjE3NjgyMDg2Nzd9.XL0i0_tQlybkT9ktLIP90WQZDujPbbARL20h6fLmeRE''\")",
|
||||
"Bash(user \")"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ REDIS_PASSWORD=
|
|||
|
||||
# Kafka (CDC)
|
||||
KAFKA_BROKERS=localhost:9092
|
||||
CDC_TOPIC_USERS=dbserver1.public.users
|
||||
CDC_ENABLED=true
|
||||
CDC_TOPIC_USERS=cdc.identity.public.user_accounts
|
||||
CDC_CONSUMER_GROUP=auth-service-cdc-group
|
||||
|
||||
# JWT
|
||||
|
|
|
|||
|
|
@ -9,28 +9,65 @@ import {
|
|||
Headers,
|
||||
} from '@nestjs/common';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { ApiTags, ApiOperation, ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsNotEmpty, MinLength, Matches } from 'class-validator';
|
||||
import { AuthService, LoginResult } from '@/application/services';
|
||||
|
||||
class RegisterDto {
|
||||
@ApiProperty({ description: '手机号', example: '13800138000' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '手机号不能为空' })
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
|
||||
phone: string;
|
||||
|
||||
@ApiProperty({ description: '密码', example: '123456' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '密码不能为空' })
|
||||
@MinLength(6, { message: '密码至少6位' })
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ description: '短信验证码', example: '123456' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '验证码不能为空' })
|
||||
@Matches(/^\d{6}$/, { message: '验证码格式不正确' })
|
||||
smsCode: string;
|
||||
}
|
||||
|
||||
class LoginDto {
|
||||
@ApiProperty({ description: '手机号', example: '13800138000' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '手机号不能为空' })
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
|
||||
phone: string;
|
||||
|
||||
@ApiProperty({ description: '密码', example: '123456' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '密码不能为空' })
|
||||
password: string;
|
||||
}
|
||||
|
||||
class LoginBySmsDto {
|
||||
@ApiProperty({ description: '手机号', example: '13800138000' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '手机号不能为空' })
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
|
||||
phone: string;
|
||||
|
||||
@ApiProperty({ description: '短信验证码', example: '123456' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '验证码不能为空' })
|
||||
@Matches(/^\d{6}$/, { message: '验证码格式不正确' })
|
||||
smsCode: string;
|
||||
}
|
||||
|
||||
class RefreshTokenDto {
|
||||
@ApiProperty({ description: '刷新令牌' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '刷新令牌不能为空' })
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class AuthController {
|
||||
|
|
@ -41,6 +78,7 @@ export class AuthController {
|
|||
* POST /auth/register
|
||||
*/
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '用户注册' })
|
||||
async register(
|
||||
@Body() dto: RegisterDto,
|
||||
@Headers('X-Device-Info') deviceInfo?: string,
|
||||
|
|
@ -61,6 +99,7 @@ export class AuthController {
|
|||
*/
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '密码登录' })
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Headers('X-Device-Info') deviceInfo?: string,
|
||||
|
|
@ -77,12 +116,36 @@ export class AuthController {
|
|||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码登录
|
||||
* POST /auth/login-sms
|
||||
*/
|
||||
@Post('login-sms')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '验证码登录' })
|
||||
async loginBySms(
|
||||
@Body() dto: LoginBySmsDto,
|
||||
@Headers('X-Device-Info') deviceInfo?: string,
|
||||
@Req() req?: any,
|
||||
): Promise<{ success: boolean; data: LoginResult }> {
|
||||
const ipAddress = req?.ip || req?.connection?.remoteAddress;
|
||||
const result = await this.authService.loginBySms({
|
||||
phone: dto.phone,
|
||||
smsCode: dto.smsCode,
|
||||
deviceInfo,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新令牌
|
||||
* POST /auth/refresh
|
||||
*/
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '刷新访问令牌' })
|
||||
async refresh(
|
||||
@Body() dto: RefreshTokenDto,
|
||||
): Promise<{ success: boolean; data: { accessToken: string; expiresIn: number } }> {
|
||||
|
|
@ -96,6 +159,7 @@ export class AuthController {
|
|||
*/
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '退出登录' })
|
||||
async logout(
|
||||
@Body() dto: RefreshTokenDto,
|
||||
): Promise<{ success: boolean }> {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import {
|
|||
SyncedLegacyUserRepository,
|
||||
REFRESH_TOKEN_REPOSITORY,
|
||||
RefreshTokenRepository,
|
||||
SMS_VERIFICATION_REPOSITORY,
|
||||
SmsVerificationRepository,
|
||||
SmsVerificationType,
|
||||
UserRegisteredEvent,
|
||||
LegacyUserMigratedEvent,
|
||||
} from '@/domain';
|
||||
|
|
@ -57,6 +60,8 @@ export class AuthService {
|
|||
private readonly syncedLegacyUserRepository: SyncedLegacyUserRepository,
|
||||
@Inject(REFRESH_TOKEN_REPOSITORY)
|
||||
private readonly refreshTokenRepository: RefreshTokenRepository,
|
||||
@Inject(SMS_VERIFICATION_REPOSITORY)
|
||||
private readonly smsVerificationRepository: SmsVerificationRepository,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly outboxService: OutboxService,
|
||||
|
|
@ -129,6 +134,53 @@ export class AuthService {
|
|||
// V1 用户首次登录,进行迁移
|
||||
return this.migrateAndLogin(legacyUser, dto.password, dto.deviceInfo, dto.ipAddress);
|
||||
}
|
||||
/**
|
||||
* 验证码登录
|
||||
*/
|
||||
async loginBySms(dto: LoginBySmsDto): Promise<LoginResult> {
|
||||
const phone = Phone.create(dto.phone);
|
||||
await this.verifySmsCode(phone, dto.smsCode, SmsVerificationType.LOGIN);
|
||||
|
||||
let user = await this.userRepository.findByPhone(phone);
|
||||
if (user) {
|
||||
if (!user.canLogin) {
|
||||
if (user.isLocked) {
|
||||
throw new UnauthorizedException('账户已被锁定,请稍后再试');
|
||||
}
|
||||
throw new UnauthorizedException('账户已被禁用');
|
||||
}
|
||||
user.recordLoginSuccess(dto.ipAddress);
|
||||
await this.userRepository.save(user);
|
||||
return this.generateTokens(user, dto.deviceInfo, dto.ipAddress);
|
||||
}
|
||||
|
||||
const legacyUser = await this.syncedLegacyUserRepository.findByPhone(phone);
|
||||
if (!legacyUser) {
|
||||
throw new UnauthorizedException('手机号未注册');
|
||||
}
|
||||
if (legacyUser.migratedToV2) {
|
||||
throw new UnauthorizedException('账户状态异常,请联系客服');
|
||||
}
|
||||
return this.performMigration(legacyUser, dto.deviceInfo, dto.ipAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证短信验证码
|
||||
*/
|
||||
private async verifySmsCode(phone: Phone, code: string, type: SmsVerificationType): Promise<void> {
|
||||
const verification = await this.smsVerificationRepository.findLatestValid(phone, type);
|
||||
if (!verification) {
|
||||
throw new BadRequestException('验证码已过期或不存在');
|
||||
}
|
||||
if (verification.attempts >= 5) {
|
||||
throw new BadRequestException('验证码尝试次数过多,请重新获取');
|
||||
}
|
||||
if (verification.code !== code) {
|
||||
await this.smsVerificationRepository.incrementAttempts(verification.id);
|
||||
throw new BadRequestException('验证码错误');
|
||||
}
|
||||
await this.smsVerificationRepository.markAsVerified(verification.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 V2 用户登录
|
||||
|
|
@ -203,6 +255,38 @@ export class AuthService {
|
|||
return this.generateTokens(savedUser, deviceInfo, ipAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行用户迁移(通用)
|
||||
*/
|
||||
private async performMigration(
|
||||
legacyUser: { accountSequence: AccountSequence; phone: Phone; passwordHash: string },
|
||||
deviceInfo?: string,
|
||||
ipAddress?: string,
|
||||
): Promise<LoginResult> {
|
||||
const user = UserAggregate.reconstitute({
|
||||
phone: legacyUser.phone,
|
||||
passwordHash: legacyUser.passwordHash,
|
||||
accountSequence: legacyUser.accountSequence,
|
||||
status: 'ACTIVE' as any,
|
||||
kycStatus: 'PENDING' as any,
|
||||
loginFailCount: 0,
|
||||
});
|
||||
|
||||
user.recordLoginSuccess(ipAddress);
|
||||
const savedUser = await this.userRepository.save(user);
|
||||
await this.syncedLegacyUserRepository.markAsMigrated(legacyUser.accountSequence);
|
||||
|
||||
await this.outboxService.publish(
|
||||
new LegacyUserMigratedEvent(
|
||||
legacyUser.accountSequence.value,
|
||||
legacyUser.phone.value,
|
||||
new Date(),
|
||||
),
|
||||
);
|
||||
|
||||
return this.generateTokens(savedUser, deviceInfo, ipAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 token
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,22 +3,24 @@ import { ConfigService } from '@nestjs/config';
|
|||
import { Kafka, Consumer, EachMessagePayload } from 'kafkajs';
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
|
||||
interface CdcUserPayload {
|
||||
before: CdcUser | null;
|
||||
after: CdcUser | null;
|
||||
source: {
|
||||
sequence: string;
|
||||
};
|
||||
op: 'c' | 'u' | 'd' | 'r'; // create, update, delete, read (snapshot)
|
||||
}
|
||||
|
||||
interface CdcUser {
|
||||
id: number;
|
||||
phone: string;
|
||||
/**
|
||||
* ExtractNewRecordState 转换后的消息格式
|
||||
* 字段来自 identity-service 的 user_accounts 表 + Debezium 元数据
|
||||
*/
|
||||
interface UnwrappedCdcUser {
|
||||
// 1.0 identity-service user_accounts 表字段
|
||||
user_id: number;
|
||||
phone_number: string;
|
||||
password_hash: string;
|
||||
account_sequence: string;
|
||||
status: string;
|
||||
created_at: number;
|
||||
registered_at: number; // timestamp in milliseconds
|
||||
|
||||
// Debezium ExtractNewRecordState 添加的元数据字段
|
||||
__op: 'c' | 'u' | 'd' | 'r'; // create, update, delete, read (snapshot)
|
||||
__table: string;
|
||||
__source_ts_ms: number;
|
||||
__deleted?: string; // 'true' for tombstone messages (delete with rewrite mode)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -59,7 +61,9 @@ export class LegacyUserCdcConsumer implements OnModuleInit, OnModuleDestroy {
|
|||
await this.consumer.connect();
|
||||
this.isConnected = true;
|
||||
|
||||
const topic = this.configService.get<string>('CDC_TOPIC_USERS', 'dbserver1.public.users');
|
||||
// Topic 格式: {topic.prefix}.{schema}.{table}
|
||||
// identity-connector.json 配置: topic.prefix = "cdc.identity"
|
||||
const topic = this.configService.get<string>('CDC_TOPIC_USERS', 'cdc.identity.public.user_accounts');
|
||||
await this.consumer.subscribe({ topic, fromBeginning: true });
|
||||
|
||||
await this.consumer.run({
|
||||
|
|
@ -87,8 +91,10 @@ export class LegacyUserCdcConsumer implements OnModuleInit, OnModuleDestroy {
|
|||
if (!message.value) return;
|
||||
|
||||
try {
|
||||
const cdcEvent: CdcUserPayload = JSON.parse(message.value.toString());
|
||||
await this.processCdcEvent(cdcEvent);
|
||||
const cdcEvent: UnwrappedCdcUser = JSON.parse(message.value.toString());
|
||||
// 使用 Kafka offset 作为序列号
|
||||
const sequenceNum = BigInt(message.offset);
|
||||
await this.processCdcEvent(cdcEvent, sequenceNum);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process CDC message from ${topic}[${partition}]`,
|
||||
|
|
@ -97,37 +103,32 @@ export class LegacyUserCdcConsumer implements OnModuleInit, OnModuleDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
private async processCdcEvent(event: CdcUserPayload) {
|
||||
const { before, after, source, op } = event;
|
||||
private async processCdcEvent(event: UnwrappedCdcUser, sequenceNum: bigint) {
|
||||
const op = event.__op;
|
||||
const isDeleted = event.__deleted === 'true';
|
||||
|
||||
// 处理删除操作(通过 rewrite mode,删除消息包含 __deleted: 'true')
|
||||
if (isDeleted || op === 'd') {
|
||||
await this.deleteLegacyUser(event.user_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理创建、更新、快照读取
|
||||
switch (op) {
|
||||
case 'c': // Create
|
||||
case 'r': // Read (snapshot)
|
||||
if (after) {
|
||||
await this.upsertLegacyUser(after, BigInt(source.sequence));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'u': // Update
|
||||
if (after) {
|
||||
await this.upsertLegacyUser(after, BigInt(source.sequence));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'd': // Delete
|
||||
if (before) {
|
||||
await this.deleteLegacyUser(before.id);
|
||||
}
|
||||
await this.upsertLegacyUser(event, sequenceNum);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertLegacyUser(user: CdcUser, sequenceNum: bigint) {
|
||||
private async upsertLegacyUser(user: UnwrappedCdcUser, sequenceNum: bigint) {
|
||||
try {
|
||||
await this.prisma.syncedLegacyUser.upsert({
|
||||
where: { legacyId: BigInt(user.id) },
|
||||
where: { legacyId: BigInt(user.user_id) },
|
||||
update: {
|
||||
phone: user.phone,
|
||||
phone: user.phone_number,
|
||||
passwordHash: user.password_hash,
|
||||
accountSequence: user.account_sequence,
|
||||
status: user.status,
|
||||
|
|
@ -135,19 +136,19 @@ export class LegacyUserCdcConsumer implements OnModuleInit, OnModuleDestroy {
|
|||
syncedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
legacyId: BigInt(user.id),
|
||||
phone: user.phone,
|
||||
legacyId: BigInt(user.user_id),
|
||||
phone: user.phone_number,
|
||||
passwordHash: user.password_hash,
|
||||
accountSequence: user.account_sequence,
|
||||
status: user.status,
|
||||
legacyCreatedAt: new Date(user.created_at),
|
||||
legacyCreatedAt: new Date(user.registered_at),
|
||||
sourceSequenceNum: sequenceNum,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.debug(`Synced legacy user: ${user.account_sequence}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to upsert legacy user ${user.id}`, error);
|
||||
this.logger.error(`Failed to upsert legacy user ${user.user_id}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ class AppConstants {
|
|||
static const String appName = '榴莲挖矿';
|
||||
|
||||
// API
|
||||
static const String baseUrl = 'http://localhost:3021';
|
||||
// 开发环境使用本地地址,生产环境使用 Kong 网关
|
||||
static const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'https://rwaapi.szaiai.com/api/v2/mining-auth',
|
||||
);
|
||||
static const Duration connectionTimeout = Duration(seconds: 30);
|
||||
static const Duration receiveTimeout = Duration(seconds: 30);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
|
|||
import '../network/api_client.dart';
|
||||
import '../network/interceptors.dart';
|
||||
import '../../data/datasources/local/cache_manager.dart';
|
||||
import '../../data/datasources/remote/auth_remote_datasource.dart';
|
||||
import '../../data/datasources/remote/mining_remote_datasource.dart';
|
||||
import '../../data/datasources/remote/trading_remote_datasource.dart';
|
||||
import '../../data/datasources/remote/contribution_remote_datasource.dart';
|
||||
|
|
@ -45,6 +46,11 @@ Future<void> configureDependencies() async {
|
|||
() => ContributionRemoteDataSourceImpl(client: getIt<ApiClient>()),
|
||||
);
|
||||
|
||||
// Auth Data Source
|
||||
getIt.registerLazySingleton<AuthRemoteDataSource>(
|
||||
() => AuthRemoteDataSourceImpl(client: getIt<ApiClient>()),
|
||||
);
|
||||
|
||||
// Repositories
|
||||
getIt.registerLazySingleton<MiningRepository>(
|
||||
() => MiningRepositoryImpl(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../error/exceptions.dart';
|
||||
|
||||
class ApiClient {
|
||||
final Dio dio;
|
||||
String? _accessToken;
|
||||
|
||||
ApiClient({required this.dio}) {
|
||||
dio.options = BaseOptions(
|
||||
|
|
@ -15,6 +17,29 @@ class ApiClient {
|
|||
'Accept': 'application/json',
|
||||
},
|
||||
);
|
||||
|
||||
// 添加请求拦截器,自动注入 token
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
if (_accessToken == null) {
|
||||
// 从存储中加载 token
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_accessToken = prefs.getString('access_token');
|
||||
}
|
||||
if (_accessToken != null) {
|
||||
options.headers['Authorization'] = 'Bearer $_accessToken';
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
void setAccessToken(String? token) {
|
||||
_accessToken = token;
|
||||
}
|
||||
|
||||
void clearAccessToken() {
|
||||
_accessToken = null;
|
||||
}
|
||||
|
||||
Future<Response> get(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
class ApiEndpoints {
|
||||
// Auth Service (v2)
|
||||
static const String sendSms = '/sms/send';
|
||||
static const String verifySms = '/sms/verify';
|
||||
static const String register = '/auth/register';
|
||||
static const String login = '/auth/login';
|
||||
static const String loginSms = '/auth/login-sms';
|
||||
static const String refreshToken = '/auth/refresh';
|
||||
static const String logout = '/auth/logout';
|
||||
static const String userProfile = '/user/profile';
|
||||
|
||||
// Mining Service
|
||||
static String shareAccount(String accountSequence) => '/api/v1/accounts/$accountSequence';
|
||||
static String miningRecords(String accountSequence) => '/api/v1/accounts/$accountSequence/records';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../presentation/pages/splash/splash_page.dart';
|
||||
import '../../presentation/pages/auth/login_page.dart';
|
||||
import '../../presentation/pages/auth/register_page.dart';
|
||||
import '../../presentation/pages/home/home_page.dart';
|
||||
import '../../presentation/pages/contribution/contribution_page.dart';
|
||||
import '../../presentation/pages/trading/trading_page.dart';
|
||||
|
|
@ -17,6 +18,14 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
path: Routes.splash,
|
||||
builder: (context, state) => const SplashPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.login,
|
||||
builder: (context, state) => const LoginPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.register,
|
||||
builder: (context, state) => const RegisterPage(),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => MainShell(child: child),
|
||||
routes: [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
class Routes {
|
||||
static const String splash = '/';
|
||||
static const String login = '/login';
|
||||
static const String register = '/register';
|
||||
static const String home = '/home';
|
||||
static const String contribution = '/contribution';
|
||||
static const String trading = '/trading';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_endpoints.dart';
|
||||
import '../../../core/error/exceptions.dart';
|
||||
|
||||
class AuthResult {
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final int expiresIn;
|
||||
final UserInfo user;
|
||||
|
||||
AuthResult({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.expiresIn,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
factory AuthResult.fromJson(Map<String, dynamic> json) {
|
||||
return AuthResult(
|
||||
accessToken: json['accessToken'] as String,
|
||||
refreshToken: json['refreshToken'] as String,
|
||||
expiresIn: json['expiresIn'] as int,
|
||||
user: UserInfo.fromJson(json['user'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserInfo {
|
||||
final String accountSequence;
|
||||
final String phone;
|
||||
final String source;
|
||||
final String kycStatus;
|
||||
|
||||
UserInfo({
|
||||
required this.accountSequence,
|
||||
required this.phone,
|
||||
required this.source,
|
||||
required this.kycStatus,
|
||||
});
|
||||
|
||||
factory UserInfo.fromJson(Map<String, dynamic> json) {
|
||||
return UserInfo(
|
||||
accountSequence: json['accountSequence'] as String,
|
||||
phone: json['phone'] as String,
|
||||
source: json['source'] as String,
|
||||
kycStatus: json['kycStatus'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class AuthRemoteDataSource {
|
||||
Future<void> sendSmsCode(String phone, String type);
|
||||
Future<bool> verifySmsCode(String phone, String code, String type);
|
||||
Future<AuthResult> register(String phone, String password, String smsCode);
|
||||
Future<AuthResult> loginWithPassword(String phone, String password);
|
||||
Future<AuthResult> loginWithSms(String phone, String smsCode);
|
||||
Future<String> refreshToken(String refreshToken);
|
||||
Future<void> logout(String refreshToken);
|
||||
Future<UserInfo> getProfile();
|
||||
}
|
||||
|
||||
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
||||
final ApiClient client;
|
||||
|
||||
AuthRemoteDataSourceImpl({required this.client});
|
||||
|
||||
@override
|
||||
Future<void> sendSmsCode(String phone, String type) async {
|
||||
try {
|
||||
await client.post(
|
||||
ApiEndpoints.sendSms,
|
||||
data: {'phone': phone, 'type': type},
|
||||
);
|
||||
} catch (e) {
|
||||
throw ServerException(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> verifySmsCode(String phone, String code, String type) async {
|
||||
try {
|
||||
final response = await client.post(
|
||||
ApiEndpoints.verifySms,
|
||||
data: {'phone': phone, 'code': code, 'type': type},
|
||||
);
|
||||
return response.data['valid'] as bool? ?? false;
|
||||
} catch (e) {
|
||||
throw ServerException(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthResult> register(String phone, String password, String smsCode) async {
|
||||
try {
|
||||
final response = await client.post(
|
||||
ApiEndpoints.register,
|
||||
data: {'phone': phone, 'password': password, 'smsCode': smsCode},
|
||||
);
|
||||
return AuthResult.fromJson(response.data as Map<String, dynamic>);
|
||||
} catch (e) {
|
||||
throw ServerException(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthResult> loginWithPassword(String phone, String password) async {
|
||||
try {
|
||||
final response = await client.post(
|
||||
ApiEndpoints.login,
|
||||
data: {'phone': phone, 'password': password},
|
||||
);
|
||||
return AuthResult.fromJson(response.data as Map<String, dynamic>);
|
||||
} catch (e) {
|
||||
throw ServerException(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthResult> loginWithSms(String phone, String smsCode) async {
|
||||
try {
|
||||
final response = await client.post(
|
||||
ApiEndpoints.loginSms,
|
||||
data: {'phone': phone, 'smsCode': smsCode},
|
||||
);
|
||||
return AuthResult.fromJson(response.data as Map<String, dynamic>);
|
||||
} catch (e) {
|
||||
throw ServerException(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> refreshToken(String refreshToken) async {
|
||||
try {
|
||||
final response = await client.post(
|
||||
ApiEndpoints.refreshToken,
|
||||
data: {'refreshToken': refreshToken},
|
||||
);
|
||||
return response.data['accessToken'] as String;
|
||||
} catch (e) {
|
||||
throw ServerException(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout(String refreshToken) async {
|
||||
try {
|
||||
await client.post(
|
||||
ApiEndpoints.logout,
|
||||
data: {'refreshToken': refreshToken},
|
||||
);
|
||||
} catch (e) {
|
||||
throw ServerException(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserInfo> getProfile() async {
|
||||
try {
|
||||
final response = await client.get(ApiEndpoints.userProfile);
|
||||
return UserInfo.fromJson(response.data as Map<String, dynamic>);
|
||||
} catch (e) {
|
||||
throw ServerException(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/router/routes.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../providers/user_providers.dart';
|
||||
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _phoneController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _smsCodeController = TextEditingController();
|
||||
|
||||
bool _isPasswordLogin = true;
|
||||
bool _obscurePassword = true;
|
||||
int _countDown = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_passwordController.dispose();
|
||||
_smsCodeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _sendSmsCode() async {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty || !RegExp(r'^1[3-9]\d{9}$').hasMatch(phone)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请输入正确的手机号')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ref.read(userNotifierProvider.notifier).sendSmsCode(phone, 'LOGIN');
|
||||
setState(() => _countDown = 60);
|
||||
_startCountDown();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('验证码已发送')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('发送失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startCountDown() {
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (_countDown > 0 && mounted) {
|
||||
setState(() => _countDown--);
|
||||
_startCountDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _login() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final phone = _phoneController.text.trim();
|
||||
|
||||
try {
|
||||
if (_isPasswordLogin) {
|
||||
await ref.read(userNotifierProvider.notifier).loginWithPassword(
|
||||
phone,
|
||||
_passwordController.text,
|
||||
);
|
||||
} else {
|
||||
await ref.read(userNotifierProvider.notifier).loginWithSms(
|
||||
phone,
|
||||
_smsCodeController.text.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
context.go(Routes.home);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('登录失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userState = ref.watch(userNotifierProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
|
||||
// Logo
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.eco,
|
||||
size: 48,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
const Text(
|
||||
'欢迎回来',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Text(
|
||||
'登录您的榴莲挖矿账户',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// 登录方式切换
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _isPasswordLogin = true),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: _isPasswordLogin ? AppColors.primary : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'密码登录',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: _isPasswordLogin ? FontWeight.bold : FontWeight.normal,
|
||||
color: _isPasswordLogin ? AppColors.primary : Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _isPasswordLogin = false),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: !_isPasswordLogin ? AppColors.primary : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'验证码登录',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: !_isPasswordLogin ? FontWeight.bold : FontWeight.normal,
|
||||
color: !_isPasswordLogin ? AppColors.primary : Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 手机号
|
||||
TextFormField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: InputDecoration(
|
||||
labelText: '手机号',
|
||||
prefixIcon: const Icon(Icons.phone),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入手机号';
|
||||
}
|
||||
if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(value)) {
|
||||
return '请输入正确的手机号';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 密码/验证码
|
||||
if (_isPasswordLogin)
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: '密码',
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||
),
|
||||
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入密码';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _smsCodeController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
decoration: InputDecoration(
|
||||
labelText: '验证码',
|
||||
prefixIcon: const Icon(Icons.sms),
|
||||
counterText: '',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入验证码';
|
||||
}
|
||||
if (value.length != 6) {
|
||||
return '验证码为6位';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
height: 56,
|
||||
child: ElevatedButton(
|
||||
onPressed: _countDown > 0 ? null : _sendSmsCode,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_countDown > 0 ? '${_countDown}s' : '获取验证码',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 登录按钮
|
||||
SizedBox(
|
||||
height: 52,
|
||||
child: ElevatedButton(
|
||||
onPressed: userState.isLoading ? null : _login,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: userState.isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'登录',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 注册入口
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'还没有账号?',
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.push(Routes.register),
|
||||
child: const Text('立即注册'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/router/routes.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../providers/user_providers.dart';
|
||||
|
||||
class RegisterPage extends ConsumerStatefulWidget {
|
||||
const RegisterPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RegisterPage> createState() => _RegisterPageState();
|
||||
}
|
||||
|
||||
class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _phoneController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
final _smsCodeController = TextEditingController();
|
||||
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
int _countDown = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_smsCodeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _sendSmsCode() async {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty || !RegExp(r'^1[3-9]\d{9}$').hasMatch(phone)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请输入正确的手机号')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ref.read(userNotifierProvider.notifier).sendSmsCode(phone, 'REGISTER');
|
||||
setState(() => _countDown = 60);
|
||||
_startCountDown();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('验证码已发送')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('发送失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startCountDown() {
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (_countDown > 0 && mounted) {
|
||||
setState(() => _countDown--);
|
||||
_startCountDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _register() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final phone = _phoneController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
final smsCode = _smsCodeController.text.trim();
|
||||
|
||||
try {
|
||||
await ref.read(userNotifierProvider.notifier).register(phone, password, smsCode);
|
||||
|
||||
if (mounted) {
|
||||
context.go(Routes.home);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('注册失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userState = ref.watch(userNotifierProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.black),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'创建账户',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Text(
|
||||
'加入榴莲挖矿,开启绿色财富之旅',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 手机号
|
||||
TextFormField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: InputDecoration(
|
||||
labelText: '手机号',
|
||||
prefixIcon: const Icon(Icons.phone),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入手机号';
|
||||
}
|
||||
if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(value)) {
|
||||
return '请输入正确的手机号';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 验证码
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _smsCodeController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
decoration: InputDecoration(
|
||||
labelText: '验证码',
|
||||
prefixIcon: const Icon(Icons.sms),
|
||||
counterText: '',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入验证码';
|
||||
}
|
||||
if (value.length != 6) {
|
||||
return '验证码为6位';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
height: 56,
|
||||
child: ElevatedButton(
|
||||
onPressed: _countDown > 0 ? null : _sendSmsCode,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_countDown > 0 ? '${_countDown}s' : '获取验证码',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 密码
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: '密码',
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||
),
|
||||
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入密码';
|
||||
}
|
||||
if (value.length < 6) {
|
||||
return '密码至少6位';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 确认密码
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _obscureConfirmPassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: '确认密码',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirmPassword ? Icons.visibility_off : Icons.visibility,
|
||||
),
|
||||
onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请确认密码';
|
||||
}
|
||||
if (value != _passwordController.text) {
|
||||
return '两次密码输入不一致';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 注册按钮
|
||||
SizedBox(
|
||||
height: 52,
|
||||
child: ElevatedButton(
|
||||
onPressed: userState.isLoading ? null : _register,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: userState.isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'注册',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 登录入口
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'已有账号?',
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('立即登录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -24,11 +24,27 @@ class _SplashPageState extends ConsumerState<SplashPage> {
|
|||
|
||||
if (!mounted) return;
|
||||
|
||||
// 设置模拟用户
|
||||
ref.read(userNotifierProvider.notifier).setMockUser();
|
||||
// 检查用户登录状态
|
||||
final userState = ref.read(userNotifierProvider);
|
||||
|
||||
if (userState.isLoggedIn) {
|
||||
// 已登录,尝试刷新token
|
||||
try {
|
||||
await ref.read(userNotifierProvider.notifier).refreshTokenIfNeeded();
|
||||
if (mounted) {
|
||||
context.go(Routes.home);
|
||||
}
|
||||
} catch (e) {
|
||||
// token刷新失败,跳转到登录页
|
||||
if (mounted) {
|
||||
context.go(Routes.login);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 未登录,跳转到登录页
|
||||
context.go(Routes.login);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
|
|||
|
|
@ -1,36 +1,198 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../data/datasources/remote/auth_remote_datasource.dart';
|
||||
import '../../core/di/injection.dart';
|
||||
|
||||
class UserState {
|
||||
final String? accountSequence;
|
||||
final String? nickname;
|
||||
final String? phone;
|
||||
final String? kycStatus;
|
||||
final String? source;
|
||||
final String? accessToken;
|
||||
final String? refreshToken;
|
||||
final bool isLoggedIn;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
UserState({
|
||||
this.accountSequence,
|
||||
this.nickname,
|
||||
this.phone,
|
||||
this.kycStatus,
|
||||
this.source,
|
||||
this.accessToken,
|
||||
this.refreshToken,
|
||||
this.isLoggedIn = false,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
UserState copyWith({
|
||||
String? accountSequence,
|
||||
String? nickname,
|
||||
String? phone,
|
||||
String? kycStatus,
|
||||
String? source,
|
||||
String? accessToken,
|
||||
String? refreshToken,
|
||||
bool? isLoggedIn,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return UserState(
|
||||
accountSequence: accountSequence ?? this.accountSequence,
|
||||
nickname: nickname ?? this.nickname,
|
||||
phone: phone ?? this.phone,
|
||||
kycStatus: kycStatus ?? this.kycStatus,
|
||||
source: source ?? this.source,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
refreshToken: refreshToken ?? this.refreshToken,
|
||||
isLoggedIn: isLoggedIn ?? this.isLoggedIn,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserNotifier extends StateNotifier<UserState> {
|
||||
UserNotifier() : super(UserState());
|
||||
final AuthRemoteDataSource _authDataSource;
|
||||
|
||||
UserNotifier(this._authDataSource) : super(UserState()) {
|
||||
_loadFromStorage();
|
||||
}
|
||||
|
||||
Future<void> _loadFromStorage() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final accessToken = prefs.getString('access_token');
|
||||
final refreshToken = prefs.getString('refresh_token');
|
||||
final accountSequence = prefs.getString('account_sequence');
|
||||
final phone = prefs.getString('phone');
|
||||
|
||||
if (accessToken != null && refreshToken != null && accountSequence != null) {
|
||||
state = state.copyWith(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
accountSequence: accountSequence,
|
||||
phone: phone,
|
||||
isLoggedIn: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveToStorage(AuthResult result) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('access_token', result.accessToken);
|
||||
await prefs.setString('refresh_token', result.refreshToken);
|
||||
await prefs.setString('account_sequence', result.user.accountSequence);
|
||||
await prefs.setString('phone', result.user.phone);
|
||||
}
|
||||
|
||||
Future<void> _clearStorage() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('access_token');
|
||||
await prefs.remove('refresh_token');
|
||||
await prefs.remove('account_sequence');
|
||||
await prefs.remove('phone');
|
||||
}
|
||||
|
||||
Future<void> sendSmsCode(String phone, String type) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
try {
|
||||
await _authDataSource.sendSmsCode(phone, type);
|
||||
state = state.copyWith(isLoading: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> register(String phone, String password, String smsCode) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
try {
|
||||
final result = await _authDataSource.register(phone, password, smsCode);
|
||||
await _saveToStorage(result);
|
||||
state = state.copyWith(
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
accountSequence: result.user.accountSequence,
|
||||
phone: result.user.phone,
|
||||
kycStatus: result.user.kycStatus,
|
||||
source: result.user.source,
|
||||
isLoggedIn: true,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loginWithPassword(String phone, String password) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
try {
|
||||
final result = await _authDataSource.loginWithPassword(phone, password);
|
||||
await _saveToStorage(result);
|
||||
state = state.copyWith(
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
accountSequence: result.user.accountSequence,
|
||||
phone: result.user.phone,
|
||||
kycStatus: result.user.kycStatus,
|
||||
source: result.user.source,
|
||||
isLoggedIn: true,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loginWithSms(String phone, String smsCode) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
try {
|
||||
final result = await _authDataSource.loginWithSms(phone, smsCode);
|
||||
await _saveToStorage(result);
|
||||
state = state.copyWith(
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
accountSequence: result.user.accountSequence,
|
||||
phone: result.user.phone,
|
||||
kycStatus: result.user.kycStatus,
|
||||
source: result.user.source,
|
||||
isLoggedIn: true,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
if (state.refreshToken != null) {
|
||||
try {
|
||||
await _authDataSource.logout(state.refreshToken!);
|
||||
} catch (_) {}
|
||||
}
|
||||
await _clearStorage();
|
||||
state = UserState();
|
||||
}
|
||||
|
||||
Future<void> refreshTokenIfNeeded() async {
|
||||
if (state.refreshToken == null) return;
|
||||
try {
|
||||
final newAccessToken = await _authDataSource.refreshToken(state.refreshToken!);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('access_token', newAccessToken);
|
||||
state = state.copyWith(accessToken: newAccessToken);
|
||||
} catch (e) {
|
||||
await logout();
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy method for compatibility
|
||||
void login({
|
||||
required String accountSequence,
|
||||
String? nickname,
|
||||
|
|
@ -44,10 +206,7 @@ class UserNotifier extends StateNotifier<UserState> {
|
|||
);
|
||||
}
|
||||
|
||||
void logout() {
|
||||
state = UserState();
|
||||
}
|
||||
|
||||
// Legacy mock method for development
|
||||
void setMockUser() {
|
||||
state = state.copyWith(
|
||||
accountSequence: '1001',
|
||||
|
|
@ -59,9 +218,17 @@ class UserNotifier extends StateNotifier<UserState> {
|
|||
}
|
||||
|
||||
final userNotifierProvider = StateNotifierProvider<UserNotifier, UserState>(
|
||||
(ref) => UserNotifier(),
|
||||
(ref) => UserNotifier(getIt<AuthRemoteDataSource>()),
|
||||
);
|
||||
|
||||
final currentAccountSequenceProvider = Provider<String?>((ref) {
|
||||
return ref.watch(userNotifierProvider).accountSequence;
|
||||
});
|
||||
|
||||
final isLoggedInProvider = Provider<bool>((ref) {
|
||||
return ref.watch(userNotifierProvider).isLoggedIn;
|
||||
});
|
||||
|
||||
final accessTokenProvider = Provider<String?>((ref) {
|
||||
return ref.watch(userNotifierProvider).accessToken;
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue