Merge branch 'codex/backend-rewrite-spacetimedb' of http://82.157.175.59:3000/GenarrativeAI/Genarrative into codex/backend-rewrite-spacetimedb
This commit is contained in:
173
docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md
Normal file
173
docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Jenkins Rust 构建与部署流水线方案
|
||||||
|
|
||||||
|
日期:`2026-04-23`
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
本方案为当前仓库补齐 3 条 Jenkins 流水线:
|
||||||
|
|
||||||
|
1. `构建`:只负责在仓库根目录执行 `npm run deploy:rust:remote -- --skip-upload`,生成发布包。
|
||||||
|
2. `部署`:只负责把指定发布版本部署到 `/home/ubuntu/Genarrative-deploy/`,禁止人工直接点击执行。
|
||||||
|
3. `构建并部署`:先构建,再把构建出的版本号传给 `部署` 流水线并等待部署完成。
|
||||||
|
|
||||||
|
本次只补 Jenkins 编排与本地部署脚本,不改现有 Rust 发布包构建逻辑,不恢复旧 `server-node` 部署链。
|
||||||
|
|
||||||
|
## 2. 执行约束
|
||||||
|
|
||||||
|
1. 构建产物目录统一使用 `build/<版本号>/`。
|
||||||
|
2. 默认使用 Jenkins `BUILD_NUMBER` 作为版本号,避免依赖时间戳;如有需要也允许显式传 `BUILD_VERSION`。
|
||||||
|
3. `部署` 流水线必须校验当前构建原因包含 `UpstreamCause`,没有上游触发则直接失败。
|
||||||
|
4. `部署` 流水线额外校验上游作业名与传入的 `EXPECTED_UPSTREAM_JOB` 一致;如配置了环境变量 `GENARRATIVE_ALLOWED_UPSTREAM_JOB`,还必须与该值一致。
|
||||||
|
5. `构建并部署` 在触发 `部署` 前先释放自己的构建节点,避免单执行器节点出现死锁。
|
||||||
|
6. `部署` 不重新构建,不重新上传,不从 Jenkins 插件仓库复制产物,直接使用上游构建节点的本地 `build/<版本号>/` 目录。
|
||||||
|
7. `部署` 流水线读取触发原因时必须使用 `currentBuild.getBuildCauses('hudson.model.Cause$UpstreamCause')` 这类白名单方法,不能直接访问 `currentBuild.rawBuild`,否则会被 Jenkins Script Security 拦截。
|
||||||
|
|
||||||
|
## 3. 节点与工作区要求
|
||||||
|
|
||||||
|
这套方案依赖“本地目录发布”,因此有两个前提:
|
||||||
|
|
||||||
|
1. `构建并部署` 与 `部署` 必须落到同一台 Ubuntu Jenkins Agent,或者落到同一块共享文件系统。
|
||||||
|
2. `构建并部署` 触发 `部署` 时,必须把 `SOURCE_NODE_NAME` 和 `SOURCE_WORKSPACE_ROOT` 一并传下去。
|
||||||
|
|
||||||
|
仓库中提供的 Jenkinsfile 已按这个约束实现:
|
||||||
|
|
||||||
|
1. `构建` / `构建并部署` 在指定源码目录内 `checkout scm` 并生成 `build/<版本号>/`。
|
||||||
|
2. `构建并部署` 结束构建节点占用后,再触发 `部署`。
|
||||||
|
3. `部署` 优先按 `SOURCE_NODE_NAME` 调度到同名节点,再读取 `SOURCE_WORKSPACE_ROOT/build/<版本号>/`。
|
||||||
|
|
||||||
|
## 4. 三条流水线定义
|
||||||
|
|
||||||
|
### 4.1 构建
|
||||||
|
|
||||||
|
脚本路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
jenkins/Jenkinsfile.build
|
||||||
|
```
|
||||||
|
|
||||||
|
核心流程:
|
||||||
|
|
||||||
|
1. 可选执行 `npm ci`。
|
||||||
|
2. 在源码根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run deploy:rust:remote -- --skip-upload --name <BUILD_VERSION>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 校验 `build/<BUILD_VERSION>/` 存在。
|
||||||
|
4. 归档 `build/<BUILD_VERSION>/**` 作为 Jenkins 产物。
|
||||||
|
|
||||||
|
默认版本号:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BUILD_VERSION = Jenkins BUILD_NUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 部署
|
||||||
|
|
||||||
|
脚本路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
jenkins/Jenkinsfile.deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
核心流程:
|
||||||
|
|
||||||
|
1. 校验触发原因必须是上游流水线,而不是人工点击。
|
||||||
|
2. 校验 `BUILD_VERSION`、`SOURCE_WORKSPACE_ROOT`、`DEPLOY_DIRECTORY` 非空。
|
||||||
|
3. 执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/jenkins-deploy-release.sh \
|
||||||
|
--source-dir <SOURCE_WORKSPACE_ROOT>/build/<BUILD_VERSION> \
|
||||||
|
--deploy-dir /home/ubuntu/Genarrative-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本语义:
|
||||||
|
|
||||||
|
1. 若部署目录已有旧版本且存在 `stop.sh`,先执行旧版本 `stop.sh`。
|
||||||
|
2. 直接清空部署目录中的全部旧文件。
|
||||||
|
3. 将指定版本目录中的内容移动到部署目录。
|
||||||
|
4. 执行新版本 `start.sh`。
|
||||||
|
|
||||||
|
这样可以满足你要求的“直接覆盖部署目录中的所有文件”。同时这也意味着部署目录内原有的 `.env`、`.env.local`、日志和本地 SpacetimeDB 数据都会被清掉,最终以构建产物中的文件为准。
|
||||||
|
|
||||||
|
### 4.3 构建并部署
|
||||||
|
|
||||||
|
脚本路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
jenkins/Jenkinsfile.build-and-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
核心流程:
|
||||||
|
|
||||||
|
1. 复用与 `构建` 相同的构建命令生成 `build/<BUILD_VERSION>/`。
|
||||||
|
2. 归档 `build/<BUILD_VERSION>/**`。
|
||||||
|
3. 记录当前 `NODE_NAME`、源码根目录、版本号。
|
||||||
|
4. 触发 `部署` 流水线,并传递:
|
||||||
|
- `BUILD_VERSION`
|
||||||
|
- `SOURCE_WORKSPACE_ROOT`
|
||||||
|
- `SOURCE_NODE_NAME`
|
||||||
|
- `DEPLOY_DIRECTORY`
|
||||||
|
- `EXPECTED_UPSTREAM_JOB`
|
||||||
|
|
||||||
|
## 5. Jenkins 参数建议
|
||||||
|
|
||||||
|
三条流水线统一建议暴露以下参数:
|
||||||
|
|
||||||
|
1. `AGENT_LABEL`:默认执行节点标签。
|
||||||
|
2. `GENARRATIVE_WORKSPACE_ROOT`:源码根目录;为空时回退到 Jenkins 当前工作区。
|
||||||
|
3. `BUILD_VERSION`:发布版本号;为空时回退到 `BUILD_NUMBER`。
|
||||||
|
4. `RUN_NPM_CI`:是否在构建前执行 `npm ci`。
|
||||||
|
|
||||||
|
如果当前 Jenkins 没有额外配置独立 Agent,而是直接在控制器自身执行任务,`AGENT_LABEL` 应填写 `built-in`。
|
||||||
|
如果目标 Ubuntu 的 Jenkins `sh` 默认实际落到 `/bin/sh -> dash`,而流水线脚本又使用了 `set -euo pipefail`,则必须显式通过 `bash -lc` 执行命令,不能直接依赖 Jenkins 默认 `sh` 解释器。
|
||||||
|
|
||||||
|
其中仅 `部署` 流水线还需要:
|
||||||
|
|
||||||
|
1. `SOURCE_WORKSPACE_ROOT`
|
||||||
|
2. `SOURCE_NODE_NAME`
|
||||||
|
3. `DEPLOY_DIRECTORY`
|
||||||
|
4. `EXPECTED_UPSTREAM_JOB`
|
||||||
|
|
||||||
|
其中仅 `构建并部署` 流水线还需要:
|
||||||
|
|
||||||
|
1. `DEPLOY_JOB_NAME`
|
||||||
|
|
||||||
|
## 6. 推荐 Job 命名
|
||||||
|
|
||||||
|
建议在 Jenkins 中创建以下 3 个 Pipeline Job,并分别指向仓库中的脚本路径:
|
||||||
|
|
||||||
|
1. `Genarrative-Build` -> `jenkins/Jenkinsfile.build`
|
||||||
|
2. `Genarrative-Deploy` -> `jenkins/Jenkinsfile.deploy`
|
||||||
|
3. `Genarrative-Build-And-Deploy` -> `jenkins/Jenkinsfile.build-and-deploy`
|
||||||
|
|
||||||
|
同时给 `Genarrative-Deploy` 配置环境变量:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GENARRATIVE_ALLOWED_UPSTREAM_JOB=Genarrative-Build-And-Deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 Job 在 Jenkins Folder 下,值应填写完整上游作业名,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
game/Genarrative-Build-And-Deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 文件清单
|
||||||
|
|
||||||
|
本方案对应的仓库文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
jenkins/Jenkinsfile.build
|
||||||
|
jenkins/Jenkinsfile.deploy
|
||||||
|
jenkins/Jenkinsfile.build-and-deploy
|
||||||
|
scripts/jenkins-deploy-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 风险与边界
|
||||||
|
|
||||||
|
1. 该方案依赖本地目录切换,不适用于“构建节点”和“部署节点”完全隔离且不共享文件系统的 Jenkins 架构。
|
||||||
|
2. 当前 `部署` 采取的是“覆盖固定部署目录”的方式,不包含版本回滚目录管理;如需保留完整历史版本,应在后续单独补一层 release/current 软链接结构。
|
||||||
|
3. 当前 `start.sh` / `stop.sh` 仍以发布包内脚本为准,不替代 `systemd`、`supervisor`、`nginx`、`tls` 与日志轮转治理。
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Axum 手机验证码真实短信 Provider 接入设计
|
||||||
|
|
||||||
|
日期:`2026-04-22`
|
||||||
|
|
||||||
|
## 1. 文档目的
|
||||||
|
|
||||||
|
这份文档用于冻结 Rust `api-server + module-auth + platform-auth` 切换到真实短信链路时的最小可编码边界,解决当前 `module-auth` 仍固定使用 mock 验证码 `123456`,导致 `server-rs` 无法接入真实短信发送与真实验证码校验的问题。
|
||||||
|
|
||||||
|
## 2. 当前问题
|
||||||
|
|
||||||
|
截至 `2026-04-22`,Rust 侧手机号登录存在以下状态:
|
||||||
|
|
||||||
|
1. `POST /api/auth/phone/send-code` 已存在,但 `module-auth` 内部仍写死 `123456`。
|
||||||
|
2. `POST /api/auth/phone/login` 校验的是本地内存快照里的固定验证码,不是真实短信平台生成的验证码。
|
||||||
|
3. 即使把发送动作切到真实阿里云短信,如果校验仍留在本地 mock,整条登录链仍然不可用。
|
||||||
|
|
||||||
|
## 3. 本次目标
|
||||||
|
|
||||||
|
本次必须达成:
|
||||||
|
|
||||||
|
1. Rust 侧短信 provider 支持 `mock` 与 `aliyun` 两种模式。
|
||||||
|
2. `send-code` 在 `aliyun` 模式下调用阿里云 `SendSmsVerifyCode`。
|
||||||
|
3. `phone/login` 与 `wechat/bind-phone` 在 `aliyun` 模式下调用阿里云 `CheckSmsVerifyCode`。
|
||||||
|
4. `module-auth` 不再保存验证码明文,只保存发送冷却、有效期和失败次数所需的最小快照。
|
||||||
|
5. `shared-contracts` 公开响应 contract 维持不变,仍只返回:
|
||||||
|
- `ok`
|
||||||
|
- `cooldownSeconds`
|
||||||
|
- `expiresInSeconds`
|
||||||
|
- `providerRequestId`
|
||||||
|
|
||||||
|
## 4. crate 边界
|
||||||
|
|
||||||
|
### 4.1 `platform-auth`
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
1. 短信 provider 配置结构。
|
||||||
|
2. `mock / aliyun` provider 实现。
|
||||||
|
3. 阿里云 RPC 请求签名、发送与校验。
|
||||||
|
4. provider 级错误归一化。
|
||||||
|
|
||||||
|
### 4.2 `module-auth`
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
1. 手机号归一化。
|
||||||
|
2. 发送冷却与验证码快照 TTL。
|
||||||
|
3. 校验失败次数累加与耗尽删除。
|
||||||
|
4. 手机号用户创建、复用、微信补绑归并。
|
||||||
|
|
||||||
|
### 4.3 `api-server`
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
1. 从环境变量读取短信 provider 配置。
|
||||||
|
2. 构建 `SmsAuthProvider` 并注入 `PhoneAuthService`。
|
||||||
|
3. 把领域错误映射成 HTTP 错误。
|
||||||
|
|
||||||
|
## 5. 配置设计
|
||||||
|
|
||||||
|
新增或继续使用以下环境变量:
|
||||||
|
|
||||||
|
1. `SMS_AUTH_ENABLED`
|
||||||
|
2. `SMS_AUTH_PROVIDER`
|
||||||
|
- `mock`
|
||||||
|
- `aliyun`
|
||||||
|
3. `ALIYUN_SMS_ENDPOINT`
|
||||||
|
- 默认 `dypnsapi.aliyuncs.com`
|
||||||
|
4. `ALIYUN_SMS_ACCESS_KEY_ID`
|
||||||
|
5. `ALIYUN_SMS_ACCESS_KEY_SECRET`
|
||||||
|
6. `ALIYUN_SMS_SIGN_NAME`
|
||||||
|
7. `ALIYUN_SMS_TEMPLATE_CODE`
|
||||||
|
8. `ALIYUN_SMS_TEMPLATE_PARAM_KEY`
|
||||||
|
- 默认 `code`
|
||||||
|
9. `ALIYUN_SMS_COUNTRY_CODE`
|
||||||
|
- 默认 `86`
|
||||||
|
10. `ALIYUN_SMS_SCHEME_NAME`
|
||||||
|
11. `ALIYUN_SMS_CODE_LENGTH`
|
||||||
|
- 默认 `6`
|
||||||
|
12. `ALIYUN_SMS_CODE_TYPE`
|
||||||
|
- 默认 `1`
|
||||||
|
13. `ALIYUN_SMS_VALID_TIME_SECONDS`
|
||||||
|
- 默认 `300`
|
||||||
|
14. `ALIYUN_SMS_INTERVAL_SECONDS`
|
||||||
|
- 默认 `60`
|
||||||
|
15. `ALIYUN_SMS_DUPLICATE_POLICY`
|
||||||
|
- 默认 `1`
|
||||||
|
16. `ALIYUN_SMS_CASE_AUTH_POLICY`
|
||||||
|
- 默认 `1`
|
||||||
|
17. `ALIYUN_SMS_RETURN_VERIFY_CODE`
|
||||||
|
- 默认 `false`
|
||||||
|
18. `SMS_AUTH_MOCK_VERIFY_CODE`
|
||||||
|
- 默认 `123456`
|
||||||
|
|
||||||
|
## 6. provider 行为
|
||||||
|
|
||||||
|
### 6.1 `mock`
|
||||||
|
|
||||||
|
1. 发送验证码时不访问外部网络。
|
||||||
|
2. 返回固定 `mock-request-id`。
|
||||||
|
3. 校验时使用内存中的 mock 验证码。
|
||||||
|
|
||||||
|
### 6.2 `aliyun`
|
||||||
|
|
||||||
|
1. 发送验证码调用 `SendSmsVerifyCode`。
|
||||||
|
2. 校验验证码调用 `CheckSmsVerifyCode`。
|
||||||
|
3. 使用阿里云 RPC 签名口径:
|
||||||
|
- `SignatureMethod=HMAC-SHA1`
|
||||||
|
- `SignatureVersion=1.0`
|
||||||
|
4. 当前仍只支持中国大陆手机号。
|
||||||
|
|
||||||
|
## 7. 状态与快照
|
||||||
|
|
||||||
|
`module-auth` 内部验证码快照保留:
|
||||||
|
|
||||||
|
1. `phone_number`
|
||||||
|
2. `scene`
|
||||||
|
3. `expires_at`
|
||||||
|
4. `last_sent_at`
|
||||||
|
5. `failed_attempts`
|
||||||
|
|
||||||
|
明确不再保留:
|
||||||
|
|
||||||
|
1. 验证码明文
|
||||||
|
2. 验证码 hash
|
||||||
|
|
||||||
|
校验流程改为:
|
||||||
|
|
||||||
|
1. 先检查是否存在活跃快照。
|
||||||
|
2. 再检查是否过期。
|
||||||
|
3. 再调用 provider 做真实验证码校验。
|
||||||
|
4. 校验失败时累加失败次数。
|
||||||
|
5. 达到上限时删除快照并返回 `429`。
|
||||||
|
6. 校验成功后删除快照。
|
||||||
|
|
||||||
|
## 8. 错误语义
|
||||||
|
|
||||||
|
1. 手机号格式错误:`400`
|
||||||
|
2. 验证码格式错误:`400`
|
||||||
|
3. 验证码不存在或已过期:`400`
|
||||||
|
4. 校验失败:`400`
|
||||||
|
5. 验证码错误次数耗尽:`429`
|
||||||
|
6. 阿里云配置缺失:`500`
|
||||||
|
7. 阿里云上游失败:`502`
|
||||||
|
|
||||||
|
## 9. 测试要求
|
||||||
|
|
||||||
|
至少覆盖:
|
||||||
|
|
||||||
|
1. `mock` provider 的发送与登录仍可跑通。
|
||||||
|
2. `aliyun` provider 缺配置时会在服务初始化阶段报错。
|
||||||
|
3. 发送冷却逻辑不依赖验证码明文仍然有效。
|
||||||
|
4. 校验失败次数耗尽后会删除快照。
|
||||||
|
5. `send-code` 成功时仍返回既有 contract。
|
||||||
|
|
||||||
|
## 10. 非目标
|
||||||
|
|
||||||
|
本次明确不做:
|
||||||
|
|
||||||
|
1. 短信送达回执接口
|
||||||
|
2. `sms_auth_event` 真实持久化
|
||||||
|
3. 图形验证码
|
||||||
|
4. 更细粒度的 provider 错误码透传 DTO
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
- [CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md](./CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md):恢复 Custom World Agent 聊天必须走大模型推理的 Rust 落地方案,冻结 submit/finalize 两阶段职责、旧 Node 提示词原样搬运、SSE 流式回复与 session 回写边界。
|
- [CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md](./CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md):恢复 Custom World Agent 聊天必须走大模型推理的 Rust 落地方案,冻结 submit/finalize 两阶段职责、旧 Node 提示词原样搬运、SSE 流式回复与 session 回写边界。
|
||||||
- [CREATION_HUB_CARD_ACTIONS_2026-04-22.md](./CREATION_HUB_CARD_ACTIONS_2026-04-22.md):冻结创作中心作品卡“体验 / 删除”入口的最小落地语义,明确 RPG 已发布作品软删除、卡片直达运行时,以及暂不扩草稿 / 拼图删除契约。
|
- [CREATION_HUB_CARD_ACTIONS_2026-04-22.md](./CREATION_HUB_CARD_ACTIONS_2026-04-22.md):冻结创作中心作品卡“体验 / 删除”入口的最小落地语义,明确 RPG 已发布作品软删除、卡片直达运行时,以及暂不扩草稿 / 拼图删除契约。
|
||||||
- [CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md](./CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md):记录创作中心点击类别后长时间停留在“正在开启”的根因与修复口径,收口前端创建会话启动超时、中文错误提示以及 Big Fish / 拼图代理上游超时兜底。
|
- [CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md](./CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md):记录创作中心点击类别后长时间停留在“正在开启”的根因与修复口径,收口前端创建会话启动超时、中文错误提示以及 Big Fish / 拼图代理上游超时兜底。
|
||||||
|
- [JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md](./JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md):冻结 Jenkins `构建 / 部署 / 构建并部署` 三条流水线的职责、版本号传递、上游触发门禁、本地目录部署脚本与 `/home/ubuntu/Genarrative-deploy/` 覆盖策略。
|
||||||
- [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):冻结 Rust 本地一键联调脚本与 Ubuntu 发布包构建脚本的执行口径,覆盖 `npm run dev:rust`、`npm run build:rust:ubuntu`、Vite release、Linux `api-server`、SpacetimeDB wasm、启动停止脚本、默认 scp 上传和安全清库开关。
|
- [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):冻结 Rust 本地一键联调脚本与 Ubuntu 发布包构建脚本的执行口径,覆盖 `npm run dev:rust`、`npm run build:rust:ubuntu`、Vite release、Linux `api-server`、SpacetimeDB wasm、启动停止脚本、默认 scp 上传和安全清库开关。
|
||||||
- [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 96 条 Axum 路由,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。
|
- [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 96 条 Axum 路由,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。
|
||||||
- [BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](./BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md):冻结后端重写收口阶段的横向治理规则,覆盖 TypeScript contract 到 Rust DTO 映射、SpacetimeDB schema 演进、大对象 / workflow cache 存储边界和文档维护门禁。
|
- [BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](./BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md):冻结后端重写收口阶段的横向治理规则,覆盖 TypeScript contract 到 Rust DTO 映射、SpacetimeDB schema 演进、大对象 / workflow cache 存储边界和文档维护门禁。
|
||||||
@@ -19,6 +20,10 @@
|
|||||||
- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md):`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、`clientLabel` 兼容策略与 Rust 首版接口边界。
|
- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md):`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、`clientLabel` 兼容策略与 Rust 首版接口边界。
|
||||||
- [PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md):手机号验证码登录最小闭环设计,冻结 mock 验证码规则、`send-code` / `phone/login` contract 与 crate 边界。
|
- [PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md):手机号验证码登录最小闭环设计,冻结 mock 验证码规则、`send-code` / `phone/login` contract 与 crate 边界。
|
||||||
- [PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md):手机号验证码冷却与失败次数限制设计,冻结同手机号同场景发送冷却、错误次数耗尽、`429` 与 `Retry-After` contract。
|
- [PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md):手机号验证码冷却与失败次数限制设计,冻结同手机号同场景发送冷却、错误次数耗尽、`429` 与 `Retry-After` contract。
|
||||||
|
- [PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](./PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md):冻结 Rust `api-server + module-auth + platform-auth` 接入真实阿里云短信 provider 的 crate 边界、发送与校验职责、配置项和错误语义。
|
||||||
|
- [PHONE_SMS_ALIYUN_RESPONSE_FIELD_MAPPING_FIX_2026-04-23.md](./PHONE_SMS_ALIYUN_RESPONSE_FIELD_MAPPING_FIX_2026-04-23.md):记录 Rust `platform-auth` 把阿里云 PascalCase 响应字段误判成空值的问题根因,并冻结字段映射修复与回归标准。
|
||||||
|
- [PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md](./PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md):冻结短信平台受理成功与最终送达状态的区分方式、追踪字段、送达回执接口和前端提示文案边界。
|
||||||
|
- [PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md](./PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md):冻结验证清单第一项“真实短信验证码链路”的本地启动、前端操作、日志观察点、通过标准与失败排查步骤。
|
||||||
- [WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](./WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md):Rust `api-server` 微信登录实现设计,冻结微信 provider 接入、系统 JWT 签发边界、`wechat/start` / `wechat/callback` / `wechat/bind-phone` 闭环,以及与后续 `SpacetimeDB` claims 透传的关系。
|
- [WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](./WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md):Rust `api-server` 微信登录实现设计,冻结微信 provider 接入、系统 JWT 签发边界、`wechat/start` / `wechat/callback` / `wechat/bind-phone` 闭环,以及与后续 `SpacetimeDB` claims 透传的关系。
|
||||||
- [WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md](./WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md):微信登录从本地 mock 到真实微信开放平台联调的执行手册,覆盖环境变量、回调域名、代理头要求、验证步骤与常见失败排查。
|
- [WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md](./WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md):微信登录从本地 mock 到真实微信开放平台联调的执行手册,覆盖环境变量、回调域名、代理头要求、验证步骤与常见失败排查。
|
||||||
- [PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](./PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md):密码登录与自动建号落地设计,冻结 `/api/auth/entry`、幂等兼容策略、模块边界以及与 JWT / refresh cookie 的衔接方式。
|
- [PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](./PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md):密码登录与自动建号落地设计,冻结 `/api/auth/entry`、幂等兼容策略、模块边界以及与 JWT / refresh cookie 的衔接方式。
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
本方案补齐 `server-rs` 在 M7 切流前需要的两类工程脚本:
|
本方案补齐 `server-rs` 在 M7 切流前需要的两类工程脚本:
|
||||||
|
|
||||||
1. 本地一键联调脚本:同时启动本地 SpacetimeDB、Rust `api-server` 与 Web 前端,并通过现有 Vite 代理开关把运行时 API 指向 Rust。
|
1. 本地一键联调脚本:同时启动本地 SpacetimeDB、Rust `api-server` 与 Web 前端,并通过现有 Vite 代理开关把运行时 API 指向 Rust。
|
||||||
2. Ubuntu 发布包构建脚本:在仓库根目录生成 `build/<当前时间>/` 发布目录,内含前端 release、Linux `api-server`、SpacetimeDB wasm、启动脚本与停止脚本,并默认通过 `scp` 上传到目标服务器。
|
2. Ubuntu 发布包构建脚本:在仓库根目录生成 `build/<当前时间>/` 发布目录,内含前端 release、Linux `api-server`、SpacetimeDB wasm、启动脚本、停止脚本,以及从仓库根目录复制的 `.env` / `.env.local`,并默认通过 `scp` 上传到目标服务器。
|
||||||
|
|
||||||
脚本只做部署与联调编排,不改变 HTTP contract、SpacetimeDB schema 命名、对象存储键规划和前端默认 Node 开发入口。
|
脚本只做部署与联调编排,不改变 HTTP contract、SpacetimeDB schema 命名、对象存储键规划和前端默认 Node 开发入口。
|
||||||
|
|
||||||
@@ -102,15 +102,20 @@ npm run deploy:rust:remote
|
|||||||
3. 使用 Vite 构建前端 release 到目标目录的 `web/`。
|
3. 使用 Vite 构建前端 release 到目标目录的 `web/`。
|
||||||
4. 执行 `cargo build -p api-server --release --target x86_64-unknown-linux-gnu --manifest-path server-rs/Cargo.toml`,并把 `api-server` 复制到目标目录。
|
4. 执行 `cargo build -p api-server --release --target x86_64-unknown-linux-gnu --manifest-path server-rs/Cargo.toml`,并把 `api-server` 复制到目标目录。
|
||||||
5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。
|
5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。
|
||||||
6. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*`、`/generated-*`、`/healthz` 反代到本包内的 `api-server`。
|
6. 把仓库根目录的 `.env` 与 `.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下。
|
||||||
7. 在目标目录写入 `start.sh` 与 `stop.sh`。
|
7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*`、`/generated-*`、`/healthz` 反代到本包内的 `api-server`。
|
||||||
8. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。
|
8. 在目标目录写入 `start.sh` 与 `stop.sh`。
|
||||||
|
9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。
|
||||||
|
|
||||||
发布包结构:
|
发布包结构:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
build/<timestamp>/
|
build/<timestamp>/
|
||||||
|
├─ .env
|
||||||
|
├─ .env.local
|
||||||
├─ web/
|
├─ web/
|
||||||
|
│ ├─ .env
|
||||||
|
│ └─ .env.local
|
||||||
├─ api-server
|
├─ api-server
|
||||||
├─ spacetime_module.wasm
|
├─ spacetime_module.wasm
|
||||||
├─ web-server.mjs
|
├─ web-server.mjs
|
||||||
@@ -137,8 +142,8 @@ cd build/<timestamp>
|
|||||||
|
|
||||||
安全边界:
|
安全边界:
|
||||||
|
|
||||||
1. 构建脚本不读取、不传输、不打印生产密钥。
|
1. 构建脚本会把仓库根目录已有的 `.env`、`.env.local` 一并复制进发布包,因此运行前必须确认这些文件内容适合被带入目标环境。
|
||||||
2. 目标服务器 `.env`、`.env.local` 或进程环境仍由服务器本身维护。
|
2. 如果仓库根目录不存在 `.env` 或 `.env.local`,脚本会打印跳过日志,但不会因此失败。
|
||||||
3. `start.sh` 默认不清空 SpacetimeDB;只有显式执行 `./start.sh --clear-database` 才允许清库重发。
|
3. `start.sh` 默认不清空 SpacetimeDB;只有显式执行 `./start.sh --clear-database` 才允许清库重发。
|
||||||
4. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm。
|
4. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm。
|
||||||
5. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置。
|
5. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置。
|
||||||
|
|||||||
59
jenkins/Jenkinsfile.build
Normal file
59
jenkins/Jenkinsfile.build
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
pipeline {
|
||||||
|
agent none
|
||||||
|
|
||||||
|
options {
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
timestamps()
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '构建节点标签')
|
||||||
|
string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区')
|
||||||
|
string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER')
|
||||||
|
booleanParam(name: 'RUN_NPM_CI', defaultValue: false, description: '构建前是否执行 npm ci')
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('构建发布包') {
|
||||||
|
agent {
|
||||||
|
label "${params.AGENT_LABEL}"
|
||||||
|
}
|
||||||
|
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
// 统一在脚本块里计算版本号,避免 declarative environment 对表达式求值不一致。
|
||||||
|
env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
|
||||||
|
// 允许 Jenkins Job 直接指定固定源码目录,未指定时回退到当前工作区。
|
||||||
|
env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd()
|
||||||
|
}
|
||||||
|
|
||||||
|
dir("${env.WORKSPACE_ROOT}") {
|
||||||
|
checkout scm
|
||||||
|
|
||||||
|
script {
|
||||||
|
// 是否重装依赖交给流水线参数决定,避免每次构建都重复执行 npm ci。
|
||||||
|
if (params.RUN_NPM_CI) {
|
||||||
|
sh 'bash -lc "npm ci"'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sh """
|
||||||
|
bash -lc '
|
||||||
|
set -euo pipefail
|
||||||
|
npm run deploy:rust:remote -- --skip-upload --name "${env.EFFECTIVE_BUILD_VERSION}"
|
||||||
|
test -d "build/${env.EFFECTIVE_BUILD_VERSION}"
|
||||||
|
'
|
||||||
|
"""
|
||||||
|
|
||||||
|
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/**", fingerprint: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
echo "构建完成,版本号: ${env.EFFECTIVE_BUILD_VERSION}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
jenkins/Jenkinsfile.build-and-deploy
Normal file
79
jenkins/Jenkinsfile.build-and-deploy
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
pipeline {
|
||||||
|
agent none
|
||||||
|
|
||||||
|
options {
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
timestamps()
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '构建节点标签')
|
||||||
|
string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区')
|
||||||
|
string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER')
|
||||||
|
booleanParam(name: 'RUN_NPM_CI', defaultValue: false, description: '构建前是否执行 npm ci')
|
||||||
|
string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Deploy', description: '部署流水线作业名')
|
||||||
|
string(name: 'DEPLOY_DIRECTORY', defaultValue: '/home/ubuntu/Genarrative-deploy', description: '固定部署目录')
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('构建发布包') {
|
||||||
|
agent {
|
||||||
|
label "${params.AGENT_LABEL}"
|
||||||
|
}
|
||||||
|
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
// 统一在脚本块里计算版本号,避免 declarative environment 对表达式求值不一致。
|
||||||
|
env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
|
||||||
|
// 允许 Jenkins Job 直接指定固定源码目录,未指定时回退到当前工作区。
|
||||||
|
env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd()
|
||||||
|
// 记录当前构建节点名,部署阶段必须回到同一节点读取本地 build 目录。
|
||||||
|
env.SOURCE_NODE_NAME = env.NODE_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
dir("${env.WORKSPACE_ROOT}") {
|
||||||
|
checkout scm
|
||||||
|
|
||||||
|
script {
|
||||||
|
// 是否重装依赖交给流水线参数决定,避免每次构建都重复执行 npm ci。
|
||||||
|
if (params.RUN_NPM_CI) {
|
||||||
|
sh 'bash -lc "npm ci"'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sh """
|
||||||
|
bash -lc '
|
||||||
|
set -euo pipefail
|
||||||
|
npm run deploy:rust:remote -- --skip-upload --name "${env.EFFECTIVE_BUILD_VERSION}"
|
||||||
|
test -d "build/${env.EFFECTIVE_BUILD_VERSION}"
|
||||||
|
'
|
||||||
|
"""
|
||||||
|
|
||||||
|
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/**", fingerprint: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('触发部署') {
|
||||||
|
steps {
|
||||||
|
// 本阶段没有声明 agent,确保触发下游前已经释放构建节点,避免单执行器死锁。
|
||||||
|
build job: params.DEPLOY_JOB_NAME,
|
||||||
|
wait: true,
|
||||||
|
propagate: true,
|
||||||
|
parameters: [
|
||||||
|
string(name: 'SOURCE_NODE_NAME', value: env.SOURCE_NODE_NAME),
|
||||||
|
string(name: 'SOURCE_WORKSPACE_ROOT', value: env.WORKSPACE_ROOT),
|
||||||
|
string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION),
|
||||||
|
string(name: 'DEPLOY_DIRECTORY', value: params.DEPLOY_DIRECTORY),
|
||||||
|
string(name: 'EXPECTED_UPSTREAM_JOB', value: env.JOB_NAME),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
echo "构建并部署完成,版本号: ${env.EFFECTIVE_BUILD_VERSION}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
jenkins/Jenkinsfile.deploy
Normal file
89
jenkins/Jenkinsfile.deploy
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
pipeline {
|
||||||
|
agent none
|
||||||
|
|
||||||
|
options {
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
timestamps()
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
string(name: 'SOURCE_NODE_NAME', defaultValue: '', description: '上游构建节点名')
|
||||||
|
string(name: 'SOURCE_WORKSPACE_ROOT', defaultValue: '', description: '上游源码根目录')
|
||||||
|
string(name: 'BUILD_VERSION', defaultValue: '', description: '待部署版本号')
|
||||||
|
string(name: 'DEPLOY_DIRECTORY', defaultValue: '/home/ubuntu/Genarrative-deploy', description: '固定部署目录')
|
||||||
|
string(name: 'EXPECTED_UPSTREAM_JOB', defaultValue: '', description: '允许触发本作业的上游作业名')
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('校验触发来源') {
|
||||||
|
agent {
|
||||||
|
label 'built-in'
|
||||||
|
}
|
||||||
|
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
// 使用 RunWrapper 白名单方法读取触发原因,避免触发 Jenkins Script Security 审批。
|
||||||
|
def upstreamCauses = currentBuild.getBuildCauses('hudson.model.Cause$UpstreamCause')
|
||||||
|
if (!upstreamCauses || upstreamCauses.isEmpty()) {
|
||||||
|
error('部署流水线禁止人工直接执行,只允许由上游构建并部署流水线触发。')
|
||||||
|
}
|
||||||
|
|
||||||
|
def upstreamCause = upstreamCauses[0]
|
||||||
|
def actualUpstreamJob = upstreamCause?.upstreamProject ?: ''
|
||||||
|
def expectedUpstreamJob = params.EXPECTED_UPSTREAM_JOB?.trim()
|
||||||
|
def allowedUpstreamJob = env.GENARRATIVE_ALLOWED_UPSTREAM_JOB?.trim()
|
||||||
|
|
||||||
|
if (!params.BUILD_VERSION?.trim()) {
|
||||||
|
error('BUILD_VERSION 不能为空。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params.SOURCE_WORKSPACE_ROOT?.trim()) {
|
||||||
|
error('SOURCE_WORKSPACE_ROOT 不能为空。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params.SOURCE_NODE_NAME?.trim()) {
|
||||||
|
error('SOURCE_NODE_NAME 不能为空。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedUpstreamJob && actualUpstreamJob != expectedUpstreamJob) {
|
||||||
|
error("上游作业校验失败,期望 ${expectedUpstreamJob},实际 ${actualUpstreamJob}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedUpstreamJob && actualUpstreamJob != allowedUpstreamJob) {
|
||||||
|
error("环境门禁校验失败,仅允许 ${allowedUpstreamJob} 触发,实际 ${actualUpstreamJob}")
|
||||||
|
}
|
||||||
|
|
||||||
|
env.UPSTREAM_JOB_NAME = actualUpstreamJob
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('部署指定版本') {
|
||||||
|
agent {
|
||||||
|
label "${params.SOURCE_NODE_NAME}"
|
||||||
|
}
|
||||||
|
|
||||||
|
steps {
|
||||||
|
dir("${params.SOURCE_WORKSPACE_ROOT}") {
|
||||||
|
sh """
|
||||||
|
bash -lc '
|
||||||
|
set -euo pipefail
|
||||||
|
test -d "build/${params.BUILD_VERSION}"
|
||||||
|
chmod +x scripts/jenkins-deploy-release.sh
|
||||||
|
# 只部署上游已构建好的版本目录,避免部署阶段再次构建产生漂移。
|
||||||
|
./scripts/jenkins-deploy-release.sh \
|
||||||
|
--source-dir "build/${params.BUILD_VERSION}" \
|
||||||
|
--deploy-dir "${params.DEPLOY_DIRECTORY}"
|
||||||
|
'
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
echo "部署完成,版本号: ${params.BUILD_VERSION},上游作业: ${env.UPSTREAM_JOB_NAME}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,22 @@ copy_required_file() {
|
|||||||
cp "${source_path}" "${target_path}"
|
cp "${source_path}" "${target_path}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
copy_optional_file() {
|
||||||
|
local source_path="$1"
|
||||||
|
local target_path_a="$2"
|
||||||
|
local target_path_b="$3"
|
||||||
|
local label="$4"
|
||||||
|
|
||||||
|
if [[ ! -f "${source_path}" ]]; then
|
||||||
|
echo "[deploy:rust] 跳过未找到的可选文件 ${label}: ${source_path}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "${source_path}" "${target_path_a}"
|
||||||
|
cp "${source_path}" "${target_path_b}"
|
||||||
|
echo "[deploy:rust] 已复制 ${label} -> ${target_path_a} 与 ${target_path_b}"
|
||||||
|
}
|
||||||
|
|
||||||
normalize_local_path_for_bash() {
|
normalize_local_path_for_bash() {
|
||||||
local value="$1"
|
local value="$1"
|
||||||
|
|
||||||
@@ -90,11 +106,11 @@ REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
|||||||
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
|
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
|
||||||
BUILD_ROOT="${REPO_ROOT}/build"
|
BUILD_ROOT="${REPO_ROOT}/build"
|
||||||
BUILD_NAME="$(date +%Y%m%d-%H%M%S)"
|
BUILD_NAME="$(date +%Y%m%d-%H%M%S)"
|
||||||
DATABASE="genarrative-dev"
|
DATABASE="xushi-p4wfr"
|
||||||
API_HOST="127.0.0.1"
|
API_HOST="127.0.0.1"
|
||||||
API_PORT="8082"
|
API_PORT="8082"
|
||||||
WEB_HOST="0.0.0.0"
|
WEB_HOST="0.0.0.0"
|
||||||
WEB_PORT="3000"
|
WEB_PORT="25001"
|
||||||
SPACETIME_HOST="127.0.0.1"
|
SPACETIME_HOST="127.0.0.1"
|
||||||
SPACETIME_PORT="3101"
|
SPACETIME_PORT="3101"
|
||||||
SSH_KEY='~\.ssh\dsk.pem'
|
SSH_KEY='~\.ssh\dsk.pem'
|
||||||
@@ -228,6 +244,9 @@ mkdir -p "${WEB_DIR}"
|
|||||||
|
|
||||||
echo "[deploy:rust] 发布包目录: ${TARGET_DIR}"
|
echo "[deploy:rust] 发布包目录: ${TARGET_DIR}"
|
||||||
|
|
||||||
|
copy_optional_file "${REPO_ROOT}/.env" "${TARGET_DIR}/.env" "${WEB_DIR}/.env" ".env"
|
||||||
|
copy_optional_file "${REPO_ROOT}/.env.local" "${TARGET_DIR}/.env.local" "${WEB_DIR}/.env.local" ".env.local"
|
||||||
|
|
||||||
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
||||||
echo "[deploy:rust] 构建 Vite release -> ${WEB_DIR}"
|
echo "[deploy:rust] 构建 Vite release -> ${WEB_DIR}"
|
||||||
(
|
(
|
||||||
@@ -571,6 +590,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
|
|||||||
|
|
||||||
## 内容
|
## 内容
|
||||||
|
|
||||||
|
- \`.env\` / \`.env.local\`:从仓库根目录复制的环境文件,同时各保留一份到 \`web/\`
|
||||||
- \`web/\`:Vite release 静态资源
|
- \`web/\`:Vite release 静态资源
|
||||||
- \`api-server\`:x86_64-unknown-linux-gnu release 可执行文件
|
- \`api-server\`:x86_64-unknown-linux-gnu release 可执行文件
|
||||||
- \`spacetime_module.wasm\`:wasm32-unknown-unknown release 模块
|
- \`spacetime_module.wasm\`:wasm32-unknown-unknown release 模块
|
||||||
|
|||||||
98
scripts/jenkins-deploy-release.sh
Normal file
98
scripts/jenkins-deploy-release.sh
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
用法:
|
||||||
|
./scripts/jenkins-deploy-release.sh --source-dir /path/to/build/123 --deploy-dir /home/ubuntu/Genarrative-deploy
|
||||||
|
|
||||||
|
说明:
|
||||||
|
1. 如果部署目录已有旧版本且存在 stop.sh,则先执行旧版本 stop.sh。
|
||||||
|
2. 直接清空部署目录中的全部旧文件。
|
||||||
|
3. 把指定发布目录中的内容移动到部署目录。
|
||||||
|
4. 最后执行新版本 start.sh。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
--source-dir <path> 必填,待部署的发布目录,例如 build/123
|
||||||
|
--deploy-dir <path> 必填,固定部署目录,例如 /home/ubuntu/Genarrative-deploy
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
require_argument() {
|
||||||
|
local value="$1"
|
||||||
|
local label="$2"
|
||||||
|
|
||||||
|
if [[ -z "${value}" ]]; then
|
||||||
|
echo "[jenkins-deploy] 缺少参数: ${label}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
SOURCE_DIR=""
|
||||||
|
DEPLOY_DIR=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--source-dir)
|
||||||
|
SOURCE_DIR="${2:?缺少 --source-dir 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--deploy-dir)
|
||||||
|
DEPLOY_DIR="${2:?缺少 --deploy-dir 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[jenkins-deploy] 未知参数: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
require_argument "${SOURCE_DIR}" "--source-dir"
|
||||||
|
require_argument "${DEPLOY_DIR}" "--deploy-dir"
|
||||||
|
|
||||||
|
if [[ ! -d "${SOURCE_DIR}" ]]; then
|
||||||
|
echo "[jenkins-deploy] 发布目录不存在: ${SOURCE_DIR}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SOURCE_DIR="$(cd "${SOURCE_DIR}" && pwd)"
|
||||||
|
mkdir -p "${DEPLOY_DIR}"
|
||||||
|
DEPLOY_DIR="$(cd "${DEPLOY_DIR}" && pwd)"
|
||||||
|
|
||||||
|
if [[ ! -f "${SOURCE_DIR}/start.sh" ]]; then
|
||||||
|
echo "[jenkins-deploy] 发布目录缺少 start.sh: ${SOURCE_DIR}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -x "${DEPLOY_DIR}/stop.sh" ]]; then
|
||||||
|
echo "[jenkins-deploy] 先停止旧版本: ${DEPLOY_DIR}"
|
||||||
|
(
|
||||||
|
cd "${DEPLOY_DIR}"
|
||||||
|
./stop.sh
|
||||||
|
)
|
||||||
|
else
|
||||||
|
echo "[jenkins-deploy] 部署目录无可执行 stop.sh,跳过停服"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[jenkins-deploy] 清空部署目录: ${DEPLOY_DIR}"
|
||||||
|
find "${DEPLOY_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||||
|
|
||||||
|
echo "[jenkins-deploy] 移动发布内容: ${SOURCE_DIR} -> ${DEPLOY_DIR}"
|
||||||
|
find "${SOURCE_DIR}" -mindepth 1 -maxdepth 1 -exec mv {} "${DEPLOY_DIR}/" \;
|
||||||
|
|
||||||
|
chmod +x "${DEPLOY_DIR}/start.sh" "${DEPLOY_DIR}/stop.sh"
|
||||||
|
|
||||||
|
echo "[jenkins-deploy] 启动新版本: ${DEPLOY_DIR}"
|
||||||
|
(
|
||||||
|
cd "${DEPLOY_DIR}"
|
||||||
|
./start.sh
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "[jenkins-deploy] 完成"
|
||||||
5
server-rs/Cargo.lock
generated
5
server-rs/Cargo.lock
generated
@@ -1847,9 +1847,14 @@ name = "platform-auth"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"hmac",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"shared-kernel",
|
"shared-kernel",
|
||||||
"time",
|
"time",
|
||||||
|
|||||||
@@ -1291,7 +1291,10 @@ mod tests {
|
|||||||
payload["expiresInSeconds"],
|
payload["expiresInSeconds"],
|
||||||
Value::Number(serde_json::Number::from(300))
|
Value::Number(serde_json::Number::from(300))
|
||||||
);
|
);
|
||||||
assert_eq!(payload["providerRequestId"], Value::Null);
|
assert_eq!(
|
||||||
|
payload["providerRequestId"],
|
||||||
|
Value::String("mock-request-id".to_string())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ pub struct AppConfig {
|
|||||||
pub refresh_cookie_same_site: String,
|
pub refresh_cookie_same_site: String,
|
||||||
pub refresh_session_ttl_days: u32,
|
pub refresh_session_ttl_days: u32,
|
||||||
pub sms_auth_enabled: bool,
|
pub sms_auth_enabled: bool,
|
||||||
|
pub sms_auth_provider: String,
|
||||||
|
pub sms_endpoint: String,
|
||||||
|
pub sms_access_key_id: Option<String>,
|
||||||
|
pub sms_access_key_secret: Option<String>,
|
||||||
|
pub sms_sign_name: String,
|
||||||
|
pub sms_template_code: String,
|
||||||
|
pub sms_template_param_key: String,
|
||||||
|
pub sms_country_code: String,
|
||||||
|
pub sms_scheme_name: Option<String>,
|
||||||
|
pub sms_code_length: u8,
|
||||||
|
pub sms_code_type: u8,
|
||||||
|
pub sms_valid_time_seconds: u64,
|
||||||
|
pub sms_interval_seconds: u64,
|
||||||
|
pub sms_duplicate_policy: u8,
|
||||||
|
pub sms_case_auth_policy: u8,
|
||||||
|
pub sms_return_verify_code: bool,
|
||||||
|
pub sms_mock_verify_code: String,
|
||||||
pub wechat_auth_enabled: bool,
|
pub wechat_auth_enabled: bool,
|
||||||
pub wechat_auth_provider: String,
|
pub wechat_auth_provider: String,
|
||||||
pub wechat_app_id: Option<String>,
|
pub wechat_app_id: Option<String>,
|
||||||
@@ -79,6 +96,23 @@ impl Default for AppConfig {
|
|||||||
refresh_cookie_same_site: "Lax".to_string(),
|
refresh_cookie_same_site: "Lax".to_string(),
|
||||||
refresh_session_ttl_days: 30,
|
refresh_session_ttl_days: 30,
|
||||||
sms_auth_enabled: false,
|
sms_auth_enabled: false,
|
||||||
|
sms_auth_provider: "mock".to_string(),
|
||||||
|
sms_endpoint: "dypnsapi.aliyuncs.com".to_string(),
|
||||||
|
sms_access_key_id: None,
|
||||||
|
sms_access_key_secret: None,
|
||||||
|
sms_sign_name: "速通互联验证码".to_string(),
|
||||||
|
sms_template_code: "100001".to_string(),
|
||||||
|
sms_template_param_key: "code".to_string(),
|
||||||
|
sms_country_code: "86".to_string(),
|
||||||
|
sms_scheme_name: None,
|
||||||
|
sms_code_length: 6,
|
||||||
|
sms_code_type: 1,
|
||||||
|
sms_valid_time_seconds: 300,
|
||||||
|
sms_interval_seconds: 60,
|
||||||
|
sms_duplicate_policy: 1,
|
||||||
|
sms_case_auth_policy: 1,
|
||||||
|
sms_return_verify_code: false,
|
||||||
|
sms_mock_verify_code: "123456".to_string(),
|
||||||
wechat_auth_enabled: false,
|
wechat_auth_enabled: false,
|
||||||
wechat_auth_provider: "mock".to_string(),
|
wechat_auth_provider: "mock".to_string(),
|
||||||
wechat_app_id: None,
|
wechat_app_id: None,
|
||||||
@@ -194,6 +228,60 @@ impl AppConfig {
|
|||||||
if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) {
|
if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) {
|
||||||
config.sms_auth_enabled = sms_auth_enabled;
|
config.sms_auth_enabled = sms_auth_enabled;
|
||||||
}
|
}
|
||||||
|
if let Some(sms_auth_provider) = read_first_non_empty_env(&["SMS_AUTH_PROVIDER"]) {
|
||||||
|
config.sms_auth_provider = sms_auth_provider;
|
||||||
|
}
|
||||||
|
if let Some(sms_endpoint) = read_first_non_empty_env(&["ALIYUN_SMS_ENDPOINT"]) {
|
||||||
|
config.sms_endpoint = sms_endpoint;
|
||||||
|
}
|
||||||
|
config.sms_access_key_id = read_first_non_empty_env(&["ALIYUN_SMS_ACCESS_KEY_ID"]);
|
||||||
|
config.sms_access_key_secret = read_first_non_empty_env(&["ALIYUN_SMS_ACCESS_KEY_SECRET"]);
|
||||||
|
if let Some(sms_sign_name) = read_first_non_empty_env(&["ALIYUN_SMS_SIGN_NAME"]) {
|
||||||
|
config.sms_sign_name = sms_sign_name;
|
||||||
|
}
|
||||||
|
if let Some(sms_template_code) = read_first_non_empty_env(&["ALIYUN_SMS_TEMPLATE_CODE"]) {
|
||||||
|
config.sms_template_code = sms_template_code;
|
||||||
|
}
|
||||||
|
if let Some(sms_template_param_key) =
|
||||||
|
read_first_non_empty_env(&["ALIYUN_SMS_TEMPLATE_PARAM_KEY"])
|
||||||
|
{
|
||||||
|
config.sms_template_param_key = sms_template_param_key;
|
||||||
|
}
|
||||||
|
if let Some(sms_country_code) = read_first_non_empty_env(&["ALIYUN_SMS_COUNTRY_CODE"]) {
|
||||||
|
config.sms_country_code = sms_country_code;
|
||||||
|
}
|
||||||
|
config.sms_scheme_name = read_first_non_empty_env(&["ALIYUN_SMS_SCHEME_NAME"]);
|
||||||
|
if let Some(sms_code_length) = read_first_u8_env(&["ALIYUN_SMS_CODE_LENGTH"]) {
|
||||||
|
config.sms_code_length = sms_code_length;
|
||||||
|
}
|
||||||
|
if let Some(sms_code_type) = read_first_u8_env(&["ALIYUN_SMS_CODE_TYPE"]) {
|
||||||
|
config.sms_code_type = sms_code_type;
|
||||||
|
}
|
||||||
|
if let Some(sms_valid_time_seconds) =
|
||||||
|
read_first_duration_seconds_env(&["ALIYUN_SMS_VALID_TIME_SECONDS"])
|
||||||
|
{
|
||||||
|
config.sms_valid_time_seconds = sms_valid_time_seconds;
|
||||||
|
}
|
||||||
|
if let Some(sms_interval_seconds) =
|
||||||
|
read_first_duration_seconds_env(&["ALIYUN_SMS_INTERVAL_SECONDS"])
|
||||||
|
{
|
||||||
|
config.sms_interval_seconds = sms_interval_seconds;
|
||||||
|
}
|
||||||
|
if let Some(sms_duplicate_policy) = read_first_u8_env(&["ALIYUN_SMS_DUPLICATE_POLICY"]) {
|
||||||
|
config.sms_duplicate_policy = sms_duplicate_policy;
|
||||||
|
}
|
||||||
|
if let Some(sms_case_auth_policy) = read_first_u8_env(&["ALIYUN_SMS_CASE_AUTH_POLICY"]) {
|
||||||
|
config.sms_case_auth_policy = sms_case_auth_policy;
|
||||||
|
}
|
||||||
|
if let Some(sms_return_verify_code) =
|
||||||
|
read_first_bool_env(&["ALIYUN_SMS_RETURN_VERIFY_CODE"])
|
||||||
|
{
|
||||||
|
config.sms_return_verify_code = sms_return_verify_code;
|
||||||
|
}
|
||||||
|
if let Some(sms_mock_verify_code) = read_first_non_empty_env(&["SMS_AUTH_MOCK_VERIFY_CODE"])
|
||||||
|
{
|
||||||
|
config.sms_mock_verify_code = sms_mock_verify_code;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(wechat_auth_enabled) = read_first_bool_env(&["WECHAT_AUTH_ENABLED"]) {
|
if let Some(wechat_auth_enabled) = read_first_bool_env(&["WECHAT_AUTH_ENABLED"]) {
|
||||||
config.wechat_auth_enabled = wechat_auth_enabled;
|
config.wechat_auth_enabled = wechat_auth_enabled;
|
||||||
@@ -439,6 +527,11 @@ fn read_first_u64_env(keys: &[&str]) -> Option<u64> {
|
|||||||
.find_map(|key| env::var(key).ok().and_then(|value| parse_u64(&value)))
|
.find_map(|key| env::var(key).ok().and_then(|value| parse_u64(&value)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_first_u8_env(keys: &[&str]) -> Option<u8> {
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|key| env::var(key).ok().and_then(|value| parse_u8(&value)))
|
||||||
|
}
|
||||||
|
|
||||||
fn read_first_positive_u16_env(keys: &[&str]) -> Option<u16> {
|
fn read_first_positive_u16_env(keys: &[&str]) -> Option<u16> {
|
||||||
keys.iter().find_map(|key| {
|
keys.iter().find_map(|key| {
|
||||||
env::var(key)
|
env::var(key)
|
||||||
@@ -515,6 +608,10 @@ fn parse_u64(raw: &str) -> Option<u64> {
|
|||||||
raw.trim().parse::<u64>().ok()
|
raw.trim().parse::<u64>().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_u8(raw: &str) -> Option<u8> {
|
||||||
|
raw.trim().parse::<u8>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_positive_u16(raw: &str) -> Option<u16> {
|
fn parse_positive_u16(raw: &str) -> Option<u16> {
|
||||||
let value = raw.trim().parse::<u16>().ok()?;
|
let value = raw.trim().parse::<u16>().ok()?;
|
||||||
if value == 0 {
|
if value == 0 {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub async fn send_phone_code(
|
|||||||
},
|
},
|
||||||
OffsetDateTime::now_utc(),
|
OffsetDateTime::now_utc(),
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.map_err(map_phone_auth_error)?;
|
.map_err(map_phone_auth_error)?;
|
||||||
|
|
||||||
Ok(json_success_body(
|
Ok(json_success_body(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use module_runtime::RuntimeSnapshotRecord;
|
|||||||
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
|
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
|
||||||
use platform_auth::{
|
use platform_auth::{
|
||||||
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||||
|
SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind, SmsProviderError,
|
||||||
};
|
};
|
||||||
use platform_llm::{LlmClient, LlmConfig, LlmError};
|
use platform_llm::{LlmClient, LlmConfig, LlmError};
|
||||||
use platform_oss::{OssClient, OssConfig, OssError};
|
use platform_oss::{OssClient, OssConfig, OssError};
|
||||||
@@ -54,6 +55,7 @@ pub struct AppState {
|
|||||||
pub enum AppStateInitError {
|
pub enum AppStateInitError {
|
||||||
Jwt(JwtError),
|
Jwt(JwtError),
|
||||||
RefreshCookie(RefreshCookieError),
|
RefreshCookie(RefreshCookieError),
|
||||||
|
SmsProvider(SmsProviderError),
|
||||||
Oss(OssError),
|
Oss(OssError),
|
||||||
Llm(LlmError),
|
Llm(LlmError),
|
||||||
}
|
}
|
||||||
@@ -78,9 +80,30 @@ impl AppState {
|
|||||||
)?;
|
)?;
|
||||||
let oss_client = build_oss_client(&config)?;
|
let oss_client = build_oss_client(&config)?;
|
||||||
let auth_store = InMemoryAuthStore::default();
|
let auth_store = InMemoryAuthStore::default();
|
||||||
|
let sms_provider = SmsAuthProvider::new(SmsAuthConfig::new(
|
||||||
|
SmsAuthProviderKind::parse(&config.sms_auth_provider).ok_or_else(|| {
|
||||||
|
SmsProviderError::InvalidConfig("短信 provider 配置非法".to_string())
|
||||||
|
})?,
|
||||||
|
config.sms_endpoint.clone(),
|
||||||
|
config.sms_access_key_id.clone(),
|
||||||
|
config.sms_access_key_secret.clone(),
|
||||||
|
config.sms_sign_name.clone(),
|
||||||
|
config.sms_template_code.clone(),
|
||||||
|
config.sms_template_param_key.clone(),
|
||||||
|
config.sms_country_code.clone(),
|
||||||
|
config.sms_scheme_name.clone(),
|
||||||
|
config.sms_code_length,
|
||||||
|
config.sms_code_type,
|
||||||
|
config.sms_valid_time_seconds,
|
||||||
|
config.sms_interval_seconds,
|
||||||
|
config.sms_duplicate_policy,
|
||||||
|
config.sms_case_auth_policy,
|
||||||
|
config.sms_return_verify_code,
|
||||||
|
config.sms_mock_verify_code.clone(),
|
||||||
|
)?)?;
|
||||||
let password_entry_service = PasswordEntryService::new(auth_store.clone());
|
let password_entry_service = PasswordEntryService::new(auth_store.clone());
|
||||||
let auth_user_service = AuthUserService::new(auth_store.clone());
|
let auth_user_service = AuthUserService::new(auth_store.clone());
|
||||||
let phone_auth_service = PhoneAuthService::new(auth_store.clone());
|
let phone_auth_service = PhoneAuthService::new(auth_store.clone(), sms_provider);
|
||||||
let wechat_auth_state_service =
|
let wechat_auth_state_service =
|
||||||
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
|
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
|
||||||
let wechat_auth_service = WechatAuthService::new(auth_store.clone());
|
let wechat_auth_service = WechatAuthService::new(auth_store.clone());
|
||||||
@@ -331,6 +354,7 @@ impl fmt::Display for AppStateInitError {
|
|||||||
match self {
|
match self {
|
||||||
Self::Jwt(error) => write!(f, "{error}"),
|
Self::Jwt(error) => write!(f, "{error}"),
|
||||||
Self::RefreshCookie(error) => write!(f, "{error}"),
|
Self::RefreshCookie(error) => write!(f, "{error}"),
|
||||||
|
Self::SmsProvider(error) => write!(f, "{error}"),
|
||||||
Self::Oss(error) => write!(f, "{error}"),
|
Self::Oss(error) => write!(f, "{error}"),
|
||||||
Self::Llm(error) => write!(f, "{error}"),
|
Self::Llm(error) => write!(f, "{error}"),
|
||||||
}
|
}
|
||||||
@@ -351,6 +375,12 @@ impl From<RefreshCookieError> for AppStateInitError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<SmsProviderError> for AppStateInitError {
|
||||||
|
fn from(value: SmsProviderError) -> Self {
|
||||||
|
Self::SmsProvider(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<OssError> for AppStateInitError {
|
impl From<OssError> for AppStateInitError {
|
||||||
fn from(value: OssError) -> Self {
|
fn from(value: OssError) -> Self {
|
||||||
Self::Oss(value)
|
Self::Oss(value)
|
||||||
|
|||||||
@@ -43,10 +43,11 @@
|
|||||||
11. [../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)
|
11. [../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)
|
||||||
12. [../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)
|
12. [../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)
|
||||||
13. [../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md)
|
13. [../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md)
|
||||||
|
14. [../../../docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](../../../docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md)
|
||||||
|
|
||||||
## 4. 边界约束
|
## 4. 边界约束
|
||||||
|
|
||||||
1. `module-auth` 负责鉴权领域规则与模块级编排,不直接把供应商 SDK 逻辑写进主工程。
|
1. `module-auth` 负责鉴权领域规则与模块级编排,不直接把供应商 SDK 或阿里云 RPC 逻辑写进主工程。
|
||||||
2. 短信、微信、JWT、Cookie、密码哈希等平台适配优先通过 `crates/platform-auth` 承接。
|
2. 短信、微信、JWT、Cookie、密码哈希等平台适配优先通过 `crates/platform-auth` 承接。
|
||||||
3. 身份与会话状态最终由 `crates/spacetime-module` 聚合,前端接口由 `crates/api-server` 暴露。
|
3. 身份与会话状态最终由 `crates/spacetime-module` 聚合,前端接口由 `crates/api-server` 暴露。
|
||||||
4. 当前阶段允许先使用进程内适配器把用例跑通,但后续切到 `SpacetimeDB` 时应保持用例接口稳定。
|
4. 当前阶段允许先使用进程内适配器把用例跑通,但后续切到 `SpacetimeDB` 时应保持用例接口稳定。
|
||||||
@@ -54,3 +55,4 @@
|
|||||||
6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。
|
6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。
|
||||||
7. 当前 `module-auth` 已承接进程内 refresh session 创建与轮换能力,供 `/api/auth/refresh` 复用。
|
7. 当前 `module-auth` 已承接进程内 refresh session 创建与轮换能力,供 `/api/auth/refresh` 复用。
|
||||||
8. 当前 `module-auth` 已承接当前 refresh session 吊销与用户 `token_version` 递增能力,供 `/api/auth/logout` 复用。
|
8. 当前 `module-auth` 已承接当前 refresh session 吊销与用户 `token_version` 递增能力,供 `/api/auth/logout` 复用。
|
||||||
|
9. 当前手机号验证码真实 provider 由 `platform-auth` 注入,`module-auth` 只保留冷却、TTL、失败次数和账号编排,不保存验证码明文。
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ use std::{
|
|||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
use platform_auth::{hash_password, verify_password};
|
use platform_auth::{
|
||||||
|
SmsAuthProvider, SmsProviderError, SmsSendCodeRequest, SmsVerifyCodeRequest, hash_password,
|
||||||
|
verify_password,
|
||||||
|
};
|
||||||
use shared_kernel::{
|
use shared_kernel::{
|
||||||
build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string,
|
build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string,
|
||||||
normalize_optional_string, normalize_required_string, parse_rfc3339,
|
normalize_optional_string, normalize_required_string, parse_rfc3339,
|
||||||
@@ -17,7 +20,6 @@ const USERNAME_MAX_LENGTH: usize = 24;
|
|||||||
const PASSWORD_MIN_LENGTH: usize = 6;
|
const PASSWORD_MIN_LENGTH: usize = 6;
|
||||||
const PASSWORD_MAX_LENGTH: usize = 128;
|
const PASSWORD_MAX_LENGTH: usize = 128;
|
||||||
const SMS_CODE_LENGTH: usize = 6;
|
const SMS_CODE_LENGTH: usize = 6;
|
||||||
const SMS_MOCK_VERIFY_CODE: &str = "123456";
|
|
||||||
const SMS_CODE_TTL_MINUTES: i64 = 5;
|
const SMS_CODE_TTL_MINUTES: i64 = 5;
|
||||||
const SMS_CODE_COOLDOWN_SECONDS: u64 = 60;
|
const SMS_CODE_COOLDOWN_SECONDS: u64 = 60;
|
||||||
const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5;
|
const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5;
|
||||||
@@ -332,10 +334,10 @@ struct StoredRefreshSession {
|
|||||||
struct StoredPhoneCode {
|
struct StoredPhoneCode {
|
||||||
phone_number: String,
|
phone_number: String,
|
||||||
scene: PhoneAuthScene,
|
scene: PhoneAuthScene,
|
||||||
verify_code: String,
|
|
||||||
expires_at: String,
|
expires_at: String,
|
||||||
last_sent_at: String,
|
last_sent_at: String,
|
||||||
failed_attempts: u32,
|
failed_attempts: u32,
|
||||||
|
provider_out_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -371,6 +373,7 @@ pub struct AuthUserService {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PhoneAuthService {
|
pub struct PhoneAuthService {
|
||||||
store: InMemoryAuthStore,
|
store: InMemoryAuthStore,
|
||||||
|
sms_provider: SmsAuthProvider,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -562,11 +565,14 @@ impl RefreshSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PhoneAuthService {
|
impl PhoneAuthService {
|
||||||
pub fn new(store: InMemoryAuthStore) -> Self {
|
pub fn new(store: InMemoryAuthStore, sms_provider: SmsAuthProvider) -> Self {
|
||||||
Self { store }
|
Self {
|
||||||
|
store,
|
||||||
|
sms_provider,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_code(
|
pub async fn send_code(
|
||||||
&self,
|
&self,
|
||||||
input: SendPhoneCodeInput,
|
input: SendPhoneCodeInput,
|
||||||
now: OffsetDateTime,
|
now: OffsetDateTime,
|
||||||
@@ -579,25 +585,33 @@ impl PhoneAuthService {
|
|||||||
PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}"))
|
PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 当前阶段先冻结 mock 短信行为,只记录验证码快照,不接真实短信供应商。
|
let provider_result = self
|
||||||
|
.sms_provider
|
||||||
|
.send_code(SmsSendCodeRequest {
|
||||||
|
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
|
||||||
|
scene: input.scene.as_str().to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(map_sms_provider_error_to_phone_error)?;
|
||||||
|
|
||||||
self.store.upsert_phone_code(
|
self.store.upsert_phone_code(
|
||||||
StoredPhoneCode {
|
StoredPhoneCode {
|
||||||
phone_number: normalized_phone.e164.clone(),
|
phone_number: normalized_phone.e164.clone(),
|
||||||
scene: input.scene,
|
scene: input.scene,
|
||||||
verify_code: SMS_MOCK_VERIFY_CODE.to_string(),
|
|
||||||
expires_at,
|
expires_at,
|
||||||
last_sent_at: format_rfc3339(now).map_err(|message| {
|
last_sent_at: format_rfc3339(now).map_err(|message| {
|
||||||
PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}"))
|
PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}"))
|
||||||
})?,
|
})?,
|
||||||
failed_attempts: 0,
|
failed_attempts: 0,
|
||||||
|
provider_out_id: provider_result.provider_out_id.clone(),
|
||||||
},
|
},
|
||||||
now,
|
now,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(SendPhoneCodeResult {
|
Ok(SendPhoneCodeResult {
|
||||||
cooldown_seconds: SMS_CODE_COOLDOWN_SECONDS,
|
cooldown_seconds: provider_result.cooldown_seconds,
|
||||||
expires_in_seconds: (SMS_CODE_TTL_MINUTES * 60) as u64,
|
expires_in_seconds: provider_result.expires_in_seconds,
|
||||||
provider_request_id: None,
|
provider_request_id: provider_result.provider_request_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,12 +622,28 @@ impl PhoneAuthService {
|
|||||||
) -> Result<PhoneLoginResult, PhoneAuthError> {
|
) -> Result<PhoneLoginResult, PhoneAuthError> {
|
||||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
||||||
verify_sms_code_format(&input.verify_code)?;
|
verify_sms_code_format(&input.verify_code)?;
|
||||||
self.store.consume_phone_code(
|
let provider_out_id = self.store.assert_phone_code_active(
|
||||||
&normalized_phone.e164,
|
&normalized_phone.e164,
|
||||||
&PhoneAuthScene::Login,
|
&PhoneAuthScene::Login,
|
||||||
input.verify_code.trim(),
|
|
||||||
now,
|
now,
|
||||||
)?;
|
)?;
|
||||||
|
match self
|
||||||
|
.sms_provider
|
||||||
|
.verify_code(SmsVerifyCodeRequest {
|
||||||
|
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
|
||||||
|
verify_code: input.verify_code.trim().to_string(),
|
||||||
|
provider_out_id: provider_out_id.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => self
|
||||||
|
.store
|
||||||
|
.consume_phone_code_success(&normalized_phone.e164, &PhoneAuthScene::Login)?,
|
||||||
|
Err(SmsProviderError::InvalidVerifyCode) => self
|
||||||
|
.store
|
||||||
|
.consume_phone_code_failure(&normalized_phone.e164, &PhoneAuthScene::Login)?,
|
||||||
|
Err(other) => return Err(map_sms_provider_error_to_phone_error(other)),
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(user) = self
|
if let Some(user) = self
|
||||||
.store
|
.store
|
||||||
@@ -651,12 +681,28 @@ impl PhoneAuthService {
|
|||||||
) -> Result<BindWechatPhoneResult, PhoneAuthError> {
|
) -> Result<BindWechatPhoneResult, PhoneAuthError> {
|
||||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
||||||
verify_sms_code_format(&input.verify_code)?;
|
verify_sms_code_format(&input.verify_code)?;
|
||||||
self.store.consume_phone_code(
|
let provider_out_id = self.store.assert_phone_code_active(
|
||||||
&normalized_phone.e164,
|
&normalized_phone.e164,
|
||||||
&PhoneAuthScene::BindPhone,
|
&PhoneAuthScene::BindPhone,
|
||||||
input.verify_code.trim(),
|
|
||||||
now,
|
now,
|
||||||
)?;
|
)?;
|
||||||
|
match self
|
||||||
|
.sms_provider
|
||||||
|
.verify_code(SmsVerifyCodeRequest {
|
||||||
|
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
|
||||||
|
verify_code: input.verify_code.trim().to_string(),
|
||||||
|
provider_out_id,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => self
|
||||||
|
.store
|
||||||
|
.consume_phone_code_success(&normalized_phone.e164, &PhoneAuthScene::BindPhone)?,
|
||||||
|
Err(SmsProviderError::InvalidVerifyCode) => self
|
||||||
|
.store
|
||||||
|
.consume_phone_code_failure(&normalized_phone.e164, &PhoneAuthScene::BindPhone)?,
|
||||||
|
Err(other) => return Err(map_sms_provider_error_to_phone_error(other)),
|
||||||
|
}
|
||||||
|
|
||||||
let current_user = self
|
let current_user = self
|
||||||
.store
|
.store
|
||||||
@@ -1187,13 +1233,12 @@ impl InMemoryAuthStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn consume_phone_code(
|
fn assert_phone_code_active(
|
||||||
&self,
|
&self,
|
||||||
phone_number: &str,
|
phone_number: &str,
|
||||||
scene: &PhoneAuthScene,
|
scene: &PhoneAuthScene,
|
||||||
verify_code: &str,
|
|
||||||
now: OffsetDateTime,
|
now: OffsetDateTime,
|
||||||
) -> Result<(), PhoneAuthError> {
|
) -> Result<Option<String>, PhoneAuthError> {
|
||||||
let mut state = self
|
let mut state = self
|
||||||
.inner
|
.inner
|
||||||
.lock()
|
.lock()
|
||||||
@@ -1213,21 +1258,47 @@ impl InMemoryAuthStore {
|
|||||||
state.phone_codes_by_key.remove(&key);
|
state.phone_codes_by_key.remove(&key);
|
||||||
return Err(PhoneAuthError::VerifyCodeExpired);
|
return Err(PhoneAuthError::VerifyCodeExpired);
|
||||||
}
|
}
|
||||||
if stored.verify_code != verify_code.trim() {
|
Ok(stored.provider_out_id)
|
||||||
let next_failed_attempts = stored.failed_attempts.saturating_add(1);
|
}
|
||||||
if next_failed_attempts >= SMS_CODE_MAX_FAILED_ATTEMPTS {
|
|
||||||
state.phone_codes_by_key.remove(&key);
|
fn consume_phone_code_success(
|
||||||
return Err(PhoneAuthError::VerifyAttemptsExceeded);
|
&self,
|
||||||
}
|
phone_number: &str,
|
||||||
if let Some(current) = state.phone_codes_by_key.get_mut(&key) {
|
scene: &PhoneAuthScene,
|
||||||
current.failed_attempts = next_failed_attempts;
|
) -> Result<(), PhoneAuthError> {
|
||||||
}
|
let mut state = self
|
||||||
return Err(PhoneAuthError::InvalidVerifyCode);
|
.inner
|
||||||
}
|
.lock()
|
||||||
|
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
|
||||||
|
let key = build_phone_code_key(phone_number, scene);
|
||||||
state.phone_codes_by_key.remove(&key);
|
state.phone_codes_by_key.remove(&key);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn consume_phone_code_failure(
|
||||||
|
&self,
|
||||||
|
phone_number: &str,
|
||||||
|
scene: &PhoneAuthScene,
|
||||||
|
) -> Result<(), PhoneAuthError> {
|
||||||
|
let mut state = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
|
||||||
|
let key = build_phone_code_key(phone_number, scene);
|
||||||
|
let Some(stored) = state.phone_codes_by_key.get(&key).cloned() else {
|
||||||
|
return Err(PhoneAuthError::VerifyCodeNotFound);
|
||||||
|
};
|
||||||
|
let next_failed_attempts = stored.failed_attempts.saturating_add(1);
|
||||||
|
if next_failed_attempts >= SMS_CODE_MAX_FAILED_ATTEMPTS {
|
||||||
|
state.phone_codes_by_key.remove(&key);
|
||||||
|
return Err(PhoneAuthError::VerifyAttemptsExceeded);
|
||||||
|
}
|
||||||
|
if let Some(current) = state.phone_codes_by_key.get_mut(&key) {
|
||||||
|
current.failed_attempts = next_failed_attempts;
|
||||||
|
}
|
||||||
|
Err(PhoneAuthError::InvalidVerifyCode)
|
||||||
|
}
|
||||||
|
|
||||||
fn insert_wechat_state(
|
fn insert_wechat_state(
|
||||||
&self,
|
&self,
|
||||||
state_record: WechatAuthStateRecord,
|
state_record: WechatAuthStateRecord,
|
||||||
@@ -1689,6 +1760,15 @@ fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthError {
|
||||||
|
match error {
|
||||||
|
SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode,
|
||||||
|
SmsProviderError::InvalidConfig(message) | SmsProviderError::Upstream(message) => {
|
||||||
|
PhoneAuthError::Store(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError {
|
fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError {
|
||||||
match error {
|
match error {
|
||||||
RefreshSessionError::Store(message) => LogoutError::Store(message),
|
RefreshSessionError::Store(message) => LogoutError::Store(message),
|
||||||
@@ -1758,6 +1838,16 @@ fn mask_phone_number(phone_number: &str) -> String {
|
|||||||
format!("{}****{}", &phone_number[..3], &phone_number[7..11])
|
format!("{}****{}", &phone_number[..3], &phone_number[7..11])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_national_phone_number(e164_phone_number: &str) -> Result<String, PhoneAuthError> {
|
||||||
|
let digits = e164_phone_number.trim().trim_start_matches('+');
|
||||||
|
if let Some(national) = digits.strip_prefix("86")
|
||||||
|
&& national.len() == 11
|
||||||
|
{
|
||||||
|
return Ok(national.to_string());
|
||||||
|
}
|
||||||
|
Err(PhoneAuthError::InvalidPhoneNumber)
|
||||||
|
}
|
||||||
|
|
||||||
fn build_random_password_seed() -> String {
|
fn build_random_password_seed() -> String {
|
||||||
format!(
|
format!(
|
||||||
"seed_{}_{}",
|
"seed_{}_{}",
|
||||||
@@ -1829,7 +1919,13 @@ impl WechatAuthScene {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use platform_auth::hash_refresh_session_token;
|
use platform_auth::{
|
||||||
|
DEFAULT_SMS_CASE_AUTH_POLICY, DEFAULT_SMS_CODE_LENGTH, DEFAULT_SMS_CODE_TYPE,
|
||||||
|
DEFAULT_SMS_COUNTRY_CODE, DEFAULT_SMS_DUPLICATE_POLICY, DEFAULT_SMS_ENDPOINT,
|
||||||
|
DEFAULT_SMS_INTERVAL_SECONDS, DEFAULT_SMS_MOCK_VERIFY_CODE, DEFAULT_SMS_TEMPLATE_PARAM_KEY,
|
||||||
|
DEFAULT_SMS_VALID_TIME_SECONDS, SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind,
|
||||||
|
hash_refresh_session_token,
|
||||||
|
};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -1842,7 +1938,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_phone_service(store: InMemoryAuthStore) -> PhoneAuthService {
|
fn build_phone_service(store: InMemoryAuthStore) -> PhoneAuthService {
|
||||||
PhoneAuthService::new(store)
|
let sms_provider = SmsAuthProvider::new(
|
||||||
|
SmsAuthConfig::new(
|
||||||
|
SmsAuthProviderKind::Mock,
|
||||||
|
DEFAULT_SMS_ENDPOINT.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
DEFAULT_SMS_TEMPLATE_PARAM_KEY.to_string(),
|
||||||
|
DEFAULT_SMS_COUNTRY_CODE.to_string(),
|
||||||
|
None,
|
||||||
|
DEFAULT_SMS_CODE_LENGTH,
|
||||||
|
DEFAULT_SMS_CODE_TYPE,
|
||||||
|
DEFAULT_SMS_VALID_TIME_SECONDS,
|
||||||
|
DEFAULT_SMS_INTERVAL_SECONDS,
|
||||||
|
DEFAULT_SMS_DUPLICATE_POLICY,
|
||||||
|
DEFAULT_SMS_CASE_AUTH_POLICY,
|
||||||
|
false,
|
||||||
|
DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
|
||||||
|
)
|
||||||
|
.expect("mock sms config should be valid"),
|
||||||
|
)
|
||||||
|
.expect("mock sms provider should be valid");
|
||||||
|
PhoneAuthService::new(store, sms_provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_refresh_service(store: InMemoryAuthStore) -> RefreshSessionService {
|
fn build_refresh_service(store: InMemoryAuthStore) -> RefreshSessionService {
|
||||||
@@ -1963,6 +2082,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.expect("first phone code should send");
|
.expect("first phone code should send");
|
||||||
|
|
||||||
let error = service
|
let error = service
|
||||||
@@ -1973,6 +2093,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
now + Duration::seconds(10),
|
now + Duration::seconds(10),
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.expect_err("same scene send should be cooled down");
|
.expect_err("same scene send should be cooled down");
|
||||||
|
|
||||||
match error {
|
match error {
|
||||||
@@ -1996,6 +2117,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.expect("login scene code should send");
|
.expect("login scene code should send");
|
||||||
let bind_result = service.send_code(
|
let bind_result = service.send_code(
|
||||||
SendPhoneCodeInput {
|
SendPhoneCodeInput {
|
||||||
@@ -2005,7 +2127,7 @@ mod tests {
|
|||||||
now + Duration::seconds(1),
|
now + Duration::seconds(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(bind_result.is_ok());
|
assert!(bind_result.await.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -2021,6 +2143,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.expect("phone code should send");
|
.expect("phone code should send");
|
||||||
|
|
||||||
for attempt in 1..SMS_CODE_MAX_FAILED_ATTEMPTS {
|
for attempt in 1..SMS_CODE_MAX_FAILED_ATTEMPTS {
|
||||||
@@ -2053,7 +2176,7 @@ mod tests {
|
|||||||
.login(
|
.login(
|
||||||
PhoneLoginInput {
|
PhoneLoginInput {
|
||||||
phone_number: "13800138000".to_string(),
|
phone_number: "13800138000".to_string(),
|
||||||
verify_code: SMS_MOCK_VERIFY_CODE.to_string(),
|
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
|
||||||
},
|
},
|
||||||
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 1)),
|
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 1)),
|
||||||
)
|
)
|
||||||
@@ -2069,12 +2192,13 @@ mod tests {
|
|||||||
},
|
},
|
||||||
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 2)),
|
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 2)),
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.expect("deleted snapshot should allow a new code");
|
.expect("deleted snapshot should allow a new code");
|
||||||
let login = service
|
let login = service
|
||||||
.login(
|
.login(
|
||||||
PhoneLoginInput {
|
PhoneLoginInput {
|
||||||
phone_number: "13800138000".to_string(),
|
phone_number: "13800138000".to_string(),
|
||||||
verify_code: SMS_MOCK_VERIFY_CODE.to_string(),
|
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
|
||||||
},
|
},
|
||||||
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 3)),
|
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 3)),
|
||||||
)
|
)
|
||||||
@@ -2370,7 +2494,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn wechat_login_hits_existing_user_by_union_id_before_openid() {
|
async fn wechat_login_hits_existing_user_by_union_id_before_openid() {
|
||||||
let store = build_store();
|
let store = build_store();
|
||||||
let phone_service = PhoneAuthService::new(store.clone());
|
let phone_service = build_phone_service(store.clone());
|
||||||
let wechat_service = WechatAuthService::new(store);
|
let wechat_service = WechatAuthService::new(store);
|
||||||
let now = OffsetDateTime::now_utc();
|
let now = OffsetDateTime::now_utc();
|
||||||
|
|
||||||
@@ -2382,6 +2506,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.expect("phone code should send");
|
.expect("phone code should send");
|
||||||
let phone_user = phone_service
|
let phone_user = phone_service
|
||||||
.login(
|
.login(
|
||||||
@@ -2434,7 +2559,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bind_wechat_phone_merges_pending_wechat_user_into_existing_phone_user() {
|
async fn bind_wechat_phone_merges_pending_wechat_user_into_existing_phone_user() {
|
||||||
let store = build_store();
|
let store = build_store();
|
||||||
let phone_service = PhoneAuthService::new(store.clone());
|
let phone_service = build_phone_service(store.clone());
|
||||||
let wechat_service = WechatAuthService::new(store.clone());
|
let wechat_service = WechatAuthService::new(store.clone());
|
||||||
let now = OffsetDateTime::now_utc();
|
let now = OffsetDateTime::now_utc();
|
||||||
|
|
||||||
@@ -2446,6 +2571,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.expect("phone login code should send");
|
.expect("phone login code should send");
|
||||||
let phone_user = phone_service
|
let phone_user = phone_service
|
||||||
.login(
|
.login(
|
||||||
@@ -2486,6 +2612,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
now + Duration::seconds(2),
|
now + Duration::seconds(2),
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.expect("bind phone code should send");
|
.expect("bind phone code should send");
|
||||||
let merged = phone_service
|
let merged = phone_service
|
||||||
.bind_wechat_phone(
|
.bind_wechat_phone(
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
|
base64 = "0.22"
|
||||||
|
hmac = "0.12"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sha1 = "0.10"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
use std::{collections::HashSet, error::Error, fmt};
|
use std::{
|
||||||
|
collections::{BTreeMap, HashSet},
|
||||||
|
error::Error,
|
||||||
|
fmt,
|
||||||
|
};
|
||||||
|
|
||||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
|
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
use jsonwebtoken::{
|
use jsonwebtoken::{
|
||||||
Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind,
|
Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind,
|
||||||
};
|
};
|
||||||
use rand_core::OsRng;
|
use rand_core::OsRng;
|
||||||
|
use reqwest::{Client, StatusCode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sha1::Sha1;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use shared_kernel::{new_uuid_simple_string, normalize_optional_string, normalize_required_string};
|
use shared_kernel::{new_uuid_simple_string, normalize_optional_string, normalize_required_string};
|
||||||
use time::{Duration, OffsetDateTime};
|
use time::{Duration, OffsetDateTime};
|
||||||
@@ -15,6 +24,18 @@ pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
|
|||||||
pub const DEFAULT_REFRESH_COOKIE_NAME: &str = "genarrative_refresh_session";
|
pub const DEFAULT_REFRESH_COOKIE_NAME: &str = "genarrative_refresh_session";
|
||||||
pub const DEFAULT_REFRESH_COOKIE_PATH: &str = "/api/auth";
|
pub const DEFAULT_REFRESH_COOKIE_PATH: &str = "/api/auth";
|
||||||
pub const DEFAULT_REFRESH_SESSION_TTL_DAYS: u32 = 30;
|
pub const DEFAULT_REFRESH_SESSION_TTL_DAYS: u32 = 30;
|
||||||
|
pub const DEFAULT_SMS_ENDPOINT: &str = "dypnsapi.aliyuncs.com";
|
||||||
|
pub const DEFAULT_SMS_COUNTRY_CODE: &str = "86";
|
||||||
|
pub const DEFAULT_SMS_TEMPLATE_PARAM_KEY: &str = "code";
|
||||||
|
pub const DEFAULT_SMS_MOCK_VERIFY_CODE: &str = "123456";
|
||||||
|
pub const DEFAULT_SMS_CODE_LENGTH: u8 = 6;
|
||||||
|
pub const DEFAULT_SMS_CODE_TYPE: u8 = 1;
|
||||||
|
pub const DEFAULT_SMS_VALID_TIME_SECONDS: u64 = 300;
|
||||||
|
pub const DEFAULT_SMS_INTERVAL_SECONDS: u64 = 60;
|
||||||
|
pub const DEFAULT_SMS_DUPLICATE_POLICY: u8 = 1;
|
||||||
|
pub const DEFAULT_SMS_CASE_AUTH_POLICY: u8 = 1;
|
||||||
|
|
||||||
|
type HmacSha1 = Hmac<Sha1>;
|
||||||
|
|
||||||
// 鉴权 provider 直接冻结成文档中约定的枚举,避免后续在多个 crate 内重复发明字符串字面量。
|
// 鉴权 provider 直接冻结成文档中约定的枚举,避免后续在多个 crate 内重复发明字符串字面量。
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -89,6 +110,71 @@ pub struct RefreshCookieConfig {
|
|||||||
refresh_session_ttl_days: u32,
|
refresh_session_ttl_days: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum SmsAuthProviderKind {
|
||||||
|
Mock,
|
||||||
|
Aliyun,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SmsAuthConfig {
|
||||||
|
pub provider: SmsAuthProviderKind,
|
||||||
|
pub endpoint: String,
|
||||||
|
pub access_key_id: Option<String>,
|
||||||
|
pub access_key_secret: Option<String>,
|
||||||
|
pub sign_name: String,
|
||||||
|
pub template_code: String,
|
||||||
|
pub template_param_key: String,
|
||||||
|
pub country_code: String,
|
||||||
|
pub scheme_name: Option<String>,
|
||||||
|
pub code_length: u8,
|
||||||
|
pub code_type: u8,
|
||||||
|
pub valid_time_seconds: u64,
|
||||||
|
pub interval_seconds: u64,
|
||||||
|
pub duplicate_policy: u8,
|
||||||
|
pub case_auth_policy: u8,
|
||||||
|
pub return_verify_code: bool,
|
||||||
|
pub mock_verify_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SmsSendCodeRequest {
|
||||||
|
pub national_phone_number: String,
|
||||||
|
pub scene: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SmsSendCodeResult {
|
||||||
|
pub cooldown_seconds: u64,
|
||||||
|
pub expires_in_seconds: u64,
|
||||||
|
pub provider_request_id: Option<String>,
|
||||||
|
pub provider_out_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SmsVerifyCodeRequest {
|
||||||
|
pub national_phone_number: String,
|
||||||
|
pub verify_code: String,
|
||||||
|
pub provider_out_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum SmsAuthProvider {
|
||||||
|
Mock(MockSmsAuthProvider),
|
||||||
|
Aliyun(AliyunSmsAuthProvider),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MockSmsAuthProvider {
|
||||||
|
config: SmsAuthConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AliyunSmsAuthProvider {
|
||||||
|
client: Client,
|
||||||
|
config: SmsAuthConfig,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub enum JwtError {
|
pub enum JwtError {
|
||||||
InvalidConfig(&'static str),
|
InvalidConfig(&'static str),
|
||||||
@@ -108,6 +194,57 @@ pub enum PasswordHashError {
|
|||||||
VerifyFailed(String),
|
VerifyFailed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum SmsProviderError {
|
||||||
|
InvalidConfig(String),
|
||||||
|
InvalidVerifyCode,
|
||||||
|
Upstream(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AliyunSendSmsVerifyCodeResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
code: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
message: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
request_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
success: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
model: Option<AliyunSendSmsVerifyCodeModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AliyunSendSmsVerifyCodeModel {
|
||||||
|
#[serde(default, rename = "BizId")]
|
||||||
|
_biz_id: Option<String>,
|
||||||
|
#[serde(default, rename = "OutId")]
|
||||||
|
out_id: Option<String>,
|
||||||
|
#[serde(default, rename = "RequestId")]
|
||||||
|
request_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AliyunCheckSmsVerifyCodeResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
code: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
message: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
success: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
model: Option<AliyunCheckSmsVerifyCodeModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AliyunCheckSmsVerifyCodeModel {
|
||||||
|
#[serde(default, rename = "OutId")]
|
||||||
|
_out_id: Option<String>,
|
||||||
|
#[serde(default, rename = "VerifyResult")]
|
||||||
|
verify_result: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl JwtConfig {
|
impl JwtConfig {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
issuer: String,
|
issuer: String,
|
||||||
@@ -211,6 +348,366 @@ impl RefreshCookieConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SmsAuthProviderKind {
|
||||||
|
pub fn parse(raw: &str) -> Option<Self> {
|
||||||
|
match raw.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"mock" => Some(Self::Mock),
|
||||||
|
"aliyun" => Some(Self::Aliyun),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmsAuthConfig {
|
||||||
|
pub fn new(
|
||||||
|
provider: SmsAuthProviderKind,
|
||||||
|
endpoint: String,
|
||||||
|
access_key_id: Option<String>,
|
||||||
|
access_key_secret: Option<String>,
|
||||||
|
sign_name: String,
|
||||||
|
template_code: String,
|
||||||
|
template_param_key: String,
|
||||||
|
country_code: String,
|
||||||
|
scheme_name: Option<String>,
|
||||||
|
code_length: u8,
|
||||||
|
code_type: u8,
|
||||||
|
valid_time_seconds: u64,
|
||||||
|
interval_seconds: u64,
|
||||||
|
duplicate_policy: u8,
|
||||||
|
case_auth_policy: u8,
|
||||||
|
return_verify_code: bool,
|
||||||
|
mock_verify_code: String,
|
||||||
|
) -> Result<Self, SmsProviderError> {
|
||||||
|
let endpoint = normalize_required_string(&endpoint)
|
||||||
|
.unwrap_or_else(|| DEFAULT_SMS_ENDPOINT.to_string());
|
||||||
|
let template_param_key = normalize_required_string(&template_param_key)
|
||||||
|
.unwrap_or_else(|| DEFAULT_SMS_TEMPLATE_PARAM_KEY.to_string());
|
||||||
|
let country_code = normalize_required_string(&country_code)
|
||||||
|
.unwrap_or_else(|| DEFAULT_SMS_COUNTRY_CODE.to_string());
|
||||||
|
let scheme_name = normalize_optional_string(scheme_name);
|
||||||
|
let mock_verify_code = normalize_required_string(&mock_verify_code)
|
||||||
|
.unwrap_or_else(|| DEFAULT_SMS_MOCK_VERIFY_CODE.to_string());
|
||||||
|
|
||||||
|
if !(4..=8).contains(&code_length) {
|
||||||
|
return Err(SmsProviderError::InvalidConfig(
|
||||||
|
"短信验证码长度必须在 4 到 8 之间".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !(1..=7).contains(&code_type) {
|
||||||
|
return Err(SmsProviderError::InvalidConfig(
|
||||||
|
"短信验证码类型取值非法".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if interval_seconds == 0 || valid_time_seconds == 0 {
|
||||||
|
return Err(SmsProviderError::InvalidConfig(
|
||||||
|
"短信验证码有效期和发送间隔必须大于 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !(1..=2).contains(&duplicate_policy) {
|
||||||
|
return Err(SmsProviderError::InvalidConfig(
|
||||||
|
"短信验证码重复策略取值非法".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !(1..=2).contains(&case_auth_policy) {
|
||||||
|
return Err(SmsProviderError::InvalidConfig(
|
||||||
|
"短信验证码大小写校验策略取值非法".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
match provider {
|
||||||
|
SmsAuthProviderKind::Mock => {}
|
||||||
|
SmsAuthProviderKind::Aliyun => {
|
||||||
|
if normalize_required_string(&sign_name).is_none() {
|
||||||
|
return Err(SmsProviderError::InvalidConfig(
|
||||||
|
"阿里云短信签名不能为空".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if normalize_required_string(&template_code).is_none() {
|
||||||
|
return Err(SmsProviderError::InvalidConfig(
|
||||||
|
"阿里云短信模板编码不能为空".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if access_key_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(normalize_required_string)
|
||||||
|
.is_none()
|
||||||
|
|| access_key_secret
|
||||||
|
.as_deref()
|
||||||
|
.and_then(normalize_required_string)
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(SmsProviderError::InvalidConfig(
|
||||||
|
"阿里云短信 AccessKey 未配置".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
provider,
|
||||||
|
endpoint,
|
||||||
|
access_key_id: access_key_id.and_then(|value| normalize_required_string(&value)),
|
||||||
|
access_key_secret: access_key_secret
|
||||||
|
.and_then(|value| normalize_required_string(&value)),
|
||||||
|
sign_name: sign_name.trim().to_string(),
|
||||||
|
template_code: template_code.trim().to_string(),
|
||||||
|
template_param_key,
|
||||||
|
country_code,
|
||||||
|
scheme_name,
|
||||||
|
code_length,
|
||||||
|
code_type,
|
||||||
|
valid_time_seconds,
|
||||||
|
interval_seconds,
|
||||||
|
duplicate_policy,
|
||||||
|
case_auth_policy,
|
||||||
|
return_verify_code,
|
||||||
|
mock_verify_code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmsAuthProvider {
|
||||||
|
pub fn new(config: SmsAuthConfig) -> Result<Self, SmsProviderError> {
|
||||||
|
match config.provider {
|
||||||
|
SmsAuthProviderKind::Mock => Ok(Self::Mock(MockSmsAuthProvider { config })),
|
||||||
|
SmsAuthProviderKind::Aliyun => Ok(Self::Aliyun(AliyunSmsAuthProvider {
|
||||||
|
client: Client::new(),
|
||||||
|
config,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_code(
|
||||||
|
&self,
|
||||||
|
request: SmsSendCodeRequest,
|
||||||
|
) -> Result<SmsSendCodeResult, SmsProviderError> {
|
||||||
|
match self {
|
||||||
|
Self::Mock(provider) => provider.send_code(request).await,
|
||||||
|
Self::Aliyun(provider) => provider.send_code(request).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn verify_code(
|
||||||
|
&self,
|
||||||
|
request: SmsVerifyCodeRequest,
|
||||||
|
) -> Result<(), SmsProviderError> {
|
||||||
|
match self {
|
||||||
|
Self::Mock(provider) => provider.verify_code(request).await,
|
||||||
|
Self::Aliyun(provider) => provider.verify_code(request).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockSmsAuthProvider {
|
||||||
|
async fn send_code(
|
||||||
|
&self,
|
||||||
|
request: SmsSendCodeRequest,
|
||||||
|
) -> Result<SmsSendCodeResult, SmsProviderError> {
|
||||||
|
let provider_out_id = build_sms_provider_out_id(&request.scene, &request.national_phone_number);
|
||||||
|
|
||||||
|
Ok(SmsSendCodeResult {
|
||||||
|
cooldown_seconds: self.config.interval_seconds,
|
||||||
|
expires_in_seconds: self.config.valid_time_seconds,
|
||||||
|
provider_request_id: Some("mock-request-id".to_string()),
|
||||||
|
provider_out_id: Some(provider_out_id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_code(
|
||||||
|
&self,
|
||||||
|
request: SmsVerifyCodeRequest,
|
||||||
|
) -> Result<(), SmsProviderError> {
|
||||||
|
if request.verify_code.trim() != self.config.mock_verify_code {
|
||||||
|
return Err(SmsProviderError::InvalidVerifyCode);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AliyunSmsAuthProvider {
|
||||||
|
async fn send_code(
|
||||||
|
&self,
|
||||||
|
request: SmsSendCodeRequest,
|
||||||
|
) -> Result<SmsSendCodeResult, SmsProviderError> {
|
||||||
|
let provider_out_id = build_sms_provider_out_id(&request.scene, &request.national_phone_number);
|
||||||
|
let template_param = serde_json::json!({
|
||||||
|
self.config.template_param_key.clone(): "##code##",
|
||||||
|
"min": self.config.valid_time_seconds,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let mut query = BTreeMap::new();
|
||||||
|
query.insert("Action".to_string(), "SendSmsVerifyCode".to_string());
|
||||||
|
query.insert("Format".to_string(), "json".to_string());
|
||||||
|
query.insert("Version".to_string(), "2017-05-25".to_string());
|
||||||
|
query.insert("Timestamp".to_string(), current_aliyun_timestamp());
|
||||||
|
query.insert("SignatureNonce".to_string(), new_uuid_simple_string());
|
||||||
|
query.insert("SignatureMethod".to_string(), "HMAC-SHA1".to_string());
|
||||||
|
query.insert("SignatureVersion".to_string(), "1.0".to_string());
|
||||||
|
query.insert(
|
||||||
|
"AccessKeyId".to_string(),
|
||||||
|
self.config
|
||||||
|
.access_key_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
query.insert(
|
||||||
|
"PhoneNumber".to_string(),
|
||||||
|
request.national_phone_number.trim().to_string(),
|
||||||
|
);
|
||||||
|
query.insert(
|
||||||
|
"CountryCode".to_string(),
|
||||||
|
self.config.country_code.clone(),
|
||||||
|
);
|
||||||
|
query.insert("SignName".to_string(), self.config.sign_name.clone());
|
||||||
|
query.insert(
|
||||||
|
"TemplateCode".to_string(),
|
||||||
|
self.config.template_code.clone(),
|
||||||
|
);
|
||||||
|
query.insert("TemplateParam".to_string(), template_param);
|
||||||
|
query.insert("CodeLength".to_string(), self.config.code_length.to_string());
|
||||||
|
query.insert("CodeType".to_string(), self.config.code_type.to_string());
|
||||||
|
query.insert(
|
||||||
|
"ValidTime".to_string(),
|
||||||
|
self.config.valid_time_seconds.to_string(),
|
||||||
|
);
|
||||||
|
query.insert(
|
||||||
|
"Interval".to_string(),
|
||||||
|
self.config.interval_seconds.to_string(),
|
||||||
|
);
|
||||||
|
query.insert(
|
||||||
|
"DuplicatePolicy".to_string(),
|
||||||
|
self.config.duplicate_policy.to_string(),
|
||||||
|
);
|
||||||
|
query.insert(
|
||||||
|
"ReturnVerifyCode".to_string(),
|
||||||
|
self.config.return_verify_code.to_string(),
|
||||||
|
);
|
||||||
|
query.insert("OutId".to_string(), provider_out_id.clone());
|
||||||
|
if let Some(scheme_name) = self.config.scheme_name.clone() {
|
||||||
|
query.insert("SchemeName".to_string(), scheme_name);
|
||||||
|
}
|
||||||
|
self.sign_query(&mut query)?;
|
||||||
|
|
||||||
|
let payload = self
|
||||||
|
.client
|
||||||
|
.post(build_aliyun_sms_url(&self.config.endpoint)?)
|
||||||
|
.form(&query)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| SmsProviderError::Upstream(format!("短信验证码发送失败:{error}")))?;
|
||||||
|
|
||||||
|
let body = parse_aliyun_json_response(payload, "短信验证码发送失败").await?;
|
||||||
|
if !body.success.unwrap_or(false) || body.code.as_deref() != Some("OK") {
|
||||||
|
return Err(map_aliyun_provider_error(
|
||||||
|
"短信验证码发送失败",
|
||||||
|
body.message,
|
||||||
|
body.code,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SmsSendCodeResult {
|
||||||
|
cooldown_seconds: self.config.interval_seconds,
|
||||||
|
expires_in_seconds: self.config.valid_time_seconds,
|
||||||
|
provider_request_id: body
|
||||||
|
.request_id
|
||||||
|
.or_else(|| body.model.as_ref().and_then(|model| model.request_id.clone())),
|
||||||
|
provider_out_id: body.model.and_then(|model| model.out_id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_code(
|
||||||
|
&self,
|
||||||
|
request: SmsVerifyCodeRequest,
|
||||||
|
) -> Result<(), SmsProviderError> {
|
||||||
|
let mut query = BTreeMap::new();
|
||||||
|
query.insert("Action".to_string(), "CheckSmsVerifyCode".to_string());
|
||||||
|
query.insert("Format".to_string(), "json".to_string());
|
||||||
|
query.insert("Version".to_string(), "2017-05-25".to_string());
|
||||||
|
query.insert("Timestamp".to_string(), current_aliyun_timestamp());
|
||||||
|
query.insert("SignatureNonce".to_string(), new_uuid_simple_string());
|
||||||
|
query.insert("SignatureMethod".to_string(), "HMAC-SHA1".to_string());
|
||||||
|
query.insert("SignatureVersion".to_string(), "1.0".to_string());
|
||||||
|
query.insert(
|
||||||
|
"AccessKeyId".to_string(),
|
||||||
|
self.config
|
||||||
|
.access_key_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
query.insert(
|
||||||
|
"PhoneNumber".to_string(),
|
||||||
|
request.national_phone_number.trim().to_string(),
|
||||||
|
);
|
||||||
|
query.insert(
|
||||||
|
"CountryCode".to_string(),
|
||||||
|
self.config.country_code.clone(),
|
||||||
|
);
|
||||||
|
query.insert(
|
||||||
|
"VerifyCode".to_string(),
|
||||||
|
request.verify_code.trim().to_string(),
|
||||||
|
);
|
||||||
|
query.insert(
|
||||||
|
"CaseAuthPolicy".to_string(),
|
||||||
|
self.config.case_auth_policy.to_string(),
|
||||||
|
);
|
||||||
|
if let Some(scheme_name) = self.config.scheme_name.clone() {
|
||||||
|
query.insert("SchemeName".to_string(), scheme_name);
|
||||||
|
}
|
||||||
|
if let Some(provider_out_id) = request.provider_out_id {
|
||||||
|
query.insert("OutId".to_string(), provider_out_id);
|
||||||
|
}
|
||||||
|
self.sign_query(&mut query)?;
|
||||||
|
|
||||||
|
let payload = self
|
||||||
|
.client
|
||||||
|
.post(build_aliyun_sms_url(&self.config.endpoint)?)
|
||||||
|
.form(&query)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| SmsProviderError::Upstream(format!("验证码校验失败:{error}")))?;
|
||||||
|
|
||||||
|
let body = parse_aliyun_json_response_for_verify(payload).await?;
|
||||||
|
if !body.success.unwrap_or(false) || body.code.as_deref() != Some("OK") {
|
||||||
|
return Err(map_aliyun_provider_error(
|
||||||
|
"验证码校验失败",
|
||||||
|
body.message,
|
||||||
|
body.code,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if body
|
||||||
|
.model
|
||||||
|
.and_then(|model| model.verify_result)
|
||||||
|
.as_deref()
|
||||||
|
!= Some("PASS")
|
||||||
|
{
|
||||||
|
return Err(SmsProviderError::InvalidVerifyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_query(&self, query: &mut BTreeMap<String, String>) -> Result<(), SmsProviderError> {
|
||||||
|
let access_key_secret = self
|
||||||
|
.config
|
||||||
|
.access_key_secret
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| SmsProviderError::InvalidConfig("阿里云短信 AccessKeySecret 未配置".to_string()))?;
|
||||||
|
let canonicalized = canonicalize_aliyun_rpc_params(query);
|
||||||
|
let string_to_sign = format!(
|
||||||
|
"POST&{}&{}",
|
||||||
|
aliyun_percent_encode("/"),
|
||||||
|
aliyun_percent_encode(&canonicalized)
|
||||||
|
);
|
||||||
|
let mut signer = HmacSha1::new_from_slice(format!("{access_key_secret}&").as_bytes())
|
||||||
|
.map_err(|error| SmsProviderError::InvalidConfig(format!("初始化短信签名器失败:{error}")))?;
|
||||||
|
signer.update(string_to_sign.as_bytes());
|
||||||
|
let signature = BASE64_STANDARD.encode(signer.finalize().into_bytes());
|
||||||
|
query.insert("Signature".to_string(), signature);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl AccessTokenClaims {
|
impl AccessTokenClaims {
|
||||||
pub fn from_input(
|
pub fn from_input(
|
||||||
input: AccessTokenClaimsInput,
|
input: AccessTokenClaimsInput,
|
||||||
@@ -506,6 +1003,187 @@ fn map_verify_error(error: jsonwebtoken::errors::Error) -> JwtError {
|
|||||||
JwtError::VerifyFailed(message)
|
JwtError::VerifyFailed(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_sms_provider_out_id(scene: &str, national_phone_number: &str) -> String {
|
||||||
|
let phone_suffix = national_phone_number
|
||||||
|
.chars()
|
||||||
|
.rev()
|
||||||
|
.take(4)
|
||||||
|
.collect::<String>()
|
||||||
|
.chars()
|
||||||
|
.rev()
|
||||||
|
.collect::<String>();
|
||||||
|
format!("{scene}_{}_{}", phone_suffix, new_uuid_simple_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_aliyun_sms_url(endpoint: &str) -> Result<String, SmsProviderError> {
|
||||||
|
let endpoint = endpoint
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches("https://")
|
||||||
|
.trim_start_matches("http://")
|
||||||
|
.trim_matches('/');
|
||||||
|
if endpoint.is_empty() {
|
||||||
|
return Err(SmsProviderError::InvalidConfig(
|
||||||
|
"阿里云短信 endpoint 不能为空".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(format!("https://{endpoint}/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_aliyun_timestamp() -> String {
|
||||||
|
OffsetDateTime::now_utc()
|
||||||
|
.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonicalize_aliyun_rpc_params(params: &BTreeMap<String, String>) -> String {
|
||||||
|
params
|
||||||
|
.iter()
|
||||||
|
.filter(|(key, _)| key.as_str() != "Signature")
|
||||||
|
.map(|(key, value)| {
|
||||||
|
format!(
|
||||||
|
"{}={}",
|
||||||
|
aliyun_percent_encode(key),
|
||||||
|
aliyun_percent_encode(value)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("&")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aliyun_percent_encode(value: &str) -> String {
|
||||||
|
urlencoding::encode(value)
|
||||||
|
.into_owned()
|
||||||
|
.replace('+', "%20")
|
||||||
|
.replace('*', "%2A")
|
||||||
|
.replace("%7E", "~")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_aliyun_json_response(
|
||||||
|
response: reqwest::Response,
|
||||||
|
fallback_message: &str,
|
||||||
|
) -> Result<AliyunSendSmsVerifyCodeResponse, SmsProviderError> {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|error| SmsProviderError::Upstream(format!("{fallback_message}:{error}")))?;
|
||||||
|
let payload = serde_json::from_str::<AliyunSendSmsVerifyCodeResponse>(&body).map_err(|error| {
|
||||||
|
SmsProviderError::Upstream(format!("{fallback_message}:响应解析失败:{error}"))
|
||||||
|
})?;
|
||||||
|
if status.is_client_error() || status.is_server_error() {
|
||||||
|
return Err(map_http_status_to_sms_provider_error(
|
||||||
|
fallback_message,
|
||||||
|
status,
|
||||||
|
serde_json::from_str::<Value>(&body).ok(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_aliyun_json_response_for_verify(
|
||||||
|
response: reqwest::Response,
|
||||||
|
) -> Result<AliyunCheckSmsVerifyCodeResponse, SmsProviderError> {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|error| SmsProviderError::Upstream(format!("验证码校验失败:{error}")))?;
|
||||||
|
let payload = serde_json::from_str::<AliyunCheckSmsVerifyCodeResponse>(&body)
|
||||||
|
.map_err(|error| SmsProviderError::Upstream(format!("验证码校验失败:响应解析失败:{error}")))?;
|
||||||
|
if status.is_client_error() || status.is_server_error() {
|
||||||
|
return Err(map_http_status_to_sms_provider_error(
|
||||||
|
"验证码校验失败",
|
||||||
|
status,
|
||||||
|
serde_json::from_str::<Value>(&body).ok(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_http_status_to_sms_provider_error(
|
||||||
|
fallback_message: &str,
|
||||||
|
status: StatusCode,
|
||||||
|
payload: Option<Value>,
|
||||||
|
) -> SmsProviderError {
|
||||||
|
let provider_message = payload
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("Message").and_then(Value::as_str))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let provider_code = payload
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("Code").and_then(Value::as_str))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if status.is_client_error() {
|
||||||
|
return map_aliyun_provider_error(
|
||||||
|
fallback_message,
|
||||||
|
Some(provider_message.to_string()),
|
||||||
|
Some(provider_code.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SmsProviderError::Upstream(build_provider_error_message(
|
||||||
|
fallback_message,
|
||||||
|
provider_message,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_aliyun_provider_error(
|
||||||
|
fallback_message: &str,
|
||||||
|
provider_message: Option<String>,
|
||||||
|
provider_code: Option<String>,
|
||||||
|
) -> SmsProviderError {
|
||||||
|
let provider_message = provider_message.unwrap_or_default();
|
||||||
|
let provider_code = provider_code.unwrap_or_default();
|
||||||
|
let normalized_code = provider_code.trim().to_ascii_uppercase();
|
||||||
|
|
||||||
|
if normalized_code.contains("VERIFY")
|
||||||
|
|| normalized_code.contains("CODE")
|
||||||
|
|| normalized_code.contains("CHECK")
|
||||||
|
{
|
||||||
|
return SmsProviderError::InvalidVerifyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized_code.contains("MOBILE")
|
||||||
|
|| normalized_code.contains("PHONE")
|
||||||
|
|| normalized_code.contains("SIGN")
|
||||||
|
|| normalized_code.contains("TEMPLATE")
|
||||||
|
|| normalized_code.contains("ACCESSKEY")
|
||||||
|
{
|
||||||
|
return SmsProviderError::InvalidConfig(build_provider_error_message(
|
||||||
|
fallback_message,
|
||||||
|
&provider_message,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
SmsProviderError::Upstream(build_provider_error_message(
|
||||||
|
fallback_message,
|
||||||
|
&provider_message,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_provider_error_message(prefix: &str, provider_message: &str) -> String {
|
||||||
|
let provider_message = provider_message.trim();
|
||||||
|
if provider_message.is_empty() {
|
||||||
|
prefix.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}:{provider_message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SmsProviderError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidConfig(message) | Self::Upstream(message) => f.write_str(message),
|
||||||
|
Self::InvalidVerifyCode => f.write_str("验证码错误"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for SmsProviderError {}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -543,6 +1221,29 @@ mod tests {
|
|||||||
.expect("refresh cookie config should be valid")
|
.expect("refresh cookie config should be valid")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_mock_sms_config() -> SmsAuthConfig {
|
||||||
|
SmsAuthConfig::new(
|
||||||
|
SmsAuthProviderKind::Mock,
|
||||||
|
DEFAULT_SMS_ENDPOINT.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
DEFAULT_SMS_TEMPLATE_PARAM_KEY.to_string(),
|
||||||
|
DEFAULT_SMS_COUNTRY_CODE.to_string(),
|
||||||
|
None,
|
||||||
|
DEFAULT_SMS_CODE_LENGTH,
|
||||||
|
DEFAULT_SMS_CODE_TYPE,
|
||||||
|
DEFAULT_SMS_VALID_TIME_SECONDS,
|
||||||
|
DEFAULT_SMS_INTERVAL_SECONDS,
|
||||||
|
DEFAULT_SMS_DUPLICATE_POLICY,
|
||||||
|
DEFAULT_SMS_CASE_AUTH_POLICY,
|
||||||
|
false,
|
||||||
|
DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
|
||||||
|
)
|
||||||
|
.expect("mock sms config should be valid")
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn round_trip_sign_and_verify_access_token() {
|
fn round_trip_sign_and_verify_access_token() {
|
||||||
let config = build_jwt_config();
|
let config = build_jwt_config();
|
||||||
@@ -669,4 +1370,103 @@ mod tests {
|
|||||||
assert!(cookie.contains("SameSite=Lax"));
|
assert!(cookie.contains("SameSite=Lax"));
|
||||||
assert!(cookie.contains("Max-Age=0"));
|
assert!(cookie.contains("Max-Age=0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sms_auth_provider_kind_parses_supported_values() {
|
||||||
|
assert_eq!(SmsAuthProviderKind::parse("mock"), Some(SmsAuthProviderKind::Mock));
|
||||||
|
assert_eq!(
|
||||||
|
SmsAuthProviderKind::parse("aliyun"),
|
||||||
|
Some(SmsAuthProviderKind::Aliyun)
|
||||||
|
);
|
||||||
|
assert_eq!(SmsAuthProviderKind::parse("other"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_sms_provider_sends_and_verifies_code() {
|
||||||
|
let provider =
|
||||||
|
SmsAuthProvider::new(build_mock_sms_config()).expect("provider should build");
|
||||||
|
let send_result = provider
|
||||||
|
.send_code(SmsSendCodeRequest {
|
||||||
|
national_phone_number: "13800138000".to_string(),
|
||||||
|
scene: "login".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("send code should succeed");
|
||||||
|
|
||||||
|
assert_eq!(send_result.cooldown_seconds, DEFAULT_SMS_INTERVAL_SECONDS);
|
||||||
|
assert_eq!(send_result.expires_in_seconds, DEFAULT_SMS_VALID_TIME_SECONDS);
|
||||||
|
assert_eq!(
|
||||||
|
send_result.provider_request_id.as_deref(),
|
||||||
|
Some("mock-request-id")
|
||||||
|
);
|
||||||
|
assert!(send_result.provider_out_id.is_some());
|
||||||
|
|
||||||
|
provider
|
||||||
|
.verify_code(SmsVerifyCodeRequest {
|
||||||
|
national_phone_number: "13800138000".to_string(),
|
||||||
|
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
|
||||||
|
provider_out_id: send_result.provider_out_id,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("verify code should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_sms_provider_rejects_wrong_code() {
|
||||||
|
let provider =
|
||||||
|
SmsAuthProvider::new(build_mock_sms_config()).expect("provider should build");
|
||||||
|
|
||||||
|
let error = provider
|
||||||
|
.verify_code(SmsVerifyCodeRequest {
|
||||||
|
national_phone_number: "13800138000".to_string(),
|
||||||
|
verify_code: "000000".to_string(),
|
||||||
|
provider_out_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("wrong verify code should fail");
|
||||||
|
|
||||||
|
assert_eq!(error, SmsProviderError::InvalidVerifyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aliyun_sms_config_requires_access_key() {
|
||||||
|
let error = SmsAuthConfig::new(
|
||||||
|
SmsAuthProviderKind::Aliyun,
|
||||||
|
DEFAULT_SMS_ENDPOINT.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"测试签名".to_string(),
|
||||||
|
"SMS_001".to_string(),
|
||||||
|
DEFAULT_SMS_TEMPLATE_PARAM_KEY.to_string(),
|
||||||
|
DEFAULT_SMS_COUNTRY_CODE.to_string(),
|
||||||
|
None,
|
||||||
|
DEFAULT_SMS_CODE_LENGTH,
|
||||||
|
DEFAULT_SMS_CODE_TYPE,
|
||||||
|
DEFAULT_SMS_VALID_TIME_SECONDS,
|
||||||
|
DEFAULT_SMS_INTERVAL_SECONDS,
|
||||||
|
DEFAULT_SMS_DUPLICATE_POLICY,
|
||||||
|
DEFAULT_SMS_CASE_AUTH_POLICY,
|
||||||
|
false,
|
||||||
|
DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
|
||||||
|
)
|
||||||
|
.expect_err("aliyun config without access key should fail");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
SmsProviderError::InvalidConfig("阿里云短信 AccessKey 未配置".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonicalize_aliyun_rpc_params_keeps_sorted_percent_encoded_order() {
|
||||||
|
let mut params = BTreeMap::new();
|
||||||
|
params.insert("TemplateParam".to_string(), "{\"code\":\"##code##\"}".to_string());
|
||||||
|
params.insert("Action".to_string(), "SendSmsVerifyCode".to_string());
|
||||||
|
params.insert("PhoneNumber".to_string(), "13800138000".to_string());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
canonicalize_aliyun_rpc_params(¶ms),
|
||||||
|
"Action=SendSmsVerifyCode&PhoneNumber=13800138000&TemplateParam=%7B%22code%22%3A%22%23%23code%23%23%22%7D"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user