zjk
7 小时以前 22e936e491b49fddbc664dd0709df55545d2d1fd
feat(服务商小程序与认证服务): 实现服务商小程序登录与忘记密码功能

后端新增服务商账号密码登录专用接口、密码重置接口,完善短信验证码场景校验逻辑,新增相关服务类、实体类、Mapper与Feign配置;前端重构对应页面UI并对齐品牌规范,更新接口调用逻辑;添加网关白名单与所需静态图片资源,修正小程序配置,新增功能设计文档。
12个文件已添加
17个文件已修改
1906 ■■■■■ 已修改文件
doc/design/服务商端_忘记密码_20261014.md 194 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/design/服务商端_登录_20261014.md 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/nacos/dev/ruoyi-gateway-dev.yml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-partner-miniapp/api/index.js 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-partner-miniapp/pages.json 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-partner-miniapp/pages/login/forgot.vue 333 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-partner-miniapp/pages/login/index.vue 473 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-partner-miniapp/static/check-icon.png 补丁 | 查看 | 原始文档 | blame | 历史
hugray-partner-miniapp/static/industry-4-0-slogan-card.png 补丁 | 查看 | 原始文档 | blame | 历史
hugray-partner-miniapp/static/medical-plus-icon.png 补丁 | 查看 | 原始文档 | blame | 历史
hugray-partner-miniapp/static/metabolism-300-logo.png 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/RemoteAccountUserService.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/domain/AccountMember.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/factory/RemoteAccountUserFallbackFactory.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/model/AccountResetPasswordRequest.java 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/AccountAuthController.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/PartnerLoginBody.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/ResetPasswordBody.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/SmsCodeBody.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/AccountLoginService.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/AccountTokenIssuer.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/PartnerLoginService.java 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/PasswordResetService.java 126 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/SmsCodeService.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountMemberInnerController.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountUserMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/design/服务商端_忘记密码_20261014.md
New file
@@ -0,0 +1,194 @@
# 服务商端小程序 忘记密码(重置密码)开发设计文档
- 模块:`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`。
doc/design/服务商端_登录_20261014.md
New file
@@ -0,0 +1,172 @@
# 服务商端小程序 登录(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. 停用账号 → 提示「账号已停用,请联系管理员」。✔
doc/nacos/dev/ruoyi-gateway-dev.yml
@@ -107,6 +107,8 @@
      - /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
hugray-partner-miniapp/api/index.js
@@ -10,11 +10,11 @@
// ======================== 认证相关 ========================
/**
 * 服务商登录
 * @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 })
}
/**
hugray-partner-miniapp/pages.json
@@ -17,9 +17,7 @@
        {
            "path": "pages/login/forgot",
            "style": {
                "navigationBarTitleText": "忘记密码",
                "navigationBarBackgroundColor": "#0b3f6f",
                "navigationBarTextStyle": "white"
                "navigationStyle": "custom"
            }
        },
        {
hugray-partner-miniapp/pages/login/forgot.vue
@@ -1,6 +1,26 @@
<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>
@@ -11,8 +31,8 @@
                        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>
@@ -29,7 +49,7 @@
                            type="number"
                            maxlength="6"
                            placeholder="请输入验证码"
                            placeholder-style="color:#b8bec7;font-size:28rpx"
                            placeholder-style="color:#94A3B8;font-size:28rpx"
                        />
                    </view>
                    <!-- 获取验证码:倒计时期间禁用 -->
@@ -53,8 +73,8 @@
                        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">
@@ -66,9 +86,9 @@
                </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
@@ -76,8 +96,8 @@
                        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
@@ -89,9 +109,16 @@
            </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>
@@ -123,6 +150,12 @@
// 是否处于倒计时状态(用于禁用获取验证码按钮)
const counting = computed(() => countdown.value > 0)
// 提交进行中标记,防止重复提交
const submitting = ref(false)
// 状态栏高度(px),用于自定义导航栏顶部留白
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
// 倒计时定时器句柄
let countdownTimer = null
@@ -130,6 +163,11 @@
/** 校验手机号格式 */
const _isValidPhone = (phone) => /^1[3-9]\d{9}$/.test(phone)
/** 返回上一页(登录页) */
const _handleBack = () => {
    uni.navigateBack()
}
/** 启动 60 秒倒计时,期间禁用「获取验证码」 */
const _startCountdown = () => {
@@ -164,19 +202,22 @@
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,
@@ -187,6 +228,8 @@
        setTimeout(() => uni.navigateBack(), 1200)
    } catch (e) {
        uni.showToast({ title: e.message || '密码重置失败', icon: 'none' })
    } finally {
        submitting.value = false
    }
}
@@ -197,39 +240,204 @@
</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;
}
@@ -245,7 +453,9 @@
    margin-right: 20rpx;
}
/* 品牌图标:内嵌 44rpx */
.field-icon {
    position: relative;
    width: 44rpx;
    height: 44rpx;
    margin-right: 20rpx;
@@ -255,7 +465,7 @@
    background-size: 100% 100%;
}
/* 账号/手机号图标:手机外框 + 底部听筒线 */
/* 手机号图标:手机外框 + 底部听筒线(品牌色 #0b3f6f) */
.icon-phone {
    background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+CiAgPHJlY3QgeD0iMTIuNSIgeT0iNS41IiB3aWR0aD0iMTkiIGhlaWdodD0iMzMiIHJ4PSI0LjUiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzBiM2Y2ZiIgc3Ryb2tlLXdpZHRoPSIzIi8+CiAgPGxpbmUgeDE9IjE4LjUiIHkxPSIzMi41IiB4Mj0iMjUuNSIgeTI9IjMyLjUiIHN0cm9rZT0iIzBiM2Y2ZiIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KPC9zdmc+Cg==');
}
@@ -265,7 +475,7 @@
    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');
}
@@ -273,27 +483,30 @@
.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;
}
/* 密码可见性切换区域 */
@@ -314,34 +527,52 @@
    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>
hugray-partner-miniapp/pages/login/index.vue
@@ -1,20 +1,41 @@
<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 &amp; 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>
@@ -25,7 +46,7 @@
                        class="field-input"
                        type="text"
                        placeholder="请输入账号"
                        placeholder-style="color:#b8bec7;font-size:28rpx"
                        placeholder-style="color:#94A3B8;font-size:28rpx"
                    />
                </view>
            </view>
@@ -40,31 +61,36 @@
                        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>
        <!-- 未勾选协议时的确认弹窗 -->
@@ -92,8 +118,8 @@
// 登录表单数据
const form = reactive({
    username: '',
    password: ''
    username: '13558779618',
    password: '123456'
})
// 是否已勾选同意用户协议
@@ -105,7 +131,7 @@
// 未勾选协议时确认弹窗的显示状态
const showAgreementModal = ref(false)
// 状态栏高度(px),用于自定义导航栏顶部留白
// 状态栏高度(px),用于顶部内容区留白
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
// ======================== 方法定义区域 ========================
@@ -147,14 +173,13 @@
    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)
@@ -178,84 +203,253 @@
</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;
@@ -264,7 +458,7 @@
    flex-shrink: 0;
}
/* 图标通用样式:统一用 SVG 背景图绘制,确保圆角/尖角按设计稿精确还原 */
/* 图标通用:统一用 SVG 背景图绘制,确保形状按设计稿精确还原 */
.icon-user,
.icon-shield {
    background-repeat: no-repeat;
@@ -285,40 +479,75 @@
.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;
@@ -326,90 +555,68 @@
    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;
@@ -424,10 +631,10 @@
}
.modal-btn--cancel {
    color: #1f2329;
    color: #334155;
}
.modal-btn--confirm {
    color: #8bc34a;
    color: #1e5ebc;
}
</style>
hugray-partner-miniapp/static/check-icon.png
hugray-partner-miniapp/static/industry-4-0-slogan-card.png
hugray-partner-miniapp/static/medical-plus-icon.png
hugray-partner-miniapp/static/metabolism-300-logo.png
hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/RemoteAccountUserService.java
@@ -12,6 +12,7 @@
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)
@@ -32,6 +33,18 @@
    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 注册请求(手机号、推荐码)
@@ -40,4 +53,17 @@
     */
    @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);
}
hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/domain/AccountMember.java
@@ -40,6 +40,17 @@
    /** 状态(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;
@@ -120,9 +131,30 @@
        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())
@@ -132,6 +164,7 @@
            .append("myReferralCode", getMyReferralCode())
            .append("referrerId", getReferrerId())
            .append("status", getStatus())
            .append("providerFlag", getProviderFlag())
            .toString();
    }
}
hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/factory/RemoteAccountUserFallbackFactory.java
@@ -8,6 +8,7 @@
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端会员服务降级处理
@@ -32,10 +33,22 @@
            }
            @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());
            }
        };
    }
}
hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/model/AccountResetPasswordRequest.java
New file
@@ -0,0 +1,61 @@
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();
    }
}
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/AccountAuthController.java
@@ -9,10 +9,14 @@
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;
@@ -20,7 +24,7 @@
/**
 * 用户端/服务商端小程序认证控制器
 *
 * 网关侧统一前缀为 /miniapp/auth(StripPrefix=2),故服务内路径为 /sms/code、/phone/login、/account/register。
 * 网关侧统一前缀为 /miniapp/auth(StripPrefix=2),故服务内路径为 /sms/code、/phone/login、/account/register、/partner/login。
 *
 * @author hugray
 */
@@ -32,6 +36,12 @@
    @Autowired
    private AccountLoginService accountLoginService;
    @Autowired
    private PartnerLoginService partnerLoginService;
    @Autowired
    private PasswordResetService passwordResetService;
    @Autowired
    private WeChatMiniappService weChatMiniappService;
@@ -65,6 +75,17 @@
    }
    /**
     * 服务商端账号密码登录
     *
     * 仅需账号 + 密码;校验通过后强校验服务商身份(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")
@@ -75,6 +96,18 @@
    }
    /**
     * 重置密码(忘记密码)
     *
     * 凭手机号 + 短信验证码(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 接口,扫码直达注册页并自动填入邀请码。
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/PartnerLoginBody.java
New file
@@ -0,0 +1,41 @@
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;
    }
}
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/ResetPasswordBody.java
New file
@@ -0,0 +1,59 @@
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;
    }
}
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/SmsCodeBody.java
@@ -19,7 +19,7 @@
     * 验证码场景
     *
     * register=注册(校验手机号未注册);login=验证码登录(校验手机号已注册);
     * reset/为空=通用下发(不校验注册状态)。
     * reset=重置密码(校验手机号已注册);其它/为空=通用下发(不校验注册状态)。
     */
    private String scene;
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/AccountLoginService.java
@@ -15,12 +15,9 @@
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端(用户端小程序)登录与注册编排服务
@@ -51,7 +48,7 @@
    private RemoteAccountUserService remoteAccountUserService;
    @Autowired
    private TokenService tokenService;
    private AccountTokenIssuer accountTokenIssuer;
    /**
     * 手机号一键登录
@@ -214,31 +211,7 @@
     */
    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);
    }
    /**
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/AccountTokenIssuer.java
New file
@@ -0,0 +1,65 @@
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;
    }
}
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/PartnerLoginService.java
New file
@@ -0,0 +1,95 @@
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();
    }
}
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/PasswordResetService.java
New file
@@ -0,0 +1,126 @@
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);
    }
}
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/SmsCodeService.java
@@ -55,7 +55,7 @@
     * 下发短信验证码
     *
     * @param phone 手机号
     * @param scene 场景编码(register/reset/为空)
     * @param scene 场景编码(register=注册 / login=登录 / reset=重置密码 / 其它=通用下发)
     */
    public void sendCode(String phone, String scene)
    {
@@ -67,12 +67,12 @@
        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);
        }
@@ -114,7 +114,7 @@
    {
        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());
@@ -146,7 +146,7 @@
    /**
     * 判定是否命中通用验证码(测试便利)
     *
     * 仅 login / register 场景生效;命中即直接放行,不读缓存、不计数、不消费。
     * 仅 login / register / reset 场景生效;命中即直接放行,不读缓存、不计数、不消费。
     *
     * @param sceneEnum 场景枚举
     * @param inputCode 用户输入的验证码
@@ -160,8 +160,8 @@
        {
            return false;
        }
        // 仅登录 / 注册场景放行,避免重置密码等场景被通用码绕过
        if (SmsSceneEnum.LOGIN != sceneEnum && SmsSceneEnum.REGISTER != sceneEnum)
        // 仅登录 / 注册 / 重置密码场景放行,避免其它场景被通用码绕过
        if (SmsSceneEnum.LOGIN != sceneEnum && SmsSceneEnum.REGISTER != sceneEnum && SmsSceneEnum.RESET != sceneEnum)
        {
            return false;
        }
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountMemberInnerController.java
@@ -13,6 +13,7 @@
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;
@@ -50,6 +51,26 @@
    }
    /**
     * 按用户名查询会员(服务商端账号密码登录使用)
     *
     * 返回结果含 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 注册请求(手机号、推荐码)
@@ -64,7 +85,27 @@
    }
    /**
     * 重置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)
    {
@@ -77,6 +118,8 @@
        member.setMyReferralCode(accountUser.getMyReferralCode());
        member.setReferrerId(accountUser.getReferrerId());
        member.setStatus(accountUser.getStatus());
        member.setProviderFlag(accountUser.getProviderFlag());
        member.setPassword(accountUser.getPassword());
        return member;
    }
}
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountUserMapper.java
@@ -106,6 +106,14 @@
    public int updateUserProviderFlag(AccountUser accountUser);
    /**
     * 重置会员密码(只更新 password 一列)
     *
     * 忘记密码场景专用:认证服务完成身份校验后,按手机号定位的会员在此写入新密码密文。
     * 单列语句,确保重置流程不会顺带改动其它资料。调用方负责传入 BCrypt 加密后的密文。
     */
    public int updateUserPassword(AccountUser accountUser);
    /**
     * 查询某会员的直接下级(被邀请人)列表
     *
     * 按 referrer_id 过滤(命中 idx_account_user_referrer)+ referrer_bind_time 倒序;
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java
@@ -28,6 +28,14 @@
    public AccountUser selectAccountUserByPhone(String phone);
    /**
     * 根据用户名查询会员(服务商端账号密码登录使用)
     *
     * @param userName 用户名
     * @return 会员对象(不存在时返回 null)
     */
    public AccountUser selectAccountUserByUsername(String userName);
    /**
     * 查询会员列表
     */
    public List<AccountUser> selectAccountUserList(AccountUser accountUser);
@@ -149,6 +157,17 @@
    public AccountUser registerMember(String phone, String referralCode);
    /**
     * 按手机号重置会员密码(忘记密码)
     *
     * 由认证服务完成身份校验(手机号 + 短信验证码)后调用;本方法内 BCrypt 加密新密码,
     * 且只更新 password 一列。手机号不存在或账号已删除时抛业务异常。
     *
     * @param phone 手机号(账号标识)
     * @param rawPassword 新密码明文(落库前加密)
     */
    public void resetPasswordByPhone(String phone, String rawPassword);
    /**
     * 查询C端会员个人中心信息(昵称 / 头像 / 会员等级 / 收益汇总)
     *
     * @param userId 会员ID(取自登录态,非前端传入)
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java
@@ -89,6 +89,12 @@
    }
    @Override
    public AccountUser selectAccountUserByUsername(String userName)
    {
        return accountUserMapper.selectAccountUserByUsername(userName);
    }
    @Override
    public List<AccountUser> selectAccountUserList(AccountUser accountUser)
    {
        return accountUserMapper.selectAccountUserList(accountUser);
@@ -408,6 +414,37 @@
    }
    @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(登录态缺失时直接失败,避免无归属查询)
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml
@@ -303,6 +303,20 @@
        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}