diff --git a/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md new file mode 100644 index 00000000..99792d15 --- /dev/null +++ b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md @@ -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 +``` + +3. 校验 `build//` 存在。 +4. 归档 `build//**` 作为 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 /build/ \ + --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//`。 +2. 归档 `build//**`。 +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` 与日志轮转治理。 diff --git a/docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md b/docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md new file mode 100644 index 00000000..d5a6a6ff --- /dev/null +++ b/docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md @@ -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 diff --git a/docs/technical/README.md b/docs/technical/README.md index 671d68e3..8384d590 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -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 回写边界。 - [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 / 拼图代理上游超时兜底。 +- [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_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 存储边界和文档维护门禁。 @@ -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 首版接口边界。 - [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_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_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 的衔接方式。 diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md index e8e292e6..724cf7fc 100644 --- a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -7,7 +7,7 @@ 本方案补齐 `server-rs` 在 M7 切流前需要的两类工程脚本: 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 开发入口。 @@ -102,15 +102,20 @@ npm run deploy:rust:remote 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` 复制到目标目录。 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`。 -7. 在目标目录写入 `start.sh` 与 `stop.sh`。 -8. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/ ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。 +6. 把仓库根目录的 `.env` 与 `.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下。 +7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*`、`/generated-*`、`/healthz` 反代到本包内的 `api-server`。 +8. 在目标目录写入 `start.sh` 与 `stop.sh`。 +9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/ ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。 发布包结构: ```text build// +├─ .env +├─ .env.local ├─ web/ +│ ├─ .env +│ └─ .env.local ├─ api-server ├─ spacetime_module.wasm ├─ web-server.mjs @@ -137,8 +142,8 @@ cd build/ 安全边界: -1. 构建脚本不读取、不传输、不打印生产密钥。 -2. 目标服务器 `.env`、`.env.local` 或进程环境仍由服务器本身维护。 +1. 构建脚本会把仓库根目录已有的 `.env`、`.env.local` 一并复制进发布包,因此运行前必须确认这些文件内容适合被带入目标环境。 +2. 如果仓库根目录不存在 `.env` 或 `.env.local`,脚本会打印跳过日志,但不会因此失败。 3. `start.sh` 默认不清空 SpacetimeDB;只有显式执行 `./start.sh --clear-database` 才允许清库重发。 4. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm。 5. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置。 diff --git a/jenkins/Jenkinsfile.build b/jenkins/Jenkinsfile.build new file mode 100644 index 00000000..f06f7ad9 --- /dev/null +++ b/jenkins/Jenkinsfile.build @@ -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}" + } + } +} diff --git a/jenkins/Jenkinsfile.build-and-deploy b/jenkins/Jenkinsfile.build-and-deploy new file mode 100644 index 00000000..47c256b7 --- /dev/null +++ b/jenkins/Jenkinsfile.build-and-deploy @@ -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}" + } + } +} diff --git a/jenkins/Jenkinsfile.deploy b/jenkins/Jenkinsfile.deploy new file mode 100644 index 00000000..be94c4f6 --- /dev/null +++ b/jenkins/Jenkinsfile.deploy @@ -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}" + } + } +} diff --git a/scripts/deploy-rust-remote.sh b/scripts/deploy-rust-remote.sh index 07d4a9fb..2ef5ad3e 100644 --- a/scripts/deploy-rust-remote.sh +++ b/scripts/deploy-rust-remote.sh @@ -57,6 +57,22 @@ copy_required_file() { 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() { local value="$1" @@ -90,11 +106,11 @@ REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" SERVER_RS_DIR="${REPO_ROOT}/server-rs" BUILD_ROOT="${REPO_ROOT}/build" BUILD_NAME="$(date +%Y%m%d-%H%M%S)" -DATABASE="genarrative-dev" +DATABASE="xushi-p4wfr" API_HOST="127.0.0.1" API_PORT="8082" WEB_HOST="0.0.0.0" -WEB_PORT="3000" +WEB_PORT="25001" SPACETIME_HOST="127.0.0.1" SPACETIME_PORT="3101" SSH_KEY='~\.ssh\dsk.pem' @@ -228,6 +244,9 @@ mkdir -p "${WEB_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 echo "[deploy:rust] 构建 Vite release -> ${WEB_DIR}" ( @@ -571,6 +590,7 @@ cat >"${TARGET_DIR}/README.md" < 必填,待部署的发布目录,例如 build/123 + --deploy-dir 必填,固定部署目录,例如 /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] 完成" diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 9e3fca47..f5d70b86 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -1847,9 +1847,14 @@ name = "platform-auth" version = "0.1.0" dependencies = [ "argon2", + "base64 0.22.1", + "hmac", "jsonwebtoken", "rand_core 0.6.4", + "reqwest", "serde", + "serde_json", + "sha1", "sha2", "shared-kernel", "time", diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index af075a9b..011bc83c 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1291,7 +1291,10 @@ mod tests { payload["expiresInSeconds"], 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] diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index b908f590..c7e8dee7 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -25,6 +25,23 @@ pub struct AppConfig { pub refresh_cookie_same_site: String, pub refresh_session_ttl_days: u32, pub sms_auth_enabled: bool, + pub sms_auth_provider: String, + pub sms_endpoint: String, + pub sms_access_key_id: Option, + pub sms_access_key_secret: Option, + 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, + 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_provider: String, pub wechat_app_id: Option, @@ -79,6 +96,23 @@ impl Default for AppConfig { refresh_cookie_same_site: "Lax".to_string(), refresh_session_ttl_days: 30, 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_provider: "mock".to_string(), wechat_app_id: None, @@ -194,6 +228,60 @@ impl AppConfig { if let Some(sms_auth_enabled) = read_first_bool_env(&["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"]) { config.wechat_auth_enabled = wechat_auth_enabled; @@ -439,6 +527,11 @@ fn read_first_u64_env(keys: &[&str]) -> Option { .find_map(|key| env::var(key).ok().and_then(|value| parse_u64(&value))) } +fn read_first_u8_env(keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| env::var(key).ok().and_then(|value| parse_u8(&value))) +} + fn read_first_positive_u16_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) @@ -515,6 +608,10 @@ fn parse_u64(raw: &str) -> Option { raw.trim().parse::().ok() } +fn parse_u8(raw: &str) -> Option { + raw.trim().parse::().ok() +} + fn parse_positive_u16(raw: &str) -> Option { let value = raw.trim().parse::().ok()?; if value == 0 { diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index 536ede85..660f4f07 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -46,6 +46,7 @@ pub async fn send_phone_code( }, OffsetDateTime::now_utc(), ) + .await .map_err(map_phone_auth_error)?; Ok(json_success_body( diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 066cfcb5..dc630bcc 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -16,6 +16,7 @@ use module_runtime::RuntimeSnapshotRecord; use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros}; use platform_auth::{ JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, + SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, }; use platform_llm::{LlmClient, LlmConfig, LlmError}; use platform_oss::{OssClient, OssConfig, OssError}; @@ -54,6 +55,7 @@ pub struct AppState { pub enum AppStateInitError { Jwt(JwtError), RefreshCookie(RefreshCookieError), + SmsProvider(SmsProviderError), Oss(OssError), Llm(LlmError), } @@ -78,9 +80,30 @@ impl AppState { )?; let oss_client = build_oss_client(&config)?; 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 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 = WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes); let wechat_auth_service = WechatAuthService::new(auth_store.clone()); @@ -331,6 +354,7 @@ impl fmt::Display for AppStateInitError { match self { Self::Jwt(error) => write!(f, "{error}"), Self::RefreshCookie(error) => write!(f, "{error}"), + Self::SmsProvider(error) => write!(f, "{error}"), Self::Oss(error) => write!(f, "{error}"), Self::Llm(error) => write!(f, "{error}"), } @@ -351,6 +375,12 @@ impl From for AppStateInitError { } } +impl From for AppStateInitError { + fn from(value: SmsProviderError) -> Self { + Self::SmsProvider(value) + } +} + impl From for AppStateInitError { fn from(value: OssError) -> Self { Self::Oss(value) diff --git a/server-rs/crates/module-auth/README.md b/server-rs/crates/module-auth/README.md index 25f38310..ce88299b 100644 --- a/server-rs/crates/module-auth/README.md +++ b/server-rs/crates/module-auth/README.md @@ -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) 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) +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. 边界约束 -1. `module-auth` 负责鉴权领域规则与模块级编排,不直接把供应商 SDK 逻辑写进主工程。 +1. `module-auth` 负责鉴权领域规则与模块级编排,不直接把供应商 SDK 或阿里云 RPC 逻辑写进主工程。 2. 短信、微信、JWT、Cookie、密码哈希等平台适配优先通过 `crates/platform-auth` 承接。 3. 身份与会话状态最终由 `crates/spacetime-module` 聚合,前端接口由 `crates/api-server` 暴露。 4. 当前阶段允许先使用进程内适配器把用例跑通,但后续切到 `SpacetimeDB` 时应保持用例接口稳定。 @@ -54,3 +55,4 @@ 6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。 7. 当前 `module-auth` 已承接进程内 refresh session 创建与轮换能力,供 `/api/auth/refresh` 复用。 8. 当前 `module-auth` 已承接当前 refresh session 吊销与用户 `token_version` 递增能力,供 `/api/auth/logout` 复用。 +9. 当前手机号验证码真实 provider 由 `platform-auth` 注入,`module-auth` 只保留冷却、TTL、失败次数和账号编排,不保存验证码明文。 diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 4e966c02..ce5a7b8b 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -5,7 +5,10 @@ use std::{ sync::{Arc, Mutex}, }; -use platform_auth::{hash_password, verify_password}; +use platform_auth::{ + SmsAuthProvider, SmsProviderError, SmsSendCodeRequest, SmsVerifyCodeRequest, hash_password, + verify_password, +}; use shared_kernel::{ build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string, 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_MAX_LENGTH: usize = 128; const SMS_CODE_LENGTH: usize = 6; -const SMS_MOCK_VERIFY_CODE: &str = "123456"; const SMS_CODE_TTL_MINUTES: i64 = 5; const SMS_CODE_COOLDOWN_SECONDS: u64 = 60; const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5; @@ -332,10 +334,10 @@ struct StoredRefreshSession { struct StoredPhoneCode { phone_number: String, scene: PhoneAuthScene, - verify_code: String, expires_at: String, last_sent_at: String, failed_attempts: u32, + provider_out_id: Option, } #[derive(Clone, Debug)] @@ -371,6 +373,7 @@ pub struct AuthUserService { #[derive(Clone, Debug)] pub struct PhoneAuthService { store: InMemoryAuthStore, + sms_provider: SmsAuthProvider, } #[derive(Clone, Debug)] @@ -562,11 +565,14 @@ impl RefreshSessionService { } impl PhoneAuthService { - pub fn new(store: InMemoryAuthStore) -> Self { - Self { store } + pub fn new(store: InMemoryAuthStore, sms_provider: SmsAuthProvider) -> Self { + Self { + store, + sms_provider, + } } - pub fn send_code( + pub async fn send_code( &self, input: SendPhoneCodeInput, now: OffsetDateTime, @@ -579,25 +585,33 @@ impl PhoneAuthService { 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( StoredPhoneCode { phone_number: normalized_phone.e164.clone(), scene: input.scene, - verify_code: SMS_MOCK_VERIFY_CODE.to_string(), expires_at, last_sent_at: format_rfc3339(now).map_err(|message| { PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}")) })?, failed_attempts: 0, + provider_out_id: provider_result.provider_out_id.clone(), }, now, )?; Ok(SendPhoneCodeResult { - cooldown_seconds: SMS_CODE_COOLDOWN_SECONDS, - expires_in_seconds: (SMS_CODE_TTL_MINUTES * 60) as u64, - provider_request_id: None, + cooldown_seconds: provider_result.cooldown_seconds, + expires_in_seconds: provider_result.expires_in_seconds, + provider_request_id: provider_result.provider_request_id, }) } @@ -608,12 +622,28 @@ impl PhoneAuthService { ) -> Result { let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; 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, &PhoneAuthScene::Login, - input.verify_code.trim(), 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 .store @@ -651,12 +681,28 @@ impl PhoneAuthService { ) -> Result { let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; 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, &PhoneAuthScene::BindPhone, - input.verify_code.trim(), 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 .store @@ -1187,13 +1233,12 @@ impl InMemoryAuthStore { Ok(()) } - fn consume_phone_code( + fn assert_phone_code_active( &self, phone_number: &str, scene: &PhoneAuthScene, - verify_code: &str, now: OffsetDateTime, - ) -> Result<(), PhoneAuthError> { + ) -> Result, PhoneAuthError> { let mut state = self .inner .lock() @@ -1213,21 +1258,47 @@ impl InMemoryAuthStore { state.phone_codes_by_key.remove(&key); return Err(PhoneAuthError::VerifyCodeExpired); } - if stored.verify_code != verify_code.trim() { - 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; - } - return Err(PhoneAuthError::InvalidVerifyCode); - } + Ok(stored.provider_out_id) + } + + fn consume_phone_code_success( + &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); state.phone_codes_by_key.remove(&key); 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( &self, 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 { match error { 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]) } +fn build_national_phone_number(e164_phone_number: &str) -> Result { + 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 { format!( "seed_{}_{}", @@ -1829,7 +1919,13 @@ impl WechatAuthScene { #[cfg(test)] 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::*; @@ -1842,7 +1938,30 @@ mod tests { } 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 { @@ -1963,6 +2082,7 @@ mod tests { }, now, ) + .await .expect("first phone code should send"); let error = service @@ -1973,6 +2093,7 @@ mod tests { }, now + Duration::seconds(10), ) + .await .expect_err("same scene send should be cooled down"); match error { @@ -1996,6 +2117,7 @@ mod tests { }, now, ) + .await .expect("login scene code should send"); let bind_result = service.send_code( SendPhoneCodeInput { @@ -2005,7 +2127,7 @@ mod tests { now + Duration::seconds(1), ); - assert!(bind_result.is_ok()); + assert!(bind_result.await.is_ok()); } #[tokio::test] @@ -2021,6 +2143,7 @@ mod tests { }, now, ) + .await .expect("phone code should send"); for attempt in 1..SMS_CODE_MAX_FAILED_ATTEMPTS { @@ -2053,7 +2176,7 @@ mod tests { .login( PhoneLoginInput { 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)), ) @@ -2069,12 +2192,13 @@ mod tests { }, now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 2)), ) + .await .expect("deleted snapshot should allow a new code"); let login = service .login( PhoneLoginInput { 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)), ) @@ -2370,7 +2494,7 @@ mod tests { #[tokio::test] async fn wechat_login_hits_existing_user_by_union_id_before_openid() { 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 now = OffsetDateTime::now_utc(); @@ -2382,6 +2506,7 @@ mod tests { }, now, ) + .await .expect("phone code should send"); let phone_user = phone_service .login( @@ -2434,7 +2559,7 @@ mod tests { #[tokio::test] async fn bind_wechat_phone_merges_pending_wechat_user_into_existing_phone_user() { 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 now = OffsetDateTime::now_utc(); @@ -2446,6 +2571,7 @@ mod tests { }, now, ) + .await .expect("phone login code should send"); let phone_user = phone_service .login( @@ -2486,6 +2612,7 @@ mod tests { }, now + Duration::seconds(2), ) + .await .expect("bind phone code should send"); let merged = phone_service .bind_wechat_phone( diff --git a/server-rs/crates/platform-auth/Cargo.toml b/server-rs/crates/platform-auth/Cargo.toml index 5a598246..603073b1 100644 --- a/server-rs/crates/platform-auth/Cargo.toml +++ b/server-rs/crates/platform-auth/Cargo.toml @@ -6,6 +6,11 @@ license.workspace = true [dependencies] 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" jsonwebtoken = "9" rand_core = { version = "0.6", features = ["getrandom"] } diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index 78863444..41c64a21 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -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 base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use hmac::{Hmac, Mac}; use jsonwebtoken::{ Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind, }; use rand_core::OsRng; +use reqwest::{Client, StatusCode}; use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha1::Sha1; use sha2::{Digest, Sha256}; use shared_kernel::{new_uuid_simple_string, normalize_optional_string, normalize_required_string}; 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_PATH: &str = "/api/auth"; 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; // 鉴权 provider 直接冻结成文档中约定的枚举,避免后续在多个 crate 内重复发明字符串字面量。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -89,6 +110,71 @@ pub struct RefreshCookieConfig { 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, + pub access_key_secret: Option, + pub sign_name: String, + pub template_code: String, + pub template_param_key: String, + pub country_code: String, + pub scheme_name: Option, + 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, + pub provider_out_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SmsVerifyCodeRequest { + pub national_phone_number: String, + pub verify_code: String, + pub provider_out_id: Option, +} + +#[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)] pub enum JwtError { InvalidConfig(&'static str), @@ -108,6 +194,57 @@ pub enum PasswordHashError { VerifyFailed(String), } +#[derive(Debug, PartialEq, Eq)] +pub enum SmsProviderError { + InvalidConfig(String), + InvalidVerifyCode, + Upstream(String), +} + +#[derive(Debug, Deserialize)] +struct AliyunSendSmsVerifyCodeResponse { + #[serde(default)] + code: Option, + #[serde(default)] + message: Option, + #[serde(default)] + request_id: Option, + #[serde(default)] + success: Option, + #[serde(default)] + model: Option, +} + +#[derive(Debug, Deserialize)] +struct AliyunSendSmsVerifyCodeModel { + #[serde(default, rename = "BizId")] + _biz_id: Option, + #[serde(default, rename = "OutId")] + out_id: Option, + #[serde(default, rename = "RequestId")] + request_id: Option, +} + +#[derive(Debug, Deserialize)] +struct AliyunCheckSmsVerifyCodeResponse { + #[serde(default)] + code: Option, + #[serde(default)] + message: Option, + #[serde(default)] + success: Option, + #[serde(default)] + model: Option, +} + +#[derive(Debug, Deserialize)] +struct AliyunCheckSmsVerifyCodeModel { + #[serde(default, rename = "OutId")] + _out_id: Option, + #[serde(default, rename = "VerifyResult")] + verify_result: Option, +} + impl JwtConfig { pub fn new( issuer: String, @@ -211,6 +348,366 @@ impl RefreshCookieConfig { } } +impl SmsAuthProviderKind { + pub fn parse(raw: &str) -> Option { + 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, + access_key_secret: Option, + sign_name: String, + template_code: String, + template_param_key: String, + country_code: String, + scheme_name: Option, + 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 { + 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 { + 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 { + 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 { + 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 { + 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) -> 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 { pub fn from_input( input: AccessTokenClaimsInput, @@ -506,6 +1003,187 @@ fn map_verify_error(error: jsonwebtoken::errors::Error) -> JwtError { 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::() + .chars() + .rev() + .collect::(); + format!("{scene}_{}_{}", phone_suffix, new_uuid_simple_string()) +} + +fn build_aliyun_sms_url(endpoint: &str) -> Result { + 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 { + params + .iter() + .filter(|(key, _)| key.as_str() != "Signature") + .map(|(key, value)| { + format!( + "{}={}", + aliyun_percent_encode(key), + aliyun_percent_encode(value) + ) + }) + .collect::>() + .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 { + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| SmsProviderError::Upstream(format!("{fallback_message}:{error}")))?; + let payload = serde_json::from_str::(&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::(&body).ok(), + )); + } + + Ok(payload) +} + +async fn parse_aliyun_json_response_for_verify( + response: reqwest::Response, +) -> Result { + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| SmsProviderError::Upstream(format!("验证码校验失败:{error}")))?; + let payload = serde_json::from_str::(&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::(&body).ok(), + )); + } + + Ok(payload) +} + +fn map_http_status_to_sms_provider_error( + fallback_message: &str, + status: StatusCode, + payload: Option, +) -> 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, + provider_code: Option, +) -> 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)] mod tests { use super::*; @@ -543,6 +1221,29 @@ mod tests { .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] fn round_trip_sign_and_verify_access_token() { let config = build_jwt_config(); @@ -669,4 +1370,103 @@ mod tests { assert!(cookie.contains("SameSite=Lax")); 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" + ); + } }