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:
hailin 2026-01-11 05:29:48 -08:00
parent 9fca17e7ed
commit 4bb995f2c2
16 changed files with 1318 additions and 52 deletions

View File

@ -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": []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: [

View File

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

View File

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

View File

@ -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('立即注册'),
),
],
),
],
),
),
),
),
);
}
}

View File

@ -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('立即登录'),
),
],
),
],
),
),
),
),
);
}
}

View File

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

View File

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