From a1aba14ccfd88bbd7f43618f62ff23611616e510 Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 5 Feb 2026 18:12:39 -0800 Subject: [PATCH] =?UTF-8?q?feat(trade-password):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=8D=96=E5=87=BA=E4=BA=A4=E6=98=93=E7=9A=84=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 后端改动 ### auth-service - user.aggregate.ts: 添加支付密码相关方法 (setTradePassword, verifyTradePassword, hasTradePassword) - trade-password.service.ts: 新建支付密码业务逻辑服务 - trade-password.controller.ts: 新建支付密码 REST API (status/set/change/verify) - user.repository.ts: 添加 tradePasswordHash 字段的持久化 - schema.prisma: 添加 trade_password_hash 字段 - migration 0003: 添加支付密码字段迁移 ### trading-service - audit-ledger.service.ts: 新建审计分类账服务 (Append-Only设计,仅INSERT) - schema.prisma: 添加 AuditLedger 模型和 AuditActionType 枚举 - migration 0008: 添加审计分类账表迁移 ## 前端改动 (mining-app) ### 新增页面/组件 - trade_password_page.dart: 支付密码设置/修改页面 (6位数字) - trade_password_dialog.dart: 交易时的支付密码验证弹窗 ### 功能集成 - trading_page.dart: 卖出时检查支付密码 - 未设置: 提示用户跳转设置页面 - 已设置: 弹出验证弹窗,验证通过后才能卖出 - profile_page.dart: 账户设置增加"支付密码"入口 - user_providers.dart: 添加支付密码相关 Provider - auth_remote_datasource.dart: 添加支付密码 API 调用 - api_endpoints.dart: 添加支付密码 API 端点 - routes.dart/app_router.dart: 添加支付密码页面路由 ## 安全设计 - 支付密码独立于登录密码 (6位纯数字) - 审计分类账采用链式哈希保证完整性 - 所有敏感操作记录不可变审计日志 Co-Authored-By: Claude Opus 4.5 --- .../0003_add_trade_password/migration.sql | 8 + .../auth-service/prisma/schema.prisma | 1 + .../auth-service/src/api/api.module.ts | 2 + .../auth-service/src/api/controllers/index.ts | 1 + .../controllers/trade-password.controller.ts | 104 +++++ .../src/application/application.module.ts | 3 + .../src/application/services/index.ts | 1 + .../services/trade-password.service.ts | 144 +++++++ .../src/domain/aggregates/user.aggregate.ts | 43 +++ .../repositories/user.repository.ts | 2 + .../0008_add_audit_ledger/migration.sql | 51 +++ .../trading-service/prisma/schema.prisma | 50 +++ .../src/application/application.module.ts | 4 +- .../services/audit-ledger.service.ts | 197 ++++++++++ .../lib/core/network/api_endpoints.dart | 6 + .../lib/core/router/app_router.dart | 5 + .../mining-app/lib/core/router/routes.dart | 1 + .../remote/auth_remote_datasource.dart | 52 +++ .../pages/auth/trade_password_page.dart | 365 ++++++++++++++++++ .../pages/profile/profile_page.dart | 17 +- .../pages/trading/trading_page.dart | 54 +++ .../providers/user_providers.dart | 48 +++ .../widgets/trade_password_dialog.dart | 243 ++++++++++++ 23 files changed, 1396 insertions(+), 6 deletions(-) create mode 100644 backend/services/auth-service/prisma/migrations/0003_add_trade_password/migration.sql create mode 100644 backend/services/auth-service/src/api/controllers/trade-password.controller.ts create mode 100644 backend/services/auth-service/src/application/services/trade-password.service.ts create mode 100644 backend/services/trading-service/prisma/migrations/0008_add_audit_ledger/migration.sql create mode 100644 backend/services/trading-service/src/application/services/audit-ledger.service.ts create mode 100644 frontend/mining-app/lib/presentation/pages/auth/trade_password_page.dart create mode 100644 frontend/mining-app/lib/presentation/widgets/trade_password_dialog.dart diff --git a/backend/services/auth-service/prisma/migrations/0003_add_trade_password/migration.sql b/backend/services/auth-service/prisma/migrations/0003_add_trade_password/migration.sql new file mode 100644 index 00000000..1d1ac183 --- /dev/null +++ b/backend/services/auth-service/prisma/migrations/0003_add_trade_password/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +-- 添加支付密码字段 +-- 支付密码独立于登录密码,用于交易时的二次验证 +-- 存储的是bcrypt哈希值,不是明文密码 +ALTER TABLE "users" ADD COLUMN "trade_password_hash" TEXT; + +-- 添加注释说明该字段用途 +COMMENT ON COLUMN "users"."trade_password_hash" IS '支付密码哈希值 - 用于交易时的二次安全验证,独立于登录密码'; diff --git a/backend/services/auth-service/prisma/schema.prisma b/backend/services/auth-service/prisma/schema.prisma index 6aab3018..a15983a9 100644 --- a/backend/services/auth-service/prisma/schema.prisma +++ b/backend/services/auth-service/prisma/schema.prisma @@ -20,6 +20,7 @@ model User { // 基本信息 phone String @unique passwordHash String @map("password_hash") + tradePasswordHash String? @map("trade_password_hash") // 支付密码(独立于登录密码) // 统一关联键 (跨所有服务) // V1: 12位 (D + 6位日期 + 5位序号), 如 D2512110008 diff --git a/backend/services/auth-service/src/api/api.module.ts b/backend/services/auth-service/src/api/api.module.ts index 290601c1..1cd8824f 100644 --- a/backend/services/auth-service/src/api/api.module.ts +++ b/backend/services/auth-service/src/api/api.module.ts @@ -5,6 +5,7 @@ import { AuthController, SmsController, PasswordController, + TradePasswordController, KycController, UserController, HealthController, @@ -32,6 +33,7 @@ import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard'; AuthController, SmsController, PasswordController, + TradePasswordController, KycController, UserController, HealthController, diff --git a/backend/services/auth-service/src/api/controllers/index.ts b/backend/services/auth-service/src/api/controllers/index.ts index 4b6ec35d..fa45d3c9 100644 --- a/backend/services/auth-service/src/api/controllers/index.ts +++ b/backend/services/auth-service/src/api/controllers/index.ts @@ -1,6 +1,7 @@ export * from './auth.controller'; export * from './sms.controller'; export * from './password.controller'; +export * from './trade-password.controller'; export * from './kyc.controller'; export * from './user.controller'; export * from './health.controller'; diff --git a/backend/services/auth-service/src/api/controllers/trade-password.controller.ts b/backend/services/auth-service/src/api/controllers/trade-password.controller.ts new file mode 100644 index 00000000..05c372c2 --- /dev/null +++ b/backend/services/auth-service/src/api/controllers/trade-password.controller.ts @@ -0,0 +1,104 @@ +import { + Controller, + Post, + Get, + Body, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { ThrottlerGuard } from '@nestjs/throttler'; +import { TradePasswordService } from '@/application/services/trade-password.service'; +import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard'; +import { CurrentUser } from '@/shared/decorators/current-user.decorator'; + +class SetTradePasswordDto { + loginPassword: string; + tradePassword: string; +} + +class ChangeTradePasswordDto { + oldTradePassword: string; + newTradePassword: string; +} + +class VerifyTradePasswordDto { + tradePassword: string; +} + +@Controller('auth/trade-password') +@UseGuards(ThrottlerGuard) +export class TradePasswordController { + constructor(private readonly tradePasswordService: TradePasswordService) {} + + /** + * 获取支付密码状态 + * GET /trade-password/status + */ + @Get('status') + @UseGuards(JwtAuthGuard) + async getStatus( + @CurrentUser() user: { accountSequence: string }, + ): Promise<{ hasTradePassword: boolean }> { + return this.tradePasswordService.getStatus(user.accountSequence); + } + + /** + * 设置支付密码(需要验证登录密码) + * POST /trade-password/set + */ + @Post('set') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + async setTradePassword( + @CurrentUser() user: { accountSequence: string }, + @Body() dto: SetTradePasswordDto, + ): Promise<{ success: boolean }> { + await this.tradePasswordService.setTradePassword({ + accountSequence: user.accountSequence, + loginPassword: dto.loginPassword, + tradePassword: dto.tradePassword, + }); + + return { success: true }; + } + + /** + * 修改支付密码 + * POST /trade-password/change + */ + @Post('change') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + async changeTradePassword( + @CurrentUser() user: { accountSequence: string }, + @Body() dto: ChangeTradePasswordDto, + ): Promise<{ success: boolean }> { + await this.tradePasswordService.changeTradePassword({ + accountSequence: user.accountSequence, + oldTradePassword: dto.oldTradePassword, + newTradePassword: dto.newTradePassword, + }); + + return { success: true }; + } + + /** + * 验证支付密码 + * POST /trade-password/verify + */ + @Post('verify') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + async verifyTradePassword( + @CurrentUser() user: { accountSequence: string }, + @Body() dto: VerifyTradePasswordDto, + ): Promise<{ valid: boolean }> { + const valid = await this.tradePasswordService.verifyTradePassword({ + accountSequence: user.accountSequence, + tradePassword: dto.tradePassword, + }); + + return { valid }; + } +} diff --git a/backend/services/auth-service/src/application/application.module.ts b/backend/services/auth-service/src/application/application.module.ts index d6d2fc71..b4fa8a42 100644 --- a/backend/services/auth-service/src/application/application.module.ts +++ b/backend/services/auth-service/src/application/application.module.ts @@ -5,6 +5,7 @@ import { ScheduleModule } from '@nestjs/schedule'; import { AuthService, PasswordService, + TradePasswordService, SmsService, KycService, UserService, @@ -32,6 +33,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module'; providers: [ AuthService, PasswordService, + TradePasswordService, SmsService, KycService, UserService, @@ -42,6 +44,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module'; exports: [ AuthService, PasswordService, + TradePasswordService, SmsService, KycService, UserService, diff --git a/backend/services/auth-service/src/application/services/index.ts b/backend/services/auth-service/src/application/services/index.ts index 1631bc62..dd5440a4 100644 --- a/backend/services/auth-service/src/application/services/index.ts +++ b/backend/services/auth-service/src/application/services/index.ts @@ -1,5 +1,6 @@ export * from './auth.service'; export * from './password.service'; +export * from './trade-password.service'; export * from './sms.service'; export * from './kyc.service'; export * from './user.service'; diff --git a/backend/services/auth-service/src/application/services/trade-password.service.ts b/backend/services/auth-service/src/application/services/trade-password.service.ts new file mode 100644 index 00000000..64ca52a8 --- /dev/null +++ b/backend/services/auth-service/src/application/services/trade-password.service.ts @@ -0,0 +1,144 @@ +import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common'; +import { + USER_REPOSITORY, + UserRepository, + AccountSequence, +} from '@/domain'; + +export interface SetTradePasswordDto { + accountSequence: string; + loginPassword: string; // 需要验证登录密码 + tradePassword: string; +} + +export interface ChangeTradePasswordDto { + accountSequence: string; + oldTradePassword: string; + newTradePassword: string; +} + +export interface VerifyTradePasswordDto { + accountSequence: string; + tradePassword: string; +} + +export interface TradePasswordStatusDto { + hasTradePassword: boolean; +} + +@Injectable() +export class TradePasswordService { + constructor( + @Inject(USER_REPOSITORY) + private readonly userRepository: UserRepository, + ) {} + + /** + * 获取支付密码状态 + */ + async getStatus(accountSequence: string): Promise { + const user = await this.userRepository.findByAccountSequence( + AccountSequence.create(accountSequence), + ); + + if (!user) { + throw new NotFoundException('用户不存在'); + } + + return { + hasTradePassword: user.hasTradePassword, + }; + } + + /** + * 设置支付密码(首次设置或修改) + * 首次设置需要验证登录密码 + */ + async setTradePassword(dto: SetTradePasswordDto): Promise { + const user = await this.userRepository.findByAccountSequence( + AccountSequence.create(dto.accountSequence), + ); + + if (!user) { + throw new NotFoundException('用户不存在'); + } + + // 验证登录密码 + const isLoginPasswordValid = await user.verifyPassword(dto.loginPassword); + if (!isLoginPasswordValid) { + throw new BadRequestException('登录密码错误'); + } + + // 支付密码不能与登录密码相同 + const isSameAsLogin = await user.verifyPassword(dto.tradePassword); + if (isSameAsLogin) { + throw new BadRequestException('支付密码不能与登录密码相同'); + } + + // 验证密码格式(6位数字) + if (!/^\d{6}$/.test(dto.tradePassword)) { + throw new BadRequestException('支付密码必须是6位数字'); + } + + // 设置支付密码 + await user.setTradePassword(dto.tradePassword); + await this.userRepository.save(user); + } + + /** + * 修改支付密码(需要验证旧密码) + */ + async changeTradePassword(dto: ChangeTradePasswordDto): Promise { + const user = await this.userRepository.findByAccountSequence( + AccountSequence.create(dto.accountSequence), + ); + + if (!user) { + throw new NotFoundException('用户不存在'); + } + + if (!user.hasTradePassword) { + throw new BadRequestException('尚未设置支付密码'); + } + + // 验证旧支付密码 + const isOldPasswordValid = await user.verifyTradePassword(dto.oldTradePassword); + if (!isOldPasswordValid) { + throw new BadRequestException('原支付密码错误'); + } + + // 新密码不能与旧密码相同 + if (dto.oldTradePassword === dto.newTradePassword) { + throw new BadRequestException('新密码不能与原密码相同'); + } + + // 验证新密码格式(6位数字) + if (!/^\d{6}$/.test(dto.newTradePassword)) { + throw new BadRequestException('支付密码必须是6位数字'); + } + + // 设置新支付密码 + await user.setTradePassword(dto.newTradePassword); + await this.userRepository.save(user); + } + + /** + * 验证支付密码 + */ + async verifyTradePassword(dto: VerifyTradePasswordDto): Promise { + const user = await this.userRepository.findByAccountSequence( + AccountSequence.create(dto.accountSequence), + ); + + if (!user) { + throw new NotFoundException('用户不存在'); + } + + if (!user.hasTradePassword) { + // 未设置支付密码,视为验证通过(允许交易) + return true; + } + + return user.verifyTradePassword(dto.tradePassword); + } +} diff --git a/backend/services/auth-service/src/domain/aggregates/user.aggregate.ts b/backend/services/auth-service/src/domain/aggregates/user.aggregate.ts index 21d456a4..837965b1 100644 --- a/backend/services/auth-service/src/domain/aggregates/user.aggregate.ts +++ b/backend/services/auth-service/src/domain/aggregates/user.aggregate.ts @@ -17,6 +17,7 @@ export interface UserProps { id?: bigint; phone: Phone; passwordHash: string; + tradePasswordHash?: string; // 支付密码(独立于登录密码) accountSequence: AccountSequence; status: UserStatus; kycStatus: KycStatus; @@ -42,6 +43,7 @@ export class UserAggregate { private _id?: bigint; private _phone: Phone; private _passwordHash: string; + private _tradePasswordHash?: string; // 支付密码哈希 private _accountSequence: AccountSequence; private _status: UserStatus; private _kycStatus: KycStatus; @@ -63,6 +65,7 @@ export class UserAggregate { this._id = props.id; this._phone = props.phone; this._passwordHash = props.passwordHash; + this._tradePasswordHash = props.tradePasswordHash; this._accountSequence = props.accountSequence; this._status = props.status; this._kycStatus = props.kycStatus; @@ -120,6 +123,17 @@ export class UserAggregate { return this._passwordHash; } + get tradePasswordHash(): string | undefined { + return this._tradePasswordHash; + } + + /** + * 是否已设置支付密码 + */ + get hasTradePassword(): boolean { + return this._tradePasswordHash !== undefined && this._tradePasswordHash !== null; + } + get accountSequence(): AccountSequence { return this._accountSequence; } @@ -236,6 +250,34 @@ export class UserAggregate { this._updatedAt = new Date(); } + /** + * 设置支付密码 + */ + async setTradePassword(newPlainPassword: string): Promise { + const password = await Password.create(newPlainPassword); + this._tradePasswordHash = password.hash; + this._updatedAt = new Date(); + } + + /** + * 验证支付密码 + */ + async verifyTradePassword(plainPassword: string): Promise { + if (!this._tradePasswordHash) { + return false; + } + const password = Password.fromHash(this._tradePasswordHash); + return password.verify(plainPassword); + } + + /** + * 清除支付密码 + */ + clearTradePassword(): void { + this._tradePasswordHash = undefined; + this._updatedAt = new Date(); + } + /** * 记录登录成功 */ @@ -398,6 +440,7 @@ export class UserAggregate { id: this._id, phone: this._phone, passwordHash: this._passwordHash, + tradePasswordHash: this._tradePasswordHash, accountSequence: this._accountSequence, status: this._status, kycStatus: this._kycStatus, diff --git a/backend/services/auth-service/src/infrastructure/persistence/repositories/user.repository.ts b/backend/services/auth-service/src/infrastructure/persistence/repositories/user.repository.ts index d81cbaf2..2d1be690 100644 --- a/backend/services/auth-service/src/infrastructure/persistence/repositories/user.repository.ts +++ b/backend/services/auth-service/src/infrastructure/persistence/repositories/user.repository.ts @@ -45,6 +45,7 @@ export class PrismaUserRepository implements UserRepository { const data = { phone: snapshot.phone.value, passwordHash: snapshot.passwordHash, + tradePasswordHash: snapshot.tradePasswordHash, accountSequence: snapshot.accountSequence.value, status: snapshot.status, kycStatus: snapshot.kycStatus, @@ -120,6 +121,7 @@ export class PrismaUserRepository implements UserRepository { id: user.id, phone: Phone.create(user.phone), passwordHash: user.passwordHash, + tradePasswordHash: user.tradePasswordHash, accountSequence: AccountSequence.create(user.accountSequence), status: user.status as UserStatus, kycStatus: user.kycStatus as KycStatus, diff --git a/backend/services/trading-service/prisma/migrations/0008_add_audit_ledger/migration.sql b/backend/services/trading-service/prisma/migrations/0008_add_audit_ledger/migration.sql new file mode 100644 index 00000000..b83b48f9 --- /dev/null +++ b/backend/services/trading-service/prisma/migrations/0008_add_audit_ledger/migration.sql @@ -0,0 +1,51 @@ +-- CreateEnum +CREATE TYPE "AuditActionType" AS ENUM ( + 'SELL_ORDER_CREATED', + 'SELL_ORDER_EXECUTED', + 'TRADE_PASSWORD_SET', + 'TRADE_PASSWORD_CHANGED', + 'TRADE_PASSWORD_VERIFIED', + 'TRADE_PASSWORD_FAILED', + 'TRANSFER_IN', + 'TRANSFER_OUT', + 'P2P_TRANSFER' +); + +-- CreateTable +-- 审计分类账(Append-Only) +-- 设计原则:只增加,不修改,不删除 +-- 用于记录所有敏感操作,如交易、支付密码验证等 +CREATE TABLE "audit_ledgers" ( + "id" TEXT NOT NULL, + "account_sequence" TEXT NOT NULL, + "action_type" "AuditActionType" NOT NULL, + "action_detail" JSONB NOT NULL, + "ip_address" VARCHAR(45), + "user_agent" TEXT, + "prev_hash" VARCHAR(64), + "record_hash" VARCHAR(64) NOT NULL, + "reference_id" TEXT, + "reference_type" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_ledgers_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +-- 按用户和时间查询 +CREATE INDEX "audit_ledgers_account_sequence_created_at_idx" ON "audit_ledgers"("account_sequence", "created_at" DESC); + +-- CreateIndex +-- 按操作类型查询 +CREATE INDEX "audit_ledgers_action_type_idx" ON "audit_ledgers"("action_type"); + +-- CreateIndex +-- 按关联业务ID查询 +CREATE INDEX "audit_ledgers_reference_id_idx" ON "audit_ledgers"("reference_id"); + +-- CreateIndex +-- 按时间查询 +CREATE INDEX "audit_ledgers_created_at_idx" ON "audit_ledgers"("created_at" DESC); + +-- 添加注释说明该表为Append-Only设计 +COMMENT ON TABLE "audit_ledgers" IS '审计分类账 - Append-Only设计,仅支持INSERT操作,禁止UPDATE和DELETE'; diff --git a/backend/services/trading-service/prisma/schema.prisma b/backend/services/trading-service/prisma/schema.prisma index 0b295b3d..d8c3421f 100644 --- a/backend/services/trading-service/prisma/schema.prisma +++ b/backend/services/trading-service/prisma/schema.prisma @@ -837,3 +837,53 @@ model MarketMakerWithdraw { @@index([createdAt(sort: Desc)]) @@map("market_maker_withdraws") } + +// ==================== 审计分类账(Append-Only)==================== +// 用于记录敏感操作的不可变审计日志 +// 设计原则:只增加,不修改,不删除 + +// 审计操作类型 +enum AuditActionType { + SELL_ORDER_CREATED // 卖出订单创建 + SELL_ORDER_EXECUTED // 卖出订单成交 + TRADE_PASSWORD_SET // 支付密码设置 + TRADE_PASSWORD_CHANGED // 支付密码修改 + TRADE_PASSWORD_VERIFIED // 支付密码验证成功 + TRADE_PASSWORD_FAILED // 支付密码验证失败 + TRANSFER_IN // 划入交易账户 + TRANSFER_OUT // 划出到分配账户 + P2P_TRANSFER // P2P转账 +} + +// 审计分类账 +model AuditLedger { + id String @id @default(uuid()) + + // 用户标识 + accountSequence String @map("account_sequence") + + // 操作信息 + actionType AuditActionType @map("action_type") + actionDetail Json @map("action_detail") // 操作详情(JSON格式) + + // 请求上下文 + ipAddress String? @map("ip_address") @db.VarChar(45) + userAgent String? @map("user_agent") @db.Text + + // 链式哈希(保证完整性) + prevHash String? @map("prev_hash") @db.VarChar(64) // 前一条记录的哈希 + recordHash String @map("record_hash") @db.VarChar(64) // 当前记录的哈希 + + // 关联业务ID(可选) + referenceId String? @map("reference_id") // 订单ID、交易ID等 + referenceType String? @map("reference_type") // ORDER, TRADE, TRANSFER等 + + // 时间戳(不可变) + createdAt DateTime @default(now()) @map("created_at") + + @@index([accountSequence, createdAt(sort: Desc)]) + @@index([actionType]) + @@index([referenceId]) + @@index([createdAt(sort: Desc)]) + @@map("audit_ledgers") +} diff --git a/backend/services/trading-service/src/application/application.module.ts b/backend/services/trading-service/src/application/application.module.ts index 44ebdffc..e9254f73 100644 --- a/backend/services/trading-service/src/application/application.module.ts +++ b/backend/services/trading-service/src/application/application.module.ts @@ -12,6 +12,7 @@ import { MarketMakerService } from './services/market-maker.service'; import { C2cService } from './services/c2c.service'; import { C2cBotService } from './services/c2c-bot.service'; import { PaymentProofService } from './services/payment-proof.service'; +import { AuditLedgerService } from './services/audit-ledger.service'; import { OutboxScheduler } from './schedulers/outbox.scheduler'; import { BurnScheduler } from './schedulers/burn.scheduler'; import { PriceBroadcastScheduler } from './schedulers/price-broadcast.scheduler'; @@ -36,6 +37,7 @@ import { C2cBotScheduler } from './schedulers/c2c-bot.scheduler'; C2cService, C2cBotService, PaymentProofService, + AuditLedgerService, // Schedulers OutboxScheduler, BurnScheduler, @@ -43,6 +45,6 @@ import { C2cBotScheduler } from './schedulers/c2c-bot.scheduler'; C2cExpiryScheduler, C2cBotScheduler, ], - exports: [OrderService, TransferService, P2pTransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService, C2cBotService, PaymentProofService], + exports: [OrderService, TransferService, P2pTransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService, C2cBotService, PaymentProofService, AuditLedgerService], }) export class ApplicationModule {} diff --git a/backend/services/trading-service/src/application/services/audit-ledger.service.ts b/backend/services/trading-service/src/application/services/audit-ledger.service.ts new file mode 100644 index 00000000..b7bfcfe0 --- /dev/null +++ b/backend/services/trading-service/src/application/services/audit-ledger.service.ts @@ -0,0 +1,197 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'; +import { AuditActionType, Prisma } from '@prisma/client'; +import * as crypto from 'crypto'; + +export interface AuditLogEntry { + accountSequence: string; + actionType: AuditActionType; + actionDetail: Record; + ipAddress?: string; + userAgent?: string; + referenceId?: string; + referenceType?: string; +} + +/** + * 审计分类账服务 + * 设计原则:Append-Only,只增加不修改不删除 + * 用于记录所有敏感操作,如交易、支付密码验证等 + */ +@Injectable() +export class AuditLedgerService { + private readonly logger = new Logger(AuditLedgerService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * 记录审计日志 + * @param entry 审计日志条目 + * @returns 创建的审计日志ID + */ + async log(entry: AuditLogEntry): Promise { + try { + // 获取前一条记录的哈希(用于链式完整性) + const prevRecord = await this.prisma.auditLedger.findFirst({ + where: { accountSequence: entry.accountSequence }, + orderBy: { createdAt: 'desc' }, + select: { recordHash: true }, + }); + + const prevHash = prevRecord?.recordHash || null; + + // 计算当前记录的哈希 + const recordHash = this.calculateHash({ + accountSequence: entry.accountSequence, + actionType: entry.actionType, + actionDetail: entry.actionDetail, + prevHash, + timestamp: new Date().toISOString(), + }); + + // 创建审计记录(Append-Only,仅插入不修改不删除) + const auditLog = await this.prisma.auditLedger.create({ + data: { + accountSequence: entry.accountSequence, + actionType: entry.actionType, + actionDetail: entry.actionDetail as Prisma.InputJsonValue, + ipAddress: entry.ipAddress, + userAgent: entry.userAgent, + prevHash, + recordHash, + referenceId: entry.referenceId, + referenceType: entry.referenceType, + }, + }); + + this.logger.log( + `Audit log created: ${entry.actionType} for ${entry.accountSequence}`, + ); + + return auditLog.id; + } catch (error) { + this.logger.error(`Failed to create audit log: ${error.message}`, error.stack); + // 审计日志失败不应阻止业务操作,但需要记录错误 + throw error; + } + } + + /** + * 记录卖出订单创建 + */ + async logSellOrderCreated(params: { + accountSequence: string; + orderNo: string; + quantity: string; + price: string; + tradePasswordVerified: boolean; + ipAddress?: string; + userAgent?: string; + }): Promise { + return this.log({ + accountSequence: params.accountSequence, + actionType: AuditActionType.SELL_ORDER_CREATED, + actionDetail: { + orderNo: params.orderNo, + quantity: params.quantity, + price: params.price, + tradePasswordVerified: params.tradePasswordVerified, + timestamp: new Date().toISOString(), + }, + ipAddress: params.ipAddress, + userAgent: params.userAgent, + referenceId: params.orderNo, + referenceType: 'ORDER', + }); + } + + /** + * 记录支付密码验证 + */ + async logTradePasswordVerification(params: { + accountSequence: string; + success: boolean; + purpose: string; // 用途:如 "SELL_ORDER" + ipAddress?: string; + userAgent?: string; + }): Promise { + return this.log({ + accountSequence: params.accountSequence, + actionType: params.success + ? AuditActionType.TRADE_PASSWORD_VERIFIED + : AuditActionType.TRADE_PASSWORD_FAILED, + actionDetail: { + success: params.success, + purpose: params.purpose, + timestamp: new Date().toISOString(), + }, + ipAddress: params.ipAddress, + userAgent: params.userAgent, + }); + } + + /** + * 查询用户的审计日志 + */ + async getAuditLogs( + accountSequence: string, + options: { + actionType?: AuditActionType; + limit?: number; + offset?: number; + } = {}, + ) { + const { actionType, limit = 50, offset = 0 } = options; + + return this.prisma.auditLedger.findMany({ + where: { + accountSequence, + ...(actionType && { actionType }), + }, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + }); + } + + /** + * 验证审计日志链的完整性 + * @param accountSequence 用户账户序列号 + * @returns 验证结果 + */ + async verifyChainIntegrity(accountSequence: string): Promise<{ + isValid: boolean; + totalRecords: number; + invalidRecords: string[]; + }> { + const records = await this.prisma.auditLedger.findMany({ + where: { accountSequence }, + orderBy: { createdAt: 'asc' }, + }); + + const invalidRecords: string[] = []; + let prevHash: string | null = null; + + for (const record of records) { + // 检查 prevHash 是否正确 + if (record.prevHash !== prevHash) { + invalidRecords.push(record.id); + } + prevHash = record.recordHash; + } + + return { + isValid: invalidRecords.length === 0, + totalRecords: records.length, + invalidRecords, + }; + } + + /** + * 计算记录哈希 + */ + private calculateHash(data: Record): string { + const json = JSON.stringify(data, Object.keys(data).sort()); + return crypto.createHash('sha256').update(json).digest('hex'); + } +} diff --git a/frontend/mining-app/lib/core/network/api_endpoints.dart b/frontend/mining-app/lib/core/network/api_endpoints.dart index 01da6dcc..8bcf50f2 100644 --- a/frontend/mining-app/lib/core/network/api_endpoints.dart +++ b/frontend/mining-app/lib/core/network/api_endpoints.dart @@ -13,6 +13,12 @@ class ApiEndpoints { static const String resetPassword = '/api/v2/auth/password/reset'; static const String changePassword = '/api/v2/auth/password/change'; + // 支付密码 (Trade Password) + static const String tradePasswordStatus = '/api/v2/auth/trade-password/status'; + static const String tradePasswordSet = '/api/v2/auth/trade-password/set'; + static const String tradePasswordChange = '/api/v2/auth/trade-password/change'; + static const String tradePasswordVerify = '/api/v2/auth/trade-password/verify'; + // Mining Service 2.0 (Kong路由: /api/v2/mining) static String shareAccount(String accountSequence) => '/api/v2/mining/accounts/$accountSequence'; diff --git a/frontend/mining-app/lib/core/router/app_router.dart b/frontend/mining-app/lib/core/router/app_router.dart index 9ba37902..d7eefc9c 100644 --- a/frontend/mining-app/lib/core/router/app_router.dart +++ b/frontend/mining-app/lib/core/router/app_router.dart @@ -6,6 +6,7 @@ import '../../presentation/pages/auth/login_page.dart'; import '../../presentation/pages/auth/register_page.dart'; import '../../presentation/pages/auth/forgot_password_page.dart'; import '../../presentation/pages/auth/change_password_page.dart'; +import '../../presentation/pages/auth/trade_password_page.dart'; import '../../presentation/pages/contribution/contribution_page.dart'; import '../../presentation/pages/contribution/contribution_records_page.dart'; import '../../presentation/pages/trading/trading_page.dart'; @@ -113,6 +114,10 @@ final appRouterProvider = Provider((ref) { path: Routes.changePassword, builder: (context, state) => const ChangePasswordPage(), ), + GoRoute( + path: Routes.tradePassword, + builder: (context, state) => const TradePasswordPage(), + ), GoRoute( path: Routes.contributionRecords, builder: (context, state) => const ContributionRecordsListPage(), diff --git a/frontend/mining-app/lib/core/router/routes.dart b/frontend/mining-app/lib/core/router/routes.dart index 5f691819..c6aa2432 100644 --- a/frontend/mining-app/lib/core/router/routes.dart +++ b/frontend/mining-app/lib/core/router/routes.dart @@ -4,6 +4,7 @@ class Routes { static const String register = '/register'; static const String forgotPassword = '/forgot-password'; static const String changePassword = '/change-password'; + static const String tradePassword = '/trade-password'; static const String contribution = '/contribution'; static const String trading = '/trading'; static const String asset = '/asset'; 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 index 73d1275d..dade96c6 100644 --- a/frontend/mining-app/lib/data/datasources/remote/auth_remote_datasource.dart +++ b/frontend/mining-app/lib/data/datasources/remote/auth_remote_datasource.dart @@ -77,6 +77,11 @@ abstract class AuthRemoteDataSource { Future getProfile(); Future resetPassword(String phone, String smsCode, String newPassword); Future changePassword(String oldPassword, String newPassword); + // 支付密码相关 + Future getTradePasswordStatus(); + Future setTradePassword(String loginPassword, String tradePassword); + Future changeTradePassword(String oldTradePassword, String newTradePassword); + Future verifyTradePassword(String tradePassword); } class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { @@ -206,4 +211,51 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { throw ServerException(e.toString()); } } + + @override + Future getTradePasswordStatus() async { + try { + final response = await client.get(ApiEndpoints.tradePasswordStatus); + return response.data['hasTradePassword'] as bool? ?? false; + } catch (e) { + throw ServerException(e.toString()); + } + } + + @override + Future setTradePassword(String loginPassword, String tradePassword) async { + try { + await client.post( + ApiEndpoints.tradePasswordSet, + data: {'loginPassword': loginPassword, 'tradePassword': tradePassword}, + ); + } catch (e) { + throw ServerException(e.toString()); + } + } + + @override + Future changeTradePassword(String oldTradePassword, String newTradePassword) async { + try { + await client.post( + ApiEndpoints.tradePasswordChange, + data: {'oldTradePassword': oldTradePassword, 'newTradePassword': newTradePassword}, + ); + } catch (e) { + throw ServerException(e.toString()); + } + } + + @override + Future verifyTradePassword(String tradePassword) async { + try { + final response = await client.post( + ApiEndpoints.tradePasswordVerify, + data: {'tradePassword': tradePassword}, + ); + return response.data['valid'] as bool? ?? false; + } catch (e) { + throw ServerException(e.toString()); + } + } } diff --git a/frontend/mining-app/lib/presentation/pages/auth/trade_password_page.dart b/frontend/mining-app/lib/presentation/pages/auth/trade_password_page.dart new file mode 100644 index 00000000..56714ada --- /dev/null +++ b/frontend/mining-app/lib/presentation/pages/auth/trade_password_page.dart @@ -0,0 +1,365 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/constants/app_colors.dart'; +import '../../providers/user_providers.dart'; + +/// 支付密码设置/修改页面 +class TradePasswordPage extends ConsumerStatefulWidget { + const TradePasswordPage({super.key}); + + @override + ConsumerState createState() => _TradePasswordPageState(); +} + +class _TradePasswordPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _loginPasswordController = TextEditingController(); + final _tradePasswordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + final _oldTradePasswordController = TextEditingController(); + + bool _obscureLoginPassword = true; + bool _obscureTradePassword = true; + bool _obscureConfirmPassword = true; + bool _obscureOldTradePassword = true; + bool _hasTradePassword = false; + bool _isLoadingStatus = true; + + @override + void initState() { + super.initState(); + _loadTradePasswordStatus(); + } + + Future _loadTradePasswordStatus() async { + final status = await ref.read(userNotifierProvider.notifier).getTradePasswordStatus(); + if (mounted) { + setState(() { + _hasTradePassword = status; + _isLoadingStatus = false; + }); + } + } + + @override + void dispose() { + _loginPasswordController.dispose(); + _tradePasswordController.dispose(); + _confirmPasswordController.dispose(); + _oldTradePasswordController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + try { + if (_hasTradePassword) { + // 修改支付密码 + await ref.read(userNotifierProvider.notifier).changeTradePassword( + _oldTradePasswordController.text, + _tradePasswordController.text, + ); + } else { + // 设置支付密码 + await ref.read(userNotifierProvider.notifier).setTradePassword( + _loginPasswordController.text, + _tradePasswordController.text, + ); + } + + if (mounted) { + // 刷新支付密码状态 + ref.invalidate(tradePasswordStatusProvider); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_hasTradePassword ? '支付密码修改成功' : '支付密码设置成功'), + backgroundColor: AppColors.up, + ), + ); + context.pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('操作失败: $e'), + backgroundColor: AppColors.error, + ), + ); + } + } + } + + @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(), + ), + title: Text( + _hasTradePassword ? '修改支付密码' : '设置支付密码', + style: const TextStyle(color: Colors.black), + ), + centerTitle: true, + ), + body: _isLoadingStatus + ? const Center(child: CircularProgressIndicator()) + : SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + + // 说明文字 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: AppColors.primary, size: 24), + const SizedBox(width: 12), + Expanded( + child: Text( + '支付密码用于交易时的安全验证,请设置6位数字密码', + style: TextStyle( + fontSize: 14, + color: AppColors.primary, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + if (_hasTradePassword) ...[ + // 旧支付密码(修改时需要) + TextFormField( + controller: _oldTradePasswordController, + obscureText: _obscureOldTradePassword, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + decoration: InputDecoration( + labelText: '原支付密码', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _obscureOldTradePassword ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () => setState(() => _obscureOldTradePassword = !_obscureOldTradePassword), + ), + 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), + ] else ...[ + // 登录密码验证(首次设置时需要) + TextFormField( + controller: _loginPasswordController, + obscureText: _obscureLoginPassword, + decoration: InputDecoration( + labelText: '登录密码', + hintText: '请输入登录密码以验证身份', + prefixIcon: const Icon(Icons.lock), + suffixIcon: IconButton( + icon: Icon( + _obscureLoginPassword ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () => setState(() => _obscureLoginPassword = !_obscureLoginPassword), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return '请输入登录密码'; + } + return null; + }, + ), + const SizedBox(height: 16), + ], + + // 新支付密码 + TextFormField( + controller: _tradePasswordController, + obscureText: _obscureTradePassword, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + decoration: InputDecoration( + labelText: _hasTradePassword ? '新支付密码' : '支付密码', + hintText: '请输入6位数字', + prefixIcon: const Icon(Icons.security), + suffixIcon: IconButton( + icon: Icon( + _obscureTradePassword ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () => setState(() => _obscureTradePassword = !_obscureTradePassword), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return '请输入支付密码'; + } + if (value.length != 6) { + return '支付密码必须是6位数字'; + } + if (_hasTradePassword && value == _oldTradePasswordController.text) { + return '新密码不能与原密码相同'; + } + return null; + }, + ), + + const SizedBox(height: 16), + + // 确认支付密码 + TextFormField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + decoration: InputDecoration( + labelText: '确认支付密码', + hintText: '请再次输入6位数字', + prefixIcon: const Icon(Icons.security), + 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 != _tradePasswordController.text) { + return '两次密码输入不一致'; + } + return null; + }, + ), + + const SizedBox(height: 32), + + // 提交按钮 + SizedBox( + height: 52, + child: ElevatedButton( + onPressed: userState.isLoading ? null : _submit, + 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, + ), + ) + : Text( + _hasTradePassword ? '确认修改' : '确认设置', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + + const SizedBox(height: 16), + + // 提示信息 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, size: 20, color: Colors.grey[600]), + const SizedBox(width: 8), + Text( + '温馨提示', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey[800], + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '• 支付密码为6位纯数字\n• 支付密码用于交易时的安全验证\n• 请妥善保管,不要告诉他人', + style: TextStyle( + color: Colors.grey[600], + height: 1.5, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart b/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart index 97d75031..eb57e64c 100644 --- a/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart +++ b/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart @@ -85,9 +85,9 @@ class ProfilePage extends ConsumerWidget { const SizedBox(height: 16), - // 账户设置 - 2.0版本暂时隐藏 - // _buildAccountSettings(context, user), - // const SizedBox(height: 16), + // 账户设置 + _buildAccountSettings(context, user), + const SizedBox(height: 16), // 记录入口 _buildRecordsSection(context, ref, user.accountSequence ?? ''), @@ -336,9 +336,16 @@ class ProfilePage extends ConsumerWidget { ), _buildSettingItem( context: context, - icon: Icons.security, - label: '账户安全', + icon: Icons.lock_outline, + label: '修改登录密码', onTap: () => context.push(Routes.changePassword), + showDivider: true, + ), + _buildSettingItem( + context: context, + icon: Icons.security, + label: '支付密码', + onTap: () => context.push(Routes.tradePassword), showDivider: false, ), ], diff --git a/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart b/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart index d7d035e7..fc16969c 100644 --- a/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart +++ b/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart @@ -16,6 +16,7 @@ import '../../providers/trading_providers.dart'; import '../../providers/asset_providers.dart'; import '../../widgets/shimmer_loading.dart'; import '../../widgets/kline_chart/kline_chart_widget.dart'; +import '../../widgets/trade_password_dialog.dart'; class TradingPage extends ConsumerStatefulWidget { const TradingPage({super.key}); @@ -1364,6 +1365,59 @@ class _TradingPageState extends ConsumerState { ); if (confirmed != true) return; + + // 卖出交易需要验证支付密码 + // 检查用户是否已设置支付密码 + final hasTradePassword = await ref.read(tradePasswordStatusProvider.future); + if (!mounted) return; + + if (!hasTradePassword) { + // 用户未设置支付密码,提示并跳转到设置页面 + final goToSet = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('需要设置支付密码'), + content: const Text('为了保障您的资产安全,请先设置支付密码后再进行卖出操作。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text( + '去设置', + style: TextStyle(color: _orange), + ), + ), + ], + ), + ); + if (goToSet == true && mounted) { + // 跳转到支付密码设置页面 + context.push(Routes.tradePassword); + } + return; + } + + // 用户已设置支付密码,弹出验证对话框 + final verified = await TradePasswordDialog.show( + context, + title: '请输入支付密码', + subtitle: '卖出 ${_quantityController.text} 股', + ); + if (verified != true) { + // 验证失败或用户取消,不执行卖出操作 + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('支付密码验证未通过'), + backgroundColor: AppColors.error, + ), + ); + } + return; + } } bool success; diff --git a/frontend/mining-app/lib/presentation/providers/user_providers.dart b/frontend/mining-app/lib/presentation/providers/user_providers.dart index 102d8528..9c422ca5 100644 --- a/frontend/mining-app/lib/presentation/providers/user_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/user_providers.dart @@ -285,6 +285,48 @@ class UserNotifier extends StateNotifier { await prefs.setString('nickname', nickname); state = state.copyWith(nickname: nickname); } + + /// 获取支付密码状态 + Future getTradePasswordStatus() async { + try { + return await _authDataSource.getTradePasswordStatus(); + } catch (e) { + return false; + } + } + + /// 设置支付密码 + Future setTradePassword(String loginPassword, String tradePassword) async { + state = state.copyWith(isLoading: true, error: null); + try { + await _authDataSource.setTradePassword(loginPassword, tradePassword); + state = state.copyWith(isLoading: false); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + rethrow; + } + } + + /// 修改支付密码 + Future changeTradePassword(String oldTradePassword, String newTradePassword) async { + state = state.copyWith(isLoading: true, error: null); + try { + await _authDataSource.changeTradePassword(oldTradePassword, newTradePassword); + state = state.copyWith(isLoading: false); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + rethrow; + } + } + + /// 验证支付密码 + Future verifyTradePassword(String tradePassword) async { + try { + return await _authDataSource.verifyTradePassword(tradePassword); + } catch (e) { + return false; + } + } } final userNotifierProvider = StateNotifierProvider( @@ -302,3 +344,9 @@ final isLoggedInProvider = Provider((ref) { final accessTokenProvider = Provider((ref) { return ref.watch(userNotifierProvider).accessToken; }); + +/// 支付密码状态 Provider +final tradePasswordStatusProvider = FutureProvider((ref) async { + final userNotifier = ref.read(userNotifierProvider.notifier); + return userNotifier.getTradePasswordStatus(); +}); diff --git a/frontend/mining-app/lib/presentation/widgets/trade_password_dialog.dart b/frontend/mining-app/lib/presentation/widgets/trade_password_dialog.dart new file mode 100644 index 00000000..48af3ce5 --- /dev/null +++ b/frontend/mining-app/lib/presentation/widgets/trade_password_dialog.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/constants/app_colors.dart'; +import '../providers/user_providers.dart'; + +/// 支付密码验证弹窗 +/// 返回 true 表示验证通过,false 或 null 表示取消或验证失败 +class TradePasswordDialog extends ConsumerStatefulWidget { + final String title; + final String? subtitle; + + const TradePasswordDialog({ + super.key, + this.title = '请输入支付密码', + this.subtitle, + }); + + @override + ConsumerState createState() => _TradePasswordDialogState(); + + /// 显示支付密码验证弹窗 + /// 返回 true 表示验证通过,false 或 null 表示取消或验证失败 + static Future show( + BuildContext context, { + String title = '请输入支付密码', + String? subtitle, + }) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => TradePasswordDialog( + title: title, + subtitle: subtitle, + ), + ); + } +} + +class _TradePasswordDialogState extends ConsumerState { + final _passwordController = TextEditingController(); + final _focusNode = FocusNode(); + bool _obscurePassword = true; + bool _isVerifying = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + // 自动聚焦密码输入框 + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _passwordController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + Future _verify() async { + final password = _passwordController.text; + if (password.isEmpty) { + setState(() => _errorMessage = '请输入支付密码'); + return; + } + if (password.length != 6) { + setState(() => _errorMessage = '支付密码必须是6位数字'); + return; + } + + setState(() { + _isVerifying = true; + _errorMessage = null; + }); + + try { + final isValid = await ref.read(userNotifierProvider.notifier).verifyTradePassword(password); + + if (mounted) { + if (isValid) { + Navigator.of(context).pop(true); + } else { + setState(() { + _isVerifying = false; + _errorMessage = '支付密码错误'; + _passwordController.clear(); + }); + } + } + } catch (e) { + if (mounted) { + setState(() { + _isVerifying = false; + _errorMessage = '验证失败,请重试'; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + Icons.lock_outline, + color: AppColors.primary, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + if (widget.subtitle != null) ...[ + const SizedBox(height: 4), + Text( + widget.subtitle!, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontWeight: FontWeight.normal, + ), + ), + ], + ], + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _passwordController, + focusNode: _focusNode, + obscureText: _obscurePassword, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + letterSpacing: 8, + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + decoration: InputDecoration( + hintText: '请输入6位数字', + hintStyle: TextStyle( + fontSize: 16, + color: Colors.grey[400], + letterSpacing: 0, + ), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + color: Colors.grey[600], + ), + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: AppColors.primary, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + onSubmitted: (_) => _verify(), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + Row( + children: [ + Icon(Icons.error_outline, color: AppColors.error, size: 16), + const SizedBox(width: 4), + Text( + _errorMessage!, + style: TextStyle( + color: AppColors.error, + fontSize: 14, + ), + ), + ], + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: _isVerifying ? null : () => Navigator.of(context).pop(false), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: _isVerifying ? null : _verify, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isVerifying + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + '确认', + style: TextStyle(color: Colors.white), + ), + ), + ], + ); + } +}