From 5b2d255506ec11f09dedd53bc05fa609373f70ed Mon Sep 17 00:00:00 2001 From: hailin Date: Fri, 19 Dec 2025 03:05:53 -0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=A2=9E=E5=BC=BA=E6=8F=90?= =?UTF-8?q?=E7=8E=B0=E5=AE=89=E5=85=A8=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 集成阿里云短信服务 (dysmsapi20170525) - 提现需同时验证短信验证码和登录密码 - identity-service 添加 /verify-password API - wallet-service 调用双重验证 - 移动端提现确认页添加密码输入 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../services/identity-service/.env.example | 25 +- .../identity-service/package-lock.json | 290 +++++++++++++++++ .../services/identity-service/package.json | 21 +- .../controllers/user-account.controller.ts | 74 ++++- .../services/user-application.service.ts | 172 ++++++++++ .../identity-service/src/config/index.ts | 12 +- .../external/sms/sms.service.ts | 252 ++++++++++++++- .../src/api/controllers/wallet.controller.ts | 59 +++- .../src/api/dto/request/withdrawal.dto.ts | 25 +- .../identity/identity-client.service.ts | 160 ++++++++++ .../lib/core/services/wallet_service.dart | 45 ++- .../pages/withdraw_confirm_page.dart | 294 +++++++++++++++++- 12 files changed, 1366 insertions(+), 63 deletions(-) diff --git a/backend/services/identity-service/.env.example b/backend/services/identity-service/.env.example index ab6cc88f..9291d32c 100644 --- a/backend/services/identity-service/.env.example +++ b/backend/services/identity-service/.env.example @@ -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 diff --git a/backend/services/identity-service/package-lock.json b/backend/services/identity-service/package-lock.json index d8c745d4..b476c54d 100644 --- a/backend/services/identity-service/package-lock.json +++ b/backend/services/identity-service/package-lock.json @@ -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", diff --git a/backend/services/identity-service/package.json b/backend/services/identity-service/package.json index c7bcafc0..6ce09b9e 100644 --- a/backend/services/identity-service/package.json +++ b/backend/services/identity-service/package.json @@ -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": { diff --git a/backend/services/identity-service/src/api/controllers/user-account.controller.ts b/backend/services/identity-service/src/api/controllers/user-account.controller.ts index ad72e9d9..fead7418 100644 --- a/backend/services/identity-service/src/api/controllers/user-account.controller.ts +++ b/backend/services/identity-service/src/api/controllers/user-account.controller.ts @@ -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: '上传用户头像' }) diff --git a/backend/services/identity-service/src/application/services/user-application.service.ts b/backend/services/identity-service/src/application/services/user-application.service.ts index e88f5932..60e4b06a 100644 --- a/backend/services/identity-service/src/application/services/user-application.service.ts +++ b/backend/services/identity-service/src/application/services/user-application.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } } diff --git a/backend/services/identity-service/src/config/index.ts b/backend/services/identity-service/src/config/index.ts index d4325aa7..fb0c8b11 100644 --- a/backend/services/identity-service/src/config/index.ts +++ b/backend/services/identity-service/src/config/index.ts @@ -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 = () => ({ diff --git a/backend/services/identity-service/src/infrastructure/external/sms/sms.service.ts b/backend/services/identity-service/src/infrastructure/external/sms/sms.service.ts index 863f3ef8..499b9f6b 100644 --- a/backend/services/identity-service/src/infrastructure/external/sms/sms.service.ts +++ b/backend/services/identity-service/src/infrastructure/external/sms/sms.service.ts @@ -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 { - const apiUrl = this.configService.get('SMS_API_URL'); - const apiKey = this.configService.get('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 { - const content = `您的验证码是${code},5分钟内有效。`; - return this.sendSms(phoneNumber, content); + async onModuleInit() { + await this.initClient(); + } + + private async initClient(): Promise { + const accessKeyId = this.configService.get('ALIYUN_ACCESS_KEY_ID'); + const accessKeySecret = this.configService.get('ALIYUN_ACCESS_KEY_SECRET'); + const endpoint = this.configService.get('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 { + // 标准化手机号(去除 +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, + ): Promise { + 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 { + 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); } } diff --git a/backend/services/wallet-service/src/api/controllers/wallet.controller.ts b/backend/services/wallet-service/src/api/controllers/wallet.controller.ts index 2a6f18a8..17c40e56 100644 --- a/backend/services/wallet-service/src/api/controllers/wallet.controller.ts +++ b/backend/services/wallet-service/src/api/controllers/wallet.controller.ts @@ -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); diff --git a/backend/services/wallet-service/src/api/dto/request/withdrawal.dto.ts b/backend/services/wallet-service/src/api/dto/request/withdrawal.dto.ts index ad9d30c7..6bcdaf37 100644 --- a/backend/services/wallet-service/src/api/dto/request/withdrawal.dto.ts +++ b/backend/services/wallet-service/src/api/dto/request/withdrawal.dto.ts @@ -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; } diff --git a/backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts b/backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts index 361d1bbd..5a4f1c4f 100644 --- a/backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts +++ b/backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts @@ -94,4 +94,164 @@ export class IdentityClientService { return false; } } + + /** + * 发送提取验证短信 + * + * @param userId 用户 ID + * @param token JWT token + */ + async sendWithdrawSmsCode(userId: string, token: string): Promise { + 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 { + 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 { + 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 { + 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); + } + } } diff --git a/frontend/mobile-app/lib/core/services/wallet_service.dart b/frontend/mobile-app/lib/core/services/wallet_service.dart index 8f153d04..83da73f0 100644 --- a/frontend/mobile-app/lib/core/services/wallet_service.dart +++ b/frontend/mobile-app/lib/core/services/wallet_service.dart @@ -279,6 +279,41 @@ class WalletService { } } + /// 发送提取验证短信 + /// + /// 调用 POST /wallet/withdraw/send-sms (wallet-service) + Future 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) { + final errorData = response.data as Map; + 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, diff --git a/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_confirm_page.dart b/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_confirm_page.dart index 9131b361..0656b306 100644 --- a/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_confirm_page.dart +++ b/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_confirm_page.dart @@ -32,12 +32,52 @@ class _WithdrawConfirmPageState extends ConsumerState { (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 _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 { for (final node in _focusNodes) { node.dispose(); } + _passwordController.dispose(); super.dispose(); } @@ -54,6 +95,60 @@ class _WithdrawConfirmPageState extends ConsumerState { context.pop(); } + /// 发送短信验证码 + Future _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 { /// 提交提取 Future _onSubmit() async { final code = _getCode(); + final password = _passwordController.text.trim(); // 验证验证码 if (code.length != 6) { @@ -105,6 +201,17 @@ class _WithdrawConfirmPageState extends ConsumerState { return; } + // 验证密码 + if (password.isEmpty) { + _showErrorSnackBar('请输入登录密码'); + return; + } + + if (password.length < 6) { + _showErrorSnackBar('密码长度至少6位'); + return; + } + setState(() { _isSubmitting = true; }); @@ -122,7 +229,8 @@ class _WithdrawConfirmPageState extends ConsumerState { 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 { _buildDetailsCard(), const SizedBox(height: 24), - // 谷歌验证器验证 + // 短信验证区域 _buildAuthenticatorSection(), + const SizedBox(height: 24), + + // 密码验证区域 + _buildPasswordSection(), const SizedBox(height: 32), // 提交按钮 @@ -401,7 +513,7 @@ class _WithdrawConfirmPageState extends ConsumerState { ); } - /// 构建谷歌验证器验证区域 + /// 构建短信验证区域 Widget _buildAuthenticatorSection() { return Container( width: double.infinity, @@ -420,13 +532,70 @@ class _WithdrawConfirmPageState extends ConsumerState { 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 { ), 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(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 { /// 构建提交按钮 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,