diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 54499695..843e9494 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 \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 \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 \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 \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 \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": [] diff --git a/backend/services/auth-service/.env.example b/backend/services/auth-service/.env.example index 3f08be64..0c908305 100644 --- a/backend/services/auth-service/.env.example +++ b/backend/services/auth-service/.env.example @@ -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 diff --git a/backend/services/auth-service/src/api/controllers/auth.controller.ts b/backend/services/auth-service/src/api/controllers/auth.controller.ts index 015de1d0..73aa5d29 100644 --- a/backend/services/auth-service/src/api/controllers/auth.controller.ts +++ b/backend/services/auth-service/src/api/controllers/auth.controller.ts @@ -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 }> { diff --git a/backend/services/auth-service/src/application/services/auth.service.ts b/backend/services/auth-service/src/application/services/auth.service.ts index 2c07a0f1..0ab586ed 100644 --- a/backend/services/auth-service/src/application/services/auth.service.ts +++ b/backend/services/auth-service/src/application/services/auth.service.ts @@ -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 { + 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 { + 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 { + 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 */ diff --git a/backend/services/auth-service/src/infrastructure/messaging/cdc/legacy-user-cdc.consumer.ts b/backend/services/auth-service/src/infrastructure/messaging/cdc/legacy-user-cdc.consumer.ts index 8ffcee9e..d7d5a59b 100644 --- a/backend/services/auth-service/src/infrastructure/messaging/cdc/legacy-user-cdc.consumer.ts +++ b/backend/services/auth-service/src/infrastructure/messaging/cdc/legacy-user-cdc.consumer.ts @@ -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('CDC_TOPIC_USERS', 'dbserver1.public.users'); + // Topic 格式: {topic.prefix}.{schema}.{table} + // identity-connector.json 配置: topic.prefix = "cdc.identity" + const topic = this.configService.get('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; } } diff --git a/frontend/mining-app/lib/core/constants/app_constants.dart b/frontend/mining-app/lib/core/constants/app_constants.dart index 17c6b305..81adaa10 100644 --- a/frontend/mining-app/lib/core/constants/app_constants.dart +++ b/frontend/mining-app/lib/core/constants/app_constants.dart @@ -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); diff --git a/frontend/mining-app/lib/core/di/injection.dart b/frontend/mining-app/lib/core/di/injection.dart index 4c6e093d..1be9d596 100644 --- a/frontend/mining-app/lib/core/di/injection.dart +++ b/frontend/mining-app/lib/core/di/injection.dart @@ -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 configureDependencies() async { () => ContributionRemoteDataSourceImpl(client: getIt()), ); + // Auth Data Source + getIt.registerLazySingleton( + () => AuthRemoteDataSourceImpl(client: getIt()), + ); + // Repositories getIt.registerLazySingleton( () => MiningRepositoryImpl( diff --git a/frontend/mining-app/lib/core/network/api_client.dart b/frontend/mining-app/lib/core/network/api_client.dart index 7ad62802..3508734b 100644 --- a/frontend/mining-app/lib/core/network/api_client.dart +++ b/frontend/mining-app/lib/core/network/api_client.dart @@ -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 get( diff --git a/frontend/mining-app/lib/core/network/api_endpoints.dart b/frontend/mining-app/lib/core/network/api_endpoints.dart index 440ab495..5def5234 100644 --- a/frontend/mining-app/lib/core/network/api_endpoints.dart +++ b/frontend/mining-app/lib/core/network/api_endpoints.dart @@ -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'; diff --git a/frontend/mining-app/lib/core/router/app_router.dart b/frontend/mining-app/lib/core/router/app_router.dart index 5c0bd3ce..07bfff97 100644 --- a/frontend/mining-app/lib/core/router/app_router.dart +++ b/frontend/mining-app/lib/core/router/app_router.dart @@ -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((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: [ diff --git a/frontend/mining-app/lib/core/router/routes.dart b/frontend/mining-app/lib/core/router/routes.dart index 6611d897..56d684b4 100644 --- a/frontend/mining-app/lib/core/router/routes.dart +++ b/frontend/mining-app/lib/core/router/routes.dart @@ -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'; diff --git a/frontend/mining-app/lib/data/datasources/remote/auth_remote_datasource.dart b/frontend/mining-app/lib/data/datasources/remote/auth_remote_datasource.dart new file mode 100644 index 00000000..09d441c8 --- /dev/null +++ b/frontend/mining-app/lib/data/datasources/remote/auth_remote_datasource.dart @@ -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 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), + ); + } +} + +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 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 sendSmsCode(String phone, String type); + Future verifySmsCode(String phone, String code, String type); + Future register(String phone, String password, String smsCode); + Future loginWithPassword(String phone, String password); + Future loginWithSms(String phone, String smsCode); + Future refreshToken(String refreshToken); + Future logout(String refreshToken); + Future getProfile(); +} + +class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { + final ApiClient client; + + AuthRemoteDataSourceImpl({required this.client}); + + @override + Future 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 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 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); + } catch (e) { + throw ServerException(e.toString()); + } + } + + @override + Future 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); + } catch (e) { + throw ServerException(e.toString()); + } + } + + @override + Future 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); + } catch (e) { + throw ServerException(e.toString()); + } + } + + @override + Future 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 logout(String refreshToken) async { + try { + await client.post( + ApiEndpoints.logout, + data: {'refreshToken': refreshToken}, + ); + } catch (e) { + throw ServerException(e.toString()); + } + } + + @override + Future getProfile() async { + try { + final response = await client.get(ApiEndpoints.userProfile); + return UserInfo.fromJson(response.data as Map); + } catch (e) { + throw ServerException(e.toString()); + } + } +} diff --git a/frontend/mining-app/lib/presentation/pages/auth/login_page.dart b/frontend/mining-app/lib/presentation/pages/auth/login_page.dart new file mode 100644 index 00000000..fc8f978b --- /dev/null +++ b/frontend/mining-app/lib/presentation/pages/auth/login_page.dart @@ -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 createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _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 _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('立即注册'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/mining-app/lib/presentation/pages/auth/register_page.dart b/frontend/mining-app/lib/presentation/pages/auth/register_page.dart new file mode 100644 index 00000000..453757f9 --- /dev/null +++ b/frontend/mining-app/lib/presentation/pages/auth/register_page.dart @@ -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 createState() => _RegisterPageState(); +} + +class _RegisterPageState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _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 _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('立即登录'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/mining-app/lib/presentation/pages/splash/splash_page.dart b/frontend/mining-app/lib/presentation/pages/splash/splash_page.dart index a4abd36a..e7ed9be7 100644 --- a/frontend/mining-app/lib/presentation/pages/splash/splash_page.dart +++ b/frontend/mining-app/lib/presentation/pages/splash/splash_page.dart @@ -24,10 +24,26 @@ class _SplashPageState extends ConsumerState { if (!mounted) return; - // 设置模拟用户 - ref.read(userNotifierProvider.notifier).setMockUser(); + // 检查用户登录状态 + final userState = ref.read(userNotifierProvider); - context.go(Routes.home); + 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 diff --git a/frontend/mining-app/lib/presentation/providers/user_providers.dart b/frontend/mining-app/lib/presentation/providers/user_providers.dart index 4954c94a..d1611498 100644 --- a/frontend/mining-app/lib/presentation/providers/user_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/user_providers.dart @@ -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 { - UserNotifier() : super(UserState()); + final AuthRemoteDataSource _authDataSource; + UserNotifier(this._authDataSource) : super(UserState()) { + _loadFromStorage(); + } + + Future _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 _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 _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 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 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 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 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 logout() async { + if (state.refreshToken != null) { + try { + await _authDataSource.logout(state.refreshToken!); + } catch (_) {} + } + await _clearStorage(); + state = UserState(); + } + + Future 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 { ); } - void logout() { - state = UserState(); - } - + // Legacy mock method for development void setMockUser() { state = state.copyWith( accountSequence: '1001', @@ -59,9 +218,17 @@ class UserNotifier extends StateNotifier { } final userNotifierProvider = StateNotifierProvider( - (ref) => UserNotifier(), + (ref) => UserNotifier(getIt()), ); final currentAccountSequenceProvider = Provider((ref) { return ref.watch(userNotifierProvider).accountSequence; }); + +final isLoggedInProvider = Provider((ref) { + return ref.watch(userNotifierProvider).isLoggedIn; +}); + +final accessTokenProvider = Provider((ref) { + return ref.watch(userNotifierProvider).accessToken; +});