feat(trade-password): 实现卖出交易的支付密码验证功能
## 后端改动 ### 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 <noreply@anthropic.com>
This commit is contained in:
parent
1c621c32ec
commit
a1aba14ccf
|
|
@ -0,0 +1,8 @@
|
|||
-- AlterTable
|
||||
-- 添加支付密码字段
|
||||
-- 支付密码独立于登录密码,用于交易时的二次验证
|
||||
-- 存储的是bcrypt哈希值,不是明文密码
|
||||
ALTER TABLE "users" ADD COLUMN "trade_password_hash" TEXT;
|
||||
|
||||
-- 添加注释说明该字段用途
|
||||
COMMENT ON COLUMN "users"."trade_password_hash" IS '支付密码哈希值 - 用于交易时的二次安全验证,独立于登录密码';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<TradePasswordStatusDto> {
|
||||
const user = await this.userRepository.findByAccountSequence(
|
||||
AccountSequence.create(accountSequence),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
hasTradePassword: user.hasTradePassword,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置支付密码(首次设置或修改)
|
||||
* 首次设置需要验证登录密码
|
||||
*/
|
||||
async setTradePassword(dto: SetTradePasswordDto): Promise<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
const password = await Password.create(newPlainPassword);
|
||||
this._tradePasswordHash = password.hash;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证支付密码
|
||||
*/
|
||||
async verifyTradePassword(plainPassword: string): Promise<boolean> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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, unknown>): string {
|
||||
const json = JSON.stringify(data, Object.keys(data).sort());
|
||||
return crypto.createHash('sha256').update(json).digest('hex');
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<GoRouter>((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(),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -77,6 +77,11 @@ abstract class AuthRemoteDataSource {
|
|||
Future<UserInfo> getProfile();
|
||||
Future<void> resetPassword(String phone, String smsCode, String newPassword);
|
||||
Future<void> changePassword(String oldPassword, String newPassword);
|
||||
// 支付密码相关
|
||||
Future<bool> getTradePasswordStatus();
|
||||
Future<void> setTradePassword(String loginPassword, String tradePassword);
|
||||
Future<void> changeTradePassword(String oldTradePassword, String newTradePassword);
|
||||
Future<bool> verifyTradePassword(String tradePassword);
|
||||
}
|
||||
|
||||
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
||||
|
|
@ -206,4 +211,51 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
|||
throw ServerException(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> 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<void> 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<void> 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<bool> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TradePasswordPage> createState() => _TradePasswordPageState();
|
||||
}
|
||||
|
||||
class _TradePasswordPageState extends ConsumerState<TradePasswordPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<void> _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<void> _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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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<TradingPage> {
|
|||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
// 卖出交易需要验证支付密码
|
||||
// 检查用户是否已设置支付密码
|
||||
final hasTradePassword = await ref.read(tradePasswordStatusProvider.future);
|
||||
if (!mounted) return;
|
||||
|
||||
if (!hasTradePassword) {
|
||||
// 用户未设置支付密码,提示并跳转到设置页面
|
||||
final goToSet = await showDialog<bool>(
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -285,6 +285,48 @@ class UserNotifier extends StateNotifier<UserState> {
|
|||
await prefs.setString('nickname', nickname);
|
||||
state = state.copyWith(nickname: nickname);
|
||||
}
|
||||
|
||||
/// 获取支付密码状态
|
||||
Future<bool> getTradePasswordStatus() async {
|
||||
try {
|
||||
return await _authDataSource.getTradePasswordStatus();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置支付密码
|
||||
Future<void> 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<void> 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<bool> verifyTradePassword(String tradePassword) async {
|
||||
try {
|
||||
return await _authDataSource.verifyTradePassword(tradePassword);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final userNotifierProvider = StateNotifierProvider<UserNotifier, UserState>(
|
||||
|
|
@ -302,3 +344,9 @@ final isLoggedInProvider = Provider<bool>((ref) {
|
|||
final accessTokenProvider = Provider<String?>((ref) {
|
||||
return ref.watch(userNotifierProvider).accessToken;
|
||||
});
|
||||
|
||||
/// 支付密码状态 Provider
|
||||
final tradePasswordStatusProvider = FutureProvider<bool>((ref) async {
|
||||
final userNotifier = ref.read(userNotifierProvider.notifier);
|
||||
return userNotifier.getTradePasswordStatus();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<TradePasswordDialog> createState() => _TradePasswordDialogState();
|
||||
|
||||
/// 显示支付密码验证弹窗
|
||||
/// 返回 true 表示验证通过,false 或 null 表示取消或验证失败
|
||||
static Future<bool?> show(
|
||||
BuildContext context, {
|
||||
String title = '请输入支付密码',
|
||||
String? subtitle,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => TradePasswordDialog(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TradePasswordDialogState extends ConsumerState<TradePasswordDialog> {
|
||||
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<void> _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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue