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:
hailin 2026-02-05 18:12:39 -08:00
parent 1c621c32ec
commit a1aba14ccf
23 changed files with 1396 additions and 6 deletions

View File

@ -0,0 +1,8 @@
-- AlterTable
-- 添加支付密码字段
-- 支付密码独立于登录密码,用于交易时的二次验证
-- 存储的是bcrypt哈希值不是明文密码
ALTER TABLE "users" ADD COLUMN "trade_password_hash" TEXT;
-- 添加注释说明该字段用途
COMMENT ON COLUMN "users"."trade_password_hash" IS '支付密码哈希值 - 用于交易时的二次安全验证,独立于登录密码';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
),
],

View File

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

View File

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

View File

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