diff --git a/.env.mac-docker.example b/.env.mac-docker.example index dbdf9ea4..94694081 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -50,3 +50,6 @@ KUASHOU_INDUSTRY_MESSAGE_SECRET= # Cloudtentacles CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc CLOUDTENTACLES_DEVICE_TYPE=1 + +# 接单平台短信验证码(打手注册,开发默认 mock,验证码打印到服务日志) +WORKER_SMS_PROVIDER=mock diff --git a/.env.server.example b/.env.server.example index 2b1bb2b3..b917687b 100644 --- a/.env.server.example +++ b/.env.server.example @@ -53,3 +53,10 @@ KUASHOU_INDUSTRY_MESSAGE_SECRET= # Cloudtentacles CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc CLOUDTENTACLES_DEVICE_TYPE=1 + +# 接单平台短信验证码(打手注册) +WORKER_SMS_PROVIDER=mock +WORKER_SMS_ACCESS_KEY_ID= +WORKER_SMS_ACCESS_KEY_SECRET= +WORKER_SMS_SIGN_NAME= +WORKER_SMS_TEMPLATE_CODE= diff --git a/README.md b/README.md index 0efc66b5..055e1ef7 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,9 @@ bash deploy/ubuntu-deploy.sh 说明: -- 打手账号需后台审核后进入大厅;来源订单同步由物品规则驱动(`syncWorkerOrdersForSourceOrder`)。 +- 打手通过**手机号验证码注册**并设置密码,注册后直接可用;登录使用手机号 + 密码。 +- 短信默认 Mock 模式(验证码打印到服务日志),生产配置 `WORKER_SMS_PROVIDER=aliyun` + 阿里云凭据后走真实短信。 +- 来源订单同步由物品规则驱动(`syncWorkerOrdersForSourceOrder`)。 - 工单状态机独立于主履约任务:`pending_material → unassigned → open → in_progress → pending_acceptance → accepted / cancelled`,另有 `problem`。 - 超时扫描定时任务默认 60 秒执行一次(配置见后台"平台配置 → 定时任务")。 diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 918d240e..e085689e 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -8,6 +8,9 @@ "name": "order-site-backend", "version": "0.1.0", "dependencies": { + "@alicloud/dysmsapi20170525": "^4.6.0", + "@alicloud/openapi-client": "^0.4.15", + "@alicloud/tea-util": "^1.4.11", "express": "^5.1.0", "minio": "^8.0.7", "multer": "^2.2.0", @@ -25,6 +28,227 @@ "typescript": "^6.0.2" } }, + "node_modules/@alicloud/credentials": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@alicloud/credentials/-/credentials-2.4.5.tgz", + "integrity": "sha512-od1ufCxOO7cP2R4EVFliOB0kGo9lUXCibyj/mzmI6yLhxeqhqsegTzVsx5p2NJJsceKJnYcmye7FWKyLJAFBkw==", + "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.6.0", + "resolved": "https://registry.npmjs.org/@alicloud/dysmsapi20170525/-/dysmsapi20170525-4.6.0.tgz", + "integrity": "sha512-cUAgK/nHEgG8TVOlvMDAEINJjPo9D/l/x9loCom7CqlE8U1uFeZh7Ko2LR2ksPoHbTzzx9NYbXQthFoWgdlThQ==", + "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.8", + "resolved": "https://registry.npmjs.org/@alicloud/openapi-core/-/openapi-core-1.0.8.tgz", + "integrity": "sha512-xs8LdgMDcEUqv13kZ4nl+Vd+Fc1mixTF0g1lm5QSrNWDaLUUD5vqpuMtvUZnKNnq9PITfUQ+8KunAvNQJpFo8g==", + "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.5" + } + }, + "node_modules/@alicloud/openapi-util": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@alicloud/openapi-util/-/openapi-util-0.3.3.tgz", + "integrity": "sha512-vf0cQ/q8R2U7ZO88X5hDiu1yV3t/WexRj+YycWxRutkH/xVXfkmpRgps8lmNEk7Ar+0xnY8+daN2T+2OyB9F4A==", + "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/@darabonba/typescript": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@darabonba/typescript/-/typescript-1.0.5.tgz", + "integrity": "sha512-pfxHFVM8I3h8K2o8skpDQLMmR5iAeQ2eNpP1HrKDEm/9ZPF8aKwDKPwwEszT1NnIo0Y3++E7x1YsVkVpGjA/xw==", + "license": "Apache License 2.0", + "dependencies": { + "@alicloud/tea-typescript": "^1.5.1", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "httpx": "^2.3.2", + "lodash": "^4.17.21", + "moment": "^2.30.1", + "moment-timezone": "^0.5.45", + "socks-proxy-agent": "^6.2.1", + "ws": "^8.18.0", + "xml2js": "^0.6.2" + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", @@ -998,6 +1222,15 @@ ], "license": "MIT" }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1065,7 +1298,6 @@ "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.19.0" @@ -1118,6 +1350,15 @@ "@types/node": "*" } }, + "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/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1131,6 +1372,18 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1866,6 +2119,58 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "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/httpx/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/httpx/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -1888,6 +2193,21 @@ "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/ip-address": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1945,6 +2265,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/kitx/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -2093,6 +2437,27 @@ "node": ">=16 || 14 >=14.17" } }, + "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", @@ -2798,6 +3163,50 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/split-on-first": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", @@ -3004,7 +3413,6 @@ "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "devOptional": true, "license": "MIT" }, "node_modules/unpipe": { @@ -3069,6 +3477,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-naming": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index 51e059aa..6bf4b261 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -24,6 +24,9 @@ "mock:feifei": "tsx scripts/mock-feifei-claim.ts" }, "dependencies": { + "@alicloud/dysmsapi20170525": "^4.6.0", + "@alicloud/openapi-client": "^0.4.15", + "@alicloud/tea-util": "^1.4.11", "express": "^5.1.0", "minio": "^8.0.7", "multer": "^2.2.0", diff --git a/apps/backend/scripts/verify-aliyun-sms.ts b/apps/backend/scripts/verify-aliyun-sms.ts new file mode 100644 index 00000000..ddf46f97 --- /dev/null +++ b/apps/backend/scripts/verify-aliyun-sms.ts @@ -0,0 +1,91 @@ +import crypto from 'node:crypto' +import https from 'node:https' + +import { runtimeConfig } from '../src/config/runtime.js' + +function percentEncode(value: string): string { + return encodeURIComponent(String(value || '')) + .replace(/\+/g, '%20') + .replace(/\*/g, '%2A') + .replace(/%7E/g, '~') +} + +function signAliyunRequest(params: Record, accessKeySecret: string): string { + const canonicalizedQuery = Object.entries(params) + .map(([key, value]) => `${percentEncode(key)}=${percentEncode(value)}`) + .sort((left, right) => + String(left[0]) < String(right[0]) ? -1 : String(left[0]) > String(right[0]) ? 1 : 0, + ) + .join('&') + const stringToSign = `GET&${percentEncode('/')}&${percentEncode(canonicalizedQuery)}` + const hmac = crypto.createHmac('sha1', `${accessKeySecret}&`) + hmac.update(stringToSign) + return hmac.digest('base64') +} + +async function main() { + const config = runtimeConfig.worker.sms + console.log('当前短信配置:') + console.log(' provider :', config.provider) + console.log(' accessKeyId :', config.accessKeyId ? `${config.accessKeyId.slice(0, 4)}...${config.accessKeyId.slice(-4)}` : '(空)') + console.log(' accessKeySecret :', config.accessKeySecret ? `${config.accessKeySecret.slice(0, 4)}...${config.accessKeySecret.slice(-4)}(长度 ${config.accessKeySecret.length})` : '(空)') + console.log(' signName :', config.signName) + console.log(' templateCode :', config.templateCode) + console.log('') + + if (!config.accessKeyId || !config.accessKeySecret || !config.signName || !config.templateCode) { + console.error('配置不完整,请检查 .env 中的 WORKER_SMS_* 配置') + return + } + + const phone = process.argv[2] || '13800000000' + const params: Record = { + AccessKeyId: config.accessKeyId, + Action: 'SendSms', + Format: 'JSON', + Version: '2017-05-25', + SignatureMethod: 'HMAC-SHA1', + SignatureVersion: '1.0', + SignatureNonce: crypto.randomUUID(), + Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), + PhoneNumbers: phone, + SignName: config.signName, + TemplateCode: config.templateCode, + TemplateParam: JSON.stringify({ code: '123456' }), + } + + const signature = signAliyunRequest(params, config.accessKeySecret) + params.Signature = signature + + const queryString = Object.entries(params) + .map(([key, value]) => `${percentEncode(key)}=${percentEncode(value)}`) + .sort() + .join('&') + const url = `https://dysmsapi.aliyuncs.com/?${queryString}` + + console.log('stringToSign:') + console.log(' ', `GET&%2F&${percentEncode( + Object.entries(params) + .filter(([key]) => key !== 'Signature') + .map(([key, value]) => `${percentEncode(key)}=${percentEncode(value)}`) + .sort((left, right) => (String(left[0]) < String(right[0]) ? -1 : String(left[0]) > String(right[0]) ? 1 : 0)) + .join('&'), + )}`) + console.log('') + console.log(`发送测试短信到 ${phone}(验证码 123456)...`) + + https + .get(url, (response) => { + const chunks: Buffer[] = [] + response.on('data', (chunk) => chunks.push(Buffer.from(chunk))) + response.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8') + console.log('响应:', body) + }) + }) + .on('error', (error) => { + console.error('请求失败:', error.message) + }) +} + +void main() diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index b8879b71..96ee04a2 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -126,5 +126,18 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15 cors: { allowedOrigins: ['*'], }, + + worker: { + sms: { + provider: 'mock', + accessKeyId: '', + accessKeySecret: '', + signName: '', + templateCode: '', + codeTtlSeconds: 300, + cooldownSeconds: 60, + dailyLimit: 10, + }, + }, } } diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index f1574005..c5ad02d9 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -156,6 +156,14 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ stringEnv('KUAISHOU_FEIFEI_BASE_URL', ['platforms', 'kuaishouFeifei', 'baseUrl']), stringEnv('KUAISHOU_FEIFEI_APP_KEY', ['platforms', 'kuaishouFeifei', 'appKey']), stringEnv('KUAISHOU_FEIFEI_APP_SECRET', ['platforms', 'kuaishouFeifei', 'appSecret']), + stringEnv('WORKER_SMS_PROVIDER', ['worker', 'sms', 'provider']), + stringEnv('WORKER_SMS_ACCESS_KEY_ID', ['worker', 'sms', 'accessKeyId']), + stringEnv('WORKER_SMS_ACCESS_KEY_SECRET', ['worker', 'sms', 'accessKeySecret']), + stringEnv('WORKER_SMS_SIGN_NAME', ['worker', 'sms', 'signName']), + stringEnv('WORKER_SMS_TEMPLATE_CODE', ['worker', 'sms', 'templateCode']), + integerEnv('WORKER_SMS_CODE_TTL_SECONDS', ['worker', 'sms', 'codeTtlSeconds']), + integerEnv('WORKER_SMS_COOLDOWN_SECONDS', ['worker', 'sms', 'cooldownSeconds']), + integerEnv('WORKER_SMS_DAILY_LIMIT', ['worker', 'sms', 'dailyLimit']), integerEnv('KUAISHOU_FEIFEI_TIMEOUT_MS', ['platforms', 'kuaishouFeifei', 'timeoutMs']), stringEnv('KUAISHOU_FEIFEI_NOTIFY_URL', ['platforms', 'kuaishouFeifei', 'notifyUrl']), kuaishouFeifeiProductRulesEnv('KUAISHOU_FEIFEI_PRODUCT_RULES_JSON', [ diff --git a/apps/backend/src/db/migrations/012_worker_sms_codes.sql b/apps/backend/src/db/migrations/012_worker_sms_codes.sql new file mode 100644 index 00000000..f689829a --- /dev/null +++ b/apps/backend/src/db/migrations/012_worker_sms_codes.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS worker_sms_codes ( + id BIGSERIAL PRIMARY KEY, + phone TEXT NOT NULL, + purpose TEXT NOT NULL DEFAULT 'register', + code_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_worker_sms_codes_phone_created + ON worker_sms_codes(phone, created_at DESC); + +UPDATE worker_users wu +SET phone = '' +FROM ( + SELECT MIN(id) AS keep_id, phone + FROM worker_users + WHERE phone != '' + GROUP BY phone + HAVING COUNT(*) > 1 +) dup +WHERE wu.phone = dup.phone AND wu.id <> dup.keep_id; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_users_phone + ON worker_users(phone) + WHERE phone != ''; + +COMMENT ON TABLE worker_sms_codes IS '打手注册短信验证码(哈希存储,一次性消费)'; diff --git a/apps/backend/src/repositories/worker-platform/index.ts b/apps/backend/src/repositories/worker-platform/index.ts index abcea49d..e730d088 100644 --- a/apps/backend/src/repositories/worker-platform/index.ts +++ b/apps/backend/src/repositories/worker-platform/index.ts @@ -2,3 +2,4 @@ export * from './types.js' export * from './shared.js' export * from './worker-repo.js' export * from './work-order-repo.js' +export * from './sms-code-repo.js' diff --git a/apps/backend/src/repositories/worker-platform/sms-code-repo.ts b/apps/backend/src/repositories/worker-platform/sms-code-repo.ts new file mode 100644 index 00000000..6cc0e680 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/sms-code-repo.ts @@ -0,0 +1,80 @@ +import { query } from '../../db/client.js' + +export type WorkerSmsCodeRow = { + id: number + phone: string + purpose: string + code_hash: string + expires_at: string + consumed_at: string | null + created_at: string +} + +export async function createWorkerSmsCode(input: { + phone: string + purpose: string + codeHash: string + expiresAt: string + now: string +}): Promise { + const result = await query( + ` + INSERT INTO worker_sms_codes (phone, purpose, code_hash, expires_at, consumed_at, created_at) + VALUES ($1, $2, $3, $4, NULL, $5) + RETURNING * + `, + [input.phone, input.purpose, input.codeHash, input.expiresAt, input.now], + ) + return result.rows[0] || null +} + +export async function getLatestWorkerSmsCode( + phone: string, + purpose: string, +): Promise { + const result = await query( + ` + SELECT * + FROM worker_sms_codes + WHERE phone = $1 AND purpose = $2 + ORDER BY id DESC + LIMIT 1 + `, + [phone, purpose], + ) + return result.rows[0] || null +} + +export async function consumeWorkerSmsCode( + codeId: number, + now: string, +): Promise { + const result = await query( + ` + UPDATE worker_sms_codes + SET consumed_at = $1 + WHERE id = $2 AND consumed_at IS NULL + RETURNING * + `, + [now, codeId], + ) + return result.rows[0] || null +} + +export async function countWorkerSmsCodesOnDay( + phone: string, + dayStartIso: string, + dayEndIso: string, +): Promise { + const result = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM worker_sms_codes + WHERE phone = $1 + AND created_at >= $2 + AND created_at < $3 + `, + [phone, dayStartIso, dayEndIso], + ) + return Number(result.rows[0]?.total || 0) +} diff --git a/apps/backend/src/repositories/worker-platform/worker-repo.ts b/apps/backend/src/repositories/worker-platform/worker-repo.ts index da4e4bc8..40d0f0ea 100644 --- a/apps/backend/src/repositories/worker-platform/worker-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-repo.ts @@ -118,6 +118,14 @@ export async function getWorkerUserByUsername(username: string): Promise { + const result = await query( + `${WORKER_USER_SELECT} WHERE wu.phone = $1 LIMIT 1`, + [String(phone || '').trim()], + ) + return result.rows[0] || null +} + export async function getWorkerUserByInviteCode( inviteCode: string, ): Promise { diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index eaa27c3a..f4249a34 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -19,6 +19,7 @@ import { loginWorker, logoutWorkerSession, registerWorker, + sendWorkerSmsCode, submitWorkerOrderAcceptance, } from '../services/worker-platform/index.js' import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' @@ -32,16 +33,31 @@ import { const router = Router() +router.post( + '/auth/sms-code', + createRateLimitMiddleware({ + scope: 'worker:sms-code', + windowMs: 60_000, + max: 5, + key: getBodyFieldRateLimitKey('phone'), + }), + createRouteHandler((req) => sendWorkerSmsCode(req.body || {}), { + successMessage: '验证码已发送', + errorMessage: '验证码发送失败', + scope: '[worker/auth/sms-code]', + }), +) + router.post( '/auth/register', createRateLimitMiddleware({ scope: 'worker:register', windowMs: 60_000, - max: 10, - key: getBodyFieldRateLimitKey('username'), + max: 5, + key: getBodyFieldRateLimitKey('phone'), }), createRouteHandler((req) => registerWorker(req.body || {}), { - successMessage: '注册成功,等待后台审核', + successMessage: '注册成功,请使用手机号登录', errorMessage: '接单账号注册失败', scope: '[worker/auth/register]', }), diff --git a/apps/backend/src/services/sms/index.ts b/apps/backend/src/services/sms/index.ts new file mode 100644 index 00000000..9dd583d6 --- /dev/null +++ b/apps/backend/src/services/sms/index.ts @@ -0,0 +1,116 @@ +import { createRequire } from 'node:module' + +import { runtimeConfig } from '../../config/runtime.js' +import { createHttpError } from '../../utils/http.js' +import { logWarn } from '../../utils/logger.js' + +const require = createRequire(import.meta.url) +const Dysmsapi = require('@alicloud/dysmsapi20170525') as { + default: new (config: unknown) => AliyunSmsClient + SendSmsRequest: new (input: Record) => unknown +} +const OpenApiClient = require('@alicloud/openapi-client') as { + Config: new (config: Record) => unknown +} + +type AliyunSmsClient = { + sendSmsWithOptions( + request: unknown, + runtime: unknown, + ): Promise<{ body?: { code?: string; message?: string } }> +} + +export interface SmsProvider { + sendCode(phone: string, code: string): Promise +} + +const ALIYUN_DEFAULT_ENDPOINT = 'dysmsapi.aliyuncs.com' + +export function getSmsProvider(): SmsProvider { + const config = runtimeConfig.worker.sms + if (String(config.provider || '').trim() === 'aliyun') { + const accessKeyId = String(config.accessKeyId || '').trim() + const accessKeySecret = String(config.accessKeySecret || '').trim() + const signName = String(config.signName || '').trim() + const templateCode = String(config.templateCode || '').trim() + if (!accessKeyId || !accessKeySecret || !signName || !templateCode) { + logWarn('[sms]', '阿里云短信配置不完整,已降级为 Mock 模式', { + hasAccessKeyId: Boolean(accessKeyId), + hasAccessKeySecret: Boolean(accessKeySecret), + hasSignName: Boolean(signName), + hasTemplateCode: Boolean(templateCode), + }) + return new MockSmsProvider() + } + return new AliyunSmsProvider({ accessKeyId, accessKeySecret, signName, templateCode }) + } + return new MockSmsProvider() +} + +export class MockSmsProvider implements SmsProvider { + async sendCode(phone: string, code: string) { + logWarn('[sms/mock]', `【开发模式】手机验证码:${phone} → ${code}`, { + phone, + code, + hint: '生产环境请配置 WORKER_SMS_PROVIDER=aliyun 后使用真实短信', + }) + } +} + +export class AliyunSmsProvider implements SmsProvider { + private readonly client: AliyunSmsClient + private readonly signName: string + private readonly templateCode: string + + constructor(input: { + accessKeyId: string + accessKeySecret: string + signName: string + templateCode: string + endpoint?: string + }) { + const Config = OpenApiClient.Config + this.client = new Dysmsapi.default( + new Config({ + accessKeyId: input.accessKeyId, + accessKeySecret: input.accessKeySecret, + endpoint: (input.endpoint || ALIYUN_DEFAULT_ENDPOINT).replace(/^https?:\/\//, ''), + }), + ) as AliyunSmsClient + this.signName = input.signName + this.templateCode = input.templateCode + } + + async sendCode(phone: string, code: string) { + try { + const request = new Dysmsapi.SendSmsRequest({ + phoneNumbers: phone, + signName: this.signName, + templateCode: this.templateCode, + templateParam: JSON.stringify({ code }), + }) + const response = await this.client.sendSmsWithOptions(request, {}) + const resultCode = String(response?.body?.code || '') + if (resultCode !== 'OK') { + throw createHttpError( + `短信发送失败:${String(response?.body?.message || resultCode || '未知错误').trim()}`, + { + statusCode: 502, + errorCode: 'worker_sms_send_failed', + }, + ) + } + } catch (error) { + if (error instanceof Error && 'errorCode' in error) { + throw error + } + throw createHttpError( + `短信发送失败:${error instanceof Error ? error.message : '未知错误'}`, + { + statusCode: 502, + errorCode: 'worker_sms_send_failed', + }, + ) + } + } +} diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 8891b9ef..8ed96f83 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -14,8 +14,11 @@ import { countWorkCategoryUsages, countWorkerLevelUsages, countWorkOrderPendingSharingSubmissions, - cancelWorkerWorkOrder, countWorkerCancellationsSince, + countWorkerSmsCodesOnDay, + cancelWorkerWorkOrder, + consumeWorkerSmsCode, + createWorkerSmsCode, createWorkOrder, createWorkOrderEvent, createWorkerFinanceRequest, @@ -28,8 +31,10 @@ import { getWorkCategoryByKey, getWorkerLevelById, getWorkerLevelByKey, + getLatestWorkerSmsCode, getWorkerUserByInviteCode, getWorkerUserById, + getWorkerUserByPhone, getWorkerUserByUsername, getWorkOrderById, getWorkOrderByOrderItemId, @@ -79,6 +84,7 @@ import { createHttpError } from '../../utils/http.js' import { randomId } from '../../utils/random.js' import { nowIso, addHours } from '../../utils/time.js' import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' +import { getSmsProvider } from '../sms/index.js' import { normalizeStoredFileUrl, refreshStoredFileUrl, @@ -131,23 +137,96 @@ export async function ensureWorkerPlatformDefaults() { return { level, category } } +export async function sendWorkerSmsCode(payload: JsonObject = {}) { + const phone = normalizeWorkerPhone(payload.phone) + const config = runtimeConfig.worker.sms + const cooldownMs = Math.max(10, Number(config.cooldownSeconds || 60)) * 1000 + const dailyLimit = Math.max(1, Number(config.dailyLimit || 10)) + const ttlSeconds = Math.max(60, Number(config.codeTtlSeconds || 300)) + + if (await getWorkerUserByPhone(phone)) { + throw createHttpError('该手机号已注册,请直接登录', { + statusCode: 409, + errorCode: 'worker_phone_registered', + }) + } + + const latest = await getLatestWorkerSmsCode(phone, 'register') + if (latest && new Date(latest.created_at).getTime() > Date.now() - cooldownMs) { + throw createHttpError('验证码发送过于频繁,请稍后再试', { + statusCode: 429, + errorCode: 'worker_sms_cooldown', + }) + } + + const dayRange = resolveChinaDayRange() + const todayCount = await countWorkerSmsCodesOnDay(phone, dayRange.start, dayRange.end) + if (todayCount >= dailyLimit) { + throw createHttpError('今日验证码发送次数已达上限,请明天再试', { + statusCode: 429, + errorCode: 'worker_sms_daily_limit', + }) + } + + const code = generateSmsCode() + await getSmsProvider().sendCode(phone, code) + await createWorkerSmsCode({ + phone, + purpose: 'register', + codeHash: hashSmsCode(code), + expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(), + now: nowIso(), + }) + return { + sent: true, + expiresInSeconds: ttlSeconds, + provider: String(runtimeConfig.worker.sms.provider || 'mock'), + } +} + export async function registerWorker(payload: JsonObject = {}) { ensureWorkerAuthConfigured() - const username = normalizeUsername(payload.username) + const phone = normalizeWorkerPhone(payload.phone) + const code = String(payload.code || payload.smsCode || '').trim() const password = normalizePassword(payload.password) - const displayName = String(payload.displayName || payload.nickname || username).trim() - const phone = String(payload.phone || '').trim() + const displayName = String(payload.displayName || payload.nickname || phone).trim() const inviteCode = String(payload.inviteCode || '').trim().toUpperCase() - validateWorkerUsername(username) validateWorkerPassword(password) + if (!code) { + throw createHttpError('请输入短信验证码', { + statusCode: 400, + errorCode: 'worker_sms_code_required', + }) + } - if (await getWorkerUserByUsername(username)) { - throw createHttpError('接单账号已存在', { + const latest = await getLatestWorkerSmsCode(phone, 'register') + const codeValid = Boolean( + latest && + !latest.consumed_at && + new Date(latest.expires_at).getTime() > Date.now() && + latest.code_hash === hashSmsCode(code), + ) + if (!codeValid) { + throw createHttpError('短信验证码错误或已过期', { + statusCode: 400, + errorCode: 'worker_sms_code_invalid', + }) + } + await consumeWorkerSmsCode(latest!.id, nowIso()) + + if (await getWorkerUserByUsername(phone)) { + throw createHttpError('该手机号已注册,请直接登录', { statusCode: 409, errorCode: 'worker_user_exists', }) } + if (await getWorkerUserByPhone(phone)) { + throw createHttpError('该手机号已注册,请直接登录', { + statusCode: 409, + errorCode: 'worker_phone_exists', + }) + } const inviter = inviteCode ? await getWorkerUserByInviteCode(inviteCode) : null if (inviteCode && !inviter) { @@ -166,12 +245,12 @@ export async function registerWorker(payload: JsonObject = {}) { const defaults = await ensureWorkerPlatformDefaults() const now = nowIso() const created = await createWorkerUser({ - username, + username: phone, passwordHash: hashWorkerPassword(password), displayName, phone, levelId: defaults.level?.id || null, - status: 'pending_review', + status: 'active', inviterId: inviter ? Number(inviter.id) : null, inviteCode: await generateInviteCode(now), now, @@ -186,10 +265,33 @@ export async function registerWorker(payload: JsonObject = {}) { return { worker: mapWorkerUser(created), - reviewRequired: true, + reviewRequired: false, } } +function normalizeWorkerPhone(value: unknown): string { + const phone = String(value || '').trim() + if (!/^1[3-9]\d{9}$/.test(phone)) { + throw createHttpError('手机号格式不正确', { + statusCode: 400, + errorCode: 'worker_phone_invalid', + }) + } + return phone +} + +function generateSmsCode(): string { + let code = '' + for (let index = 0; index < 6; index += 1) { + code += String(crypto.randomInt(10)) + } + return code +} + +function hashSmsCode(code: string): string { + return crypto.createHash('sha256').update(String(code || '')).digest('hex') +} + async function generateInviteCode(now: string): Promise { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' for (let attempt = 0; attempt < INVITE_CODE_RETRY_LIMIT; attempt++) { diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index f9189697..f7cf46f6 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -118,6 +118,18 @@ export type RuntimeConfig = { productRules: KuaishouFeifeiProductRule[] } } + worker: { + sms: { + provider: string + accessKeyId: string + accessKeySecret: string + signName: string + templateCode: string + codeTtlSeconds: number + cooldownSeconds: number + dailyLimit: number + } + } cors: { allowedOrigins: string[] } diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 1ba6f1fb..a2c1528f 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -7,6 +7,8 @@ "checkJs": false, "noEmit": true, "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "skipLibCheck": true, diff --git a/apps/frontend/src/pages/worker/WorkerLoginPage.tsx b/apps/frontend/src/pages/worker/WorkerLoginPage.tsx index 45bc1ae2..7b984ff2 100644 --- a/apps/frontend/src/pages/worker/WorkerLoginPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerLoginPage.tsx @@ -1,15 +1,25 @@ -import { LockOutlined, UserOutlined } from '@ant-design/icons' -import { App, Button, Card, Form, Input, Tabs, Typography } from 'antd' -import { useState } from 'react' +import { LockOutlined, MobileOutlined, UserOutlined } from '@ant-design/icons' +import { App, Button, Card, Form, Input, Space, Tabs, Typography } from 'antd' +import { useEffect, useState } from 'react' import { Navigate, useNavigate } from 'react-router' -import { loginWorker, registerWorker } from '@/services/worker' +import { loginWorker, registerWorker, sendWorkerSmsCode } from '@/services/worker' import { hasWorkerSession, setWorkerSession } from '@/utils/worker-auth' +const SMS_COUNTDOWN_SECONDS = 60 + export default function WorkerLoginPage() { const navigate = useNavigate() const { message } = App.useApp() const [loading, setLoading] = useState(false) + const [registerForm] = Form.useForm() + const [countdown, setCountdown] = useState(0) + + useEffect(() => { + if (countdown <= 0) return + const timer = window.setInterval(() => setCountdown((value) => value - 1), 1000) + return () => window.clearInterval(timer) + }, [countdown]) if (hasWorkerSession()) { return @@ -29,17 +39,40 @@ export default function WorkerLoginPage() { } } + async function requestSmsCode() { + const phone = String(registerForm.getFieldValue('phone') || '').trim() + if (!/^1[3-9]\d{9}$/.test(phone)) { + message.error('请先填写正确的手机号') + return + } + setLoading(true) + try { + const response = await sendWorkerSmsCode({ phone }) + setCountdown(SMS_COUNTDOWN_SECONDS) + message.success( + response.data.provider === 'mock' + ? '验证码已发送(开发模式,验证码见服务日志)' + : `验证码已发送,${response.data.expiresInSeconds} 秒内有效`, + ) + } catch (error) { + message.error(error instanceof Error ? error.message : '验证码发送失败') + } finally { + setLoading(false) + } + } + async function submitRegister(values: { - username: string + phone: string + code: string password: string displayName?: string - phone?: string inviteCode?: string }) { setLoading(true) try { await registerWorker(values) - message.success('注册成功,等待后台审核') + message.success('注册成功,请使用手机号登录') + registerForm.resetFields() } catch (error) { message.error(error instanceof Error ? error.message : '注册失败') } finally { @@ -52,7 +85,7 @@ export default function WorkerLoginPage() { 接单平台 - 账号注册后需要后台审核,通过后可进入抢单大厅。 + 使用手机号验证码注册,注册后可直接进入抢单大厅。 } autoComplete="username" /> @@ -85,18 +118,30 @@ export default function WorkerLoginPage() { key: 'register', label: '注册', children: ( -
+ - } autoComplete="username" /> + } autoComplete="tel" maxLength={11} /> - - + + + + + - + diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 57850559..8eca418e 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -13,11 +13,18 @@ import type { WorkerWalletLedger, } from '@/types/worker-platform' +export function sendWorkerSmsCode(payload: { phone: string }) { + return apiPost<{ sent: boolean; expiresInSeconds: number; provider: string }>( + '/api/v1/worker/auth/sms-code', + payload, + ) +} + export function registerWorker(payload: { - username: string + phone: string + code: string password: string displayName?: string - phone?: string inviteCode?: string }) { return apiPost<{ worker: WorkerUser; reviewRequired: boolean }>(