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
|
phone String @unique
|
||||||
passwordHash String @map("password_hash")
|
passwordHash String @map("password_hash")
|
||||||
|
tradePasswordHash String? @map("trade_password_hash") // 支付密码(独立于登录密码)
|
||||||
|
|
||||||
// 统一关联键 (跨所有服务)
|
// 统一关联键 (跨所有服务)
|
||||||
// V1: 12位 (D + 6位日期 + 5位序号), 如 D2512110008
|
// V1: 12位 (D + 6位日期 + 5位序号), 如 D2512110008
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
AuthController,
|
AuthController,
|
||||||
SmsController,
|
SmsController,
|
||||||
PasswordController,
|
PasswordController,
|
||||||
|
TradePasswordController,
|
||||||
KycController,
|
KycController,
|
||||||
UserController,
|
UserController,
|
||||||
HealthController,
|
HealthController,
|
||||||
|
|
@ -32,6 +33,7 @@ import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
AuthController,
|
AuthController,
|
||||||
SmsController,
|
SmsController,
|
||||||
PasswordController,
|
PasswordController,
|
||||||
|
TradePasswordController,
|
||||||
KycController,
|
KycController,
|
||||||
UserController,
|
UserController,
|
||||||
HealthController,
|
HealthController,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export * from './auth.controller';
|
export * from './auth.controller';
|
||||||
export * from './sms.controller';
|
export * from './sms.controller';
|
||||||
export * from './password.controller';
|
export * from './password.controller';
|
||||||
|
export * from './trade-password.controller';
|
||||||
export * from './kyc.controller';
|
export * from './kyc.controller';
|
||||||
export * from './user.controller';
|
export * from './user.controller';
|
||||||
export * from './health.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 {
|
import {
|
||||||
AuthService,
|
AuthService,
|
||||||
PasswordService,
|
PasswordService,
|
||||||
|
TradePasswordService,
|
||||||
SmsService,
|
SmsService,
|
||||||
KycService,
|
KycService,
|
||||||
UserService,
|
UserService,
|
||||||
|
|
@ -32,6 +33,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
PasswordService,
|
PasswordService,
|
||||||
|
TradePasswordService,
|
||||||
SmsService,
|
SmsService,
|
||||||
KycService,
|
KycService,
|
||||||
UserService,
|
UserService,
|
||||||
|
|
@ -42,6 +44,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||||
exports: [
|
exports: [
|
||||||
AuthService,
|
AuthService,
|
||||||
PasswordService,
|
PasswordService,
|
||||||
|
TradePasswordService,
|
||||||
SmsService,
|
SmsService,
|
||||||
KycService,
|
KycService,
|
||||||
UserService,
|
UserService,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export * from './auth.service';
|
export * from './auth.service';
|
||||||
export * from './password.service';
|
export * from './password.service';
|
||||||
|
export * from './trade-password.service';
|
||||||
export * from './sms.service';
|
export * from './sms.service';
|
||||||
export * from './kyc.service';
|
export * from './kyc.service';
|
||||||
export * from './user.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;
|
id?: bigint;
|
||||||
phone: Phone;
|
phone: Phone;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
|
tradePasswordHash?: string; // 支付密码(独立于登录密码)
|
||||||
accountSequence: AccountSequence;
|
accountSequence: AccountSequence;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
kycStatus: KycStatus;
|
kycStatus: KycStatus;
|
||||||
|
|
@ -42,6 +43,7 @@ export class UserAggregate {
|
||||||
private _id?: bigint;
|
private _id?: bigint;
|
||||||
private _phone: Phone;
|
private _phone: Phone;
|
||||||
private _passwordHash: string;
|
private _passwordHash: string;
|
||||||
|
private _tradePasswordHash?: string; // 支付密码哈希
|
||||||
private _accountSequence: AccountSequence;
|
private _accountSequence: AccountSequence;
|
||||||
private _status: UserStatus;
|
private _status: UserStatus;
|
||||||
private _kycStatus: KycStatus;
|
private _kycStatus: KycStatus;
|
||||||
|
|
@ -63,6 +65,7 @@ export class UserAggregate {
|
||||||
this._id = props.id;
|
this._id = props.id;
|
||||||
this._phone = props.phone;
|
this._phone = props.phone;
|
||||||
this._passwordHash = props.passwordHash;
|
this._passwordHash = props.passwordHash;
|
||||||
|
this._tradePasswordHash = props.tradePasswordHash;
|
||||||
this._accountSequence = props.accountSequence;
|
this._accountSequence = props.accountSequence;
|
||||||
this._status = props.status;
|
this._status = props.status;
|
||||||
this._kycStatus = props.kycStatus;
|
this._kycStatus = props.kycStatus;
|
||||||
|
|
@ -120,6 +123,17 @@ export class UserAggregate {
|
||||||
return this._passwordHash;
|
return this._passwordHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get tradePasswordHash(): string | undefined {
|
||||||
|
return this._tradePasswordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否已设置支付密码
|
||||||
|
*/
|
||||||
|
get hasTradePassword(): boolean {
|
||||||
|
return this._tradePasswordHash !== undefined && this._tradePasswordHash !== null;
|
||||||
|
}
|
||||||
|
|
||||||
get accountSequence(): AccountSequence {
|
get accountSequence(): AccountSequence {
|
||||||
return this._accountSequence;
|
return this._accountSequence;
|
||||||
}
|
}
|
||||||
|
|
@ -236,6 +250,34 @@ export class UserAggregate {
|
||||||
this._updatedAt = new Date();
|
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,
|
id: this._id,
|
||||||
phone: this._phone,
|
phone: this._phone,
|
||||||
passwordHash: this._passwordHash,
|
passwordHash: this._passwordHash,
|
||||||
|
tradePasswordHash: this._tradePasswordHash,
|
||||||
accountSequence: this._accountSequence,
|
accountSequence: this._accountSequence,
|
||||||
status: this._status,
|
status: this._status,
|
||||||
kycStatus: this._kycStatus,
|
kycStatus: this._kycStatus,
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export class PrismaUserRepository implements UserRepository {
|
||||||
const data = {
|
const data = {
|
||||||
phone: snapshot.phone.value,
|
phone: snapshot.phone.value,
|
||||||
passwordHash: snapshot.passwordHash,
|
passwordHash: snapshot.passwordHash,
|
||||||
|
tradePasswordHash: snapshot.tradePasswordHash,
|
||||||
accountSequence: snapshot.accountSequence.value,
|
accountSequence: snapshot.accountSequence.value,
|
||||||
status: snapshot.status,
|
status: snapshot.status,
|
||||||
kycStatus: snapshot.kycStatus,
|
kycStatus: snapshot.kycStatus,
|
||||||
|
|
@ -120,6 +121,7 @@ export class PrismaUserRepository implements UserRepository {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
phone: Phone.create(user.phone),
|
phone: Phone.create(user.phone),
|
||||||
passwordHash: user.passwordHash,
|
passwordHash: user.passwordHash,
|
||||||
|
tradePasswordHash: user.tradePasswordHash,
|
||||||
accountSequence: AccountSequence.create(user.accountSequence),
|
accountSequence: AccountSequence.create(user.accountSequence),
|
||||||
status: user.status as UserStatus,
|
status: user.status as UserStatus,
|
||||||
kycStatus: user.kycStatus as KycStatus,
|
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)])
|
@@index([createdAt(sort: Desc)])
|
||||||
@@map("market_maker_withdraws")
|
@@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 { C2cService } from './services/c2c.service';
|
||||||
import { C2cBotService } from './services/c2c-bot.service';
|
import { C2cBotService } from './services/c2c-bot.service';
|
||||||
import { PaymentProofService } from './services/payment-proof.service';
|
import { PaymentProofService } from './services/payment-proof.service';
|
||||||
|
import { AuditLedgerService } from './services/audit-ledger.service';
|
||||||
import { OutboxScheduler } from './schedulers/outbox.scheduler';
|
import { OutboxScheduler } from './schedulers/outbox.scheduler';
|
||||||
import { BurnScheduler } from './schedulers/burn.scheduler';
|
import { BurnScheduler } from './schedulers/burn.scheduler';
|
||||||
import { PriceBroadcastScheduler } from './schedulers/price-broadcast.scheduler';
|
import { PriceBroadcastScheduler } from './schedulers/price-broadcast.scheduler';
|
||||||
|
|
@ -36,6 +37,7 @@ import { C2cBotScheduler } from './schedulers/c2c-bot.scheduler';
|
||||||
C2cService,
|
C2cService,
|
||||||
C2cBotService,
|
C2cBotService,
|
||||||
PaymentProofService,
|
PaymentProofService,
|
||||||
|
AuditLedgerService,
|
||||||
// Schedulers
|
// Schedulers
|
||||||
OutboxScheduler,
|
OutboxScheduler,
|
||||||
BurnScheduler,
|
BurnScheduler,
|
||||||
|
|
@ -43,6 +45,6 @@ import { C2cBotScheduler } from './schedulers/c2c-bot.scheduler';
|
||||||
C2cExpiryScheduler,
|
C2cExpiryScheduler,
|
||||||
C2cBotScheduler,
|
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 {}
|
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 resetPassword = '/api/v2/auth/password/reset';
|
||||||
static const String changePassword = '/api/v2/auth/password/change';
|
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)
|
// Mining Service 2.0 (Kong路由: /api/v2/mining)
|
||||||
static String shareAccount(String accountSequence) =>
|
static String shareAccount(String accountSequence) =>
|
||||||
'/api/v2/mining/accounts/$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/register_page.dart';
|
||||||
import '../../presentation/pages/auth/forgot_password_page.dart';
|
import '../../presentation/pages/auth/forgot_password_page.dart';
|
||||||
import '../../presentation/pages/auth/change_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_page.dart';
|
||||||
import '../../presentation/pages/contribution/contribution_records_page.dart';
|
import '../../presentation/pages/contribution/contribution_records_page.dart';
|
||||||
import '../../presentation/pages/trading/trading_page.dart';
|
import '../../presentation/pages/trading/trading_page.dart';
|
||||||
|
|
@ -113,6 +114,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
path: Routes.changePassword,
|
path: Routes.changePassword,
|
||||||
builder: (context, state) => const ChangePasswordPage(),
|
builder: (context, state) => const ChangePasswordPage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.tradePassword,
|
||||||
|
builder: (context, state) => const TradePasswordPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.contributionRecords,
|
path: Routes.contributionRecords,
|
||||||
builder: (context, state) => const ContributionRecordsListPage(),
|
builder: (context, state) => const ContributionRecordsListPage(),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ class Routes {
|
||||||
static const String register = '/register';
|
static const String register = '/register';
|
||||||
static const String forgotPassword = '/forgot-password';
|
static const String forgotPassword = '/forgot-password';
|
||||||
static const String changePassword = '/change-password';
|
static const String changePassword = '/change-password';
|
||||||
|
static const String tradePassword = '/trade-password';
|
||||||
static const String contribution = '/contribution';
|
static const String contribution = '/contribution';
|
||||||
static const String trading = '/trading';
|
static const String trading = '/trading';
|
||||||
static const String asset = '/asset';
|
static const String asset = '/asset';
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,11 @@ abstract class AuthRemoteDataSource {
|
||||||
Future<UserInfo> getProfile();
|
Future<UserInfo> getProfile();
|
||||||
Future<void> resetPassword(String phone, String smsCode, String newPassword);
|
Future<void> resetPassword(String phone, String smsCode, String newPassword);
|
||||||
Future<void> changePassword(String oldPassword, 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 {
|
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
||||||
|
|
@ -206,4 +211,51 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
||||||
throw ServerException(e.toString());
|
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),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 账户设置 - 2.0版本暂时隐藏
|
// 账户设置
|
||||||
// _buildAccountSettings(context, user),
|
_buildAccountSettings(context, user),
|
||||||
// const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 记录入口
|
// 记录入口
|
||||||
_buildRecordsSection(context, ref, user.accountSequence ?? ''),
|
_buildRecordsSection(context, ref, user.accountSequence ?? ''),
|
||||||
|
|
@ -336,9 +336,16 @@ class ProfilePage extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
_buildSettingItem(
|
_buildSettingItem(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.security,
|
icon: Icons.lock_outline,
|
||||||
label: '账户安全',
|
label: '修改登录密码',
|
||||||
onTap: () => context.push(Routes.changePassword),
|
onTap: () => context.push(Routes.changePassword),
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
_buildSettingItem(
|
||||||
|
context: context,
|
||||||
|
icon: Icons.security,
|
||||||
|
label: '支付密码',
|
||||||
|
onTap: () => context.push(Routes.tradePassword),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import '../../providers/trading_providers.dart';
|
||||||
import '../../providers/asset_providers.dart';
|
import '../../providers/asset_providers.dart';
|
||||||
import '../../widgets/shimmer_loading.dart';
|
import '../../widgets/shimmer_loading.dart';
|
||||||
import '../../widgets/kline_chart/kline_chart_widget.dart';
|
import '../../widgets/kline_chart/kline_chart_widget.dart';
|
||||||
|
import '../../widgets/trade_password_dialog.dart';
|
||||||
|
|
||||||
class TradingPage extends ConsumerStatefulWidget {
|
class TradingPage extends ConsumerStatefulWidget {
|
||||||
const TradingPage({super.key});
|
const TradingPage({super.key});
|
||||||
|
|
@ -1364,6 +1365,59 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed != true) return;
|
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;
|
bool success;
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,48 @@ class UserNotifier extends StateNotifier<UserState> {
|
||||||
await prefs.setString('nickname', nickname);
|
await prefs.setString('nickname', nickname);
|
||||||
state = state.copyWith(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>(
|
final userNotifierProvider = StateNotifierProvider<UserNotifier, UserState>(
|
||||||
|
|
@ -302,3 +344,9 @@ final isLoggedInProvider = Provider<bool>((ref) {
|
||||||
final accessTokenProvider = Provider<String?>((ref) {
|
final accessTokenProvider = Provider<String?>((ref) {
|
||||||
return ref.watch(userNotifierProvider).accessToken;
|
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