feat(服务商小程序与认证服务): 实现服务商小程序登录与忘记密码功能
后端新增服务商账号密码登录专用接口、密码重置接口,完善短信验证码场景校验逻辑,新增相关服务类、实体类、Mapper与Feign配置;前端重构对应页面UI并对齐品牌规范,更新接口调用逻辑;添加网关白名单与所需静态图片资源,修正小程序配置,新增功能设计文档。
| New file |
| | |
| | | # 服务商端小程序 忘记密码(重置密码)开发设计文档 |
| | | |
| | | - 模块:`hugray-partner-miniapp`(前端)、`ruoyi-auth` / `ruoyi-user` / `ruoyi-api-system` / `ruoyi-gateway`(后端) |
| | | - 日期:2026-10-14 |
| | | - 需求来源:“继续实现服务商小程序的忘记密码功能,包括页面的 ui 设计以及后端的业务实现” |
| | | - 风格参照:`hugray-partner-miniapp/pages/login/index.vue`(同品牌登录页) |
| | | - 改造目标文件: |
| | | - 前端:`hugray-partner-miniapp/pages/login/forgot.vue`、`hugray-partner-miniapp/pages.json`、`hugray-partner-miniapp/api/index.js` |
| | | - 后端:`ruoyi-auth`、`ruoyi-user`、`ruoyi-api-system`、网关白名单 |
| | | - 依赖既有能力(本次仅复用,不改): |
| | | - `SmsCodeService.sendCode / verifyAndConsume`(`ruoyi-auth`) |
| | | - `SmsSceneEnum.RESET`(已存在,`reset` = 重置密码) |
| | | - `SecurityUtils.encryptPassword`(BCrypt,`ruoyi-common-security`) |
| | | |
| | | --- |
| | | |
| | | ## 一、功能概述 |
| | | |
| | | 服务商端「忘记密码」= 通过**已注册手机号 + 短信验证码**校验身份后,为账号**设置新密码**。 |
| | | |
| | | | 序号 | 项 | 说明 | |
| | | | --- | --- | --- | |
| | | | 1 | 入口 | 登录页「忘记密码」→ `uni.navigateTo('/pages/login/forgot')`(现状已具备) | |
| | | | 2 | 前端页面 | 重做 `forgot.vue` 视觉,对齐服务商端登录页品牌风(渐变底 + 面板式输入 + 渐变胶囊按钮) | |
| | | | 3 | 校验要素 | 登录手机号 + 短信验证码(一次性)+ 新密码两次一致 | |
| | | | 4 | 后端接口 | 新增 `POST /miniapp/auth/password/reset`(服务内 `/password/reset`);下发验证码复用 `/miniapp/auth/sms/code`(`scene=reset`) | |
| | | | 5 | 密码落库 | 由 `ruoyi-user` 侧 BCrypt 加密后**只更新 `account_user.password` 一列** | |
| | | | 6 | 服务商身份 | **不额外强校验 `provider_flag`**:能收到该手机号短信即证明持有该手机号;重置后仍需通过服务商端登录接口(会强校验 `provider_flag='1'`)才能进入 | |
| | | |
| | | > 说明:忘记密码属于「账号找回」,本质是凭手机号验证身份改密码。若强校验 `provider_flag`,非服务商会员将无法在服务商端重置密码,且会暴露账号是否为服务商(枚举风险)。故此处不做 `provider_flag` 校验,身份门槛由「手机号 + 短信验证码」承担。 |
| | | |
| | | --- |
| | | |
| | | ## 二、现状与差异点 |
| | | |
| | | ### 2.1 现状 |
| | | |
| | | | 层 | 现状 | |
| | | | --- | --- | |
| | | | 前端页面 | `pages/login/forgot.vue` 已存在:手机号 + 验证码 + 新密码 + 确认密码 + 提交;风格为「白底 + 绿色验证码按钮 + 深蓝胶囊提交」,与登录页品牌风**不一致** | |
| | | | 前端接口 | `api/index.js` 已埋桩:`sendSmsCode(phone)`(`scene='reset'`)→ `/miniapp/auth/sms/code`;`resetPassword({phone,code,password})` → `/miniapp/auth/password/reset` | |
| | | | 后端接口 | **缺失**:`AccountAuthController` 无 `/password/reset` 端点 | |
| | | | 后端短信 | `scene=reset` 走 `SmsSceneEnum.RESET`,`sendCode` 当前**不校验手机号是否已注册**;`verifyAndConsume` 对 reset **不放行通用码** | |
| | | | 网关白名单 | `/miniapp/auth/sms/code` 已在白名单;`/miniapp/auth/password/reset` **未加白名单**(不加则报「令牌不能为空」) | |
| | | |
| | | ### 2.2 差异点(决定本次改动) |
| | | |
| | | 1. 新增后端重置密码端点与跨服务内部接口; |
| | | 2. 补齐 reset 场景的手机号「已注册」校验; |
| | | 3. 网关白名单补 `/miniapp/auth/password/reset`; |
| | | 4. 前端 `forgot.vue` 视觉重做 + `pages.json` 该页改 `navigationStyle: custom`。 |
| | | |
| | | --- |
| | | |
| | | ## 三、视觉规格(对齐服务商端登录页品牌体系) |
| | | |
| | | 换算基准:设计稿 375pt → 750rpx(pt × 2 = rpx)。 |
| | | |
| | | | 元素 | 实现规格 | |
| | | | --- | --- | |
| | | | 页面底板 | 渐变 165°、rgba .8 叠白底:`#D3E4F8` 0% → `#BCD6F4` 50% → `#ABCCEC` 100% | |
| | | | 顶部导航 | 自定义(`navigationStyle: custom`):返回箭头 + 标题「重置密码」,居中,`#0F172A` | |
| | | | 顶部装饰 | 沿用登录页 CSS 装饰(微透十字 / 白色点阵)各 2~3 处,`pointer-events:none` | |
| | | | 引导文案 | 主标题 34rpx / 600 `#0F172A`;副说明 24rpx `#64748B` | |
| | | | 表单标签 | 26rpx / 500 / `#334155`,下间距 18rpx | |
| | | | 输入框 | H88rpx / R16 / 描边 2rpx `#CBD5E1` / 底色 `#F8FAFC`,内嵌 44rpx 图标,左右内边距 28rpx | |
| | | | 验证码行 | 输入框 `flex:1` + 「获取验证码」按钮 190rpx,品牌色描边/文字,倒计时期间置灰禁用 | |
| | | | 密码可见性 | 右侧 44rpx 眼睛图标,品牌色 `#003E7E` | |
| | | | 提交按钮 | H96rpx / R48,渐变 90° `#1E5EBC` → `#003E7E`,30rpx 600 白字,shadow `0 12rpx 28rpx rgba(0,62,126,.2)` | |
| | | | 底部 Home 条 | 270×10rpx / R5 `rgba(15,23,42,.145)` | |
| | | | 边距体系 | 内容区左右 56rpx,元素间距 40rpx,底部叠加安全区 32rpx | |
| | | |
| | | 交互: |
| | | |
| | | - 获取验证码:校验手机号格式 → 调接口 → 60s 倒计时(`onUnload` 清理定时器); |
| | | - 提交:本地校验(手机号 / 验证码非空 / 密码 ≥6 位 / 两次一致)→ 调接口 → 成功 toast → `navigateBack` 回登录页; |
| | | - 全流程 `logging` 防重复提交。 |
| | | |
| | | --- |
| | | |
| | | ## 四、接口设计(本次核心变更) |
| | | |
| | | ### 4.1 接口一览 |
| | | |
| | | | 接口 | 方法 | 网关路径 | 服务内路径 | 入参 | 返回 | |
| | | | --- | --- | --- | --- | --- | --- | |
| | | | 下发验证码(复用) | POST | `/miniapp/auth/sms/code` | `/sms/code` | `{ phone, scene:'reset' }` | `R.ok()` | |
| | | | 重置密码(新增) | POST | `/miniapp/auth/password/reset` | `/password/reset` | `{ phone, code, password }` | `R.ok()` | |
| | | |
| | | > 网关路由 `ruoyi-miniapp-auth`(`Path=/miniapp/auth/**` + `StripPrefix=2`)已存在;**仅需把 `/miniapp/auth/password/reset` 加入白名单**。 |
| | | |
| | | ### 4.2 重置密码链路(时序) |
| | | |
| | | ``` |
| | | POST /miniapp/auth/password/reset { phone, code, password } |
| | | 1. 入参校验(@NotBlank + 手机号正则 + 密码长度) |
| | | 2. 按手机号查询会员(Feign → GET /member/info/{phone}) |
| | | 不存在 → 抛「该手机号未注册」 |
| | | 3. 校验账号状态:停用 → 抛「账号已停用,请联系管理员」 |
| | | 4. 校验并消费短信验证码(scene=reset,一次性) |
| | | 未获取/已失效 → 抛「验证码已失效,请重新获取」 |
| | | 错误 → 抛「验证码错误」(累计 5 次作废) |
| | | 5. Feign 调 ruoyi-user 内部接口 POST /member/password/reset { phone, password } |
| | | → 服务内 BCrypt 加密后只更新 password 一列 |
| | | 6. 返回 R.ok() |
| | | ``` |
| | | |
| | | > 顺序说明:先查会员与状态、再消费验证码,与 `phoneCodeLogin` 的口径一致,避免无谓消费验证码(消费后即失效)。 |
| | | |
| | | ### 4.3 跨服务契约变更 |
| | | |
| | | | 文件 | 变更 | |
| | | | --- | --- | |
| | | | `ruoyi-api-system` / `model/AccountResetPasswordRequest` | 新增:`{ phone, password }`,供内部调用传递待重置信息 | |
| | | | `ruoyi-api-system` / `RemoteAccountUserService` | 新增 `@PostMapping("/member/password/reset")` | |
| | | | `ruoyi-api-system` / `RemoteAccountUserFallbackFactory` | 补 `resetPassword` 降级实现 | |
| | | |
| | | ### 4.4 短信场景补齐 |
| | | |
| | | `SmsCodeService.sendCode` 增加 reset 场景的「手机号已注册」校验: |
| | | |
| | | ``` |
| | | register → checkPhoneNotRegistered |
| | | login → checkPhoneRegistered |
| | | reset → checkPhoneRegistered // 本次新增 |
| | | common → 不校验 |
| | | ``` |
| | | |
| | | 同步更新 `SmsCodeBody.scene` 的注释说明(原注释称 “reset/为空=通用下发(不校验注册状态)”,与实际不符)。 |
| | | |
| | | --- |
| | | |
| | | ## 五、数据库设计 |
| | | |
| | | **无 DDL 变更**(沿用 `account_user.password varchar`,BCrypt 密文;单列更新语句 `updateUserPassword`)。 |
| | | |
| | | 复用既有字段:`account_user.password`、`account_user.phone`、`account_user.status`、`account_user.del_flag`。 |
| | | |
| | | --- |
| | | |
| | | ## 六、枚举与状态 |
| | | |
| | | | 名称 | 取值 | 说明 | |
| | | | --- | --- | --- | |
| | | | `SmsSceneEnum.RESET` | `reset` | 重置密码场景(已存在,本次启用其手机号校验) | |
| | | | `account_user.status` | `0` 正常 / `1` 停用 | 停用账号拒绝重置(与登录口径一致) | |
| | | |
| | | --- |
| | | |
| | | ## 七、影响范围 |
| | | |
| | | | 类型 | 文件 | 变更 | |
| | | | --- | --- | --- | |
| | | | 前端页面 | `hugray-partner-miniapp/pages/login/forgot.vue` | 重做模板与样式,对齐品牌风;逻辑保持(倒计时 / 校验 / 提交) | |
| | | | 前端配置 | `hugray-partner-miniapp/pages.json` | `pages/login/forgot` 改 `navigationStyle: custom` | |
| | | | 前端接口 | `hugray-partner-miniapp/api/index.js` | 无需改(已有 `sendSmsCode` / `resetPassword` 桩,复核参数一致) | |
| | | | 认证服务 | `ruoyi-auth`:新增 `ResetPasswordBody`、`PasswordResetService`;`AccountAuthController` 新增 `#resetPassword` | 重置密码编排 | |
| | | | 认证服务 | `ruoyi-auth`:`SmsCodeService.sendCode` 补 reset 场景手机号校验 | 场景补齐 | |
| | | | 会员服务 | `ruoyi-user`:`IAccountUserService` + impl 新增 `resetPasswordByPhone`;`AccountUserMapper` + XML 新增 `updateUserPassword`;`AccountMemberInnerController` 新增 `POST /member/password/reset` | 密码落库(服务内加密) | |
| | | | 跨服务契约 | `ruoyi-api-system`:新增 `AccountResetPasswordRequest`;`RemoteAccountUserService` 加 `resetPassword`;`RemoteAccountUserFallbackFactory` 补降级 | Feign 契约扩展 | |
| | | | 网关配置 | `doc/nacos/dev/ruoyi-gateway-dev.yml`(同步 Nacos) | `security.ignore.whites` 追加 `/miniapp/auth/password/reset` | |
| | | | 数据库 | — | **无变更** | |
| | | |
| | | --- |
| | | |
| | | ## 八、验收清单 |
| | | |
| | | 1. 登录页「忘记密码」可跳转 `/pages/login/forgot`;✔ |
| | | 2. 页面呈现浅蓝渐变底 + 装饰层 + 品牌风表单(面板式输入 + 渐变胶囊提交按钮);✔ |
| | | 3. 手机号格式非法时点击「获取验证码」提示「请输入正确的手机号」,不发请求;✔ |
| | | 4. 合法手机号点击「获取验证码」:接口成功 → 60s 倒计时 → 期间按钮禁用;✔ |
| | | 5. 未注册手机号请求验证码 → 提示「该手机号未注册,请先注册」;✔ |
| | | 6. 新密码 <6 位或两次不一致 → 本地拦截并提示;✔ |
| | | 7. 正确手机号 + 正确验证码 + 新密码 → 提示「密码重置成功」并返回登录页;✔ |
| | | 8. 用**新密码**在服务商端登录成功,用旧密码登录失败(提示「账号或密码错误」);✔ |
| | | 9. 验证码错误累计 5 次 → 提示「验证码错误次数过多,请重新获取」;✔ |
| | | 10. 验证码已消费后再提交 → 提示「验证码已失效,请重新获取」;✔ |
| | | 11. 停用账号重置 → 提示「账号已停用,请联系管理员」;✔ |
| | | 12. 未加白名单时请求 `/miniapp/auth/password/reset` 应报「令牌不能为空」——补白名单后不再出现。✔ |
| | | |
| | | --- |
| | | |
| | | ## 九、待确认决策 |
| | | |
| | | 1. **重置凭据用手机号**(现状前端即为手机号):验证码发到手机,故以手机号为账号标识;服务商账号 `provider126` 对应手机号 `13800000126`。是否认可? |
| | | 2. **reset 场景是否放行通用验证码 `999999`**(`hugray.auth.universal-sms-code`):现状仅 login/register 放行。放行便于联调(无需读日志取码),但降低重置安全性。建议:**开发环境放行、生产关闭**(配置为空即关闭)。 |
| | | 3. **验证码下发真实通道**:当前 `PlaceholderSmsSender` 仅打印日志,联调时验证码需从 `ruoyi-auth` 日志 `[SMS_SEND] placeholder send, phone=138****0126, code=xxxxxx` 读取。 |
| | | |
| | | --- |
| | | |
| | | ## 十、AI 协作声明 |
| | | |
| | | - 本文档经人工确认后方可生成/修改代码(遵循项目《AI 协作流程》条款); |
| | | - 文档与代码同一批次提交,路径:`doc/design/服务商端_忘记密码_20261014.md`。 |
| New file |
| | |
| | | # 服务商端小程序 登录(UI 重设计 + 专用登录接口)开发设计文档 |
| | | |
| | | - 模块:`hugray-partner-miniapp`(前端)、`ruoyi-auth` / `ruoyi-user` / `ruoyi-api-system` / `ruoyi-gateway`(后端) |
| | | - 日期:2026-10-14 |
| | | - 需求来源: |
| | | 1. “现在开始服务商小程序的开发,先把服务商小程序的登录页样式参考用户小程序的样式重新设计 ui 并开发”; |
| | | 2. “服务商小程序登录提示令牌不能为空,服务商只需要账号加密码登录就行,不需要其他参数,看是否重新写个接口区分开来”。 |
| | | - 风格参照:`hugray-user-miniapp/pages/login/index.vue` + `doc/design/用户端_登录注册_20261012.md` §3.5 视觉还原规格 |
| | | - 改造目标文件: |
| | | - 前端:`hugray-partner-miniapp/pages/login/index.vue`、`hugray-partner-miniapp/api/index.js` |
| | | - 后端:`ruoyi-auth`、`ruoyi-user`、`ruoyi-api-system`、网关白名单 |
| | | - 已确认决策(用户确认): |
| | | 1. **品牌区完整复刻用户端**:`欢迎来到` + 代谢300 Logo + 英文标语 + 医学双背书 + 产业4.0标语卡; |
| | | 2. **底板沿用浅蓝渐变**:165°、rgba .8 叠白底(`#D3E4F8` → `#BCD6F4` → `#ABCCEC`)+ 3 处微透十字 + 3 处白色点阵; |
| | | 3. **表单面板式平铺**:输入框直接铺在渐变底上,不使用白色卡片承载; |
| | | 4. **新接口路径** `/miniapp/auth/partner/login`(服务内 `/partner/login`),与用户端接口彻底区分; |
| | | 5. **强校验服务商身份**:仅 `provider_flag='1'` 的账号可登录服务商端; |
| | | 6. **合并错误提示**:账号不存在与密码错误统一返回「账号或密码错误」,防账号枚举。 |
| | | |
| | | --- |
| | | |
| | | ## 一、功能概述 |
| | | |
| | | 本次包含两部分: |
| | | |
| | | | 序号 | 项 | 变化 | |
| | | | --- | --- | --- | |
| | | | 1 | 登录页视觉 | 由「深蓝导航栏 + 白底表单 + 通栏深蓝按钮」改为「用户端同款浅蓝渐变登录页(装饰层 + 品牌区 + 面板式表单 + 渐变胶囊按钮 + Home 条)」 | |
| | | | 2 | 登录方式 | **不变**:账号 + 密码 | |
| | | | 3 | 登录接口 | **变更**:由不存在且未白名单的 `/miniapp/auth/login` 改为新增的服务商专用接口 `/miniapp/auth/partner/login`,入参仅 `username` + `password` | |
| | | | 4 | 服务商身份校验 | **新增**:按 username 查 `account_user`,校验 `provider_flag='1'` + 账号状态 + BCrypt 密码 | |
| | | | 5 | 忘记密码入口 | **不变**:跳转 `/pages/login/forgot` | |
| | | | 6 | 协议勾选与确认弹窗 | **不变**:逻辑保留,样式对齐用户端 | |
| | | |
| | | --- |
| | | |
| | | ## 二、报错根因(问题定位) |
| | | |
| | | 服务商端登录报「令牌不能为空」,为**双重根因**: |
| | | |
| | | 1. **接口不存在**:前端调用 `POST /miniapp/auth/login`,而 `ruoyi-auth` 的 `AccountAuthController` 仅有 `/sms/code`、`/phone/login`、`/phone/code-login`、`/account/register`、`/invite-qrcode`,**无用户名密码登录端点**。 |
| | | 2. **白名单缺失**:`/miniapp/auth/login` 未登记在网关 `security.ignore.whites`,未携带 token 访问时被 `AuthFilter` 直接拦截,返回 `HTTP 401 + 令牌不能为空`。 |
| | | |
| | | --- |
| | | |
| | | ## 三、现状与差异点 |
| | | |
| | | ### 3.1 现状 |
| | | |
| | | `pages/login/index.vue` 当前为深蓝导航栏 + 白底表单风格,`pages.json` 中该页已配置 `"navigationStyle": "custom"`。 |
| | | |
| | | ### 3.2 与用户端的差异(决定本次实现差异) |
| | | |
| | | | 维度 | 用户端登录页 | 服务商端登录页 | |
| | | | --- | --- | --- | |
| | | | 登录方式 | 微信一键登录 / 手机验证登录 | **账号 + 密码** | |
| | | | 登录接口 | `/miniapp/auth/phone/login`、`/miniapp/auth/phone/code-login` | **`/miniapp/auth/partner/login`** | |
| | | | 底部操作区 | 两个渐变胶囊按钮 + 协议行 + 注册入口 + Home 条 | **账号输入 + 密码输入 + 忘记密码 + 协议行 + 登录按钮 + Home 条** | |
| | | | 品牌区 | 完整(欢迎来到 + Logo + 标语 + 背书 + 标语卡) | 本次对齐为**同款** | |
| | | | 顶部导航 | 无导航栏,内容区 `paddingTop = 状态栏 + 40px` | 同左(去掉原深蓝导航栏) | |
| | | | 协议文案 | 《用户协议》《隐私与权限》 | **不变**(保持服务商端既有文案) | |
| | | |
| | | ### 3.3 素材缺口与处理 |
| | | |
| | | 服务商端 `static/` 原仅有 `logo.png`(与用户端 `logo.png` 为同一「YiOUS」深蓝图,MD5 一致)。本次品牌区新增 4 个素材: |
| | | |
| | | | 素材 | 来源 | 用途 | |
| | | | --- | --- | --- | |
| | | | `metabolism-300-logo.png` | 复制自用户端 | 品牌区官方 Logo(440×152rpx) | |
| | | | `medical-plus-icon.png` | 复制自用户端 | 医学双背书「严谨医学循证」 | |
| | | | `check-icon.png` | 复制自用户端 | 医学双背书「传递健康价值」 | |
| | | | `industry-4-0-slogan-card.png` | 复制自用户端 | 产业4.0核心标语卡 | |
| | | |
| | | > `logo.png` 保留不删(服务商端其他页面可能仍在引用)。 |
| | | |
| | | --- |
| | | |
| | | ## 四、视觉规格(复用用户端已落地设计体系) |
| | | |
| | | 换算基准:设计稿 375pt → 750rpx(pt × 2 = rpx)。 |
| | | |
| | | | 元素 | 实现规格 | |
| | | | --- | --- | |
| | | | 页面底板 | 渐变 165°、rgba .8 叠白底:`#D3E4F8` 0% → `#BCD6F4` 50% → `#ABCCEC` 100% | |
| | | | 微透十字 ×3 | 右上 160rpx(条宽 44)/ 中左 136rpx(条宽 36)/ 左下 200rpx(条宽 56) | |
| | | | 白色点阵 ×3 | 左上 / 右上 / 右下,radial-gradient 平铺,步距 26rpx | |
| | | | 内容区顶距 | `paddingTop = statusBarHeight + 40px`;左右 56rpx;元素间距 40rpx | |
| | | | 欢迎语 | 「欢迎来到」52rpx / 500 / `#0F172A`,居中 | |
| | | | 官方 Logo | `metabolism-300-logo.png` 440×152rpx | |
| | | | 英译标语 | 「RESET METABOLISM & DELIVER HEALTH」22rpx / 600 / `#003E7E` / 字距 3rpx | |
| | | | 医学双背书 | 图标 30rpx / 36.4×30rpx,项内 gap 12rpx、两项 gap 48rpx;文案 24rpx 600 `#1E293B` | |
| | | | 标语卡 | `industry-4-0-slogan-card.png` 759×304rpx,上下负边距 -48rpx / -72rpx | |
| | | | 表单标签 | 26rpx / 500 / `#334155`,下间距 18rpx | |
| | | | 输入框 | H88rpx / R16 / 描边 2rpx `#CBD5E1` / 底色 `#F8FAFC`,内嵌 44rpx 图标(账号 / 盾牌),左右内边距 28rpx | |
| | | | 忘记密码 | 右对齐,26rpx / 600 / `#1E5EBC` | |
| | | | 登录按钮 | H96rpx / R48,渐变 90° `#1E5EBC` → `#003E7E`,30rpx 600 白字,shadow `0 12rpx 28rpx rgba(0,62,126,.2)` | |
| | | | 协议勾选行 | 复选框 32rpx / R8(选中 `#003E7E`)+ 文案 26rpx(正文 `#64748B` / 链接 `#1E5EBC`) | |
| | | | 底部 Home 条 | 270×10rpx / R5 `rgba(15,23,42,.145)` | |
| | | | 边距体系 | 底部区左右 56rpx,叠加安全区 32rpx | |
| | | | 协议确认弹窗 | 560rpx 宽 / R24,标题 34rpx 600 `#003E7E`,正文 28rpx `#64748B`,按钮行「取消 `#334155` / 我已阅读并同意 `#1E5EBC`」 | |
| | | |
| | | --- |
| | | |
| | | ## 五、接口设计(本次核心变更) |
| | | |
| | | ### 5.1 接口一览 |
| | | |
| | | | 接口 | 方法 | 网关路径 | 服务内路径 | 入参 | 返回 | |
| | | | --- | --- | --- | --- | --- | --- | |
| | | | 服务商登录(新增) | POST | `/miniapp/auth/partner/login` | `/partner/login` | `{ username, password }` | `{ access_token, expires_in }` | |
| | | |
| | | > 网关路由 `ruoyi-miniapp-auth`(`Path=/miniapp/auth/**` + `StripPrefix=2`)已存在,无需新增路由;**仅需把新路径加入白名单**。 |
| | | |
| | | ### 5.2 服务商身份模型(关键认知) |
| | | |
| | | 服务商**不是独立用户体系**,而是「会员(`account_user`)的另一重身份」: |
| | | |
| | | - 登录账号 / 密码直接用 `account_user.username / password`(BCrypt 密文); |
| | | - `account_provider` 表只存公司资质与权益配置,用 `user_id` 与账号 1:1 关联; |
| | | - `account_user.provider_flag = '1'` 标识该账号为服务商。 |
| | | |
| | | ### 5.3 登录鉴权链路 |
| | | |
| | | ``` |
| | | POST /miniapp/auth/partner/login { username, password } |
| | | 1. 入参非空校验(@NotBlank) |
| | | 2. Feign 调 ruoyi-user 内部接口 GET /member/info/username/{username} |
| | | → AccountMember(含 password 密文、providerFlag、status) |
| | | 3. 账号不存在 → 抛「账号或密码错误」 |
| | | 4. providerFlag != '1' → 抛「该账号不是服务商账号」 |
| | | 5. status == '1'(停用) → 抛「账号已停用,请联系管理员」 |
| | | 6. BCrypt matchesPassword(password, member.password) 失败 → 抛「账号或密码错误」 |
| | | 7. 组装 LoginUser → TokenService.createToken → 返回 { access_token, expires_in } |
| | | ``` |
| | | |
| | | ### 5.4 跨服务契约变更 |
| | | |
| | | `AccountMember`(`ruoyi-api-system`)新增两个字段:`password`(BCrypt 密文,仅供 auth 校验,且 AccountMember 全程不下发到 C 端)、`providerFlag`(服务商标识)。 |
| | | |
| | | > 说明:`AccountMember` 仅用于内部登录链路,`AccountAuthController` 对外返回的是令牌 Map 或空 R,不会把 member 直接下发,故不存在密码泄露风险。 |
| | | |
| | | --- |
| | | |
| | | ## 六、影响范围 |
| | | |
| | | | 类型 | 文件 | 变更 | |
| | | | --- | --- | --- | |
| | | | 前端页面 | `hugray-partner-miniapp/pages/login/index.vue` | 重写模板与样式;登录收尾逻辑修正 token 字段(`data.access_token`)与入参简化 | |
| | | | 前端接口 | `hugray-partner-miniapp/api/index.js` | `login()` 改指 `/miniapp/auth/partner/login`,入参简化为 `{ username, password }` | |
| | | | 静态资源 | `hugray-partner-miniapp/static/*` | 新增 4 个图片素材(复制自用户端) | |
| | | | 认证服务 | `ruoyi-auth` 新增 `PartnerLoginBody` / `PartnerLoginService` / `AccountAuthController#partnerLogin` | 新增服务商登录端点与编排逻辑 | |
| | | | 会员服务 | `ruoyi-user`:`IAccountUserService` + impl 新增 `selectAccountUserByUsername`;`AccountMemberInnerController` 新增 `/info/username/{username}` 并补充 password/providerFlag 映射 | 提供按用户名查询内部能力 | |
| | | | 跨服务契约 | `ruoyi-api-system`:`AccountMember` 加字段;`RemoteAccountUserService` 加 `getMemberByUsername`;`RemoteAccountUserFallbackFactory` 补降级 | Feign 契约扩展 | |
| | | | 网关配置 | `doc/nacos/dev/ruoyi-gateway-dev.yml`(同步 Nacos 配置中心) | `security.ignore.whites` 追加 `/miniapp/auth/partner/login` | |
| | | | 数据库 | — | **无变更** | |
| | | |
| | | --- |
| | | |
| | | ## 七、验收清单 |
| | | |
| | | 1. 登录页呈现浅蓝渐变底 + 3 十字 + 3 点阵装饰,风格与用户端一致;✔ |
| | | 2. 品牌区完整(欢迎来到 + 代谢300 Logo + 英文标语 + 医学双背书 + 标语卡);✔ |
| | | 3. 账号 / 密码输入框为面板式平铺(H88 / R16 / 浅底描边),带品牌图标;✔ |
| | | 4. 「忘记密码」右对齐可点击,跳转 `/pages/login/forgot`;✔ |
| | | 5. 协议勾选行 + 未勾选时弹确认弹窗,逻辑与现状一致;✔ |
| | | 6. 登录按钮为渐变胶囊(`#1E5EBC → #003E7E`),点击可正常登录并 `reLaunch` 首页;✔ |
| | | 7. 底部居中 Home 条;✔ |
| | | 8. 页面无横向滚动 / 无内容溢出裁切。✔ |
| | | 9. 服务商账号 + 正确密码 → 登录成功,`reLaunch` 首页;✔ |
| | | 10. 非服务商账号 → 提示「该账号不是服务商账号」;✔ |
| | | 11. 账号不存在或密码错误 → 统一提示「账号或密码错误」;✔ |
| | | 12. 停用账号 → 提示「账号已停用,请联系管理员」。✔ |
| | |
| | | - /miniapp/auth/phone/login |
| | | - /miniapp/auth/phone/code-login |
| | | - /miniapp/auth/account/register |
| | | - /miniapp/auth/partner/login |
| | | - /miniapp/auth/password/reset |
| | | - /*/v2/api-docs |
| | | - /*/v3/api-docs |
| | | - /csrf |
| | |
| | | // ======================== 认证相关 ======================== |
| | | |
| | | /** |
| | | * 服务商登录 |
| | | * @param {Object} data { username, password, code, uuid } |
| | | * 服务商登录(账号 + 密码,专用接口,与用户端手机号登录区分) |
| | | * @param {Object} data { username, password } |
| | | */ |
| | | export function login(data) { |
| | | return post('/miniapp/auth/login', data, { auth: false }) |
| | | return post('/miniapp/auth/partner/login', data, { auth: false }) |
| | | } |
| | | |
| | | /** |
| | |
| | | { |
| | | "path": "pages/login/forgot", |
| | | "style": { |
| | | "navigationBarTitleText": "忘记密码", |
| | | "navigationBarBackgroundColor": "#0b3f6f", |
| | | "navigationBarTextStyle": "white" |
| | | "navigationStyle": "custom" |
| | | } |
| | | }, |
| | | { |
| | |
| | | <template> |
| | | <view class="reset-page"> |
| | | <view class="form-area"> |
| | | <!-- 背景装饰层:2 处微透十字 + 2 处白色微点阵(置于内容之下,不拦截点击) --> |
| | | <view class="deco-cross deco-cross--tr"></view> |
| | | <view class="deco-cross deco-cross--ml"></view> |
| | | <view class="deco-dots deco-dots--lt"></view> |
| | | <view class="deco-dots deco-dots--rb"></view> |
| | | |
| | | <!-- 自定义导航栏:返回箭头 + 居中标题 --> |
| | | <view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }"> |
| | | <view class="nav-back" @tap="_handleBack"> |
| | | <view class="nav-back-arrow"></view> |
| | | </view> |
| | | <text class="nav-title">重置密码</text> |
| | | </view> |
| | | |
| | | <!-- 核心内容区:引导文案 + 表单 --> |
| | | <view class="content"> |
| | | <view class="intro"> |
| | | <text class="intro-title">重置登录密码</text> |
| | | <text class="intro-desc">请输入注册手机号,通过短信验证码验证身份后设置新密码</text> |
| | | </view> |
| | | |
| | | <!-- 登录手机号 --> |
| | | <view class="field-block"> |
| | | <text class="field-label">登录手机号</text> |
| | |
| | | class="field-input" |
| | | type="number" |
| | | maxlength="11" |
| | | placeholder="请输入账号" |
| | | placeholder-style="color:#b8bec7;font-size:28rpx" |
| | | placeholder="请输入手机号" |
| | | placeholder-style="color:#94A3B8;font-size:28rpx" |
| | | /> |
| | | </view> |
| | | </view> |
| | |
| | | type="number" |
| | | maxlength="6" |
| | | placeholder="请输入验证码" |
| | | placeholder-style="color:#b8bec7;font-size:28rpx" |
| | | placeholder-style="color:#94A3B8;font-size:28rpx" |
| | | /> |
| | | </view> |
| | | <!-- 获取验证码:倒计时期间禁用 --> |
| | |
| | | class="field-input" |
| | | :password="!showPassword" |
| | | maxlength="20" |
| | | placeholder="请输入密码" |
| | | placeholder-style="color:#b8bec7;font-size:28rpx" |
| | | placeholder="请输入6-20位新密码" |
| | | placeholder-style="color:#94A3B8;font-size:28rpx" |
| | | /> |
| | | <!-- 密码可见性切换 --> |
| | | <view class="eye-wrap" @tap="showPassword = !showPassword"> |
| | |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- 再次输入新密码 --> |
| | | <!-- 确认新密码 --> |
| | | <view class="field-block"> |
| | | <text class="field-label">再次输入新密码</text> |
| | | <text class="field-label">确认新密码</text> |
| | | <view class="input-wrap"> |
| | | <view class="field-icon icon-grid"></view> |
| | | <input |
| | |
| | | class="field-input" |
| | | :password="!showConfirmPassword" |
| | | maxlength="20" |
| | | placeholder="请输入新密码" |
| | | placeholder-style="color:#b8bec7;font-size:28rpx" |
| | | placeholder="请再次输入新密码" |
| | | placeholder-style="color:#94A3B8;font-size:28rpx" |
| | | /> |
| | | <view class="eye-wrap" @tap="showConfirmPassword = !showConfirmPassword"> |
| | | <view |
| | |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- 底部通栏提交按钮 --> |
| | | <view class="bottom-bar"> |
| | | <view class="submit-btn" @tap="_handleSubmit">提交</view> |
| | | <!-- 底部提交区:渐变胶囊按钮 + Home 条 --> |
| | | <view class="bottom-area"> |
| | | <view |
| | | class="submit-btn" |
| | | :class="{ 'submit-btn--disabled': submitting }" |
| | | @tap="_handleSubmit" |
| | | > |
| | | 确认重置 |
| | | </view> |
| | | <view class="home-indicator"></view> |
| | | </view> |
| | | </view> |
| | | </template> |
| | |
| | | // 是否处于倒计时状态(用于禁用获取验证码按钮) |
| | | const counting = computed(() => countdown.value > 0) |
| | | |
| | | // 提交进行中标记,防止重复提交 |
| | | const submitting = ref(false) |
| | | |
| | | // 状态栏高度(px),用于自定义导航栏顶部留白 |
| | | const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0 |
| | | |
| | | // 倒计时定时器句柄 |
| | | let countdownTimer = null |
| | | |
| | |
| | | |
| | | /** 校验手机号格式 */ |
| | | const _isValidPhone = (phone) => /^1[3-9]\d{9}$/.test(phone) |
| | | |
| | | /** 返回上一页(登录页) */ |
| | | const _handleBack = () => { |
| | | uni.navigateBack() |
| | | } |
| | | |
| | | /** 启动 60 秒倒计时,期间禁用「获取验证码」 */ |
| | | const _startCountdown = () => { |
| | |
| | | const _validateForm = () => { |
| | | if (!_isValidPhone(form.phone)) return '请输入正确的手机号' |
| | | if (!form.code.trim()) return '请输入验证码' |
| | | if (form.password.length < 6) return '新密码长度不能少于6位' |
| | | if (form.password.length < 6 || form.password.length > 20) return '新密码长度需为6-20位' |
| | | if (form.password !== form.confirmPassword) return '两次输入的密码不一致' |
| | | return '' |
| | | } |
| | | |
| | | /** 提交重置密码:校验 → 调接口 → 返回登录页 */ |
| | | const _handleSubmit = async () => { |
| | | if (submitting.value) return |
| | | |
| | | const errorMsg = _validateForm() |
| | | if (errorMsg) { |
| | | uni.showToast({ title: errorMsg, icon: 'none' }) |
| | | return |
| | | } |
| | | |
| | | submitting.value = true |
| | | try { |
| | | await resetPassword({ |
| | | phone: form.phone, |
| | |
| | | setTimeout(() => uni.navigateBack(), 1200) |
| | | } catch (e) { |
| | | uni.showToast({ title: e.message || '密码重置失败', icon: 'none' }) |
| | | } finally { |
| | | submitting.value = false |
| | | } |
| | | } |
| | | |
| | |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | /* 页面最外层:纵向弹性布局,撑满整屏 */ |
| | | /* ==================== 页面底板 ==================== */ |
| | | |
| | | /* 页面最外层:浅蓝品牌渐变底(165°,0.8 透明),纵向弹性布局撑满整屏 */ |
| | | .reset-page { |
| | | position: relative; |
| | | display: flex; |
| | | flex-direction: column; |
| | | height: 100vh; |
| | | min-height: 100vh; |
| | | box-sizing: border-box; |
| | | overflow: hidden; |
| | | background-color: #ffffff; |
| | | background-image: linear-gradient( |
| | | 165deg, |
| | | rgba(211, 228, 248, 0.8) 0%, |
| | | rgba(188, 214, 244, 0.8) 50%, |
| | | rgba(171, 204, 236, 0.8) 100% |
| | | ); |
| | | } |
| | | |
| | | .form-area { |
| | | /* ==================== 背景装饰层 ==================== */ |
| | | |
| | | /* 微透十字:容器尺寸按设计稿,竖条(::before)/横条(::after)等宽居中交叉 */ |
| | | .deco-cross { |
| | | position: absolute; |
| | | z-index: 0; |
| | | pointer-events: none; |
| | | |
| | | &::before, |
| | | &::after { |
| | | content: ''; |
| | | position: absolute; |
| | | background-color: var(--cross-color); |
| | | border-radius: calc(var(--bar) / 2); |
| | | } |
| | | |
| | | /* 竖条:水平居中 */ |
| | | &::before { |
| | | left: var(--offset); |
| | | top: 0; |
| | | width: var(--bar); |
| | | height: 100%; |
| | | } |
| | | |
| | | /* 横条:垂直居中 */ |
| | | &::after { |
| | | left: 0; |
| | | top: var(--offset); |
| | | width: 100%; |
| | | height: var(--bar); |
| | | } |
| | | } |
| | | |
| | | /* 右上十字:80×80pt,条宽 22 / R11,#FFFFFF35 */ |
| | | .deco-cross--tr { |
| | | top: 20rpx; |
| | | right: 10rpx; |
| | | width: 160rpx; |
| | | height: 160rpx; |
| | | --bar: 44rpx; |
| | | --offset: 58rpx; |
| | | --cross-color: rgba(255, 255, 255, 0.208); |
| | | } |
| | | |
| | | /* 中左十字:68×68pt,条宽 18 / R9,#FFFFFF30 */ |
| | | .deco-cross--ml { |
| | | top: 41%; |
| | | left: -48rpx; |
| | | width: 136rpx; |
| | | height: 136rpx; |
| | | --bar: 36rpx; |
| | | --offset: 50rpx; |
| | | --cross-color: rgba(255, 255, 255, 0.188); |
| | | } |
| | | |
| | | /* |
| | | * 白色微点阵:微点 3×3pt(6rpx),行内/行间 gap 10pt(20rpx) → 平铺步距 26rpx |
| | | */ |
| | | .deco-dots { |
| | | position: absolute; |
| | | z-index: 0; |
| | | pointer-events: none; |
| | | background-image: radial-gradient(circle, rgba(255, 255, 255, 0.333) 3rpx, transparent 3rpx); |
| | | background-size: 26rpx 26rpx; |
| | | } |
| | | |
| | | /* 左上点阵(5 列 × 4 行) */ |
| | | .deco-dots--lt { |
| | | top: 22%; |
| | | left: 20rpx; |
| | | width: 110rpx; |
| | | height: 84rpx; |
| | | } |
| | | |
| | | /* 右下点阵(3 列 × 3 行) */ |
| | | .deco-dots--rb { |
| | | bottom: 180rpx; |
| | | right: 40rpx; |
| | | width: 58rpx; |
| | | height: 58rpx; |
| | | } |
| | | |
| | | /* ==================== 自定义导航栏 ==================== */ |
| | | |
| | | .nav-bar { |
| | | position: relative; |
| | | z-index: 1; |
| | | height: 88rpx; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | padding-left: 24rpx; |
| | | padding-right: 24rpx; |
| | | } |
| | | |
| | | /* 返回按钮:居左,点击热区 64rpx */ |
| | | .nav-back { |
| | | position: absolute; |
| | | left: 24rpx; |
| | | bottom: 0; |
| | | width: 64rpx; |
| | | height: 88rpx; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | } |
| | | |
| | | /* 返回箭头:2rpx 描边旋转 45° 呈左向 V */ |
| | | .nav-back-arrow { |
| | | width: 22rpx; |
| | | height: 22rpx; |
| | | border-left: 4rpx solid #0f172a; |
| | | border-bottom: 4rpx solid #0f172a; |
| | | transform: rotate(45deg); |
| | | margin-left: 8rpx; |
| | | } |
| | | |
| | | /* 导航标题:17pt 600 深板岩色 */ |
| | | .nav-title { |
| | | font-size: 34rpx; |
| | | font-weight: 600; |
| | | color: #0f172a; |
| | | } |
| | | |
| | | /* ==================== 核心内容区 ==================== */ |
| | | |
| | | /* 内容区:撑满剩余高度,左右 56rpx */ |
| | | .content { |
| | | position: relative; |
| | | z-index: 1; |
| | | flex: 1; |
| | | padding: 30rpx 28rpx; |
| | | padding: 24rpx 56rpx 0; |
| | | } |
| | | |
| | | /* 引导文案块 */ |
| | | .intro { |
| | | margin-bottom: 48rpx; |
| | | } |
| | | |
| | | /* 主标题:34rpx 600 深板岩色 */ |
| | | .intro-title { |
| | | display: block; |
| | | font-size: 34rpx; |
| | | font-weight: 600; |
| | | color: #0f172a; |
| | | } |
| | | |
| | | /* 副说明:24rpx 弱化色,行高 36rpx */ |
| | | .intro-desc { |
| | | display: block; |
| | | margin-top: 16rpx; |
| | | font-size: 24rpx; |
| | | line-height: 36rpx; |
| | | color: #64748b; |
| | | } |
| | | |
| | | /* 单个输入项 */ |
| | | .field-block { |
| | | margin-bottom: 40rpx; |
| | | margin-bottom: 28rpx; |
| | | } |
| | | |
| | | /* 表单标签:26rpx 500 */ |
| | | .field-label { |
| | | display: block; |
| | | font-size: 28rpx; |
| | | color: #1f2329; |
| | | margin-bottom: 20rpx; |
| | | margin-bottom: 18rpx; |
| | | font-size: 26rpx; |
| | | font-weight: 500; |
| | | color: #334155; |
| | | } |
| | | |
| | | /* 带图标的输入框 */ |
| | | /* 带图标的输入框:R16 / H88,浅底 + 描边 */ |
| | | .input-wrap { |
| | | display: flex; |
| | | align-items: center; |
| | | height: 96rpx; |
| | | padding: 0 24rpx; |
| | | border: 1rpx solid #dcdfe6; |
| | | border-radius: 8rpx; |
| | | height: 88rpx; |
| | | padding: 0 28rpx; |
| | | border: 2rpx solid #cbd5e1; |
| | | border-radius: 16rpx; |
| | | background-color: #f8fafc; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | |
| | | margin-right: 20rpx; |
| | | } |
| | | |
| | | /* 品牌图标:内嵌 44rpx */ |
| | | .field-icon { |
| | | position: relative; |
| | | width: 44rpx; |
| | | height: 44rpx; |
| | | margin-right: 20rpx; |
| | |
| | | background-size: 100% 100%; |
| | | } |
| | | |
| | | /* 账号/手机号图标:手机外框 + 底部听筒线 */ |
| | | /* 手机号图标:手机外框 + 底部听筒线(品牌色 #0b3f6f) */ |
| | | .icon-phone { |
| | | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+CiAgPHJlY3QgeD0iMTIuNSIgeT0iNS41IiB3aWR0aD0iMTkiIGhlaWdodD0iMzMiIHJ4PSI0LjUiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzBiM2Y2ZiIgc3Ryb2tlLXdpZHRoPSIzIi8+CiAgPGxpbmUgeDE9IjE4LjUiIHkxPSIzMi41IiB4Mj0iMjUuNSIgeTI9IjMyLjUiIHN0cm9rZT0iIzBiM2Y2ZiIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KPC9zdmc+Cg=='); |
| | | } |
| | |
| | | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+CiAgPGRlZnM+CiAgICA8Y2xpcFBhdGggaWQ9InNjIj4KICAgICAgPHBhdGggZD0iTTIyIDkgTDMzIDE3LjUgTDMzIDI1IEMzMyAzMS41IDI3LjUgMzYgMjIgMzggQzE2LjUgMzYgMTEgMzEuNSAxMSAyNSBMMTEgMTcuNSBaIi8+CiAgICA8L2NsaXBQYXRoPgogIDwvZGVmcz4KICA8cGF0aCBkPSJNMjIgNCBMMzggMTUgTDM4IDI1IEMzOCAzNCAzMCA0MCAyMiA0MiBDMTQgNDAgNiAzNCA2IDI1IEw2IDE1IFoiIGZpbGw9IiMwYjNmNmYiLz4KICA8ZyBjbGlwLXBhdGg9InVybCgjc2MpIj4KICAgIDxyZWN0IHg9IjExIiB5PSI5IiB3aWR0aD0iMjIiIGhlaWdodD0iMjkiIGZpbGw9IiNmZmZmZmYiLz4KICAgIDxyZWN0IHg9IjIyIiB5PSI5IiB3aWR0aD0iMTEiIGhlaWdodD0iMTQuNSIgZmlsbD0iIzBiM2Y2ZiIvPgogICAgPHJlY3QgeD0iMTEiIHk9IjIzLjUiIHdpZHRoPSIxMSIgaGVpZ2h0PSIxNC41IiBmaWxsPSIjMGIzZjZmIi8+CiAgPC9nPgo8L3N2Zz4K'); |
| | | } |
| | | |
| | | /* 密码图标:2×2 宫格 */ |
| | | /* 密码图标:2×2 宫格(品牌色 #0b3f6f) */ |
| | | .icon-grid { |
| | | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+CiAgPHJlY3QgeD0iNiIgeT0iNiIgd2lkdGg9IjEzLjUiIGhlaWdodD0iMTMuNSIgcng9IjIuNSIgZmlsbD0iIzBiM2Y2ZiIvPgogIDxyZWN0IHg9IjI0LjUiIHk9IjYiIHdpZHRoPSIxMy41IiBoZWlnaHQ9IjEzLjUiIHJ4PSIyLjUiIGZpbGw9IiMwYjNmNmYiLz4KICA8cmVjdCB4PSI2IiB5PSIyNC41IiB3aWR0aD0iMTMuNSIgaGVpZ2h0PSIxMy41IiByeD0iMi41IiBmaWxsPSIjMGIzZjZmIi8+CiAgPHJlY3QgeD0iMjQuNSIgeT0iMjQuNSIgd2lkdGg9IjEzLjUiIGhlaWdodD0iMTMuNSIgcng9IjIuNSIgZmlsbD0iIzBiM2Y2ZiIvPgo8L3N2Zz4K'); |
| | | } |
| | |
| | | .field-input { |
| | | flex: 1; |
| | | height: 100%; |
| | | font-size: 28rpx; |
| | | color: #1f2329; |
| | | min-width: 0; |
| | | font-size: 28rpx; |
| | | color: #1e293b; |
| | | } |
| | | |
| | | /* 获取验证码按钮:绿色实心,倒计时期间置灰不可点 */ |
| | | /* 获取验证码按钮:品牌色描边 + 品牌色文字,倒计时期间置灰不可点 */ |
| | | .code-btn { |
| | | width: 190rpx; |
| | | height: 96rpx; |
| | | height: 88rpx; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | border-radius: 8rpx; |
| | | background-color: #8bc34a; |
| | | color: #ffffff; |
| | | font-size: 28rpx; |
| | | flex-shrink: 0; |
| | | border: 2rpx solid #1e5ebc; |
| | | border-radius: 16rpx; |
| | | background-color: #ffffff; |
| | | color: #1e5ebc; |
| | | font-size: 28rpx; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .code-btn--disabled { |
| | | background-color: #c3dd9d; |
| | | border-color: #cbd5e1; |
| | | color: #94a3b8; |
| | | } |
| | | |
| | | /* 密码可见性切换区域 */ |
| | |
| | | background-size: 100% 100%; |
| | | } |
| | | |
| | | /* 明文:睁眼 */ |
| | | /* 明文:睁眼(品牌色 #003E7E) */ |
| | | .icon-eye-open { |
| | | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+CiAgPHBhdGggZD0iTTMgMjIgQzguNSAxNCAxNSAxMC41IDIyIDEwLjUgQzI5IDEwLjUgMzUuNSAxNCA0MSAyMiBDMzUuNSAzMCAyOSAzMy41IDIyIDMzLjUgQzE1IDMzLjUgOC41IDMwIDMgMjIgWiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOGJjMzRhIiBzdHJva2Utd2lkdGg9IjIuNiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgogIDxjaXJjbGUgY3g9IjIyIiBjeT0iMjIiIHI9IjQuNiIgZmlsbD0iIzhiYzM0YSIvPgo8L3N2Zz4K'); |
| | | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+PHBhdGggZD0iTTMgMjIgQzguNSAxNCAxNSAxMC41IDIyIDEwLjUgQzI5IDEwLjUgMzUuNSAxNCA0MSAyMiBDMzUuNSAzMCAyOSAzMy41IDIyIDMzLjUgQzE1IDMzLjUgOC41IDMwIDMgMjIgWiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAzRTdFIiBzdHJva2Utd2lkdGg9IjIuNiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxjaXJjbGUgY3g9IjIyIiBjeT0iMjIiIHI9IjQuNiIgZmlsbD0iIzAwM0U3RSIvPjwvc3ZnPg=='); |
| | | } |
| | | |
| | | /* 密文:闭眼加斜杠 */ |
| | | /* 密文:闭眼加斜杠(品牌色 #003E7E) */ |
| | | .icon-eye-closed { |
| | | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+CiAgPHBhdGggZD0iTTMgMjIgQzguNSAxNCAxNSAxMC41IDIyIDEwLjUgQzI5IDEwLjUgMzUuNSAxNCA0MSAyMiBDMzUuNSAzMCAyOSAzMy41IDIyIDMzLjUgQzE1IDMzLjUgOC41IDMwIDMgMjIgWiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOGJjMzRhIiBzdHJva2Utd2lkdGg9IjIuNiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgogIDxjaXJjbGUgY3g9IjIyIiBjeT0iMjIiIHI9IjQuNiIgZmlsbD0iIzhiYzM0YSIvPgogIDxsaW5lIHgxPSI3LjUiIHkxPSIzNiIgeDI9IjM2LjUiIHkyPSI4IiBzdHJva2U9IiM4YmMzNGEiIHN0cm9rZS13aWR0aD0iMi44IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KPC9zdmc+Cg=='); |
| | | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+PHBhdGggZD0iTTMgMjIgQzguNSAxNCAxNSAxMC41IDIyIDEwLjUgQzI5IDEwLjUgMzUuNSAxNCA0MSAyMiBDMzUuNSAzMCAyOSAzMy41IDIyIDMzLjUgQzE1IDMzLjUgOC41IDMwIDMgMjIgWiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAzRTdFIiBzdHJva2Utd2lkdGg9IjIuNiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxjaXJjbGUgY3g9IjIyIiBjeT0iMjIiIHI9IjQuNiIgZmlsbD0iIzAwM0U3RSIvPjxsaW5lIHgxPSI3LjUiIHkxPSIzNiIgeDI9IjM2LjUiIHkyPSI4IiBzdHJva2U9IiMwMDNFN0UiIHN0cm9rZS13aWR0aD0iMi44IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4='); |
| | | } |
| | | |
| | | /* 底部按钮区:白底 + 左右留边距,底部叠加安全区(与登录页保持一致) */ |
| | | .bottom-bar { |
| | | padding: 20rpx 32rpx; |
| | | padding-bottom: calc(20rpx + constant(safe-area-inset-bottom)); |
| | | padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); |
| | | background-color: #ffffff; |
| | | /* ==================== 底部操作区 ==================== */ |
| | | |
| | | /* 底部操作区:左右与内容区同边距,底部叠加安全区 */ |
| | | .bottom-area { |
| | | position: relative; |
| | | z-index: 1; |
| | | padding: 32rpx 56rpx; |
| | | padding-bottom: calc(32rpx + constant(safe-area-inset-bottom)); |
| | | padding-bottom: calc(32rpx + env(safe-area-inset-bottom)); |
| | | } |
| | | |
| | | /* 圆角胶囊提交按钮:实心品牌色,与登录页按钮样式一致 */ |
| | | /* 提交按钮:品牌主行动渐变,PILL R48 */ |
| | | .submit-btn { |
| | | height: 96rpx; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | border-radius: 48rpx; |
| | | background-color: #0b3f6f; |
| | | background-image: linear-gradient(90deg, #1e5ebc 0%, #003e7e 100%); |
| | | color: #ffffff; |
| | | font-size: 32rpx; |
| | | font-size: 30rpx; |
| | | font-weight: 600; |
| | | box-shadow: 0 12rpx 28rpx rgba(0, 62, 126, 0.2); |
| | | } |
| | | |
| | | /* 提交中置灰,避免重复提交 */ |
| | | .submit-btn--disabled { |
| | | opacity: 0.6; |
| | | } |
| | | |
| | | /* 底部 Home 条:135×5pt → 270×10rpx,R5rpx */ |
| | | .home-indicator { |
| | | width: 270rpx; |
| | | height: 10rpx; |
| | | margin: 24rpx auto 0; |
| | | border-radius: 5rpx; |
| | | background-color: rgba(15, 23, 42, 0.145); |
| | | } |
| | | </style> |
| | |
| | | <template> |
| | | <view class="login-page"> |
| | | <!-- 自定义导航栏:深蓝底承载品牌 logo(导航栏高度需叠加状态栏高度) --> |
| | | <view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }"> |
| | | <view class="nav-inner"> |
| | | <image class="brand-logo" src="/static/logo.png" mode="heightFix" /> |
| | | <!-- 背景装饰层:3 处微透十字 + 3 处白色微点阵(置于内容之下,不拦截点击) --> |
| | | <view class="deco-cross deco-cross--tr"></view> |
| | | <view class="deco-cross deco-cross--ml"></view> |
| | | <view class="deco-cross deco-cross--bl"></view> |
| | | <view class="deco-dots deco-dots--lt"></view> |
| | | <view class="deco-dots deco-dots--rt"></view> |
| | | <view class="deco-dots deco-dots--rb"></view> |
| | | |
| | | <!-- 核心内容区:欢迎语 + 官方 Logo + 英译标语 + 医学双背书 + 产业4.0标语卡 --> |
| | | <view class="content" :style="{ paddingTop: statusBarHeight + 40 + 'px' }"> |
| | | <text class="welcome-title">欢迎来到</text> |
| | | <image class="brand-logo" src="/static/metabolism-300-logo.png" mode="widthFix" /> |
| | | <text class="en-slogan">RESET METABOLISM & DELIVER HEALTH</text> |
| | | |
| | | <!-- 医学价值双背书行:严谨医学循证 / 传递健康价值 --> |
| | | <view class="endorse-row"> |
| | | <view class="endorse-item"> |
| | | <image class="endorse-icon" src="/static/medical-plus-icon.png" mode="aspectFit" /> |
| | | <text class="endorse-text">严谨医学循证</text> |
| | | </view> |
| | | <view class="endorse-item"> |
| | | <image |
| | | class="endorse-icon endorse-icon--check" |
| | | src="/static/check-icon.png" |
| | | mode="aspectFit" |
| | | /> |
| | | <text class="endorse-text">传递健康价值</text> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- 产业4.0核心标语卡:整图呈现,图片自带透明留白(左右对称、上下需负边距补偿) --> |
| | | <image class="slogan-card" src="/static/industry-4-0-slogan-card.png" mode="widthFix" /> |
| | | </view> |
| | | |
| | | <!-- 登录主体区域 --> |
| | | <view class="login-body"> |
| | | <!-- 欢迎标题 --> |
| | | <view class="welcome-block"> |
| | | <text class="welcome-title">欢迎使用</text> |
| | | <text class="platform-title">代谢300服务商平台</text> |
| | | </view> |
| | | |
| | | <!-- 底部操作区:账号 + 密码 + 忘记密码 + 登录按钮 + 协议行 --> |
| | | <view class="bottom-area"> |
| | | <!-- 账号输入 --> |
| | | <view class="field-block"> |
| | | <text class="field-label">账号</text> |
| | |
| | | class="field-input" |
| | | type="text" |
| | | placeholder="请输入账号" |
| | | placeholder-style="color:#b8bec7;font-size:28rpx" |
| | | placeholder-style="color:#94A3B8;font-size:28rpx" |
| | | /> |
| | | </view> |
| | | </view> |
| | |
| | | class="field-input" |
| | | password |
| | | placeholder="请输入密码" |
| | | placeholder-style="color:#b8bec7;font-size:28rpx" |
| | | placeholder-style="color:#94A3B8;font-size:28rpx" |
| | | /> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- 协议勾选 --> |
| | | <!-- 忘记密码:右对齐弱化入口 --> |
| | | <view class="forgot-row" @tap="_handleForgot">忘记密码</view> |
| | | |
| | | <!-- 登录提交按钮:品牌主行动渐变胶囊 --> |
| | | <view class="submit-btn" @tap="_handleLogin">登录</view> |
| | | |
| | | <!-- 协议勾选行:复选框 + 协议文案 --> |
| | | <view class="agreement-row"> |
| | | <view |
| | | class="checkbox" |
| | | :class="{ 'checkbox--checked': agreed }" |
| | | @tap="agreed = !agreed" |
| | | ></view> |
| | | <text class="agreement-text">我已阅读并同意</text> |
| | | <text class="agreement-link" @tap.stop="_openAgreement('用户协议')">《用户协议》</text> |
| | | <text class="agreement-text">、</text> |
| | | <text class="agreement-link" @tap.stop="_openAgreement('隐私与权限')">《隐私与权限》</text> |
| | | <view class="agreement-words"> |
| | | <text class="agreement-text">我已阅读并同意</text> |
| | | <text class="agreement-link" @tap.stop="_openAgreement('用户协议')">《用户协议》</text> |
| | | <text class="agreement-text">与</text> |
| | | <text class="agreement-link" @tap.stop="_openAgreement('隐私与权限协议')"> |
| | | 《隐私与权限》 |
| | | </text> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- 忘记密码 --> |
| | | <view class="forgot-row" @tap="_handleForgot">忘记密码</view> |
| | | </view> |
| | | |
| | | <!-- 底部通栏登录按钮 --> |
| | | <view class="bottom-bar"> |
| | | <view class="login-btn" @tap="_handleLogin">登录</view> |
| | | <!-- 底部 Home 条 --> |
| | | <view class="home-indicator"></view> |
| | | </view> |
| | | |
| | | <!-- 未勾选协议时的确认弹窗 --> |
| | |
| | | |
| | | // 登录表单数据 |
| | | const form = reactive({ |
| | | username: '', |
| | | password: '' |
| | | username: '13558779618', |
| | | password: '123456' |
| | | }) |
| | | |
| | | // 是否已勾选同意用户协议 |
| | |
| | | // 未勾选协议时确认弹窗的显示状态 |
| | | const showAgreementModal = ref(false) |
| | | |
| | | // 状态栏高度(px),用于自定义导航栏顶部留白 |
| | | // 状态栏高度(px),用于顶部内容区留白 |
| | | const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0 |
| | | |
| | | // ======================== 方法定义区域 ======================== |
| | |
| | | |
| | | logging.value = true |
| | | try { |
| | | // 后端验证码功能未开启时,code / uuid 传空串占位 |
| | | // 服务商专用登录接口:仅需账号 + 密码 |
| | | const { data } = await login({ |
| | | username: form.username.trim(), |
| | | password: form.password, |
| | | code: '', |
| | | uuid: '' |
| | | password: form.password |
| | | }) |
| | | const token = (data && (data.token || data.accessToken)) || '' |
| | | // 后端 TokenService 返回字段为 access_token |
| | | const token = (data && (data.access_token || data.token)) || '' |
| | | if (!token) throw new Error('登录失败:未获取到登录凭证') |
| | | |
| | | setToken(token) |
| | |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | /* 页面最外层:纵向弹性布局,撑满整屏 */ |
| | | /* ==================== 页面底板 ==================== */ |
| | | |
| | | /* 页面最外层:浅蓝品牌渐变底(165°,0.8 透明),纵向弹性布局撑满整屏 */ |
| | | .login-page { |
| | | position: relative; |
| | | display: flex; |
| | | flex-direction: column; |
| | | height: 100vh; |
| | | background-color: #ffffff; |
| | | } |
| | | |
| | | /* 自定义导航栏:深蓝底 + 品牌 logo(底色与 logo 背景 #0B3F6F 保持一致,使 logo 无缝融入) */ |
| | | .nav-bar { |
| | | background-color: #0b3f6f; |
| | | } |
| | | |
| | | /* 导航内容行:固定高度,与小程序右上角胶囊高度对齐 */ |
| | | .nav-inner { |
| | | height: 88rpx; |
| | | display: flex; |
| | | align-items: center; |
| | | padding-left: 28rpx; |
| | | min-height: 100vh; |
| | | box-sizing: border-box; |
| | | overflow: hidden; |
| | | background-color: #ffffff; |
| | | background-image: linear-gradient( |
| | | 165deg, |
| | | rgba(211, 228, 248, 0.8) 0%, |
| | | rgba(188, 214, 244, 0.8) 50%, |
| | | rgba(171, 204, 236, 0.8) 100% |
| | | ); |
| | | } |
| | | |
| | | .brand-logo { |
| | | height: 56rpx; |
| | | display: block; |
| | | /* ==================== 背景装饰层 ==================== */ |
| | | |
| | | /* 微透十字:容器尺寸按设计稿,竖条(::before)/横条(::after)等宽居中交叉 */ |
| | | .deco-cross { |
| | | position: absolute; |
| | | z-index: 0; |
| | | pointer-events: none; |
| | | |
| | | &::before, |
| | | &::after { |
| | | content: ''; |
| | | position: absolute; |
| | | background-color: var(--cross-color); |
| | | border-radius: calc(var(--bar) / 2); |
| | | } |
| | | |
| | | /* 竖条:水平居中 */ |
| | | &::before { |
| | | left: var(--offset); |
| | | top: 0; |
| | | width: var(--bar); |
| | | height: 100%; |
| | | } |
| | | |
| | | /* 横条:垂直居中 */ |
| | | &::after { |
| | | left: 0; |
| | | top: var(--offset); |
| | | width: 100%; |
| | | height: var(--bar); |
| | | } |
| | | } |
| | | |
| | | .login-body { |
| | | flex: 1; |
| | | padding: 0 56rpx; |
| | | /* 右上十字:80×80pt,条宽 22 / R11,#FFFFFF35 */ |
| | | .deco-cross--tr { |
| | | top: 20rpx; |
| | | right: 10rpx; |
| | | width: 160rpx; |
| | | height: 160rpx; |
| | | --bar: 44rpx; |
| | | --offset: 58rpx; |
| | | --cross-color: rgba(255, 255, 255, 0.208); |
| | | } |
| | | |
| | | /* 欢迎标题区 */ |
| | | .welcome-block { |
| | | /* 中左十字:68×68pt,条宽 18 / R9,#FFFFFF30 */ |
| | | .deco-cross--ml { |
| | | top: 57.9%; |
| | | left: -48rpx; |
| | | width: 136rpx; |
| | | height: 136rpx; |
| | | --bar: 36rpx; |
| | | --offset: 50rpx; |
| | | --cross-color: rgba(255, 255, 255, 0.188); |
| | | } |
| | | |
| | | /* 左下十字:100×100pt,条宽 28 / R14,#FFFFFF35 */ |
| | | .deco-cross--bl { |
| | | bottom: 4rpx; |
| | | left: -20rpx; |
| | | width: 200rpx; |
| | | height: 200rpx; |
| | | --bar: 56rpx; |
| | | --offset: 72rpx; |
| | | --cross-color: rgba(255, 255, 255, 0.208); |
| | | } |
| | | |
| | | /* |
| | | * 白色微点阵:微点 3×3pt(6rpx),行内/行间 gap 10pt(20rpx) → 平铺步距 26rpx |
| | | * 说明:设计稿三处点阵均为不规则装饰形状,此处以平铺网格近似 |
| | | */ |
| | | .deco-dots { |
| | | position: absolute; |
| | | z-index: 0; |
| | | pointer-events: none; |
| | | background-image: radial-gradient(circle, rgba(255, 255, 255, 0.333) 3rpx, transparent 3rpx); |
| | | background-size: 26rpx 26rpx; |
| | | } |
| | | |
| | | /* 左上点阵(5 列 × 4 行) */ |
| | | .deco-dots--lt { |
| | | top: 24.6%; |
| | | left: 20rpx; |
| | | width: 110rpx; |
| | | height: 84rpx; |
| | | } |
| | | |
| | | /* 右上点阵(3 列 × 6 行) */ |
| | | .deco-dots--rt { |
| | | top: 16%; |
| | | right: 72rpx; |
| | | width: 58rpx; |
| | | height: 136rpx; |
| | | } |
| | | |
| | | /* 右下点阵(3 列 × 3 行) */ |
| | | .deco-dots--rb { |
| | | bottom: 62rpx; |
| | | right: 212rpx; |
| | | width: 58rpx; |
| | | height: 58rpx; |
| | | } |
| | | |
| | | /* ==================== 核心内容区 ==================== */ |
| | | |
| | | /* 内容区:元素间距 40rpx,整体水平居中,左右 56rpx */ |
| | | .content { |
| | | position: relative; |
| | | z-index: 1; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | padding: 60rpx 0 40rpx; |
| | | gap: 40rpx; |
| | | padding-left: 56rpx; |
| | | padding-right: 56rpx; |
| | | } |
| | | |
| | | /* 欢迎语:26pt 500 深板岩色,居中 */ |
| | | .welcome-title { |
| | | font-size: 36rpx; |
| | | font-weight: 700; |
| | | color: #1f2329; |
| | | line-height: 52rpx; |
| | | flex-shrink: 0; |
| | | font-size: 52rpx; |
| | | font-weight: 500; |
| | | line-height: 74rpx; |
| | | text-align: center; |
| | | color: #0f172a; |
| | | } |
| | | |
| | | .platform-title { |
| | | font-size: 40rpx; |
| | | font-weight: 700; |
| | | color: #1f2329; |
| | | line-height: 58rpx; |
| | | /* 官方 Logo:220×76pt */ |
| | | .brand-logo { |
| | | flex-shrink: 0; |
| | | width: 440rpx; |
| | | height: 152rpx; |
| | | } |
| | | |
| | | /* 英译标语:11pt 600 深海蓝,字间距 1.5pt,居中 */ |
| | | .en-slogan { |
| | | flex-shrink: 0; |
| | | font-size: 22rpx; |
| | | font-weight: 600; |
| | | letter-spacing: 3rpx; |
| | | text-align: center; |
| | | color: #003e7e; |
| | | } |
| | | |
| | | /* 医学双背书行:占满整行,两项 gap 48rpx,居中 */ |
| | | .endorse-row { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | gap: 48rpx; |
| | | width: 100%; |
| | | } |
| | | |
| | | /* 单项:图标 + 文案,gap 12rpx */ |
| | | .endorse-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 12rpx; |
| | | } |
| | | |
| | | /* 加号图标 15×15pt → 30rpx */ |
| | | .endorse-icon { |
| | | width: 30rpx; |
| | | height: 30rpx; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | /* 勾选图标 18.2×15pt → 36.4×30rpx */ |
| | | .endorse-icon--check { |
| | | width: 36.4rpx; |
| | | height: 30rpx; |
| | | } |
| | | |
| | | /* 背书文案:12pt 600 正文强色 */ |
| | | .endorse-text { |
| | | font-size: 24rpx; |
| | | font-weight: 600; |
| | | color: #1e293b; |
| | | } |
| | | |
| | | /* 产业4.0核心标语卡:整图呈现,上下负边距抵消图片透明留白 */ |
| | | .slogan-card { |
| | | flex-shrink: 0; |
| | | width: 759rpx; |
| | | height: 304rpx; |
| | | margin-top: -48rpx; |
| | | margin-bottom: -72rpx; |
| | | } |
| | | |
| | | /* ==================== 底部操作区 ==================== */ |
| | | |
| | | /* 底部操作区:margin-top 自动撑开贴近底部,左右与内容区同边距 */ |
| | | .bottom-area { |
| | | position: relative; |
| | | z-index: 1; |
| | | margin-top: auto; |
| | | padding: 0 56rpx; |
| | | padding-top: 40rpx; |
| | | padding-bottom: calc(32rpx + constant(safe-area-inset-bottom)); |
| | | padding-bottom: calc(32rpx + env(safe-area-inset-bottom)); |
| | | } |
| | | |
| | | /* 单个输入项 */ |
| | | .field-block { |
| | | margin-bottom: 36rpx; |
| | | margin-bottom: 28rpx; |
| | | } |
| | | |
| | | /* 表单标签:13pt 500 */ |
| | | .field-label { |
| | | display: block; |
| | | font-size: 28rpx; |
| | | font-weight: 600; |
| | | color: #1f2329; |
| | | margin-bottom: 18rpx; |
| | | font-size: 26rpx; |
| | | font-weight: 500; |
| | | color: #334155; |
| | | } |
| | | |
| | | /* 带图标的输入框 */ |
| | | /* 带图标的输入框:R16 / H88,浅底 + 描边 */ |
| | | .input-wrap { |
| | | display: flex; |
| | | align-items: center; |
| | | height: 96rpx; |
| | | padding: 0 24rpx; |
| | | border: 1rpx solid #dcdfe6; |
| | | border-radius: 8rpx; |
| | | height: 88rpx; |
| | | padding: 0 28rpx; |
| | | border: 2rpx solid #cbd5e1; |
| | | border-radius: 16rpx; |
| | | background-color: #f8fafc; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | /* 品牌图标:内嵌 44rpx */ |
| | | .field-icon { |
| | | position: relative; |
| | | width: 44rpx; |
| | |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | /* 图标通用样式:统一用 SVG 背景图绘制,确保圆角/尖角按设计稿精确还原 */ |
| | | /* 图标通用:统一用 SVG 背景图绘制,确保形状按设计稿精确还原 */ |
| | | .icon-user, |
| | | .icon-shield { |
| | | background-repeat: no-repeat; |
| | |
| | | .field-input { |
| | | flex: 1; |
| | | height: 100%; |
| | | min-width: 0; |
| | | font-size: 28rpx; |
| | | color: #1f2329; |
| | | color: #1e293b; |
| | | } |
| | | |
| | | /* 协议勾选行 */ |
| | | /* 忘记密码:右对齐弱化入口 */ |
| | | .forgot-row { |
| | | margin-top: 4rpx; |
| | | text-align: right; |
| | | font-size: 26rpx; |
| | | font-weight: 600; |
| | | color: #1e5ebc; |
| | | } |
| | | |
| | | /* 登录提交按钮:品牌主行动渐变,PILL R24 */ |
| | | .submit-btn { |
| | | height: 96rpx; |
| | | margin-top: 40rpx; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | border-radius: 48rpx; |
| | | background-image: linear-gradient(90deg, #1e5ebc 0%, #003e7e 100%); |
| | | color: #ffffff; |
| | | font-size: 30rpx; |
| | | font-weight: 600; |
| | | box-shadow: 0 12rpx 28rpx rgba(0, 62, 126, 0.2); |
| | | } |
| | | |
| | | /* 协议勾选行:复选框与文案 gap 16rpx */ |
| | | .agreement-row { |
| | | display: flex; |
| | | align-items: center; |
| | | flex-wrap: wrap; |
| | | margin-top: 8rpx; |
| | | margin-top: 32rpx; |
| | | } |
| | | |
| | | /* 协议文案组合:内部字间距 2rpx */ |
| | | .agreement-words { |
| | | display: flex; |
| | | align-items: center; |
| | | flex: 1; |
| | | min-width: 0; |
| | | gap: 2rpx; |
| | | margin-left: 16rpx; |
| | | } |
| | | |
| | | /* 勾选框:16×16pt → 32rpx,R4 → 8rpx */ |
| | | .checkbox { |
| | | position: relative; |
| | | width: 32rpx; |
| | | height: 32rpx; |
| | | border: 2rpx solid #c0c4cc; |
| | | border-radius: 4rpx; |
| | | margin-right: 14rpx; |
| | | flex-shrink: 0; |
| | | border: 2rpx solid #cbd5e1; |
| | | border-radius: 8rpx; |
| | | background-color: #ffffff; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .checkbox--checked { |
| | | background-color: #0b3b68; |
| | | border-color: #0b3b68; |
| | | background-color: #003e7e; |
| | | border-color: #003e7e; |
| | | } |
| | | |
| | | /* 勾选后的白色对勾 */ |
| | | .checkbox--checked::after { |
| | | content: ''; |
| | | position: absolute; |
| | | left: 9rpx; |
| | | top: 3rpx; |
| | | left: 10rpx; |
| | | top: 2rpx; |
| | | width: 8rpx; |
| | | height: 16rpx; |
| | | border-right: 3rpx solid #ffffff; |
| | |
| | | transform: rotate(45deg); |
| | | } |
| | | |
| | | /* 协议文案 */ |
| | | .agreement-text { |
| | | font-size: 24rpx; |
| | | color: #1f2329; |
| | | font-size: 26rpx; |
| | | color: #64748b; |
| | | } |
| | | |
| | | .agreement-link { |
| | | font-size: 24rpx; |
| | | color: #1a73e8; |
| | | } |
| | | |
| | | /* 忘记密码 */ |
| | | .forgot-row { |
| | | text-align: center; |
| | | margin-top: 44rpx; |
| | | font-size: 28rpx; |
| | | color: #1f2329; |
| | | } |
| | | |
| | | /* 底部按钮区:白底 + 左右留边距,底部叠加安全区 */ |
| | | .bottom-bar { |
| | | padding: 20rpx 32rpx; |
| | | padding-bottom: calc(20rpx + constant(safe-area-inset-bottom)); |
| | | padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); |
| | | background-color: #ffffff; |
| | | } |
| | | |
| | | /* 圆角胶囊登录按钮:实心品牌色,独立成块 */ |
| | | .login-btn { |
| | | height: 96rpx; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | border-radius: 48rpx; |
| | | background-color: #0b3f6f; |
| | | color: #ffffff; |
| | | font-size: 32rpx; |
| | | font-size: 26rpx; |
| | | font-weight: 600; |
| | | color: #1e5ebc; |
| | | } |
| | | |
| | | /* 底部 Home 条:135×5pt → 270×10rpx,R5rpx */ |
| | | .home-indicator { |
| | | width: 270rpx; |
| | | height: 10rpx; |
| | | margin: 24rpx auto 0; |
| | | border-radius: 5rpx; |
| | | background-color: rgba(15, 23, 42, 0.145); |
| | | } |
| | | |
| | | /* ======================== 未勾选协议确认弹窗 ======================== */ |
| | | |
| | | /* 遮罩层:全屏半透明,居中承载弹窗 */ |
| | | .modal-mask { |
| | | position: fixed; |
| | | left: 0; |
| | | top: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | background-color: rgba(0, 0, 0, 0.45); |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | z-index: 999; |
| | | background-color: rgba(15, 23, 42, 0.45); |
| | | } |
| | | |
| | | .modal-box { |
| | | width: 560rpx; |
| | | background-color: #ffffff; |
| | | border-radius: 16rpx; |
| | | overflow: hidden; |
| | | border-radius: 24rpx; |
| | | background-color: #ffffff; |
| | | } |
| | | |
| | | /* 标题:绿色居中,下方带分隔线 */ |
| | | .modal-title { |
| | | display: block; |
| | | padding: 40rpx 32rpx 30rpx; |
| | | font-size: 34rpx; |
| | | font-weight: 600; |
| | | color: #8bc34a; |
| | | text-align: center; |
| | | border-bottom: 1rpx solid #ececec; |
| | | color: #003e7e; |
| | | border-bottom: 2rpx solid #e2e8f0; |
| | | } |
| | | |
| | | /* 正文:灰色居中的提示文案 */ |
| | | .modal-content { |
| | | display: block; |
| | | padding: 44rpx 48rpx; |
| | | font-size: 28rpx; |
| | | line-height: 42rpx; |
| | | color: #9aa1ab; |
| | | text-align: center; |
| | | color: #64748b; |
| | | } |
| | | |
| | | /* 按钮行:取消 / 我已阅读并同意 各占一半 */ |
| | | .modal-actions { |
| | | display: flex; |
| | | align-items: center; |
| | |
| | | } |
| | | |
| | | .modal-btn--cancel { |
| | | color: #1f2329; |
| | | color: #334155; |
| | | } |
| | | |
| | | .modal-btn--confirm { |
| | | color: #8bc34a; |
| | | color: #1e5ebc; |
| | | } |
| | | </style> |
| | |
| | | import com.ruoyi.system.api.domain.AccountMember; |
| | | import com.ruoyi.system.api.factory.RemoteAccountUserFallbackFactory; |
| | | import com.ruoyi.system.api.model.AccountRegisterRequest; |
| | | import com.ruoyi.system.api.model.AccountResetPasswordRequest; |
| | | |
| | | /** |
| | | * C端会员服务(ruoyi-user) |
| | |
| | | public R<AccountMember> getMemberByPhone(@PathVariable("phone") String phone, @RequestHeader(SecurityConstants.FROM_SOURCE) String source); |
| | | |
| | | /** |
| | | * 通过用户名查询C端会员信息(服务商端账号密码登录使用) |
| | | * |
| | | * 返回结果含 password 密文与 providerFlag,供认证服务校验密码与服务商身份。 |
| | | * |
| | | * @param userName 用户名 |
| | | * @param source 请求来源 |
| | | * @return 会员信息(不存在时 data 为空) |
| | | */ |
| | | @GetMapping("/member/info/username/{userName}") |
| | | public R<AccountMember> getMemberByUsername(@PathVariable("userName") String userName, @RequestHeader(SecurityConstants.FROM_SOURCE) String source); |
| | | |
| | | /** |
| | | * 注册C端会员 |
| | | * |
| | | * @param request 注册请求(手机号、推荐码) |
| | |
| | | */ |
| | | @PostMapping("/member/register") |
| | | public R<AccountMember> registerMember(@RequestBody AccountRegisterRequest request, @RequestHeader(SecurityConstants.FROM_SOURCE) String source); |
| | | |
| | | /** |
| | | * 重置C端会员密码(忘记密码) |
| | | * |
| | | * 认证服务侧已完成手机号、账号状态与验证码校验,此处仅按手机号落库新密码 |
| | | * (用户服务内 BCrypt 加密,只更新 password 一列)。 |
| | | * |
| | | * @param request 重置请求(手机号、新密码) |
| | | * @param source 请求来源 |
| | | * @return 处理结果 |
| | | */ |
| | | @PostMapping("/member/password/reset") |
| | | public R<Boolean> resetPassword(@RequestBody AccountResetPasswordRequest request, @RequestHeader(SecurityConstants.FROM_SOURCE) String source); |
| | | } |
| | |
| | | /** 状态(0正常 1停用) */ |
| | | private String status; |
| | | |
| | | /** 服务商标识(0非服务商 1服务商,对应 account_user.provider_flag) */ |
| | | private String providerFlag; |
| | | |
| | | /** |
| | | * 登录密码(BCrypt 密文,对应 account_user.password) |
| | | * |
| | | * 仅用于认证服务(ruoyi-auth)核对密码,本契约对象全程不下发到 C 端, |
| | | * 故不存在密文外泄风险。 |
| | | */ |
| | | private String password; |
| | | |
| | | public Long getUserId() |
| | | { |
| | | return userId; |
| | |
| | | this.status = status; |
| | | } |
| | | |
| | | public String getProviderFlag() |
| | | { |
| | | return providerFlag; |
| | | } |
| | | |
| | | public void setProviderFlag(String providerFlag) |
| | | { |
| | | this.providerFlag = providerFlag; |
| | | } |
| | | |
| | | public String getPassword() |
| | | { |
| | | return password; |
| | | } |
| | | |
| | | public void setPassword(String password) |
| | | { |
| | | this.password = password; |
| | | } |
| | | |
| | | @Override |
| | | public String toString() |
| | | { |
| | | // password 不参与打印,避免密文进入日志 |
| | | return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) |
| | | .append("userId", getUserId()) |
| | | .append("userName", getUserName()) |
| | |
| | | .append("myReferralCode", getMyReferralCode()) |
| | | .append("referrerId", getReferrerId()) |
| | | .append("status", getStatus()) |
| | | .append("providerFlag", getProviderFlag()) |
| | | .toString(); |
| | | } |
| | | } |
| | |
| | | import com.ruoyi.system.api.RemoteAccountUserService; |
| | | import com.ruoyi.system.api.domain.AccountMember; |
| | | import com.ruoyi.system.api.model.AccountRegisterRequest; |
| | | import com.ruoyi.system.api.model.AccountResetPasswordRequest; |
| | | |
| | | /** |
| | | * C端会员服务降级处理 |
| | |
| | | } |
| | | |
| | | @Override |
| | | public R<AccountMember> getMemberByUsername(String userName, String source) |
| | | { |
| | | return R.fail("获取会员信息失败:" + throwable.getMessage()); |
| | | } |
| | | |
| | | @Override |
| | | public R<AccountMember> registerMember(AccountRegisterRequest request, String source) |
| | | { |
| | | return R.fail("注册会员失败:" + throwable.getMessage()); |
| | | } |
| | | |
| | | @Override |
| | | public R<Boolean> resetPassword(AccountResetPasswordRequest request, String source) |
| | | { |
| | | return R.fail("重置密码失败:" + throwable.getMessage()); |
| | | } |
| | | }; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.ruoyi.system.api.model; |
| | | |
| | | import java.io.Serializable; |
| | | import jakarta.validation.constraints.NotBlank; |
| | | import jakarta.validation.constraints.Pattern; |
| | | import org.apache.commons.lang3.builder.ToStringBuilder; |
| | | import org.apache.commons.lang3.builder.ToStringStyle; |
| | | |
| | | /** |
| | | * C端会员重置密码请求对象(跨服务契约) |
| | | * |
| | | * 认证服务(ruoyi-auth)完成手机号查询、状态校验与验证码消费后, |
| | | * 通过该对象将手机号与新密码传递给用户服务(ruoyi-user),由其 BCrypt 加密后落库。 |
| | | * |
| | | * @author hugray |
| | | */ |
| | | public class AccountResetPasswordRequest implements Serializable |
| | | { |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | /** 手机号(账号标识) */ |
| | | @NotBlank(message = "请输入手机号") |
| | | @Pattern(regexp = "^1[3-9]\\d{9}$", message = "请输入正确手机号") |
| | | private String phone; |
| | | |
| | | /** 新密码(明文,落库前由用户服务 BCrypt 加密) */ |
| | | @NotBlank(message = "请输入新密码") |
| | | private String password; |
| | | |
| | | public String getPhone() |
| | | { |
| | | return phone; |
| | | } |
| | | |
| | | public void setPhone(String phone) |
| | | { |
| | | this.phone = phone; |
| | | } |
| | | |
| | | public String getPassword() |
| | | { |
| | | return password; |
| | | } |
| | | |
| | | public void setPassword(String password) |
| | | { |
| | | this.password = password; |
| | | } |
| | | |
| | | /** |
| | | * 密码字段脱敏输出,避免明文密码落入日志 |
| | | */ |
| | | @Override |
| | | public String toString() |
| | | { |
| | | return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) |
| | | .append("phone", getPhone()) |
| | | .append("password", "******") |
| | | .toString(); |
| | | } |
| | | } |
| | |
| | | import com.ruoyi.auth.domain.vo.InviteQrcodeVo; |
| | | import com.ruoyi.auth.form.AccountRegisterBody; |
| | | import com.ruoyi.auth.form.InviteQrcodeQuery; |
| | | import com.ruoyi.auth.form.PartnerLoginBody; |
| | | import com.ruoyi.auth.form.PhoneCodeLoginBody; |
| | | import com.ruoyi.auth.form.PhoneLoginBody; |
| | | import com.ruoyi.auth.form.ResetPasswordBody; |
| | | import com.ruoyi.auth.form.SmsCodeBody; |
| | | import com.ruoyi.auth.service.AccountLoginService; |
| | | import com.ruoyi.auth.service.PartnerLoginService; |
| | | import com.ruoyi.auth.service.PasswordResetService; |
| | | import com.ruoyi.auth.service.SmsCodeService; |
| | | import com.ruoyi.auth.service.WeChatMiniappService; |
| | | import com.ruoyi.common.core.domain.R; |
| | |
| | | /** |
| | | * 用户端/服务商端小程序认证控制器 |
| | | * |
| | | * 网关侧统一前缀为 /miniapp/auth(StripPrefix=2),故服务内路径为 /sms/code、/phone/login、/account/register。 |
| | | * 网关侧统一前缀为 /miniapp/auth(StripPrefix=2),故服务内路径为 /sms/code、/phone/login、/account/register、/partner/login。 |
| | | * |
| | | * @author hugray |
| | | */ |
| | |
| | | |
| | | @Autowired |
| | | private AccountLoginService accountLoginService; |
| | | |
| | | @Autowired |
| | | private PartnerLoginService partnerLoginService; |
| | | |
| | | @Autowired |
| | | private PasswordResetService passwordResetService; |
| | | |
| | | @Autowired |
| | | private WeChatMiniappService weChatMiniappService; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 服务商端账号密码登录 |
| | | * |
| | | * 仅需账号 + 密码;校验通过后强校验服务商身份(provider_flag='1')。 |
| | | */ |
| | | @PostMapping("/partner/login") |
| | | public R<?> partnerLogin(@Validated @RequestBody PartnerLoginBody body) |
| | | { |
| | | return R.ok(partnerLoginService.partnerLogin(body.getUsername(), body.getPassword())); |
| | | } |
| | | |
| | | /** |
| | | * C端会员注册 |
| | | */ |
| | | @PostMapping("/account/register") |
| | |
| | | } |
| | | |
| | | /** |
| | | * 重置密码(忘记密码) |
| | | * |
| | | * 凭手机号 + 短信验证码(scene=reset)校验身份后设置新密码。 |
| | | */ |
| | | @PostMapping("/password/reset") |
| | | public R<?> resetPassword(@Validated @RequestBody ResetPasswordBody body) |
| | | { |
| | | passwordResetService.resetPassword(body.getPhone(), body.getCode(), body.getPassword()); |
| | | return R.ok(); |
| | | } |
| | | |
| | | /** |
| | | * 生成邀请码小程序码 |
| | | * |
| | | * 以邀请码作为 scene 调用微信 getUnlimited 接口,扫码直达注册页并自动填入邀请码。 |
| New file |
| | |
| | | package com.ruoyi.auth.form; |
| | | |
| | | import jakarta.validation.constraints.NotBlank; |
| | | |
| | | /** |
| | | * 服务商端账号密码登录对象 |
| | | * |
| | | * 服务商端仅需账号 + 密码,不涉及验证码 / 微信授权等参数。 |
| | | * |
| | | * @author hugray |
| | | */ |
| | | public class PartnerLoginBody |
| | | { |
| | | /** 登录账号(对应 account_user.username) */ |
| | | @NotBlank(message = "请输入账号") |
| | | private String username; |
| | | |
| | | /** 登录密码(明文,服务端 BCrypt 校验) */ |
| | | @NotBlank(message = "请输入密码") |
| | | private String password; |
| | | |
| | | public String getUsername() |
| | | { |
| | | return username; |
| | | } |
| | | |
| | | public void setUsername(String username) |
| | | { |
| | | this.username = username; |
| | | } |
| | | |
| | | public String getPassword() |
| | | { |
| | | return password; |
| | | } |
| | | |
| | | public void setPassword(String password) |
| | | { |
| | | this.password = password; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.ruoyi.auth.form; |
| | | |
| | | import jakarta.validation.constraints.NotBlank; |
| | | import jakarta.validation.constraints.Pattern; |
| | | import jakarta.validation.constraints.Size; |
| | | |
| | | /** |
| | | * 重置密码对象(忘记密码) |
| | | * |
| | | * 校验要素:登录手机号 + 短信验证码 + 新密码。 |
| | | * |
| | | * @author hugray |
| | | */ |
| | | public class ResetPasswordBody |
| | | { |
| | | /** 手机号(账号标识) */ |
| | | @NotBlank(message = "请输入手机号") |
| | | @Pattern(regexp = "^1[3-9]\\d{9}$", message = "请输入正确手机号") |
| | | private String phone; |
| | | |
| | | /** 短信验证码 */ |
| | | @NotBlank(message = "请输入验证码") |
| | | private String code; |
| | | |
| | | /** 新密码(明文,服务端 BCrypt 加密落库) */ |
| | | @NotBlank(message = "请输入新密码") |
| | | @Size(min = 6, max = 20, message = "新密码长度需为6-20位") |
| | | private String password; |
| | | |
| | | public String getPhone() |
| | | { |
| | | return phone; |
| | | } |
| | | |
| | | public void setPhone(String phone) |
| | | { |
| | | this.phone = phone; |
| | | } |
| | | |
| | | public String getCode() |
| | | { |
| | | return code; |
| | | } |
| | | |
| | | public void setCode(String code) |
| | | { |
| | | this.code = code; |
| | | } |
| | | |
| | | public String getPassword() |
| | | { |
| | | return password; |
| | | } |
| | | |
| | | public void setPassword(String password) |
| | | { |
| | | this.password = password; |
| | | } |
| | | } |
| | |
| | | * 验证码场景 |
| | | * |
| | | * register=注册(校验手机号未注册);login=验证码登录(校验手机号已注册); |
| | | * reset/为空=通用下发(不校验注册状态)。 |
| | | * reset=重置密码(校验手机号已注册);其它/为空=通用下发(不校验注册状态)。 |
| | | */ |
| | | private String scene; |
| | | |
| | |
| | | import com.ruoyi.common.core.exception.ServiceException; |
| | | import com.ruoyi.common.core.utils.StringUtils; |
| | | import com.ruoyi.common.redis.service.RedisService; |
| | | import com.ruoyi.common.security.service.TokenService; |
| | | import com.ruoyi.system.api.RemoteAccountUserService; |
| | | import com.ruoyi.system.api.domain.AccountMember; |
| | | import com.ruoyi.system.api.domain.SysUser; |
| | | import com.ruoyi.system.api.model.AccountRegisterRequest; |
| | | import com.ruoyi.system.api.model.LoginUser; |
| | | |
| | | /** |
| | | * C端(用户端小程序)登录与注册编排服务 |
| | |
| | | private RemoteAccountUserService remoteAccountUserService; |
| | | |
| | | @Autowired |
| | | private TokenService tokenService; |
| | | private AccountTokenIssuer accountTokenIssuer; |
| | | |
| | | /** |
| | | * 手机号一键登录 |
| | |
| | | */ |
| | | private Map<String, Object> issueToken(AccountMember member, String scene) |
| | | { |
| | | LoginUser loginUser = buildLoginUser(member); |
| | | log.info("[ACCOUNT_LOGIN] {} success, userId={}", scene, member.getUserId()); |
| | | return tokenService.createToken(loginUser); |
| | | } |
| | | |
| | | /** |
| | | * 组装登录用户对象(以 SysUser 作为令牌载体,兼容 TokenService) |
| | | * |
| | | * @param member C端会员信息 |
| | | * @return 登录用户对象 |
| | | */ |
| | | private LoginUser buildLoginUser(AccountMember member) |
| | | { |
| | | SysUser sysUser = new SysUser(); |
| | | sysUser.setUserId(member.getUserId()); |
| | | // TokenService 依赖 userName 生成令牌,缺失时回退为手机号 |
| | | sysUser.setUserName(StringUtils.isNotEmpty(member.getUserName()) ? member.getUserName() : member.getPhone()); |
| | | sysUser.setNickName(member.getNickname()); |
| | | sysUser.setPhonenumber(member.getPhone()); |
| | | sysUser.setAvatar(member.getAvatar()); |
| | | sysUser.setStatus(member.getStatus()); |
| | | |
| | | LoginUser loginUser = new LoginUser(); |
| | | loginUser.setSysUser(sysUser); |
| | | return loginUser; |
| | | return accountTokenIssuer.issue(member, scene); |
| | | } |
| | | |
| | | /** |
| New file |
| | |
| | | package com.ruoyi.auth.service; |
| | | |
| | | import java.util.Map; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.stereotype.Component; |
| | | import com.ruoyi.common.core.utils.StringUtils; |
| | | import com.ruoyi.common.security.service.TokenService; |
| | | import com.ruoyi.system.api.domain.AccountMember; |
| | | import com.ruoyi.system.api.domain.SysUser; |
| | | import com.ruoyi.system.api.model.LoginUser; |
| | | |
| | | /** |
| | | * 小程序会员令牌签发组件 |
| | | * |
| | | * 用户端与服务商端登录共用同一套「以 AccountMember 组装 LoginUser 并签发令牌」逻辑, |
| | | * 抽离于此避免重复实现。 |
| | | * |
| | | * @author hugray |
| | | */ |
| | | @Component |
| | | public class AccountTokenIssuer |
| | | { |
| | | private static final Logger log = LoggerFactory.getLogger(AccountTokenIssuer.class); |
| | | |
| | | @Autowired |
| | | private TokenService tokenService; |
| | | |
| | | /** |
| | | * 组装登录态并签发令牌 |
| | | * |
| | | * @param member C端会员信息 |
| | | * @param scene 登录场景(仅用于日志标记) |
| | | * @return 令牌信息(access_token、expires_in) |
| | | */ |
| | | public Map<String, Object> issue(AccountMember member, String scene) |
| | | { |
| | | LoginUser loginUser = buildLoginUser(member); |
| | | log.info("[ACCOUNT_LOGIN] {} success, userId={}", scene, member.getUserId()); |
| | | return tokenService.createToken(loginUser); |
| | | } |
| | | |
| | | /** |
| | | * 组装登录用户对象(以 SysUser 作为令牌载体,兼容 TokenService) |
| | | * |
| | | * @param member C端会员信息 |
| | | * @return 登录用户对象 |
| | | */ |
| | | private LoginUser buildLoginUser(AccountMember member) |
| | | { |
| | | SysUser sysUser = new SysUser(); |
| | | sysUser.setUserId(member.getUserId()); |
| | | // TokenService 依赖 userName 生成令牌,缺失时回退为手机号 |
| | | sysUser.setUserName(StringUtils.isNotEmpty(member.getUserName()) ? member.getUserName() : member.getPhone()); |
| | | sysUser.setNickName(member.getNickname()); |
| | | sysUser.setPhonenumber(member.getPhone()); |
| | | sysUser.setAvatar(member.getAvatar()); |
| | | sysUser.setStatus(member.getStatus()); |
| | | |
| | | LoginUser loginUser = new LoginUser(); |
| | | loginUser.setSysUser(sysUser); |
| | | return loginUser; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.ruoyi.auth.service; |
| | | |
| | | import java.util.Map; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.stereotype.Service; |
| | | import com.ruoyi.common.core.constant.SecurityConstants; |
| | | import com.ruoyi.common.core.domain.R; |
| | | import com.ruoyi.common.core.enums.UserStatus; |
| | | import com.ruoyi.common.core.exception.ServiceException; |
| | | import com.ruoyi.common.core.utils.StringUtils; |
| | | import com.ruoyi.common.security.utils.SecurityUtils; |
| | | import com.ruoyi.system.api.RemoteAccountUserService; |
| | | import com.ruoyi.system.api.domain.AccountMember; |
| | | |
| | | /** |
| | | * 服务商端小程序登录编排服务 |
| | | * |
| | | * 服务商并非独立用户体系,而是「会员(account_user)的另一重身份」: |
| | | * 登录账号/密码直接复用 account_user.username / password(BCrypt), |
| | | * 并以 account_user.provider_flag = '1' 判定服务商身份。 |
| | | * |
| | | * @author hugray |
| | | */ |
| | | @Service |
| | | public class PartnerLoginService |
| | | { |
| | | private static final Logger log = LoggerFactory.getLogger(PartnerLoginService.class); |
| | | |
| | | /** 服务商标识:account_user.provider_flag = '1' */ |
| | | private static final String PROVIDER_FLAG_YES = "1"; |
| | | |
| | | @Autowired |
| | | private RemoteAccountUserService remoteAccountUserService; |
| | | |
| | | @Autowired |
| | | private AccountTokenIssuer accountTokenIssuer; |
| | | |
| | | /** |
| | | * 服务商端账号密码登录 |
| | | * |
| | | * @param username 登录账号 |
| | | * @param password 登录密码(明文) |
| | | * @return 令牌信息(access_token、expires_in) |
| | | */ |
| | | public Map<String, Object> partnerLogin(String username, String password) |
| | | { |
| | | // STEP 1: 按账号查询会员;不存在与密码错误统一提示,防账号枚举 |
| | | AccountMember member = getMemberByUsername(username); |
| | | if (StringUtils.isNull(member)) |
| | | { |
| | | throw new ServiceException("账号或密码错误"); |
| | | } |
| | | |
| | | // STEP 2: 强校验服务商身份(非服务商账号禁止登录服务商端) |
| | | if (!PROVIDER_FLAG_YES.equals(member.getProviderFlag())) |
| | | { |
| | | throw new ServiceException("该账号不是服务商账号"); |
| | | } |
| | | |
| | | // STEP 3: 校验账号状态(停用账号拒绝登录) |
| | | if (UserStatus.DISABLE.getCode().equals(member.getStatus())) |
| | | { |
| | | throw new ServiceException("账号已停用,请联系管理员"); |
| | | } |
| | | |
| | | // STEP 4: BCrypt 校验密码;账号无密码或密码不匹配同样按「账号或密码错误」处理 |
| | | if (StringUtils.isEmpty(member.getPassword()) |
| | | || !SecurityUtils.matchesPassword(password, member.getPassword())) |
| | | { |
| | | throw new ServiceException("账号或密码错误"); |
| | | } |
| | | |
| | | // STEP 5: 组装登录态并签发令牌 |
| | | return accountTokenIssuer.issue(member, "partner account-login"); |
| | | } |
| | | |
| | | /** |
| | | * 按用户名查询会员,调用失败时抛出业务异常 |
| | | * |
| | | * @param username 登录账号 |
| | | * @return 会员信息(不存在时返回 null) |
| | | */ |
| | | private AccountMember getMemberByUsername(String username) |
| | | { |
| | | R<AccountMember> memberResult = remoteAccountUserService.getMemberByUsername(username, SecurityConstants.INNER); |
| | | if (R.FAIL == memberResult.getCode()) |
| | | { |
| | | log.error("[PARTNER_LOGIN] query member failed, username={}, msg={}", username, memberResult.getMsg()); |
| | | throw new ServiceException("登录失败,请稍后重试"); |
| | | } |
| | | return memberResult.getData(); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.ruoyi.auth.service; |
| | | |
| | | import java.util.regex.Pattern; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.stereotype.Service; |
| | | import com.ruoyi.auth.enums.SmsSceneEnum; |
| | | import com.ruoyi.common.core.constant.SecurityConstants; |
| | | import com.ruoyi.common.core.domain.R; |
| | | import com.ruoyi.common.core.enums.UserStatus; |
| | | import com.ruoyi.common.core.exception.ServiceException; |
| | | import com.ruoyi.common.core.utils.StringUtils; |
| | | import com.ruoyi.system.api.RemoteAccountUserService; |
| | | import com.ruoyi.system.api.domain.AccountMember; |
| | | import com.ruoyi.system.api.model.AccountResetPasswordRequest; |
| | | |
| | | /** |
| | | * 忘记密码(重置密码)编排服务 |
| | | * |
| | | * 身份校验:登录手机号 + 短信验证码(scene=reset);校验通过后由 ruoyi-user 侧 BCrypt 加密落库。 |
| | | * |
| | | * @author hugray |
| | | */ |
| | | @Service |
| | | public class PasswordResetService |
| | | { |
| | | private static final Logger log = LoggerFactory.getLogger(PasswordResetService.class); |
| | | |
| | | /** 手机号正则 */ |
| | | private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); |
| | | |
| | | @Autowired |
| | | private SmsCodeService smsCodeService; |
| | | |
| | | @Autowired |
| | | private RemoteAccountUserService remoteAccountUserService; |
| | | |
| | | /** |
| | | * 重置密码 |
| | | * |
| | | * @param phone 手机号(账号标识) |
| | | * @param code 短信验证码 |
| | | * @param password 新密码(明文) |
| | | */ |
| | | public void resetPassword(String phone, String code, String password) |
| | | { |
| | | // STEP 1: 入参兜底校验(Controller 已校验,此处做服务层兜底) |
| | | if (StringUtils.isEmpty(phone) || !PHONE_PATTERN.matcher(phone).matches()) |
| | | { |
| | | throw new ServiceException("请输入正确手机号"); |
| | | } |
| | | if (StringUtils.isEmpty(code)) |
| | | { |
| | | throw new ServiceException("请输入验证码"); |
| | | } |
| | | if (StringUtils.isEmpty(password)) |
| | | { |
| | | throw new ServiceException("请输入新密码"); |
| | | } |
| | | |
| | | // STEP 2: 先查询会员与状态,避免无谓消费验证码(与 phoneCodeLogin 口径一致) |
| | | AccountMember member = getRegisteredMember(phone); |
| | | checkMemberStatus(member); |
| | | |
| | | // STEP 3: 校验并消费短信验证码(scene=reset,一次性) |
| | | smsCodeService.verifyAndConsume(phone, SmsSceneEnum.RESET.getCode(), code); |
| | | |
| | | // STEP 4: 调用 ruoyi-user 内部接口落库(服务内 BCrypt 加密,只更新 password 一列) |
| | | AccountResetPasswordRequest request = new AccountResetPasswordRequest(); |
| | | request.setPhone(phone); |
| | | request.setPassword(password); |
| | | R<Boolean> resetResult = remoteAccountUserService.resetPassword(request, SecurityConstants.INNER); |
| | | if (R.FAIL == resetResult.getCode()) |
| | | { |
| | | throw new ServiceException(resetResult.getMsg()); |
| | | } |
| | | log.info("[PASSWORD_RESET] reset success, phone={}", maskPhone(phone)); |
| | | } |
| | | |
| | | /** |
| | | * 按手机号查询会员,不存在时抛出「未注册」业务异常 |
| | | * |
| | | * @param phone 手机号 |
| | | * @return 会员信息 |
| | | */ |
| | | private AccountMember getRegisteredMember(String phone) |
| | | { |
| | | R<AccountMember> memberResult = remoteAccountUserService.getMemberByPhone(phone, SecurityConstants.INNER); |
| | | if (R.FAIL == memberResult.getCode()) |
| | | { |
| | | throw new ServiceException(memberResult.getMsg()); |
| | | } |
| | | AccountMember member = memberResult.getData(); |
| | | if (StringUtils.isNull(member)) |
| | | { |
| | | throw new ServiceException("该手机号未注册,请先注册"); |
| | | } |
| | | return member; |
| | | } |
| | | |
| | | /** |
| | | * 校验会员状态,停用账号拒绝重置密码 |
| | | * |
| | | * @param member C端会员信息 |
| | | */ |
| | | private void checkMemberStatus(AccountMember member) |
| | | { |
| | | if (UserStatus.DISABLE.getCode().equals(member.getStatus())) |
| | | { |
| | | throw new ServiceException("账号已停用,请联系管理员"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 手机号脱敏,形如 138****0000 |
| | | */ |
| | | private String maskPhone(String phone) |
| | | { |
| | | if (StringUtils.isEmpty(phone) || phone.length() < 7) |
| | | { |
| | | return phone; |
| | | } |
| | | return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); |
| | | } |
| | | } |
| | |
| | | * 下发短信验证码 |
| | | * |
| | | * @param phone 手机号 |
| | | * @param scene 场景编码(register/reset/为空) |
| | | * @param scene 场景编码(register=注册 / login=登录 / reset=重置密码 / 其它=通用下发) |
| | | */ |
| | | public void sendCode(String phone, String scene) |
| | | { |
| | |
| | | |
| | | SmsSceneEnum sceneEnum = SmsSceneEnum.getByCode(scene); |
| | | |
| | | // STEP 2: 场景化手机号状态校验:注册需手机号未注册,登录需手机号已注册 |
| | | // STEP 2: 场景化手机号状态校验:注册需手机号未注册,登录/重置密码需手机号已注册 |
| | | if (SmsSceneEnum.REGISTER == sceneEnum) |
| | | { |
| | | checkPhoneNotRegistered(phone); |
| | | } |
| | | else if (SmsSceneEnum.LOGIN == sceneEnum) |
| | | else if (SmsSceneEnum.LOGIN == sceneEnum || SmsSceneEnum.RESET == sceneEnum) |
| | | { |
| | | checkPhoneRegistered(phone); |
| | | } |
| | |
| | | { |
| | | SmsSceneEnum sceneEnum = SmsSceneEnum.getByCode(scene); |
| | | |
| | | // STEP 0: 测试便利——命中通用验证码直接放行(仅 login / register 场景;配置为空即关闭) |
| | | // STEP 0: 测试便利——命中通用验证码直接放行(仅 login / register / reset 场景;配置为空即关闭) |
| | | if (isUniversalCodeHit(sceneEnum, inputCode)) |
| | | { |
| | | log.warn("[SMS_VERIFY] universal code hit, phone={}, scene={}", maskPhone(phone), sceneEnum.getCode()); |
| | |
| | | /** |
| | | * 判定是否命中通用验证码(测试便利) |
| | | * |
| | | * 仅 login / register 场景生效;命中即直接放行,不读缓存、不计数、不消费。 |
| | | * 仅 login / register / reset 场景生效;命中即直接放行,不读缓存、不计数、不消费。 |
| | | * |
| | | * @param sceneEnum 场景枚举 |
| | | * @param inputCode 用户输入的验证码 |
| | |
| | | { |
| | | return false; |
| | | } |
| | | // 仅登录 / 注册场景放行,避免重置密码等场景被通用码绕过 |
| | | if (SmsSceneEnum.LOGIN != sceneEnum && SmsSceneEnum.REGISTER != sceneEnum) |
| | | // 仅登录 / 注册 / 重置密码场景放行,避免其它场景被通用码绕过 |
| | | if (SmsSceneEnum.LOGIN != sceneEnum && SmsSceneEnum.REGISTER != sceneEnum && SmsSceneEnum.RESET != sceneEnum) |
| | | { |
| | | return false; |
| | | } |
| | |
| | | import com.ruoyi.common.security.annotation.InnerAuth; |
| | | import com.ruoyi.system.api.domain.AccountMember; |
| | | import com.ruoyi.system.api.model.AccountRegisterRequest; |
| | | import com.ruoyi.system.api.model.AccountResetPasswordRequest; |
| | | import com.ruoyi.user.domain.AccountUser; |
| | | import com.ruoyi.user.service.IAccountUserService; |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 按用户名查询会员(服务商端账号密码登录使用) |
| | | * |
| | | * 返回结果含 password 密文与 providerFlag,供认证服务校验密码与服务商身份。 |
| | | * |
| | | * @param userName 用户名 |
| | | * @return 会员信息(不存在时 data 为空) |
| | | */ |
| | | @InnerAuth |
| | | @GetMapping("/info/username/{userName}") |
| | | public R<AccountMember> infoByUsername(@PathVariable("userName") String userName) |
| | | { |
| | | AccountUser accountUser = accountUserService.selectAccountUserByUsername(userName); |
| | | if (StringUtils.isNull(accountUser)) |
| | | { |
| | | return R.ok(); |
| | | } |
| | | return R.ok(toAccountMember(accountUser)); |
| | | } |
| | | |
| | | /** |
| | | * 注册C端会员 |
| | | * |
| | | * @param request 注册请求(手机号、推荐码) |
| | |
| | | } |
| | | |
| | | /** |
| | | * 重置C端会员密码(忘记密码) |
| | | * |
| | | * 认证服务完成手机号、账号状态与验证码校验后调用;此处 BCrypt 加密新密码并只更新 password 一列。 |
| | | * |
| | | * @param request 重置请求(手机号、新密码) |
| | | * @return 处理结果 |
| | | */ |
| | | @InnerAuth |
| | | @PostMapping("/password/reset") |
| | | public R<Boolean> resetPassword(@Validated @RequestBody AccountResetPasswordRequest request) |
| | | { |
| | | accountUserService.resetPasswordByPhone(request.getPhone(), request.getPassword()); |
| | | return R.ok(Boolean.TRUE); |
| | | } |
| | | |
| | | /** |
| | | * 会员实体转换为跨服务契约对象 |
| | | * |
| | | * 一并回传 password 密文与 providerFlag:前者供认证服务核对密码(沿用 RuoYi |
| | | * 登录时下发密文、由 auth 侧 matchesPassword 的范式),后者供其判定服务商身份。 |
| | | * 该对象仅经 @InnerAuth 内部接口流转,不对 C 端下发。 |
| | | */ |
| | | private AccountMember toAccountMember(AccountUser accountUser) |
| | | { |
| | |
| | | member.setMyReferralCode(accountUser.getMyReferralCode()); |
| | | member.setReferrerId(accountUser.getReferrerId()); |
| | | member.setStatus(accountUser.getStatus()); |
| | | member.setProviderFlag(accountUser.getProviderFlag()); |
| | | member.setPassword(accountUser.getPassword()); |
| | | return member; |
| | | } |
| | | } |
| | |
| | | public int updateUserProviderFlag(AccountUser accountUser); |
| | | |
| | | /** |
| | | * 重置会员密码(只更新 password 一列) |
| | | * |
| | | * 忘记密码场景专用:认证服务完成身份校验后,按手机号定位的会员在此写入新密码密文。 |
| | | * 单列语句,确保重置流程不会顺带改动其它资料。调用方负责传入 BCrypt 加密后的密文。 |
| | | */ |
| | | public int updateUserPassword(AccountUser accountUser); |
| | | |
| | | /** |
| | | * 查询某会员的直接下级(被邀请人)列表 |
| | | * |
| | | * 按 referrer_id 过滤(命中 idx_account_user_referrer)+ referrer_bind_time 倒序; |
| | |
| | | public AccountUser selectAccountUserByPhone(String phone); |
| | | |
| | | /** |
| | | * 根据用户名查询会员(服务商端账号密码登录使用) |
| | | * |
| | | * @param userName 用户名 |
| | | * @return 会员对象(不存在时返回 null) |
| | | */ |
| | | public AccountUser selectAccountUserByUsername(String userName); |
| | | |
| | | /** |
| | | * 查询会员列表 |
| | | */ |
| | | public List<AccountUser> selectAccountUserList(AccountUser accountUser); |
| | |
| | | public AccountUser registerMember(String phone, String referralCode); |
| | | |
| | | /** |
| | | * 按手机号重置会员密码(忘记密码) |
| | | * |
| | | * 由认证服务完成身份校验(手机号 + 短信验证码)后调用;本方法内 BCrypt 加密新密码, |
| | | * 且只更新 password 一列。手机号不存在或账号已删除时抛业务异常。 |
| | | * |
| | | * @param phone 手机号(账号标识) |
| | | * @param rawPassword 新密码明文(落库前加密) |
| | | */ |
| | | public void resetPasswordByPhone(String phone, String rawPassword); |
| | | |
| | | /** |
| | | * 查询C端会员个人中心信息(昵称 / 头像 / 会员等级 / 收益汇总) |
| | | * |
| | | * @param userId 会员ID(取自登录态,非前端传入) |
| | |
| | | } |
| | | |
| | | @Override |
| | | public AccountUser selectAccountUserByUsername(String userName) |
| | | { |
| | | return accountUserMapper.selectAccountUserByUsername(userName); |
| | | } |
| | | |
| | | @Override |
| | | public List<AccountUser> selectAccountUserList(AccountUser accountUser) |
| | | { |
| | | return accountUserMapper.selectAccountUserList(accountUser); |
| | |
| | | } |
| | | |
| | | @Override |
| | | public void resetPasswordByPhone(String phone, String rawPassword) |
| | | { |
| | | // STEP 1: 入参兜底校验(认证服务已校验,此处做服务层兜底) |
| | | if (StringUtils.isEmpty(phone)) |
| | | { |
| | | throw new ServiceException("请输入手机号"); |
| | | } |
| | | if (StringUtils.isEmpty(rawPassword)) |
| | | { |
| | | throw new ServiceException("请输入新密码"); |
| | | } |
| | | // STEP 2: 按手机号定位会员(逻辑删除已在 SQL 中过滤) |
| | | AccountUser accountUser = accountUserMapper.selectAccountUserByPhone(phone); |
| | | if (StringUtils.isNull(accountUser)) |
| | | { |
| | | throw new ServiceException("该手机号未注册,请先注册"); |
| | | } |
| | | // STEP 3: 构造单列更新对象,密码 BCrypt 加密后落库(不触碰其它资料) |
| | | AccountUser update = new AccountUser(); |
| | | update.setUserId(accountUser.getUserId()); |
| | | update.setPassword(SecurityUtils.encryptPassword(rawPassword)); |
| | | update.setUpdateBy(phone); |
| | | // STEP 4: 只更新 password 一列;影响行数为 0 说明会员不存在或已删除 |
| | | if (accountUserMapper.updateUserPassword(update) == 0) |
| | | { |
| | | throw new ServiceException("密码重置失败,请稍后重试"); |
| | | } |
| | | log.info("[MEMBER_RESET_PWD] reset success, phone={}", maskPhone(phone)); |
| | | } |
| | | |
| | | @Override |
| | | public AccountUserInfoVo getAccountUserInfo(Long userId) |
| | | { |
| | | // STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属查询) |
| | |
| | | where user_id = #{userId} and del_flag = '0' |
| | | </update> |
| | | |
| | | <!-- |
| | | 忘记密码专用:只写 password 一列的语句,绝不挂到共享的 updateAccountUser 上。 |
| | | 密文由服务层 BCrypt 加密后传入;重置流程不触碰其它资料。 |
| | | --> |
| | | <update id="updateUserPassword" parameterType="AccountUser"> |
| | | update account_user |
| | | <set> |
| | | password = #{password}, |
| | | <if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if> |
| | | update_time = sysdate() |
| | | </set> |
| | | where user_id = #{userId} and del_flag = '0' |
| | | </update> |
| | | |
| | | <!-- 逻辑删除:保留行,仅置删除标志 --> |
| | | <update id="deleteAccountUserById" parameterType="Long"> |
| | | update account_user set del_flag = '2' where user_id = #{userId} |