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:
parent
9d693b743b
commit
5b2d255506
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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: '上传用户头像' })
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = () => ({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue