修复打手短信验证码发送
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 秒执行一次(配置见后台"平台配置 → 定时任务")。
|
||||
|
||||
|
||||
Generated
+431
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, string>, 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<string, string> = {
|
||||
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()
|
||||
@@ -126,5 +126,18 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15
|
||||
cors: {
|
||||
allowedOrigins: ['*'],
|
||||
},
|
||||
|
||||
worker: {
|
||||
sms: {
|
||||
provider: 'mock',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
signName: '',
|
||||
templateCode: '',
|
||||
codeTtlSeconds: 300,
|
||||
cooldownSeconds: 60,
|
||||
dailyLimit: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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 '打手注册短信验证码(哈希存储,一次性消费)';
|
||||
@@ -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'
|
||||
|
||||
@@ -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<WorkerSmsCodeRow | null> {
|
||||
const result = await query<WorkerSmsCodeRow>(
|
||||
`
|
||||
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<WorkerSmsCodeRow | null> {
|
||||
const result = await query<WorkerSmsCodeRow>(
|
||||
`
|
||||
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<WorkerSmsCodeRow | null> {
|
||||
const result = await query<WorkerSmsCodeRow>(
|
||||
`
|
||||
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<number> {
|
||||
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)
|
||||
}
|
||||
@@ -118,6 +118,14 @@ export async function getWorkerUserByUsername(username: string): Promise<WorkerU
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getWorkerUserByPhone(phone: string): Promise<WorkerUserRow | null> {
|
||||
const result = await query<WorkerUserRow>(
|
||||
`${WORKER_USER_SELECT} WHERE wu.phone = $1 LIMIT 1`,
|
||||
[String(phone || '').trim()],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getWorkerUserByInviteCode(
|
||||
inviteCode: string,
|
||||
): Promise<WorkerUserRow | null> {
|
||||
|
||||
@@ -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]',
|
||||
}),
|
||||
|
||||
@@ -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<string, unknown>) => unknown
|
||||
}
|
||||
const OpenApiClient = require('@alicloud/openapi-client') as {
|
||||
Config: new (config: Record<string, unknown>) => unknown
|
||||
}
|
||||
|
||||
type AliyunSmsClient = {
|
||||
sendSmsWithOptions(
|
||||
request: unknown,
|
||||
runtime: unknown,
|
||||
): Promise<{ body?: { code?: string; message?: string } }>
|
||||
}
|
||||
|
||||
export interface SmsProvider {
|
||||
sendCode(phone: string, code: string): Promise<void>
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
for (let attempt = 0; attempt < INVITE_CODE_RETRY_LIMIT; attempt++) {
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"checkJs": false,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -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 <Navigate to="/worker/hall" replace />
|
||||
@@ -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() {
|
||||
<Card className="worker-auth-card" bordered={false}>
|
||||
<Typography.Title level={3}>接单平台</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
账号注册后需要后台审核,通过后可进入抢单大厅。
|
||||
使用手机号验证码注册,注册后可直接进入抢单大厅。
|
||||
</Typography.Paragraph>
|
||||
<Tabs
|
||||
items={[
|
||||
@@ -62,9 +95,9 @@ export default function WorkerLoginPage() {
|
||||
children: (
|
||||
<Form layout="vertical" onFinish={submitLogin}>
|
||||
<Form.Item
|
||||
label="账号"
|
||||
label="账号 / 手机号"
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入账号' }]}
|
||||
rules={[{ required: true, message: '请输入账号或手机号' }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} autoComplete="username" />
|
||||
</Form.Item>
|
||||
@@ -85,18 +118,30 @@ export default function WorkerLoginPage() {
|
||||
key: 'register',
|
||||
label: '注册',
|
||||
children: (
|
||||
<Form layout="vertical" onFinish={submitRegister}>
|
||||
<Form form={registerForm} layout="vertical" onFinish={submitRegister}>
|
||||
<Form.Item
|
||||
label="账号"
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入账号' }]}
|
||||
label="手机号"
|
||||
name="phone"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} autoComplete="username" />
|
||||
<Input prefix={<MobileOutlined />} autoComplete="tel" maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item label="昵称" name="displayName">
|
||||
<Input />
|
||||
<Form.Item
|
||||
label="短信验证码"
|
||||
name="code"
|
||||
rules={[{ required: true, message: '请输入短信验证码' }]}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input placeholder="6 位数字验证码" maxLength={6} />
|
||||
<Button loading={loading} disabled={countdown > 0} onClick={requestSmsCode}>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="手机号" name="phone">
|
||||
<Form.Item label="昵称(选填)" name="displayName">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="邀请码(选填)" name="inviteCode">
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
Reference in New Issue
Block a user