feat(auth): 增强提现安全验证

- 集成阿里云短信服务 (dysmsapi20170525)
- 提现需同时验证短信验证码和登录密码
- identity-service 添加 /verify-password API
- wallet-service 调用双重验证
- 移动端提现确认页添加密码输入

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-19 03:05:53 -08:00
parent 9d693b743b
commit 5b2d255506
12 changed files with 1366 additions and 63 deletions

View File

@ -55,10 +55,29 @@ KAFKA_CLIENT_ID="identity-service"
KAFKA_GROUP_ID="identity-service-group"
# =============================================================================
# SMS Service (External)
# SMS Service - Aliyun (阿里云短信服务)
# =============================================================================
SMS_API_URL="https://sms-api.example.com"
SMS_API_KEY="your-sms-api-key"
# 阿里云 AccessKey (建议使用 RAM 子账号)
# 创建地址: https://ram.console.aliyun.com/manage/ak
ALIYUN_ACCESS_KEY_ID="your-aliyun-access-key-id"
ALIYUN_ACCESS_KEY_SECRET="your-aliyun-access-key-secret"
# 短信签名 (需在阿里云短信控制台申请)
# 例如: "榴莲皇后"
ALIYUN_SMS_SIGN_NAME="榴莲皇后"
# 短信模板代码 (需在阿里云短信控制台申请)
# 验证码模板示例: SMS_123456789
# 模板内容: 您的验证码是${code}5分钟内有效。
ALIYUN_SMS_TEMPLATE_CODE="SMS_123456789"
# 阿里云短信 API 端点 (默认无需修改)
ALIYUN_SMS_ENDPOINT="dysmsapi.aliyuncs.com"
# 是否启用真实短信发送 (开发环境建议设为 false)
# false: 模拟模式,验证码打印到日志
# true: 真实发送短信
SMS_ENABLED="false"
# =============================================================================
# Wallet Encryption

View File

@ -9,6 +9,9 @@
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"@alicloud/dysmsapi20170525": "^4.3.1",
"@alicloud/openapi-client": "^0.4.15",
"@alicloud/tea-util": "^1.4.11",
"@nestjs/axios": "^3.0.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
@ -71,6 +74,209 @@
"integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==",
"license": "MIT"
},
"node_modules/@alicloud/credentials": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@alicloud/credentials/-/credentials-2.4.4.tgz",
"integrity": "sha512-/eRAGSKcniLIFQ1UCpDhB/IrHUZisQ1sc65ws/c2avxUMpXwH1rWAohb76SVAUJhiF4mwvLzLJM1Mn1XL4Xe/Q==",
"license": "MIT",
"dependencies": {
"@alicloud/tea-typescript": "^1.8.0",
"httpx": "^2.3.3",
"ini": "^1.3.5",
"kitx": "^2.0.0"
}
},
"node_modules/@alicloud/darabonba-array": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-array/-/darabonba-array-0.1.2.tgz",
"integrity": "sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ==",
"license": "ISC",
"dependencies": {
"@alicloud/tea-typescript": "^1.7.1"
}
},
"node_modules/@alicloud/darabonba-encode-util": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.2.tgz",
"integrity": "sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A==",
"license": "ISC",
"dependencies": {
"moment": "^2.29.1"
}
},
"node_modules/@alicloud/darabonba-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-map/-/darabonba-map-0.0.1.tgz",
"integrity": "sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw==",
"license": "ISC",
"dependencies": {
"@alicloud/tea-typescript": "^1.7.1"
}
},
"node_modules/@alicloud/darabonba-signature-util": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-signature-util/-/darabonba-signature-util-0.0.4.tgz",
"integrity": "sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg==",
"license": "ISC",
"dependencies": {
"@alicloud/darabonba-encode-util": "^0.0.1"
}
},
"node_modules/@alicloud/darabonba-signature-util/node_modules/@alicloud/darabonba-encode-util": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.1.tgz",
"integrity": "sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw==",
"license": "ISC",
"dependencies": {
"@alicloud/tea-typescript": "^1.7.1",
"moment": "^2.29.1"
}
},
"node_modules/@alicloud/darabonba-string": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-string/-/darabonba-string-1.0.3.tgz",
"integrity": "sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1"
}
},
"node_modules/@alicloud/dysmsapi20170525": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@alicloud/dysmsapi20170525/-/dysmsapi20170525-4.3.1.tgz",
"integrity": "sha512-JZWs3c4uJhRUIMSF0lob1pMR68Yj9y+6ROWVk6kqblrrRoVMRgezMXx2bg+VY0I7noWej4WfvRJYMRTsNyuYQQ==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/openapi-core": "^1.0.0",
"@darabonba/typescript": "^1.0.0"
}
},
"node_modules/@alicloud/endpoint-util": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@alicloud/endpoint-util/-/endpoint-util-0.0.1.tgz",
"integrity": "sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1",
"kitx": "^2.0.0"
}
},
"node_modules/@alicloud/gateway-pop": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@alicloud/gateway-pop/-/gateway-pop-0.0.6.tgz",
"integrity": "sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA==",
"license": "ISC",
"dependencies": {
"@alicloud/credentials": "^2",
"@alicloud/darabonba-array": "^0.1.0",
"@alicloud/darabonba-encode-util": "^0.0.2",
"@alicloud/darabonba-map": "^0.0.1",
"@alicloud/darabonba-signature-util": "^0.0.4",
"@alicloud/darabonba-string": "^1.0.2",
"@alicloud/endpoint-util": "^0.0.1",
"@alicloud/gateway-spi": "^0.0.8",
"@alicloud/openapi-util": "^0.3.2",
"@alicloud/tea-typescript": "^1.7.1",
"@alicloud/tea-util": "^1.4.8"
}
},
"node_modules/@alicloud/gateway-spi": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@alicloud/gateway-spi/-/gateway-spi-0.0.8.tgz",
"integrity": "sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==",
"license": "ISC",
"dependencies": {
"@alicloud/credentials": "^2",
"@alicloud/tea-typescript": "^1.7.1"
}
},
"node_modules/@alicloud/openapi-client": {
"version": "0.4.15",
"resolved": "https://registry.npmjs.org/@alicloud/openapi-client/-/openapi-client-0.4.15.tgz",
"integrity": "sha512-4VE0/k5ZdQbAhOSTqniVhuX1k5DUeUMZv74degn3wIWjLY6Bq+hxjaGsaHYlLZ2gA5wUrs8NcI5TE+lIQS3iiA==",
"license": "ISC",
"dependencies": {
"@alicloud/credentials": "^2.4.2",
"@alicloud/gateway-spi": "^0.0.8",
"@alicloud/openapi-util": "^0.3.2",
"@alicloud/tea-typescript": "^1.7.1",
"@alicloud/tea-util": "1.4.9",
"@alicloud/tea-xml": "0.0.3"
}
},
"node_modules/@alicloud/openapi-client/node_modules/@alicloud/tea-util": {
"version": "1.4.9",
"resolved": "https://registry.npmjs.org/@alicloud/tea-util/-/tea-util-1.4.9.tgz",
"integrity": "sha512-S0wz76rGtoPKskQtRTGqeuqBHFj8BqUn0Vh+glXKun2/9UpaaaWmuJwcmtImk6bJZfLYEShDF/kxDmDJoNYiTw==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1",
"kitx": "^2.0.0"
}
},
"node_modules/@alicloud/openapi-core": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@alicloud/openapi-core/-/openapi-core-1.0.6.tgz",
"integrity": "sha512-E5meOaZmdMHgvrSuSVRjMkrvttvW1aDfnzUnO+fjloYak/o/S6F8C3NXfPuhVVO/1GRfP6vf/4Vxo+tuus1UwA==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@alicloud/credentials": "^2.4.2",
"@alicloud/gateway-pop": "0.0.6",
"@alicloud/gateway-spi": "^0.0.8",
"@darabonba/typescript": "^1.0.2"
}
},
"node_modules/@alicloud/openapi-util": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@alicloud/openapi-util/-/openapi-util-0.3.2.tgz",
"integrity": "sha512-EC2JvxdcOgMlBAEG0+joOh2IB1um8CPz9EdYuRfTfd1uP8Yc9D8QRUWVGjP6scnj6fWSOaHFlit9H6PrJSyFow==",
"license": "ISC",
"dependencies": {
"@alicloud/tea-typescript": "^1.7.1",
"@alicloud/tea-util": "^1.3.0",
"kitx": "^2.1.0",
"sm3": "^1.0.3"
}
},
"node_modules/@alicloud/tea-typescript": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@alicloud/tea-typescript/-/tea-typescript-1.8.0.tgz",
"integrity": "sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==",
"license": "ISC",
"dependencies": {
"@types/node": "^12.0.2",
"httpx": "^2.2.6"
}
},
"node_modules/@alicloud/tea-typescript/node_modules/@types/node": {
"version": "12.20.55",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
"integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==",
"license": "MIT"
},
"node_modules/@alicloud/tea-util": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@alicloud/tea-util/-/tea-util-1.4.11.tgz",
"integrity": "sha512-HyPEEQ8F0WoZegiCp7sVdrdm6eBOB+GCvGl4182u69LDFktxfirGLcAx3WExUr1zFWkq2OSmBroTwKQ4w/+Yww==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1",
"@darabonba/typescript": "^1.0.0",
"kitx": "^2.0.0"
}
},
"node_modules/@alicloud/tea-xml": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@alicloud/tea-xml/-/tea-xml-0.0.3.tgz",
"integrity": "sha512-+/9GliugjrLglsXVrd1D80EqqKgGpyA0eQ6+1ZdUOYCaRguaSwz44trX3PaxPu/HhIPJg9PsGQQ3cSLXWZjbAA==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1",
"@types/xml2js": "^0.4.5",
"xml2js": "^0.6.0"
}
},
"node_modules/@angular-devkit/core": {
"version": "17.3.11",
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz",
@ -793,6 +999,20 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@darabonba/typescript": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@darabonba/typescript/-/typescript-1.0.3.tgz",
"integrity": "sha512-/y2y6wf5TsxD7pCPIm0OvTC+5qV0Tk7HQYxwpIuWRLXQLB0CRDvr6qk4bR6rTLO/JglJa8z2uCGZsaLYpQNqFQ==",
"license": "Apache License 2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1",
"httpx": "^2.3.2",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"xml2js": "^0.6.2"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
@ -2803,6 +3023,15 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT"
},
"node_modules/@types/xml2js": {
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz",
"integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@ -6183,6 +6412,16 @@
"node": ">= 0.8"
}
},
"node_modules/httpx": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/httpx/-/httpx-2.3.3.tgz",
"integrity": "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==",
"license": "MIT",
"dependencies": {
"@types/node": "^20",
"debug": "^4.1.1"
}
},
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@ -6300,6 +6539,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/inquirer": {
"version": "8.2.6",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz",
@ -7561,6 +7806,24 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kitx": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/kitx/-/kitx-2.2.0.tgz",
"integrity": "sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==",
"license": "MIT",
"dependencies": {
"@types/node": "^22.5.4"
}
},
"node_modules/kitx/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@ -8011,6 +8274,27 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/moment-timezone": {
"version": "0.5.48",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -9465,6 +9749,12 @@
"node": ">=8"
}
},
"node_modules/sm3": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sm3/-/sm3-1.0.3.tgz",
"integrity": "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==",
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",

View File

@ -28,6 +28,9 @@
"prisma:studio": "prisma studio"
},
"dependencies": {
"@alicloud/dysmsapi20170525": "^4.3.1",
"@alicloud/openapi-client": "^0.4.15",
"@alicloud/tea-util": "^1.4.11",
"@nestjs/axios": "^3.0.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
@ -46,10 +49,10 @@
"class-validator": "^0.14.0",
"ethers": "^6.9.0",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.0",
"kafkajs": "^2.2.4",
"minio": "^8.0.1",
"multer": "^1.4.5-lts.1",
"kafkajs": "^2.2.4",
"jsonwebtoken": "^9.0.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
@ -61,12 +64,12 @@
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/jsonwebtoken": "^9.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^4.0.0",
"@types/supertest": "^6.0.0",
"@types/uuid": "^9.0.0",
"@types/multer": "^1.4.12",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
@ -84,13 +87,19 @@
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {

View File

@ -12,7 +12,7 @@ import {
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
UpdateProfileCommand, SubmitKYCCommand, RemoveDeviceCommand, SendSmsCodeCommand,
GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery, GetWalletStatusQuery,
MarkMnemonicBackedUpCommand,
MarkMnemonicBackedUpCommand, VerifySmsCodeCommand, SetPasswordCommand,
} from '@/application/commands';
import {
AutoCreateAccountDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto,
@ -23,6 +23,7 @@ import {
AutoCreateAccountResponseDto, RecoverAccountResponseDto, LoginResponseDto,
UserProfileResponseDto, DeviceResponseDto,
WalletStatusReadyResponseDto, WalletStatusGeneratingResponseDto,
VerifySmsCodeDto, SetPasswordDto,
} from '@/api/dto';
@ApiTags('User')
@ -89,6 +90,17 @@ export class UserAccountController {
return { message: '验证码已发送' };
}
@Public()
@Post('verify-sms-code')
@ApiOperation({ summary: '验证短信验证码', description: '仅验证验证码是否正确,不进行登录或注册' })
@ApiResponse({ status: 200, description: '验证成功' })
async verifySmsCode(@Body() dto: VerifySmsCodeDto) {
await this.userService.verifySmsCode(
new VerifySmsCodeCommand(dto.phoneNumber, dto.smsCode, dto.type as 'REGISTER' | 'LOGIN' | 'BIND' | 'RECOVER'),
);
return { message: '验证成功' };
}
@Public()
@Post('register')
@ApiOperation({ summary: '用户注册(手机号)' })
@ -122,6 +134,17 @@ export class UserAccountController {
return { message: '绑定成功' };
}
@Post('set-password')
@ApiBearerAuth()
@ApiOperation({ summary: '设置登录密码', description: '首次设置或修改登录密码' })
@ApiResponse({ status: 200, description: '密码设置成功' })
async setPassword(@CurrentUser() user: CurrentUserData, @Body() dto: SetPasswordDto) {
await this.userService.setPassword(
new SetPasswordCommand(user.userId, dto.password),
);
return { message: '密码设置成功' };
}
@Get('my-profile')
@ApiBearerAuth()
@ApiOperation({ summary: '查询我的资料' })
@ -266,6 +289,55 @@ export class UserAccountController {
});
}
@Post('sms/send-withdraw-code')
@ApiBearerAuth()
@ApiOperation({ summary: '发送提取验证短信', description: '向用户绑定的手机号发送提取验证码' })
@ApiResponse({ status: 200, description: '发送成功' })
async sendWithdrawSmsCode(@CurrentUser() user: CurrentUserData) {
await this.userService.sendWithdrawSmsCode(user.userId);
return { message: '验证码已发送' };
}
@Post('sms/verify-withdraw-code')
@ApiBearerAuth()
@ApiOperation({ summary: '验证提取短信验证码', description: '验证提取操作的短信验证码' })
@ApiResponse({ status: 200, description: '验证结果' })
async verifyWithdrawSmsCode(
@CurrentUser() user: CurrentUserData,
@Body() body: { code: string },
) {
const valid = await this.userService.verifyWithdrawSmsCode(user.userId, body.code);
return { valid };
}
@Post('verify-password')
@ApiBearerAuth()
@ApiOperation({ summary: '验证登录密码', description: '验证用户的登录密码,用于敏感操作二次验证' })
@ApiResponse({ status: 200, description: '验证结果' })
async verifyPassword(
@CurrentUser() user: CurrentUserData,
@Body() body: { password: string },
) {
const valid = await this.userService.verifyPassword(user.userId, body.password);
return { valid };
}
@Get('users/resolve-address/:accountSequence')
@ApiBearerAuth()
@ApiOperation({ summary: '解析充值ID到区块链地址', description: '通过用户的 accountSequence 获取其区块链钱包地址' })
@ApiResponse({ status: 200, description: '返回区块链地址' })
@ApiResponse({ status: 404, description: '找不到用户' })
async resolveAccountSequenceToAddress(
@Param('accountSequence') accountSequence: string,
) {
// 默认返回 KAVA 链地址(支持所有 EVM 链)
const address = await this.userService.resolveAccountSequenceToAddress(
accountSequence,
'KAVA',
);
return { address };
}
@Post('upload-avatar')
@ApiBearerAuth()
@ApiOperation({ summary: '上传用户头像' })

View File

@ -1287,4 +1287,176 @@ export class UserApplicationService {
const crypto = await import('crypto');
return crypto.createHash('sha256').update(code).digest('hex');
}
/**
* (/)
*/
async verifySmsCode(command: { phoneNumber: string; smsCode: string; type: string }): Promise<void> {
this.logger.log(`Verifying SMS code for phone: ${this.maskPhoneNumber(command.phoneNumber)}`);
const phoneNumber = PhoneNumber.create(command.phoneNumber);
// 验证验证码
const isValid = await this.smsService.verifySmsCode(
phoneNumber.value,
command.smsCode,
command.type,
);
if (!isValid) {
throw new ApplicationError('验证码错误或已过期');
}
this.logger.log(`SMS code verified successfully for phone: ${this.maskPhoneNumber(command.phoneNumber)}`);
}
/**
*
*/
async setPassword(command: { userId: string; password: string }): Promise<void> {
this.logger.log(`Setting password for user: ${command.userId}`);
const userId = UserId.create(command.userId);
const user = await this.userRepository.findById(userId);
if (!user) {
throw new ApplicationError('用户不存在');
}
// 使用 bcrypt 哈希密码
const bcrypt = await import('bcrypt');
const saltRounds = 10;
const passwordHash = await bcrypt.hash(command.password, saltRounds);
// 更新数据库
await this.prisma.userAccount.update({
where: { userId: BigInt(command.userId) },
data: { passwordHash },
});
this.logger.log(`Password set successfully for user: ${command.userId}`);
}
/**
*
*/
async sendWithdrawSmsCode(userId: string): Promise<void> {
this.logger.log(`Sending withdraw SMS code for user: ${userId}`);
const user = await this.userRepository.findById(UserId.create(userId));
if (!user) {
throw new ApplicationError('用户不存在');
}
if (!user.phoneNumber) {
throw new ApplicationError('请先绑定手机号');
}
const code = this.generateSmsCode();
const cacheKey = `sms:withdraw:${user.phoneNumber.value}`;
await this.smsService.sendVerificationCode(user.phoneNumber.value, code);
await this.redisService.set(cacheKey, code, 300); // 5分钟有效
this.logger.log(`Withdraw SMS code sent successfully to: ${this.maskPhoneNumber(user.phoneNumber.value)}`);
}
/**
*
*/
async verifyWithdrawSmsCode(userId: string, smsCode: string): Promise<boolean> {
this.logger.log(`Verifying withdraw SMS code for user: ${userId}`);
const user = await this.userRepository.findById(UserId.create(userId));
if (!user) {
throw new ApplicationError('用户不存在');
}
if (!user.phoneNumber) {
throw new ApplicationError('用户未绑定手机号');
}
const cacheKey = `sms:withdraw:${user.phoneNumber.value}`;
const cachedCode = await this.redisService.get(cacheKey);
const isValid = cachedCode === smsCode;
if (isValid) {
// 验证成功后删除验证码,防止重复使用
await this.redisService.delete(cacheKey);
}
this.logger.log(`Withdraw SMS verification result for user ${userId}: ${isValid}`);
return isValid;
}
/**
* accountSequence
*/
async resolveAccountSequenceToAddress(accountSequence: string, chainType: string): Promise<string | null> {
this.logger.log(`Resolving accountSequence ${accountSequence} to ${chainType} address`);
// 查询用户
const user = await this.prisma.userAccount.findFirst({
where: { accountSequence },
include: {
walletAddresses: true,
},
});
if (!user) {
this.logger.warn(`User not found for accountSequence: ${accountSequence}`);
throw new ApplicationError('未找到该充值ID对应的用户');
}
// 查找对应链的地址
const walletAddress = user.walletAddresses.find(
(w) => w.chainType === chainType.toUpperCase(),
);
if (!walletAddress) {
this.logger.warn(`No ${chainType} address found for accountSequence: ${accountSequence}`);
throw new ApplicationError(`未找到该用户的 ${chainType} 地址`);
}
this.logger.log(`Resolved ${accountSequence} to ${walletAddress.address}`);
return walletAddress.address;
}
/**
*
*
* @param userId ID
* @param password
* @returns
*/
async verifyPassword(userId: string, password: string): Promise<boolean> {
this.logger.log(`Verifying password for user: ${userId}`);
// 查询用户
const user = await this.prisma.userAccount.findUnique({
where: { userId: BigInt(userId) },
select: { passwordHash: true },
});
if (!user || !user.passwordHash) {
this.logger.warn(`User ${userId} has no password set`);
throw new ApplicationError('请先设置登录密码');
}
// 使用 bcrypt 验证密码
const bcrypt = await import('bcrypt');
const isValid = await bcrypt.compare(password, user.passwordHash);
this.logger.log(`Password verification result for user ${userId}: ${isValid}`);
return isValid;
}
/**
*
*/
private maskPhoneNumber(phone: string): string {
if (phone.length < 7) return phone;
return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4);
}
}

View File

@ -27,8 +27,16 @@ export const kafkaConfig = () => ({
});
export const smsConfig = () => ({
apiUrl: process.env.SMS_API_URL || '',
apiKey: process.env.SMS_API_KEY || '',
// 阿里云 SMS 配置
aliyun: {
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID || '',
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET || '',
signName: process.env.ALIYUN_SMS_SIGN_NAME || '榴莲皇后',
templateCode: process.env.ALIYUN_SMS_TEMPLATE_CODE || '',
endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com',
},
// 是否启用真实发送(开发环境可关闭)
enabled: process.env.SMS_ENABLED === 'true',
});
export const walletConfig = () => ({

View File

@ -1,23 +1,247 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525';
import * as $OpenApi from '@alicloud/openapi-client';
import * as $Util from '@alicloud/tea-util';
export interface SmsSendResult {
success: boolean;
requestId?: string;
bizId?: string;
code?: string;
message?: string;
}
@Injectable()
export class SmsService {
constructor(private readonly configService: ConfigService) {}
export class SmsService implements OnModuleInit {
private readonly logger = new Logger(SmsService.name);
private client: Dysmsapi20170525 | null = null;
private readonly signName: string;
private readonly templateCode: string;
private readonly enabled: boolean;
async sendSms(phoneNumber: string, content: string): Promise<boolean> {
const apiUrl = this.configService.get<string>('SMS_API_URL');
const apiKey = this.configService.get<string>('SMS_API_KEY');
constructor(private readonly configService: ConfigService) {
const smsConfig = this.configService.get('smsConfig') || {};
const aliyunConfig = smsConfig.aliyun || {};
// 实际项目中调用SMS API
console.log(`[SMS] Sending to ${phoneNumber}: ${content}`);
// 模拟发送成功
return true;
this.signName = aliyunConfig.signName || this.configService.get('ALIYUN_SMS_SIGN_NAME', '榴莲皇后');
this.templateCode = aliyunConfig.templateCode || this.configService.get('ALIYUN_SMS_TEMPLATE_CODE', '');
this.enabled = smsConfig.enabled ?? this.configService.get('SMS_ENABLED') === 'true';
}
async sendVerificationCode(phoneNumber: string, code: string): Promise<boolean> {
const content = `您的验证码是${code},5分钟内有效。`;
return this.sendSms(phoneNumber, content);
async onModuleInit() {
await this.initClient();
}
private async initClient(): Promise<void> {
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET');
const endpoint = this.configService.get<string>('ALIYUN_SMS_ENDPOINT', 'dysmsapi.aliyuncs.com');
if (!accessKeyId || !accessKeySecret) {
this.logger.warn('阿里云 SMS 配置缺失,短信功能将使用模拟模式');
return;
}
try {
const config = new $OpenApi.Config({
accessKeyId,
accessKeySecret,
endpoint,
});
this.client = new Dysmsapi20170525(config);
this.logger.log('阿里云 SMS 客户端初始化成功');
} catch (error) {
this.logger.error('阿里云 SMS 客户端初始化失败', error);
}
}
/**
*
*
* @param phoneNumber +86xxx
* @param code
* @returns
*/
async sendVerificationCode(phoneNumber: string, code: string): Promise<SmsSendResult> {
// 标准化手机号(去除 +86 前缀)
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
this.logger.log(`[SMS] 发送验证码到 ${this.maskPhoneNumber(normalizedPhone)}`);
// 开发环境或未启用时,使用模拟模式
if (!this.enabled || !this.client) {
this.logger.warn(`[SMS] 模拟模式: 验证码 ${code} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`);
return {
success: true,
requestId: 'mock-request-id',
bizId: 'mock-biz-id',
code: 'OK',
message: '模拟发送成功',
};
}
try {
const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({
phoneNumbers: normalizedPhone,
signName: this.signName,
templateCode: this.templateCode,
templateParam: JSON.stringify({ code }),
});
const runtime = new $Util.RuntimeOptions({});
const response = await this.client.sendSmsWithOptions(sendSmsRequest, runtime);
const body = response.body;
const result: SmsSendResult = {
success: body?.code === 'OK',
requestId: body?.requestId,
bizId: body?.bizId,
code: body?.code,
message: body?.message,
};
if (result.success) {
this.logger.log(`[SMS] 发送成功: requestId=${result.requestId}, bizId=${result.bizId}`);
} else {
this.logger.error(`[SMS] 发送失败: code=${result.code}, message=${result.message}`);
}
return result;
} catch (error: any) {
this.logger.error(`[SMS] 发送异常: ${error.message}`, error.stack);
// 解析阿里云错误
if (error.code) {
return {
success: false,
code: error.code,
message: error.message || '短信发送失败',
};
}
return {
success: false,
code: 'UNKNOWN_ERROR',
message: error.message || '短信发送失败',
};
}
}
/**
*
*
* @param phoneNumber
* @param templateCode
* @param templateParam
* @returns
*/
async sendSms(
phoneNumber: string,
templateCode: string,
templateParam: Record<string, string>,
): Promise<SmsSendResult> {
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
if (!this.enabled || !this.client) {
this.logger.warn(`[SMS] 模拟模式: 模板 ${templateCode} 发送到 ${this.maskPhoneNumber(normalizedPhone)}`);
return {
success: true,
requestId: 'mock-request-id',
code: 'OK',
message: '模拟发送成功',
};
}
try {
const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({
phoneNumbers: normalizedPhone,
signName: this.signName,
templateCode,
templateParam: JSON.stringify(templateParam),
});
const runtime = new $Util.RuntimeOptions({});
const response = await this.client.sendSmsWithOptions(sendSmsRequest, runtime);
const body = response.body;
return {
success: body?.code === 'OK',
requestId: body?.requestId,
bizId: body?.bizId,
code: body?.code,
message: body?.message,
};
} catch (error: any) {
this.logger.error(`[SMS] 发送异常: ${error.message}`);
return {
success: false,
code: error.code || 'UNKNOWN_ERROR',
message: error.message || '短信发送失败',
};
}
}
/**
*
*
* @param phoneNumber
* @param bizId ID
* @param sendDate (yyyyMMdd )
*/
async querySendDetails(
phoneNumber: string,
bizId: string,
sendDate: string,
): Promise<any> {
if (!this.client) {
this.logger.warn('[SMS] 客户端未初始化,无法查询');
return null;
}
try {
const querySendDetailsRequest = new $Dysmsapi20170525.QuerySendDetailsRequest({
phoneNumber: this.normalizePhoneNumber(phoneNumber),
bizId,
sendDate,
pageSize: 10,
currentPage: 1,
});
const runtime = new $Util.RuntimeOptions({});
const response = await this.client.querySendDetailsWithOptions(querySendDetailsRequest, runtime);
return response.body;
} catch (error: any) {
this.logger.error(`[SMS] 查询发送详情失败: ${error.message}`);
return null;
}
}
/**
*
*/
private normalizePhoneNumber(phoneNumber: string): string {
let normalized = phoneNumber.trim();
// 去除 +86 或 86 前缀
if (normalized.startsWith('+86')) {
normalized = normalized.substring(3);
} else if (normalized.startsWith('86') && normalized.length === 13) {
normalized = normalized.substring(2);
}
return normalized;
}
/**
*
*/
private maskPhoneNumber(phoneNumber: string): string {
if (phoneNumber.length < 7) {
return phoneNumber;
}
return phoneNumber.substring(0, 3) + '****' + phoneNumber.substring(phoneNumber.length - 4);
}
}

View File

@ -55,8 +55,22 @@ export class WalletController {
return { settlementOrderId: orderId };
}
@Post('withdraw/send-sms')
@ApiOperation({ summary: '发送提取验证短信', description: '向用户手机发送提取验证码' })
@ApiResponse({ status: 200, description: '发送成功' })
async sendWithdrawSms(
@CurrentUser() user: CurrentUserPayload,
@Headers('authorization') authHeader: string,
): Promise<{ message: string }> {
const token = authHeader?.replace('Bearer ', '') || '';
// 调用 identity-service 发送短信验证码
await this.identityClient.sendWithdrawSmsCode(user.userId, token);
return { message: '验证码已发送' };
}
@Post('withdraw')
@ApiOperation({ summary: '申请提现', description: '将USDT提现到指定地址需要TOTP验证如已启用' })
@ApiOperation({ summary: '申请提现', description: '将USDT提现到指定地址需要短信验证和密码验证' })
@ApiResponse({ status: 201, type: WithdrawalResponseDTO })
async requestWithdrawal(
@CurrentUser() user: CurrentUserPayload,
@ -66,26 +80,45 @@ export class WalletController {
// 提取 JWT token
const token = authHeader?.replace('Bearer ', '') || '';
// 检查用户是否启用了 TOTP
const totpEnabled = await this.identityClient.isTotpEnabled(user.userId, token);
// 验证短信验证码
if (!dto.smsCode) {
throw new HttpException('请输入短信验证码', HttpStatus.BAD_REQUEST);
}
if (totpEnabled) {
// 如果启用了 TOTP必须提供验证码
if (!dto.totpCode) {
throw new HttpException('请输入谷歌验证码', HttpStatus.BAD_REQUEST);
}
const isSmsValid = await this.identityClient.verifyWithdrawSmsCode(user.userId, dto.smsCode, token);
if (!isSmsValid) {
throw new HttpException('短信验证码错误,请重试', HttpStatus.BAD_REQUEST);
}
// 验证 TOTP 码
const isValid = await this.identityClient.verifyTotp(user.userId, dto.totpCode, token);
if (!isValid) {
throw new HttpException('验证码错误,请重试', HttpStatus.BAD_REQUEST);
// 验证登录密码
if (!dto.password) {
throw new HttpException('请输入登录密码', HttpStatus.BAD_REQUEST);
}
const isPasswordValid = await this.identityClient.verifyPassword(user.userId, dto.password, token);
if (!isPasswordValid) {
throw new HttpException('登录密码错误,请重试', HttpStatus.BAD_REQUEST);
}
// 处理 toAddress: 如果是 accountSequence 格式,转换为区块链地址
let actualAddress = dto.toAddress;
if (dto.toAddress.startsWith('D') && dto.toAddress.length === 12) {
// accountSequence 格式,需要查询对应的区块链地址
const resolvedAddress = await this.identityClient.resolveAccountSequenceToAddress(
dto.toAddress,
dto.chainType,
token,
);
if (!resolvedAddress) {
throw new HttpException('无效的充值ID未找到对应地址', HttpStatus.BAD_REQUEST);
}
actualAddress = resolvedAddress;
}
const command = new RequestWithdrawalCommand(
user.userId,
dto.amount,
dto.toAddress,
actualAddress,
dto.chainType,
);
return this.walletService.requestWithdrawal(command);

View File

@ -9,11 +9,13 @@ export class RequestWithdrawalDTO {
amount: number;
@ApiProperty({
description: '提现目标地址 (EVM地址)',
description: '提现目标地址 (EVM地址或充值ID)',
example: '0x1234567890abcdef1234567890abcdef12345678',
})
@IsString()
@Matches(/^0x[a-fA-F0-9]{40}$/, { message: '无效的EVM地址格式' })
@Matches(/^(0x[a-fA-F0-9]{40}|D\d{11})$/, {
message: '无效的地址格式请输入EVM地址(0x...)或充值ID(D...)',
})
toAddress: string;
@ApiProperty({
@ -24,13 +26,20 @@ export class RequestWithdrawalDTO {
@IsEnum(ChainType)
chainType: ChainType;
@ApiPropertyOptional({
description: 'TOTP 验证码 (如已启用二次验证)',
@ApiProperty({
description: '短信验证码',
example: '123456',
})
@IsOptional()
@IsString()
@Length(6, 6, { message: 'TOTP 验证码必须是6位数字' })
@Matches(/^\d{6}$/, { message: 'TOTP 验证码必须是6位数字' })
totpCode?: string;
@Length(6, 6, { message: '短信验证码必须是6位数字' })
@Matches(/^\d{6}$/, { message: '短信验证码必须是6位数字' })
smsCode: string;
@ApiProperty({
description: '登录密码',
example: 'password123',
})
@IsString()
@Length(6, 32, { message: '密码长度必须在6-32位之间' })
password: string;
}

View File

@ -94,4 +94,164 @@ export class IdentityClientService {
return false;
}
}
/**
*
*
* @param userId ID
* @param token JWT token
*/
async sendWithdrawSmsCode(userId: string, token: string): Promise<void> {
try {
this.logger.log(`发送提取验证短信: userId=${userId}`);
await this.httpClient.post(
'/sms/send-withdraw-code',
{},
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
this.logger.log(`发送提取验证短信成功: userId=${userId}`);
} catch (error: any) {
this.logger.error(`发送提取验证短信失败: userId=${userId}, error=${error.message}`);
if (error.response) {
const message = error.response.data?.message || '发送验证码失败';
throw new HttpException(message, error.response.status || HttpStatus.BAD_REQUEST);
}
throw new HttpException('短信服务不可用', HttpStatus.SERVICE_UNAVAILABLE);
}
}
/**
*
*
* @param userId ID
* @param smsCode
* @param token JWT token
* @returns
*/
async verifyWithdrawSmsCode(userId: string, smsCode: string, token: string): Promise<boolean> {
try {
this.logger.log(`验证提取短信验证码: userId=${userId}`);
const response = await this.httpClient.post(
'/sms/verify-withdraw-code',
{ code: smsCode },
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
const valid = response.data?.valid ?? false;
this.logger.log(`提取短信验证码验证结果: userId=${userId}, valid=${valid}`);
return valid;
} catch (error: any) {
this.logger.error(`验证提取短信验证码失败: userId=${userId}, error=${error.message}`);
if (error.response) {
const status = error.response.status;
const message = error.response.data?.message || '验证码错误';
if (status === 400 || status === 401) {
throw new HttpException(message, HttpStatus.BAD_REQUEST);
}
}
throw new HttpException('短信验证服务不可用', HttpStatus.SERVICE_UNAVAILABLE);
}
}
/**
*
*
* @param userId ID
* @param password
* @param token JWT token
* @returns
*/
async verifyPassword(userId: string, password: string, token: string): Promise<boolean> {
try {
this.logger.log(`验证登录密码: userId=${userId}`);
const response = await this.httpClient.post(
'/verify-password',
{ password },
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
const valid = response.data?.valid ?? false;
this.logger.log(`登录密码验证结果: userId=${userId}, valid=${valid}`);
return valid;
} catch (error: any) {
this.logger.error(`验证登录密码失败: userId=${userId}, error=${error.message}`);
if (error.response) {
const status = error.response.status;
const message = error.response.data?.message || '密码验证失败';
if (status === 400 || status === 401) {
throw new HttpException(message, HttpStatus.BAD_REQUEST);
}
}
throw new HttpException('密码验证服务不可用', HttpStatus.SERVICE_UNAVAILABLE);
}
}
/**
* accountSequence
*
* @param accountSequence ( D25121400005)
* @param chainType (KAVA, BSC)
* @param token JWT token
* @returns null
*/
async resolveAccountSequenceToAddress(
accountSequence: string,
chainType: string,
token: string,
): Promise<string | null> {
try {
this.logger.log(`解析 accountSequence: ${accountSequence}, chainType=${chainType}`);
const response = await this.httpClient.get(
`/users/resolve-address/${accountSequence}`,
{
params: { chainType },
headers: {
Authorization: `Bearer ${token}`,
},
},
);
const address = response.data?.address;
this.logger.log(`accountSequence 解析结果: ${accountSequence} -> ${address}`);
return address || null;
} catch (error: any) {
this.logger.error(
`解析 accountSequence 失败: ${accountSequence}, error=${error.message}`,
);
if (error.response?.status === 404) {
return null;
}
throw new HttpException('无法解析充值ID', HttpStatus.SERVICE_UNAVAILABLE);
}
}
}

View File

@ -279,6 +279,41 @@ class WalletService {
}
}
///
///
/// POST /wallet/withdraw/send-sms (wallet-service)
Future<void> sendWithdrawSmsCode() async {
try {
debugPrint('[WalletService] ========== 发送提取验证短信 ==========');
debugPrint('[WalletService] 请求: POST /wallet/withdraw/send-sms');
final response = await _apiClient.post('/wallet/withdraw/send-sms');
debugPrint('[WalletService] 响应状态码: ${response.statusCode}');
debugPrint('[WalletService] 响应数据: ${response.data}');
if (response.statusCode == 200 || response.statusCode == 201) {
debugPrint('[WalletService] 发送成功');
debugPrint('[WalletService] ================================');
return;
}
debugPrint('[WalletService] 发送失败,状态码: ${response.statusCode}');
String errorMessage = '发送验证码失败';
if (response.data is Map<String, dynamic>) {
final errorData = response.data as Map<String, dynamic>;
errorMessage = errorData['message'] ?? errorData['error'] ?? errorMessage;
}
throw Exception(errorMessage);
} catch (e, stackTrace) {
debugPrint('[WalletService] !!!!!!!!!! 发送提取验证短信异常 !!!!!!!!!!');
debugPrint('[WalletService] 错误: $e');
debugPrint('[WalletService] 堆栈: $stackTrace');
rethrow;
}
}
///
///
/// POST /wallet/withdraw (wallet-service)
@ -287,7 +322,8 @@ class WalletService {
required double amount,
required String toAddress,
required String chainType,
String? totpCode,
required String smsCode,
required String password,
}) async {
try {
debugPrint('[WalletService] ========== 提取积分 ==========');
@ -298,13 +334,10 @@ class WalletService {
'amount': amount,
'toAddress': toAddress,
'chainType': chainType,
'smsCode': smsCode,
'password': password,
};
// TOTP
if (totpCode != null && totpCode.isNotEmpty) {
data['totpCode'] = totpCode;
}
final response = await _apiClient.post(
'/wallet/withdraw',
data: data,

View File

@ -32,12 +32,52 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
(index) => FocusNode(),
);
///
final TextEditingController _passwordController = TextEditingController();
///
bool _isPasswordVisible = false;
///
bool _isSubmitting = false;
///
bool _isSendingSms = false;
///
int _countdown = 0;
///
String? _maskedPhoneNumber;
///
final double _feeRate = 0.001;
@override
void initState() {
super.initState();
_loadUserPhone();
}
///
Future<void> _loadUserPhone() async {
try {
final accountService = ref.read(accountServiceProvider);
final meResponse = await accountService.getMe();
if (meResponse.phoneNumber != null) {
//
final phone = meResponse.phoneNumber!;
if (phone.length >= 7) {
setState(() {
_maskedPhoneNumber = '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}';
});
}
}
} catch (e) {
debugPrint('[WithdrawConfirmPage] 加载用户手机号失败: $e');
}
}
@override
void dispose() {
for (final controller in _codeControllers) {
@ -46,6 +86,7 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
for (final node in _focusNodes) {
node.dispose();
}
_passwordController.dispose();
super.dispose();
}
@ -54,6 +95,60 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
context.pop();
}
///
Future<void> _sendSmsCode() async {
if (_isSendingSms || _countdown > 0) return;
setState(() {
_isSendingSms = true;
});
try {
final walletService = ref.read(walletServiceProvider);
await walletService.sendWithdrawSmsCode();
if (mounted) {
setState(() {
_isSendingSms = false;
_countdown = 60;
});
_startCountdown();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('验证码已发送'),
backgroundColor: Color(0xFF4CAF50),
duration: Duration(seconds: 2),
),
);
}
} catch (e) {
debugPrint('[WithdrawConfirmPage] 发送验证码失败: $e');
if (mounted) {
setState(() {
_isSendingSms = false;
});
String errorMsg = e.toString();
if (errorMsg.contains('Exception:')) {
errorMsg = errorMsg.replaceAll('Exception:', '').trim();
}
_showErrorSnackBar(errorMsg);
}
}
}
///
void _startCountdown() {
Future.doWhile(() async {
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return false;
setState(() {
_countdown--;
});
return _countdown > 0;
});
}
///
String _getCode() {
return _codeControllers.map((c) => c.text).join();
@ -98,6 +193,7 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
///
Future<void> _onSubmit() async {
final code = _getCode();
final password = _passwordController.text.trim();
//
if (code.length != 6) {
@ -105,6 +201,17 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
return;
}
//
if (password.isEmpty) {
_showErrorSnackBar('请输入登录密码');
return;
}
if (password.length < 6) {
_showErrorSnackBar('密码长度至少6位');
return;
}
setState(() {
_isSubmitting = true;
});
@ -122,7 +229,8 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
amount: widget.params.amount,
toAddress: widget.params.address,
chainType: _getChainType(widget.params.network),
totpCode: code,
smsCode: code,
password: password,
);
debugPrint('[WithdrawConfirmPage] 提取成功: orderNo=${response.orderNo}');
@ -262,8 +370,12 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
_buildDetailsCard(),
const SizedBox(height: 24),
//
//
_buildAuthenticatorSection(),
const SizedBox(height: 24),
//
_buildPasswordSection(),
const SizedBox(height: 32),
//
@ -401,7 +513,7 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
);
}
///
///
Widget _buildAuthenticatorSection() {
return Container(
width: double.infinity,
@ -420,13 +532,70 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
Row(
children: const [
Icon(
Icons.security,
Icons.sms_outlined,
size: 24,
color: Color(0xFFD4AF37),
),
SizedBox(width: 8),
Text(
'谷歌验证器',
'短信验证',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
],
),
const SizedBox(height: 8),
Text(
_maskedPhoneNumber != null
? '验证码将发送至 $_maskedPhoneNumber'
: '请输入短信验证码',
style: const TextStyle(
fontSize: 14,
fontFamily: 'Inter',
color: Color(0xFF8B5A2B),
),
),
const SizedBox(height: 20),
//
_buildCodeInput(),
const SizedBox(height: 16),
//
_buildSendCodeButton(),
],
),
);
}
///
Widget _buildPasswordSection() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0x80FFFFFF),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0x33D4AF37),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
Icon(
Icons.lock_outline,
size: 24,
color: Color(0xFFD4AF37),
),
SizedBox(width: 8),
Text(
'密码验证',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
@ -438,21 +607,125 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
),
const SizedBox(height: 8),
const Text(
'请输入谷歌验证器中的6位验证码',
'请输入您的登录密码以确认提取操作',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
color: Color(0xFF8B5A2B),
),
),
const SizedBox(height: 20),
//
_buildCodeInput(),
const SizedBox(height: 16),
//
TextField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
style: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
color: Color(0xFF5D4037),
),
decoration: InputDecoration(
hintText: '请输入登录密码',
hintStyle: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
color: Color(0xFF8B5A2B),
),
filled: true,
fillColor: const Color(0xFFFFF5E6),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0x33D4AF37),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0x33D4AF37),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFFD4AF37),
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
color: const Color(0xFF8B5A2B),
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
),
onChanged: (_) => setState(() {}),
),
],
),
);
}
///
Widget _buildSendCodeButton() {
final canSend = !_isSendingSms && _countdown <= 0;
return GestureDetector(
onTap: canSend ? _sendSmsCode : null,
child: Container(
width: double.infinity,
height: 44,
decoration: BoxDecoration(
color: canSend
? const Color(0xFFD4AF37).withValues(alpha: 0.15)
: const Color(0xFFD4AF37).withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: canSend
? const Color(0xFFD4AF37).withValues(alpha: 0.5)
: const Color(0xFFD4AF37).withValues(alpha: 0.2),
width: 1,
),
),
child: Center(
child: _isSendingSms
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFFD4AF37)),
),
)
: Text(
_countdown > 0 ? '${_countdown}s 后重新发送' : '获取验证码',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: canSend
? const Color(0xFFD4AF37)
: const Color(0xFFD4AF37).withValues(alpha: 0.5),
),
),
),
),
);
}
///
Widget _buildCodeInput() {
return Row(
@ -518,7 +791,8 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
///
Widget _buildSubmitButton() {
final code = _getCode();
final isValid = code.length == 6;
final password = _passwordController.text.trim();
final isValid = code.length == 6 && password.length >= 6;
return GestureDetector(
onTap: (isValid && !_isSubmitting) ? _onSubmit : null,