zjk
昨天 f8c73a38709f7120d11b04bd70c6af3ccc53014d
feat: 新增用户小程序会员个人中心及资料编辑页面

- 实现后端C端会员个人中心查询接口,包含用户基础信息、会员等级及收益汇总
- 新增小程序端资料编辑页面并注册路由
- 新增分转元通用格式化工具统一金额展示逻辑
- 修复后端推荐人字段命名不一致问题
- 更新开发环境数据库配置,新增收益流水表SQL脚本
- 替换个人中心页面mock数据为真实接口,移除废弃API
- 为资料页配置自定义导航栏
13个文件已添加
14个文件已修改
2066 ■■■■■ 已修改文件
doc/design/用户端_个人中心_20261013.md 263 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/design/用户端_我的资料_20261013.html 377 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/design/用户端_登录注册_20261012.md 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/design/用户端_首页与导航_20261012.md 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/nacos/dev/ruoyi-user-dev.yml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/domain/AccountMember.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountMemberInnerController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountProfileController.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountEarningRecord.java 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountUser.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountUserInfoVo.java 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/EarningSummaryVo.java 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/enums/EarningSourceEnum.java 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/enums/MemberLevelEnum.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountEarningRecordMapper.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountEarningRecordMapper.xml 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountUserServiceImplTest.java 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/sql/hugray_account_20261012.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-server/sql/hugray_account_20261013.sql 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-user-miniapp/api/index.js 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-user-miniapp/pages.json 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-user-miniapp/pages/profile/edit.vue 447 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-user-miniapp/pages/profile/index.vue 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
hugray-user-miniapp/utils/format.js 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/design/用户端_个人中心_20261013.md
New file
@@ -0,0 +1,263 @@
# 用户端小程序 个人中心 接口 开发设计文档
- 模块:ruoyi-user(会员业务)+ ruoyi-gateway(路由/鉴权头注入)
- 日期:2026-10-13
- 需求来源:“个人中心页的接口功能实现,根据现在展示的字段实现对应的 java 后端业务”
- 关联前端:`hugray-user-miniapp`(`pages/profile/index.vue`)
- 关联文档:`doc/design/用户端_登录注册_20261012.md`(C 端会员落库与令牌载体方案)、`doc/design/用户端_首页与导航_20261012.md`(9.3 个人中心页规格)
- 数据权威源:`192.168.110.111:3306/hugray-account`(实际表结构优先于本文档)
---
## 一、功能概述
个人中心页(`pages/profile/index.vue`)当前为本地 mock,需由后端下发以下字段,页面据此渲染:
| 展示字段 | 页面位置 | 现状 | 本次是否提供 |
| --- | --- | --- | --- |
| 姓名 `nickname` | 头部文本 | mock `辉煌G891` | 是(`account_user.nickname`;注册未采集姓名字段,落库为空,前端回退展示脱敏手机号) |
| 手机号 `phone`(脱敏) | 头部名称回退 | 无 | 是(`account_user.phone`,脱敏为 `133****5555`,不下发明文) |
| 会员等级 `memberLevel` | 等级胶囊 `C`/`L1~L4` | mock `L4` | 是(复用既有 `account_user.system_level`) |
| 头像 `avatar` | 头部头像 | 相机图标占位 | 是(已存在字段,有值前端渲染图片,无值回退图标) |
| 今日 / 本月 / 累计收益 | 收益玻璃卡三栏 | mock `10.00 / 400.00 / 128000.00` | 是(新增收益流水表,实时聚合) |
| 仓库订单角标数量 | 菜单角标 | mock `1` | 否(见 2.5,无数据来源,本期不入接口) |
展示规则(沿用已确认业务约定):`isRestrictedView = !isLoggedIn || memberLevel === 'C'`;**未登录或 C 普通用户**仅展示「收货地址」「联系我们」两行菜单并隐藏收益卡;**L1~L4** 展示全部内容。该判定由前端完成,后端只负责下发 `memberLevel` 真实值。
本期只新增 **一个 C 端查询接口**:`GET /miniapp/account/user/info`(对应前端已预留的 `getMemberInfo()`)。
---
## 二、现状与关键约束
### 2.1 表结构缺口
`account_user`(`hugray-account` 库)现有字段:`user_id, username, phone, password, nickname, avatar, gender, birthday, status, del_flag, my_referral_code, system_level, referrer_id, referrer_bind_time, create_*, update_*, remark`。
**已有「会员等级」字段 `system_level`(`varchar(8)`,默认 `C`),无任何「收益」相关表/字段** → 收益部分本次新增;等级复用既有列,不再新增重复列。
### 2.2 已有接口不足以支撑
`ruoyi-user` 现有控制器:
- `AccountUserController`(`/user`,后台 CRUD,全部挂 `@RequiresPermissions`)——面向后端管理,不适合 C 端调用。
- `AccountMemberInnerController`(`/member`,`@InnerAuth`)——仅服务间调用,按手机号查会员 / 注册。
**无按当前登录会员返回个人中心数据的接口** → 本次新增。
### 2.3 登录态与取数来源(已定方案)
- C 端令牌沿用 RuoYi `TokenService`,网关 `AuthFilter` 校验通过后向目标服务注入请求头 `user_id`、`username`(`SecurityConstants.DETAILS_USER_ID/DETAILS_USERNAME`)。
- `ruoyi-user` 侧通过 `SecurityUtils.getUserId()` 读取 `user_id`,即当前登录会员 ID。
- 该路径**不经过** `/miniapp/system/user/getInfo`(后者面向后台 `sys_user`,见 2.4)。
### 2.4 前端预留入口的取舍
`hugray-user-miniapp/api/index.js` 现有两个未接线函数:
| 函数 | 路径 | 处理 |
| --- | --- | --- |
| `getMemberInfo()` | `/miniapp/account/user/info` | **本期采用**(指向 ruoyi-user) |
| `getUserInfo()` | `/miniapp/system/user/getInfo` | **误导**:指向后台 `sys_user`,与 C 端会员不同源;全仓无调用点,建议删除 |
结论:个人中心统一走 `getMemberInfo()`;`getUserInfo()` 予以移除(避免后续误用)。
### 2.5 仓库订单角标数量:本期不提供
角标数量应来源于「仓库订单」,但 `ruoyi-product` 模块**当前仅有一个启动类**(无订单/仓库任何表与接口),无数据来源。本期接口不返回该字段,前端保持现状(`withBadge` 已登录展示,值仍为本地 mock `1`),待订单模块就绪后由订单服务提供独立接口,不在本次范围。
### 2.6 金额单位约定
用户规范要求“金额用分(long)”。故接口 `earnings` 三值均以 **分** 返回(如 `1000` = 10.00 元),前端新增 `分→元` 格式化后再展示为两位小数。
---
## 三、接口设计
统一响应体:`R<T>`(`code=200` 成功),与 C 端 `/miniapp/**` 既有约定一致;前端 `utils/request.js` 按 `successCode=200` / `unauthorizedCode=401` 解包,调用方取 `res.data`。
> 规范说明:用户规则中的“`Result<T>`、`code=0 成功`、错误码分段”为通用模板;本工程实际框架为 RuoYi `R`/`AjaxResult`(200 成功)+ `@RestControllerAdvice` 全局异常。按“与现有工程保持一致”优先,采用框架既有约定。
### 3.1 查询当前会员个人中心信息
- **外部路径**:`GET /miniapp/account/user/info`(网关 `StripPrefix=2` → 服务内 `/user/info`)
- **鉴权**:需登录。路径不在网关白名单内,未登录由 `AuthFilter` 直接返回 401,前端 `request.js` 自动跳登录页;**后端不做未登录分支**。
- **入参**:无(会员身份取自网关注入的 `user_id` 头)
- **出参**:`R<AccountUserInfoVo>`
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "userId": 100,
    "nickname": null,
    "phone": "133****5555",
    "avatar": null,
    "memberLevel": "L4",
    "earnings": { "today": 1000, "month": 40000, "total": 12800000 }
  }
}
```
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| userId | Long | 会员ID |
| nickname | String | 姓名/昵称(注册未采集,可为空;为空时前端回退展示脱敏手机号) |
| phone | String | 手机号(**脱敏**,形如 `133****5555`,不下发明文) |
| avatar | String | 头像地址,可为空 |
| memberLevel | String | 会员等级 `C`/`L1~L4`(空或非法兜底为 `C`;对外字段名沿用 `memberLevel`,数据源为 `account_user.system_level`) |
| earnings.today | Long | 今日收益(单位:分) |
| earnings.month | Long | 本月收益(单位:分) |
| earnings.total | Long | 累计收益(单位:分) |
- **错误提示**:`获取会员信息失败`(会员不存在或查询异常)
- **说明**:手机号仅下发**脱敏**值(`133****5555`),不下发明文;`userName`(C 端即手机号)不下发,避免敏感信息外泄。
---
## 四、数据库设计
库:`hugray-account`(权威库 `192.168.110.111:3306`)。脚本:`sql/hugray_account_20261013.sql`(与代码同一次提交,含变更 + 回滚)。
### 4.1 会员等级:复用既有 `account_user.system_level`(无 DDL)
权威库 `account_user` 已存在 `system_level varchar(8) DEFAULT 'C'`(取值 `C<L1<L2<L3<L4`,从低到高),故**不再新增 `member_level` 重复列**。代码统一读写既有 `system_level`,对外出参仍为 `memberLevel`。
### 4.2 变更:新增收益流水表 `account_earning_record`
```sql
CREATE TABLE `account_earning_record` (
  `record_id`   bigint(20)   NOT NULL AUTO_INCREMENT COMMENT '收益记录ID',
  `user_id`     bigint(20)   NOT NULL                COMMENT '会员ID(关联 account_user.user_id)',
  `amount`      bigint(20)   NOT NULL DEFAULT 0      COMMENT '收益金额(单位:分,可正可负)',
  `source`      char(1)      NOT NULL                COMMENT '收益来源(1订单收益 2邀请奖励 3等级差价 9其他)',
  `biz_no`      varchar(64)  DEFAULT NULL            COMMENT '业务单号(同来源幂等去重)',
  `remark`      varchar(255) DEFAULT NULL            COMMENT '备注',
  `create_time` datetime     DEFAULT NULL            COMMENT '发生时间',
  `update_time` datetime     DEFAULT NULL            COMMENT '更新时间',
  PRIMARY KEY (`record_id`),
  KEY `idx_earning_user_time` (`user_id`, `create_time`),
  UNIQUE KEY `uk_earning_source_biz` (`source`, `biz_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员收益流水表';
```
- 采用**流水 + 实时聚合**而非三个汇总结算字段:今日 / 本月随日期自然滚动,无需定时归零;同时天然支撑菜单「收入明细」页(后续)。
- `idx_earning_user_time` 覆盖 `WHERE user_id=? AND create_time>=?` 聚合,避免全表扫描。
- `uk_earning_source_biz` 由 `(source, biz_no)` 保证业务幂等;`biz_no` 为 `NULL` 时 MySQL 唯一索引不冲突(手工补录场景允许)。
**回滚**:
```sql
DROP TABLE IF EXISTS `account_earning_record`;
```
---
## 五、流程设计
### 5.1 查询个人中心信息(`AccountUserServiceImpl.getAccountUserInfo`)
```
STEP 1: 校验 userId 非空(未登录由网关拦截,此处兜底),为空抛「获取会员信息失败」
STEP 2: 按 userId 查 account_user(del_flag='0');为空抛「获取会员信息失败」
STEP 3: 查收益汇总(单条 SQL,单位分),无流水返回 0
STEP 4: 组装 AccountUserInfoVo:nickname/avatar 直取;phone 脱敏(`133****5555`);memberLevel 取 system_level,空或非法兜底 MemberLevelEnum.C
STEP 5: 返回 VO
```
收益汇总 SQL(一次查询出三值):
```sql
SELECT
  IFNULL(SUM(CASE WHEN create_time >= CURDATE() THEN amount END), 0)                        AS today,
  IFNULL(SUM(CASE WHEN create_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') THEN amount END), 0) AS month,
  IFNULL(SUM(amount), 0)                                                                     AS total
FROM account_earning_record
WHERE user_id = #{userId}
```
---
## 六、枚举与状态
| 名称 | 取值 | 说明 |
| --- | --- | --- |
| 会员等级 `memberLevel`(源 `system_level`) | `C` / `L1` / `L2` / `L3` / `L4` | `MemberLevelEnum`:`C`=普通用户(受限展示);`L1~L4`=会员;含 `code` + `desc` + `getByCode` + `normalize` |
| 会员状态 `status` | `0` 正常 / `1` 停用 | 复用现有语义 |
| 删除标志 `delFlag` | `0` 存在 / `2` 删除 | 复用现有语义 |
| 收益来源 `source` | `1` 订单收益 / `2` 邀请奖励 / `3` 等级差价 / `9` 其他 | `EarningSourceEnum`(本期仅建枚举,写入方待订单/邀请模块接入) |
> 等级升降级规则(何种行为升到 L1~L4)属于会员体系策略,尚未定义。本期 `system_level` 由后台维护(`AccountUserController` 编辑),接口只负责读取下发;后续等级规则确定后新增独立升级流程。
---
## 七、影响范围与文件清单
### 新增(ruoyi-user)
| 文件 | 说明 |
| --- | --- |
| `enums/MemberLevelEnum.java` | 会员等级枚举(`C`/`L1~L4`,含 `getByCode` + `normalize`) |
| `enums/EarningSourceEnum.java` | 收益来源枚举(`1/2/3/9`) |
| `domain/AccountEarningRecord.java` | 收益流水实体(含字段注释) |
| `domain/vo/AccountUserInfoVo.java` | 个人中心信息出参 VO(含脱敏手机号 `phone`) |
| `domain/vo/EarningSummaryVo.java` | 收益汇总出参(today/month/total,单位分) |
| `mapper/AccountEarningRecordMapper.java` + `resources/mapper/user/AccountEarningRecordMapper.xml` | 收益汇总查询 |
| `controller/AccountProfileController.java` | C 端接口 `GET /user/info` |
### 修改
| 文件 | 改动 |
| --- | --- |
| ruoyi-user `domain/AccountUser.java` | 复用既有 `systemLevel`(源 `system_level`)字段;推荐关系字段为 `referrerId`(源 `referrer_id`)+ getter/setter + toString |
| ruoyi-user `mapper/AccountUserMapper.xml` | `system_level` / `referrer_id` 纳入 resultMap / 列 / insert / update |
| ruoyi-user `service/IAccountUserService.java` + `service/impl/AccountUserServiceImpl.java` | 新增 `getAccountUserInfo(Long userId)`;注册不再以手机号填充姓名(`nickname` 留空);出参手机号脱敏 |
| `sql/hugray_account_20261013.sql` | 新增 DDL(变更 + 回滚) |
| 前端 `hugray-user-miniapp/api/index.js` | 删除误导且无调用的 `getUserInfo()` |
| 前端 `hugray-user-miniapp/utils/`(新增格式化方法) | `分→元` 两位小数格式化 |
| 前端 `hugray-user-miniapp/pages/profile/index.vue` | `onShow` 调 `getMemberInfo()` 替换 mock;头部名称优先姓名、为空回退脱敏手机号 |
| `doc/design/用户端_首页与导航_20261012.md` | 9.3 / 9.5 标注 mock 已由接口替换 |
> 网关无需改动:`/miniapp/account/**` → `ruoyi-user` 路由已存在。
---
## 八、边界与异常清单
| # | 场景 | 处理 |
| --- | --- | --- |
| 1 | 未登录访问 | 网关 `AuthFilter` 返回 401,前端跳登录页;后端不做分支 |
| 2 | `user_id` 头缺失/为空 | 服务内兜底判空,抛「获取会员信息失败」(不 NPE) |
| 3 | 会员不存在 / 已逻辑删除 | 抛「获取会员信息失败」 |
| 4 | `system_level` 为空或非法(历史脏数据) | 兜底为 `C`,不影响页面渲染 |
| 5 | 无任何收益流水 | 三值均返回 `0`(分),前端展示 `0.00` |
| 6 | 收益为负(退款冲正) | `amount` 支持负数,聚合自然抵扣 |
| 7 | 金额单位一致性 | 接口统一返回分;前端统一格式化,禁止后端直接返回字符串金额 |
| 8 | 敏感信息 | 接口下发**脱敏**手机号(`133****5555`),不下发明文与 `userName`;日志手机号脱敏 |
| 9 | 越权 | 会员身份取自令牌注入头,不接受前端传入 `userId`,天然防水平越权 |
| 10 | 姓名未采集(注册未填) | `nickname` 为空,前端头部回退展示脱敏手机号,不报错 |
---
## 九、规范符合性说明
- 分层:Controller 只做取身份 + 转发,业务在 Service,SQL 在 Mapper;Controller 不直接调 Mapper。
- 命名:`getAccountUserInfo` / `selectEarningSummary` 等见名知意;Entity / VO 分离。
- 注释:类注释、字段注释(含取值与单位说明)、Javadoc、核心流程 `// STEP x:` 注释。
- 魔法值:等级、收益来源收口枚举;CUCRATE 等 SQL 表达式内聚在 Mapper。
- 异常:统一 `ServiceException` + 全局处理器,不散落 try-catch 拼返回值。
- 日志:`@Slf4j` 占位符 + `[业务标记]` 前缀,敏感字段脱敏。
- 数据库:字段显式长度 + COMMENT;聚合列有索引;无 `SELECT *`;无 `${}` 拼接;无循环查库。
- 单测:`getAccountUserInfo`(正常 / 会员不存在 / 无流水 / 等级空兜底 / 等级非法兜底)补充单测,Mockito Mock Mapper。
- 说明(与现有工程一致优先):`account_earning_record` 主键沿用自增(与同库 `account_user` 及全工程 RuoYi 风格一致),未采用雪花 ID,作为已知项记录。
---
## 十、已确认决策
1. **等级存储方式**:**已定** —— 复用既有 `account_user.system_level`(`C`/`L1~L4`,默认 `C`),不新增 `member_level` 重复列。
2. **收益数据模型**:**已定** —— 新建流水表 `account_earning_record` + 实时聚合。
3. **金额单位**:**已定** —— 接口返回分(`long`),前端格式化为两位小数。
4. **仓库订单角标**:**已定** —— 本期不提供(`ruoyi-product` 尚无订单实现)。
5. **数据库连接**:**已定** —— `ruoyi-user-dev.yml` master 已由 `localhost` 改为权威库 `192.168.110.111:3306/hugray-account`(与 `ruoyi-system`/`ruoyi-product` 一致)。
doc/design/用户端_我的资料_20261013.html
New file
@@ -0,0 +1,377 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>我的资料 · UI 高保真原型</title>
<style>
    :root {
        --brand-blue: #003e7e;
        --brand-blue-2: #1e5ebc;
        --green: #639c1f;
        --green-2: #82c236;
        --ink: #0f172a;
        --label: #1f2937;
        --sub: #64748b;
        --muted: #94a3b8;
        --line: #f1f5f9;
        --line-2: #e2e8f0;
    }
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
        font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
        background: #eef2f7;
        color: var(--ink);
        display: flex;
        flex-direction: column;
        align-items: center;
        padding: 40px 16px 64px;
        min-height: 100vh;
    }
    /* ==================== 文档头 ==================== */
    .doc-head { text-align: center; margin-bottom: 28px; }
    .doc-title { font-size: 20px; font-weight: 700; }
    .doc-sub { margin-top: 6px; font-size: 13px; color: var(--sub); }
    /* ==================== 手机框 ==================== */
    .phone {
        width: 375px;
        height: 812px;
        background: #fff;
        border-radius: 44px;
        box-shadow: 0 24px 60px rgba(15, 23, 42, .18), 0 2px 8px rgba(15, 23, 42, .06);
        overflow: hidden;
        position: relative;
        display: flex;
        flex-direction: column;
    }
    /* 状态栏(模拟) */
    .status-bar {
        height: 44px;
        flex-shrink: 0;
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 0 22px 0 28px;
        font-size: 14px;
        font-weight: 600;
        letter-spacing: .2px;
    }
    .status-icons { display: flex; align-items: center; gap: 5px; }
    /* 自定义导航栏 */
    .nav {
        height: 44px;
        flex-shrink: 0;
        position: relative;
        display: flex;
        align-items: center;
        justify-content: center;
    }
    .nav-back {
        position: absolute;
        left: 10px;
        width: 40px;
        height: 40px;
        display: flex;
        align-items: center;
        justify-content: center;
    }
    .nav-title { font-size: 17px; font-weight: 700; letter-spacing: .5px; }
    .nav-actions {
        position: absolute;
        right: 14px;
        display: flex;
        align-items: center;
        gap: 16px;
    }
    /* ==================== 列表 ==================== */
    .body { flex: 1; display: flex; flex-direction: column; }
    .list { padding: 4px 16px 0; }
    .row {
        min-height: 58px;
        display: flex;
        align-items: center;
        justify-content: space-between;
        border-bottom: 1px solid var(--line);
    }
    .row:last-child { border-bottom: none; }
    .row-label { font-size: 15px; color: var(--label); }
    .row-right { display: flex; align-items: center; gap: 8px; }
    .row-value { font-size: 15px; color: var(--muted); letter-spacing: .3px; }
    /* 头像 */
    .avatar {
        width: 46px;
        height: 46px;
        border-radius: 50%;
        background: #f1f5f9;
        border: 1px solid var(--line-2);
        display: flex;
        align-items: center;
        justify-content: center;
        overflow: hidden;
    }
    .avatar img { width: 100%; height: 100%; object-fit: cover; }
    /* 认证状态徽标 */
    .badge {
        font-size: 13px;
        font-weight: 500;
        line-height: 1;
        padding: 5px 10px;
        border-radius: 6px;
        border: 1px solid;
    }
    .badge--none { color: #ea580c; background: #fff7ed; border-color: #fdba74; }
    .badge--ing  { color: #1e5ebc; background: #eff6ff; border-color: #93c5fd; }
    .badge--fail { color: #dc2626; background: #fef2f2; border-color: #fca5a5; }
    .badge--done { color: #4d7c0f; background: #f7fee7; border-color: #bef264; }
    /* ==================== 底部操作区 ==================== */
    .footer {
        margin-top: auto;
        padding: 16px 24px 22px;
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 18px;
    }
    .btn-logout {
        width: 100%;
        height: 48px;
        border-radius: 24px;
        background: linear-gradient(90deg, #82c236 0%, #639c1f 100%);
        color: #fff;
        font-size: 15px;
        font-weight: 600;
        display: flex;
        align-items: center;
        justify-content: center;
        box-shadow: 0 8px 20px rgba(99, 156, 31, .28);
    }
    .link-cancel {
        font-size: 14px;
        color: var(--brand-blue-2);
        text-decoration: underline;
        text-underline-offset: 3px;
    }
    /* Home 指示条 */
    .home-bar {
        height: 26px;
        display: flex;
        align-items: center;
        justify-content: center;
        flex-shrink: 0;
    }
    .home-bar span {
        width: 134px;
        height: 5px;
        border-radius: 3px;
        background: #0f172a;
        opacity: .85;
    }
    /* ==================== 图例 / 设计说明 ==================== */
    .legend {
        width: 375px;
        margin-top: 32px;
        display: flex;
        flex-direction: column;
        gap: 18px;
    }
    .card {
        background: #fff;
        border-radius: 16px;
        padding: 18px 20px;
        box-shadow: 0 6px 18px rgba(15, 23, 42, .05);
    }
    .card h3 {
        font-size: 14px;
        font-weight: 700;
        margin-bottom: 14px;
        color: var(--ink);
    }
    .badge-row { display: flex; flex-wrap: wrap; gap: 10px; }
    .legend-note {
        font-size: 13px;
        line-height: 1.7;
        color: var(--sub);
    }
    .legend-note b { color: var(--label); font-weight: 600; }
    .legend-note .no { color: var(--brand-blue-2); font-weight: 700; margin-right: 4px; }
    .legend-note li { list-style: none; margin-bottom: 6px; }
    .avatar-row { display: flex; gap: 28px; align-items: center; }
    .avatar-item { display: flex; flex-direction: column; align-items: center; gap: 8px; }
    .avatar-item small { font-size: 12px; color: var(--muted); }
</style>
</head>
<body>
    <div class="doc-head">
        <div class="doc-title">「我的资料」页面 · UI 高保真原型</div>
        <div class="doc-sub">用户端小程序 · 375×812 · 本期交互为占位</div>
    </div>
    <!-- ==================== 手机原型 ==================== -->
    <div class="phone">
        <!-- 状态栏 -->
        <div class="status-bar">
            <span>12:00</span>
            <span class="status-icons">
                <!-- 信号 -->
                <svg width="17" height="11" viewBox="0 0 17 11" fill="#0f172a">
                    <rect x="0" y="7" width="3" height="4" rx="1"></rect>
                    <rect x="4.5" y="5" width="3" height="6" rx="1"></rect>
                    <rect x="9" y="2.5" width="3" height="8.5" rx="1"></rect>
                    <rect x="13.5" y="0" width="3" height="11" rx="1"></rect>
                </svg>
                <!-- WiFi -->
                <svg width="16" height="11" viewBox="0 0 16 11" fill="#0f172a">
                    <path d="M8 10.5 5.4 7.6a4 4 0 0 1 5.2 0L8 10.5Z"></path>
                    <path d="M8 5.6c-1.9 0-3.6.7-4.9 1.9L1.4 5.6A9.6 9.6 0 0 1 8 3.1c2.6 0 4.9 1 6.6 2.5l-1.7 1.9A7.1 7.1 0 0 0 8 5.6Z" opacity=".95"></path>
                </svg>
                <!-- 电池 -->
                <svg width="25" height="12" viewBox="0 0 25 12" fill="none">
                    <rect x=".5" y=".5" width="21" height="11" rx="3" stroke="#0f172a" opacity=".4"></rect>
                    <rect x="2" y="2" width="18" height="8" rx="1.6" fill="#0f172a"></rect>
                    <rect x="22.5" y="4" width="2" height="4" rx="1" fill="#0f172a" opacity=".5"></rect>
                </svg>
            </span>
        </div>
        <!-- 导航栏 -->
        <div class="nav">
            <div class="nav-back">
                <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#1f2937" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
                    <path d="m15 18-6-6 6-6" />
                </svg>
            </div>
            <div class="nav-title">我的资料</div>
            <div class="nav-actions">
                <!-- 更多 -->
                <svg width="22" height="22" viewBox="0 0 24 24" fill="#334155">
                    <circle cx="5" cy="12" r="1.7"></circle>
                    <circle cx="12" cy="12" r="1.7"></circle>
                    <circle cx="19" cy="12" r="1.7"></circle>
                </svg>
                <!-- 帮助 -->
                <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#334155" stroke-width="2">
                    <circle cx="12" cy="12" r="9"></circle>
                    <path d="M9.4 9.2a2.6 2.6 0 1 1 3.3 2.5c-.8.3-1.3 1-1.3 1.9v.3" stroke-linecap="round"></path>
                    <circle cx="11.4" cy="17" r="1" fill="#334155" stroke="none"></circle>
                </svg>
            </div>
        </div>
        <!-- 内容 -->
        <div class="body">
            <div class="list">
                <!-- 头像 -->
                <div class="row">
                    <span class="row-label">头像</span>
                    <div class="row-right">
                        <div class="avatar">
                            <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                                <path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path>
                                <circle cx="12" cy="13" r="3"></circle>
                            </svg>
                        </div>
                        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                            <path d="m9 18 6-6-6-6" />
                        </svg>
                    </div>
                </div>
                <!-- 手机号(不可更换) -->
                <div class="row">
                    <span class="row-label">手机号</span>
                    <div class="row-right">
                        <span class="row-value">136****6542</span>
                    </div>
                </div>
                <!-- 实名认证 -->
                <div class="row">
                    <span class="row-label">实名认证</span>
                    <div class="row-right">
                        <span class="badge badge--none">未认证</span>
                        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                            <path d="m9 18 6-6-6-6" />
                        </svg>
                    </div>
                </div>
                <!-- 企业 / 个体户认证 -->
                <div class="row">
                    <span class="row-label">企业/个体户认证</span>
                    <div class="row-right">
                        <span class="badge badge--none">未认证</span>
                        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                            <path d="m9 18 6-6-6-6" />
                        </svg>
                    </div>
                </div>
            </div>
            <!-- 底部操作 -->
            <div class="footer">
                <div class="btn-logout">退出登录</div>
                <span class="link-cancel">注销账号</span>
            </div>
        </div>
        <!-- Home 指示条 -->
        <div class="home-bar"><span></span></div>
    </div>
    <!-- ==================== 图例与设计说明 ==================== -->
    <div class="legend">
        <div class="card">
            <h3>认证状态徽标(4 态)</h3>
            <div class="badge-row">
                <span class="badge badge--none">未认证</span>
                <span class="badge badge--ing">认证中</span>
                <span class="badge badge--fail">认证失败</span>
                <span class="badge badge--done">已认证</span>
            </div>
        </div>
        <div class="card">
            <h3>头像两种状态</h3>
            <div class="avatar-row">
                <div class="avatar-item">
                    <div class="avatar">
                        <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                            <path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path>
                            <circle cx="12" cy="13" r="3"></circle>
                        </svg>
                    </div>
                    <small>未设置(相机占位)</small>
                </div>
                <div class="avatar-item">
                    <div class="avatar">
                        <img src="https://coresg-normal.trae.ai/api/ide/v1/text_to_image?prompt=portrait%20photo%20of%20a%20smiling%20chinese%20young%20man%2C%20studio%20headshot%2C%20clean%20light%20background%2C%20high%20quality&image_size=square" alt="avatar" />
                    </div>
                    <small>已设置(圆形裁切)</small>
                </div>
            </div>
        </div>
        <div class="card">
            <h3>原型标注</h3>
            <ul class="legend-note">
                <li><span class="no">1</span>认证状态共 <b>4</b> 种:未认证 / 认证中 / 认证失败 / 已认证,由后端返回状态枚举驱动徽标样式。</li>
                <li><span class="no">2</span>后台按会员等级设置所需认证方式;完成<b>企业/个体户认证</b>后,页面将有限展示企业/个体户信息。</li>
                <li><span class="no">3</span>手机号在小程序<b>暂不支持更换</b>,仅只读展示(已脱敏),故该行无箭头。</li>
            </ul>
        </div>
    </div>
</body>
</html>
doc/design/用户端_登录注册_20261012.md
@@ -78,7 +78,7 @@
### 2.7 表结构缺口
`account_user` 无推荐码、无推荐人字段 → 新增 `my_referral_code`、`referrer_user_id`。
`account_user` 无推荐码字段 → 新增 `my_referral_code`;推荐关系复用既有 `referrer_id`(权威库已存在),不新增重复列。
### 2.8 既有代码缺陷
@@ -212,18 +212,18 @@
```sql
ALTER TABLE `account_user`
  ADD COLUMN `my_referral_code` varchar(20) DEFAULT NULL COMMENT '本人推荐码(注册时生成,全局唯一)' AFTER `remark`,
  ADD COLUMN `referrer_user_id` bigint(20) DEFAULT NULL COMMENT '推荐人会员ID(关联 account_user.user_id)' AFTER `my_referral_code`;
  ADD COLUMN `my_referral_code` varchar(20) DEFAULT NULL COMMENT '本人推荐码(注册时生成,全局唯一)' AFTER `remark`;
-- 推荐码全局唯一:随机生成且不重复使用,逻辑删除行占用不影响业务
CREATE UNIQUE INDEX `uk_account_user_referral_code` ON `account_user` (`my_referral_code`);
-- 推荐关系复用既有 referrer_id(权威库已存在),不新增 referrer_user_id
```
回滚:
```sql
DROP INDEX `uk_account_user_referral_code` ON `account_user`;
ALTER TABLE `account_user` DROP COLUMN `referrer_user_id`, DROP COLUMN `my_referral_code`;
ALTER TABLE `account_user` DROP COLUMN `my_referral_code`;
```
说明:
@@ -266,7 +266,7 @@
STEP 5: Feign 调 ruoyi-user 注册:
       5.1 按推荐码查推荐人,不存在抛「推荐人码无效」;推荐人 status=1 抛「推荐人账号异常」
       5.2 生成唯一 my_referral_code(随机 8 位大写字母数字,库内查重,冲突重试 ≤5 次)
       5.3 组装 AccountUser:userName=phone、phone、nickname=phone、myReferralCode、referrerUserId、status=0、delFlag=0
       5.3 组装 AccountUser:userName=phone、phone、nickname=phone、myReferralCode、referrerId、status=0、delFlag=0
       5.4 插入(无密码,password 为空)
STEP 6: 删除验证码 key;finally 释放锁
```
@@ -311,8 +311,9 @@
通用推荐码(ruoyi-user)
  AccountUserServiceImpl.registerMember 定位推荐人前先判定:
    命中「配置的通用推荐码」→ 跳过推荐人查询与校验,referrerUserId 留空(不绑定推荐人)
    否则走原有「按码查推荐人 → 不存在/停用则拒绝」逻辑
    命中「配置的通用推荐码」→ 跳过推荐人查询与校验,referrerId 留空(不绑定推荐人);
                              同时直接发放 L4 会员等级(system_level = L4)
    否则走原有「按码查推荐人 → 不存在/停用则拒绝」逻辑,且不设等级(走库默认值 C)
```
命中通用码时打印 WARN 日志留痕(手机号脱敏)。
@@ -320,7 +321,7 @@
| 服务 | 配置项 | 示例值 | 说明 |
| --- | --- | --- | --- |
| ruoyi-auth | `hugray.auth.universal-sms-code` | `999999` | 空 = 关闭;仅 `login` / `register` 场景生效 |
| ruoyi-user | `hugray.user.universal-referral-code` | `HUGRAY88` | 空 = 关闭;命中则不绑定推荐人 |
| ruoyi-user | `hugray.user.universal-referral-code` | `HUGRAY88` | 空 = 关闭;命中则不绑定推荐人,且注册用户直接为 L4 等级 |
---
@@ -365,7 +366,7 @@
| 文件 | 改动 |
| --- | --- |
| ruoyi-user `domain/AccountUser.java` | 新增 `myReferralCode`、`referrerUserId` 字段与注释、toString |
| ruoyi-user `domain/AccountUser.java` | 新增 `myReferralCode` 字段与注释、toString;推荐关系复用既有 `referrerId`(源 `referrer_id`) |
| ruoyi-user `mapper/AccountUserMapper.java` + `resources/mapper/user/AccountUserMapper.xml` | resultMap/列/insert/update 增列;新增 `selectAccountUserByReferralCode` |
| ruoyi-user `service/impl/AccountUserServiceImpl.insertAccountUser` | 密码为空时跳过加密(当前无条件 `encryptPassword`,C 端无密码会 NPE) |
| `sql/hugray_account_20261012.sql` | 新增 DDL 脚本 |
@@ -497,7 +498,7 @@
| 20 | 验证码登录:手机号未注册 | 下发(scene=login)即拦「该手机号未注册,请先注册」;登录接口再兜底,均**不消费验证码** |
| 21 | 验证码登录:账号停用 / 删除 | 「账号已停用,请联系管理员」/「账号不存在」,校验在消费验证码之前 |
| 22 | 通用验证码命中(`login` / `register` 且 `hugray.auth.universal-sms-code` 已配置) | 直接放行:不读缓存、不计数、不消费;`reset` / `common` 场景不生效;配置为空即关闭 |
| 23 | 通用推荐码命中(`hugray.user.universal-referral-code` 已配置且有值) | 跳过推荐人查询与校验,`referrerUserId` 留空(不绑定推荐人);命中通用码时不再校验「推荐人码无效 / 推荐人账号异常」 |
| 23 | 通用推荐码命中(`hugray.user.universal-referral-code` 已配置且有值) | 跳过推荐人查询与校验,`referrerId` 留空(不绑定推荐人),且注册用户等级直接为 `L4`;命中通用码时不再校验「推荐人码无效 / 推荐人账号异常」 |
---
@@ -526,7 +527,7 @@
6. 登录页双按钮(微信一键登录 / 手机验证登录),「手机验证登录」为页内嵌面板;后端 `POST /miniapp/auth/phone/code-login` 与 `SmsSceneEnum.LOGIN` 同步落地。✔
7. 登录页按 `.pen` 设计稿 + `static/` 素材做视觉 1:1 还原(背景装饰、Logo、背书图标、标语卡、按钮尺寸与渐变、协议行、Home 条),规格见 3.5 节。✔
8. 登录页 UI 缺陷修复:微信一键登录恢复通栏;协议行字号 23rpx → 26rpx;展开验证码面板后隐藏「手机验证登录」按钮并在面板内提供「收起」入口。✔
9. 测试便利:通用验证码 `999999`(`hugray.auth.universal-sms-code`,仅 `login` / `register` 生效)+ 通用推荐码 `HUGRAY88`(`hugray.user.universal-referral-code`,命中不绑定推荐人);均通过 nacos 配置开启,配置为空即关闭,命中打印 WARN 留痕。✔
9. 测试便利:通用验证码 `999999`(`hugray.auth.universal-sms-code`,仅 `login` / `register` 生效)+ 通用推荐码 `HUGRAY88`(`hugray.user.universal-referral-code`,命中不绑定推荐人且注册用户直接发放 `L4` 等级);均通过 nacos 配置开启,配置为空即关闭,命中打印 WARN 留痕。✔
10. 注册流程:注册页**无需阅读/勾选协议**(提交仅校验手机号 / 验证码 / 推荐码);注册成功页与登录/注册页视觉统一(渐变底 + 装饰层 + 居中结果区 + 渐变胶囊「返回登录」按钮 + Home 条),规格见 3.7 节。✔
> 编码顺序:常量 → DDL → Feign/内部接口 → auth 服务与控制器 → 网关配置 → 前端前缀对齐 → `GetDiagnostics` 校验 → 单测。
doc/design/用户端_首页与导航_20261012.md
@@ -231,7 +231,7 @@
| 3 | 个人中心角标数量(未读)由后端下发 | 待接口(当前默认 1) |
| 4 | 缺货订单列表页真实内容 | 占位 |
| 5 | 「探索更多」产品详情页 | 占位(toast 提示) |
| 6 | 三页真实数据接口(订单列表 / 收益数值 / 会员等级与昵称) | 待接口(当前 mock) |
| 6 | 真实数据接口:个人中心「昵称 / 头像 / 等级 / 收益」已由 `getMemberInfo` 下发;订单列表仍待 `getOrderList` | 部分完成 |
| 7 | 三页图片素材(方案卡、订单缩略图、用户头像)入仓后替换渐变占位 | 待素材 |
@@ -273,16 +273,16 @@
| 页面底色 | 175° 渐变 `#D6E6F8` 0 → `#C4DCF6` .18 → `#E8F1FC` .38 → `#F8FAFC` .58 → `#F8FAFC` 1 | `.page` |
| 头部信息区 | x20 335×72,居中,头像与文本间隙 14 | `.header` |
| 头像 | 70×70 R35 fill `#F1F5F9` + 2.5 白描边 | 已登录内嵌 `camera` 32×32 `#94A3B8`;未登录内嵌 `user` 34×34 `#CBD5E1` |
| 文本信息 | 昵称 18pt 700 `#0F172A` | 已登录「辉煌G891」+ 等级胶囊(`userLevel` 动态展示 L0~L4);未登录「点击登录 / 注册」+ 副标 12pt `#64748B` |
| 文本信息 | 昵称 18pt 700 `#0F172A` | 已登录「辉煌G891」+ 等级胶囊(`userLevel` 动态展示 C/L1~L4);未登录「点击登录 / 注册」+ 副标 12pt `#64748B` |
| 会员等级胶囊 | h22 渐变 90° `#FDE68A→#FCD34D→#F59E0B`,V 勋章 18×18 渐变 145° | `.level-pill` / `.level-medal`;等级文字 12.5pt 800 `#78350F` |
| 主按钮 | h32 渐变 90° `#1E5EBC→#003E7E` R16 | 已登录「编辑资料」/ 未登录「立即登录」 |
| 收益玻璃卡 | x16 343×74,180° 半透明白渐变 + blur20,R16 | 三栏等分:**今日 10.00 / 本月 400.00 / 累计 128000.00**;未登录统一 `0.00`;**未登录或 L0 普通用户不展示该卡** |
| 功能菜单卡 | x16 343×408,同款玻璃态,6 行 × 68(未登录 / L0 仅 2 行) | 行图标 20×20 渐变描边 `#3B82F6→#1D4ED8`,右箭头 16×16 `#94A3B8` |
| 菜单项与等级 | — | L1~L4 且已登录时展示 6 项:`share-2` 我的邀请码 / `crown` 会员中心 / `file-text` 仓库订单 / `badge-cent` 收入明细 / `map-pin` 收货地址 / `contact` 联系我们;**未登录或 L0 普通用户仅展示「收货地址」「联系我们」** |
| 收益玻璃卡 | x16 343×74,180° 半透明白渐变 + blur20,R16 | 三栏等分:**今日 10.00 / 本月 400.00 / 累计 128000.00**;未登录统一 `0.00`;**未登录或 C 普通用户不展示该卡** |
| 功能菜单卡 | x16 343×408,同款玻璃态,6 行 × 68(未登录 / C 仅 2 行) | 行图标 20×20 渐变描边 `#3B82F6→#1D4ED8`,右箭头 16×16 `#94A3B8` |
| 菜单项与等级 | — | L1~L4 且已登录时展示 6 项:`share-2` 我的邀请码 / `crown` 会员中心 / `file-text` 仓库订单 / `badge-cent` 收入明细 / `map-pin` 收货地址 / `contact` 联系我们;**未登录或 C 普通用户仅展示「收货地址」「联系我们」** |
| 仓库订单角标 | 18×18 `#EF4444` R9 文字 1 | **仅已登录态**展示 |
| ICP 区 | x16 343×40 居中 | `京ICP备2026092800号-1X` + 版权行;**内容不足一屏时贴页面底部**(`.page` 为纵向 flex,`.icp` 用 `margin-top: auto` 吸收剩余空间) |
> **用户等级与展示规则(L0~L4)**:由 `userLevel` 控制,`isRestrictedView = !isLoggedIn || userLevel === 'L0'`。**未登录或 L0 普通用户**在个人中心仅保留「收货地址」「联系我们」两行菜单,并隐藏收益数据卡;**已登录且 L1~L4** 展示全部内容。等级接口未联调(当前 mock `L4`),联调后由 `getUserInfo` 下发等级替换。
> **用户等级与展示规则(C/L1~L4)**:由 `userLevel` 控制,`isRestrictedView = !isLoggedIn || userLevel === 'C'`。**未登录或 C 普通用户**在个人中心仅保留「收货地址」「联系我们」两行菜单,并隐藏收益数据卡;**已登录且 L1~L4** 展示全部内容。昵称、头像、等级、收益均已由 `getMemberInfo`(`GET /miniapp/account/user/info`)下发;会员等级空或非法时后端兜底 `C`。
### 9.4 新增内联 base64 图标
@@ -300,7 +300,7 @@
- 三页模板均引入 `<stock-order-toast />`,保证缺货提示全局可见。
- 首页 / 健康方案 / 我的订单 / 个人中心:**均不校验登录态**,未登录可正常浏览(原 `ensureLogin()` 守卫已移除,仅缺货订单列表页保留)。
- 个人中心:`onShow` 读 `getToken()` 仅用于切换「已登录 / 未登录」展示态,不做跳转;未登录时点击头像 / 名称 / 主按钮直接 `uni.reLaunch` 至登录页,**点击菜单项先 `uni.showModal` 提示登录、确认后跳转**。
- 订单列表、收益数值、用户昵称、用户等级均为本地 mock,待 `getOrderList` / `getMemberInfo` / `getUserInfo` 就绪后替换。
- 订单列表、仓库订单角标仍为本地 mock,待 `getOrderList` 就绪后替换;收益数值、用户昵称、头像、用户等级已由 `getMemberInfo`(`GET /miniapp/account/user/info`,金额单位分,前端 `formatYuan` 格式化)替换。
- 「探索更多 / 二级筛选 / 去支付 / 查看订单 / 编辑资料」本轮统一为 toast 占位(菜单项仅在已登录态为占位)。
### 9.6 联系我们页(`pages/contact/index.vue`)
doc/nacos/dev/ruoyi-user-dev.yml
@@ -33,7 +33,7 @@
          # 主库数据源
          master:
            driver-class-name: com.mysql.cj.jdbc.Driver
            url: jdbc:mysql://localhost:3306/hugray-account?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
            url: jdbc:mysql://192.168.110.111:3306/hugray-account?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
            username: root
            password: <脱敏归档,真实值见配置中心>
          # 从库数据源
hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/domain/AccountMember.java
@@ -35,7 +35,7 @@
    private String myReferralCode;
    /** 推荐人会员ID(关联 account_user.user_id) */
    private Long referrerUserId;
    private Long referrerId;
    /** 状态(0正常 1停用) */
    private String status;
@@ -100,14 +100,14 @@
        this.myReferralCode = myReferralCode;
    }
    public Long getReferrerUserId()
    public Long getReferrerId()
    {
        return referrerUserId;
        return referrerId;
    }
    public void setReferrerUserId(Long referrerUserId)
    public void setReferrerId(Long referrerId)
    {
        this.referrerUserId = referrerUserId;
        this.referrerId = referrerId;
    }
    public String getStatus()
@@ -130,7 +130,7 @@
            .append("nickname", getNickname())
            .append("avatar", getAvatar())
            .append("myReferralCode", getMyReferralCode())
            .append("referrerUserId", getReferrerUserId())
            .append("referrerId", getReferrerId())
            .append("status", getStatus())
            .toString();
    }
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountMemberInnerController.java
@@ -75,7 +75,7 @@
        member.setNickname(accountUser.getNickname());
        member.setAvatar(accountUser.getAvatar());
        member.setMyReferralCode(accountUser.getMyReferralCode());
        member.setReferrerUserId(accountUser.getReferrerUserId());
        member.setReferrerId(accountUser.getReferrerId());
        member.setStatus(accountUser.getStatus());
        return member;
    }
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountProfileController.java
New file
@@ -0,0 +1,42 @@
package com.ruoyi.user.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.user.domain.vo.AccountUserInfoVo;
import com.ruoyi.user.service.IAccountUserService;
/**
 * C端会员 个人中心接口
 *
 * 网关前缀 /miniapp/account 由 StripPrefix=2 剥掉,故此处映射为 /user,
 * 外部实际路径为 /miniapp/account/user/**。
 *
 * 会员身份取自网关注入的登录态(SecurityUtils.getUserId),不接受前端传 userId,
 * 天然防止水平越权;未登录请求由网关直接以 401 拦截,故此处不处理未登录分支。
 *
 * @author hugray
 */
@RestController
@RequestMapping("/user")
public class AccountProfileController
{
    @Autowired
    private IAccountUserService accountUserService;
    /**
     * 查询当前登录会员的个人中心信息
     *
     * 返回昵称、头像、会员等级与收益汇总(今日 / 本月 / 累计,单位分)。
     *
     * @return 会员信息展示对象
     */
    @GetMapping("/info")
    public R<AccountUserInfoVo> info()
    {
        return R.ok(accountUserService.getAccountUserInfo(SecurityUtils.getUserId()));
    }
}
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountEarningRecord.java
New file
@@ -0,0 +1,121 @@
package com.ruoyi.user.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
 * 会员收益流水 account_earning_record
 *
 * 一条记录代表一次收益事件;个人中心「今日 / 本月 / 累计收益」由本表按时间实时聚合得出,
 * 无需在 account_user 上维护需要定时归零的汇总字段。
 *
 * @author hugray
 */
public class AccountEarningRecord
{
    /** 收益记录ID */
    private Long recordId;
    /** 会员ID(关联 account_user.user_id) */
    private Long userId;
    /** 收益金额(单位:分,可正可负,负数表示扣减) */
    private Long amount;
    /** 收益来源(1订单收益 2邀请奖励 3等级差价 9其他,见 EarningSourceEnum) */
    private String source;
    /** 业务单号(同来源幂等去重,可为空) */
    private String bizNo;
    /** 备注 */
    private String remark;
    /** 发生时间 */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date createTime;
    /** 更新时间 */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date updateTime;
    public Long getRecordId()
    {
        return recordId;
    }
    public void setRecordId(Long recordId)
    {
        this.recordId = recordId;
    }
    public Long getUserId()
    {
        return userId;
    }
    public void setUserId(Long userId)
    {
        this.userId = userId;
    }
    public Long getAmount()
    {
        return amount;
    }
    public void setAmount(Long amount)
    {
        this.amount = amount;
    }
    public String getSource()
    {
        return source;
    }
    public void setSource(String source)
    {
        this.source = source;
    }
    public String getBizNo()
    {
        return bizNo;
    }
    public void setBizNo(String bizNo)
    {
        this.bizNo = bizNo;
    }
    public String getRemark()
    {
        return remark;
    }
    public void setRemark(String remark)
    {
        this.remark = remark;
    }
    public Date getCreateTime()
    {
        return createTime;
    }
    public void setCreateTime(Date createTime)
    {
        this.createTime = createTime;
    }
    public Date getUpdateTime()
    {
        return updateTime;
    }
    public void setUpdateTime(Date updateTime)
    {
        this.updateTime = updateTime;
    }
}
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountUser.java
@@ -70,7 +70,11 @@
    private String myReferralCode;
    /** 推荐人会员ID(关联 account_user.user_id) */
    private Long referrerUserId;
    private Long referrerId;
    /** 系统等级(C普通用户 L1~L4会员,对应既有列 account_user.system_level,见 MemberLevelEnum) */
    @Excel(name = "系统等级")
    private String systemLevel;
    public Long getUserId()
    {
@@ -187,14 +191,24 @@
        this.myReferralCode = myReferralCode;
    }
    public Long getReferrerUserId()
    public Long getReferrerId()
    {
        return referrerUserId;
        return referrerId;
    }
    public void setReferrerUserId(Long referrerUserId)
    public void setReferrerId(Long referrerId)
    {
        this.referrerUserId = referrerUserId;
        this.referrerId = referrerId;
    }
    public String getSystemLevel()
    {
        return systemLevel;
    }
    public void setSystemLevel(String systemLevel)
    {
        this.systemLevel = systemLevel;
    }
    @Override
@@ -210,7 +224,8 @@
            .append("status", getStatus())
            .append("delFlag", getDelFlag())
            .append("myReferralCode", getMyReferralCode())
            .append("referrerUserId", getReferrerUserId())
            .append("referrerId", getReferrerId())
            .append("systemLevel", getSystemLevel())
            .append("createBy", getCreateBy())
            .append("createTime", getCreateTime())
            .append("updateBy", getUpdateBy())
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountUserInfoVo.java
New file
@@ -0,0 +1,90 @@
package com.ruoyi.user.domain.vo;
/**
 * C端会员信息 VO(个人中心页展示用)
 *
 * 仅暴露页面所需字段;手机号仅下发脱敏值(如 133****5555),不下发明文;
 * 会员身份由服务端从登录态解析,不接受前端传入。
 *
 * @author hugray
 */
public class AccountUserInfoVo
{
    /** 会员ID */
    private Long userId;
    /** 姓名/昵称(注册未采集,可为空;为空时前端回退展示脱敏手机号) */
    private String nickname;
    /** 手机号(脱敏,形如 133****5555;仅供展示,不下发明文) */
    private String phone;
    /** 头像地址(可为空,为空时前端回退默认图标) */
    private String avatar;
    /** 会员等级(对外展示字段,取值 C普通用户 / L1~L4会员,源自 account_user.system_level) */
    private String memberLevel;
    /** 收益汇总(今日 / 本月 / 累计,单位:分) */
    private EarningSummaryVo earnings;
    public Long getUserId()
    {
        return userId;
    }
    public void setUserId(Long userId)
    {
        this.userId = userId;
    }
    public String getNickname()
    {
        return nickname;
    }
    public void setNickname(String nickname)
    {
        this.nickname = nickname;
    }
    public String getPhone()
    {
        return phone;
    }
    public void setPhone(String phone)
    {
        this.phone = phone;
    }
    public String getAvatar()
    {
        return avatar;
    }
    public void setAvatar(String avatar)
    {
        this.avatar = avatar;
    }
    public String getMemberLevel()
    {
        return memberLevel;
    }
    public void setMemberLevel(String memberLevel)
    {
        this.memberLevel = memberLevel;
    }
    public EarningSummaryVo getEarnings()
    {
        return earnings;
    }
    public void setEarnings(EarningSummaryVo earnings)
    {
        this.earnings = earnings;
    }
}
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/EarningSummaryVo.java
New file
@@ -0,0 +1,72 @@
package com.ruoyi.user.domain.vo;
/**
 * 会员收益汇总 VO
 *
 * 金额单位统一为「分」,由前端负责格式化为元展示;
 * 无收益流水时三栏均为 0,避免前端出现 null 判断分支。
 *
 * @author hugray
 */
public class EarningSummaryVo
{
    /** 今日收益(单位:分) */
    private Long today;
    /** 本月收益(单位:分) */
    private Long month;
    /** 累计收益(单位:分) */
    private Long total;
    public EarningSummaryVo()
    {
    }
    public EarningSummaryVo(Long today, Long month, Long total)
    {
        this.today = today;
        this.month = month;
        this.total = total;
    }
    /**
     * 全零汇总:用于会员无任何收益流水的场景
     *
     * @return 三栏均为 0 的汇总对象
     */
    public static EarningSummaryVo zero()
    {
        return new EarningSummaryVo(0L, 0L, 0L);
    }
    public Long getToday()
    {
        return today;
    }
    public void setToday(Long today)
    {
        this.today = today;
    }
    public Long getMonth()
    {
        return month;
    }
    public void setMonth(Long month)
    {
        this.month = month;
    }
    public Long getTotal()
    {
        return total;
    }
    public void setTotal(Long total)
    {
        this.total = total;
    }
}
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/enums/EarningSourceEnum.java
New file
@@ -0,0 +1,61 @@
package com.ruoyi.user.enums;
/**
 * 收益来源枚举
 *
 * 与 account_earning_record.source 字段一一对应;本期仅定义取值,
 * 实际写入方(订单收益 / 邀请奖励 / 等级差价)待对应业务模块接入后补齐。
 *
 * @author hugray
 */
public enum EarningSourceEnum
{
    /** 订单收益 */
    ORDER("1", "订单收益"),
    /** 邀请奖励 */
    INVITE("2", "邀请奖励"),
    /** 等级差价 */
    LEVEL_DIFF("3", "等级差价"),
    /** 其他 */
    OTHER("9", "其他");
    /** 来源编码(落库值) */
    private final String code;
    /** 来源描述 */
    private final String desc;
    EarningSourceEnum(String code, String desc)
    {
        this.code = code;
        this.desc = desc;
    }
    public String getCode()
    {
        return code;
    }
    public String getDesc()
    {
        return desc;
    }
    /**
     * 按编码获取枚举
     *
     * @param code 来源编码
     * @return 命中返回枚举;未命中返回 null
     */
    public static EarningSourceEnum getByCode(String code)
    {
        for (EarningSourceEnum source : values())
        {
            if (source.code.equals(code))
            {
                return source;
            }
        }
        return null;
    }
}
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/enums/MemberLevelEnum.java
New file
@@ -0,0 +1,78 @@
package com.ruoyi.user.enums;
/**
 * 会员等级枚举
 *
 * 与 account_user.system_level 字段一一对应(复用既有列,不再另建等级列):
 *   C    —— 普通用户(个人中心受限展示:仅「收货地址」「联系我们」)
 *   L1~L4 —— 会员(个人中心展示全部内容,含收益卡与全部菜单)
 *
 * @author hugray
 */
public enum MemberLevelEnum
{
    /** 普通用户(受限展示) */
    C("C", "普通用户"),
    /** 会员 L1 */
    L1("L1", "会员"),
    /** 会员 L2 */
    L2("L2", "会员"),
    /** 会员 L3 */
    L3("L3", "会员"),
    /** 会员 L4 */
    L4("L4", "会员");
    /** 等级编码(落库值) */
    private final String code;
    /** 等级描述 */
    private final String desc;
    /** 默认等级:普通用户,用于等级为空或非法的兜底 */
    public static final MemberLevelEnum DEFAULT = C;
    MemberLevelEnum(String code, String desc)
    {
        this.code = code;
        this.desc = desc;
    }
    public String getCode()
    {
        return code;
    }
    public String getDesc()
    {
        return desc;
    }
    /**
     * 按编码获取枚举
     *
     * @param code 等级编码
     * @return 命中返回枚举;未命中返回 null
     */
    public static MemberLevelEnum getByCode(String code)
    {
        for (MemberLevelEnum level : values())
        {
            if (level.code.equals(code))
            {
                return level;
            }
        }
        return null;
    }
    /**
     * 归一化等级编码:非法或空值统一兜底为默认等级,避免脏数据影响前端展示
     *
     * @param code 原始等级编码
     * @return 合法的等级编码
     */
    public static String normalize(String code)
    {
        return getByCode(code) == null ? DEFAULT.getCode() : code;
    }
}
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountEarningRecordMapper.java
New file
@@ -0,0 +1,22 @@
package com.ruoyi.user.mapper;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.user.domain.vo.EarningSummaryVo;
/**
 * 会员收益流水 数据层
 *
 * @author hugray
 */
public interface AccountEarningRecordMapper
{
    /**
     * 汇总指定会员的收益(今日 / 本月 / 累计)
     *
     * 单条 SQL 聚合三栏,命中 idx_earning_user_time 索引;无流水时三栏均为 0。
     *
     * @param userId 会员ID
     * @return 收益汇总(单位:分)
     */
    public EarningSummaryVo selectEarningSummary(@Param("userId") Long userId);
}
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java
@@ -2,6 +2,7 @@
import java.util.List;
import com.ruoyi.user.domain.AccountUser;
import com.ruoyi.user.domain.vo.AccountUserInfoVo;
/**
 * 会员用户 业务层
@@ -62,4 +63,12 @@
     * @return 注册成功后的会员信息(含生成的 userId 与 myReferralCode)
     */
    public AccountUser registerMember(String phone, String referralCode);
    /**
     * 查询C端会员个人中心信息(昵称 / 头像 / 会员等级 / 收益汇总)
     *
     * @param userId 会员ID(取自登录态,非前端传入)
     * @return 会员信息展示对象;会员不存在时抛业务异常
     */
    public AccountUserInfoVo getAccountUserInfo(Long userId);
}
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java
@@ -13,6 +13,10 @@
import com.ruoyi.user.config.UniversalCodeProperties;
import com.ruoyi.user.constant.ReferralConstants;
import com.ruoyi.user.domain.AccountUser;
import com.ruoyi.user.domain.vo.AccountUserInfoVo;
import com.ruoyi.user.domain.vo.EarningSummaryVo;
import com.ruoyi.user.enums.MemberLevelEnum;
import com.ruoyi.user.mapper.AccountEarningRecordMapper;
import com.ruoyi.user.mapper.AccountUserMapper;
import com.ruoyi.user.service.IAccountUserService;
@@ -28,6 +32,9 @@
    @Autowired
    private AccountUserMapper accountUserMapper;
    @Autowired
    private AccountEarningRecordMapper accountEarningRecordMapper;
    @Autowired
    private UniversalCodeProperties universalCodeProperties;
@@ -118,24 +125,74 @@
        {
            throw new ServiceException("手机号已注册,请前往登录");
        }
        // STEP 2: 解析推荐人——命中通用推荐码则不绑定推荐人,否则按码定位并校验
        Long referrerUserId = resolveReferrerUserId(phone, referralCode);
        // STEP 3: 生成全局唯一的本人推荐码(库内查重,冲突重试)
        // STEP 2: 判定是否命中通用推荐码(命中则不绑定推荐人,且直接发放 L4 会员等级)
        boolean universalReferral = isUniversalReferralCode(referralCode);
        // STEP 3: 解析推荐人——命中通用推荐码则不绑定推荐人,否则按码定位并校验
        Long referrerUserId = resolveReferrerUserId(phone, referralCode, universalReferral);
        // STEP 4: 生成全局唯一的本人推荐码(库内查重,冲突重试)
        String myReferralCode = generateUniqueReferralCode();
        // STEP 4: 组装会员信息(C端无密码,账号=手机号),落库(userId 回填)
        // STEP 5: 组装会员信息(C端无密码,账号=手机号;姓名注册未采集,落库留空,由用户后续在资料页完善)
        AccountUser accountUser = new AccountUser();
        accountUser.setUserName(phone);
        accountUser.setPhone(phone);
        accountUser.setNickname(phone);
        accountUser.setStatus(UserConstants.NORMAL);
        accountUser.setMyReferralCode(myReferralCode);
        accountUser.setReferrerUserId(referrerUserId);
        accountUser.setReferrerId(referrerUserId);
        // 通用推荐码注册的会员直接发放 L4 等级;其余不设等级,走库默认值 C(普通用户)
        if (universalReferral)
        {
            accountUser.setSystemLevel(MemberLevelEnum.L4.getCode());
        }
        accountUser.setCreateBy(phone);
        if (insertAccountUser(accountUser) <= 0)
        {
            throw new ServiceException("注册失败,请稍后重试");
        }
        return accountUser;
    }
    @Override
    public AccountUserInfoVo getAccountUserInfo(Long userId)
    {
        // STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属查询)
        if (StringUtils.isNull(userId))
        {
            throw new ServiceException("获取会员信息失败");
        }
        // STEP 2: 按会员ID查询会员(逻辑删除已在 SQL 中过滤)
        AccountUser accountUser = accountUserMapper.selectAccountUserById(userId);
        if (StringUtils.isNull(accountUser))
        {
            throw new ServiceException("获取会员信息失败");
        }
        // STEP 3: 查询收益汇总(单位分),无任何流水时直接返回全零,避免前端 null 分支
        EarningSummaryVo earnings = accountEarningRecordMapper.selectEarningSummary(userId);
        if (StringUtils.isNull(earnings))
        {
            earnings = EarningSummaryVo.zero();
        }
        // STEP 4: 组装展示对象;等级为空或非法值统一兜底为 C(普通用户);手机号脱敏后下发
        AccountUserInfoVo vo = new AccountUserInfoVo();
        vo.setUserId(accountUser.getUserId());
        vo.setNickname(accountUser.getNickname());
        vo.setPhone(maskPhone(accountUser.getPhone()));
        vo.setAvatar(accountUser.getAvatar());
        vo.setMemberLevel(MemberLevelEnum.normalize(accountUser.getSystemLevel()));
        vo.setEarnings(earnings);
        // STEP 5: 返回个人中心展示数据
        return vo;
    }
    /**
     * 判定是否为通用推荐码
     *
     * @param referralCode 注册提交的推荐码
     * @return true 表示命中通用推荐码
     */
    private boolean isUniversalReferralCode(String referralCode)
    {
        String universalCode = universalCodeProperties.getUniversalReferralCode();
        return StringUtils.isNotEmpty(universalCode) && universalCode.equals(referralCode);
    }
    /**
@@ -146,13 +203,13 @@
     *
     * @param phone 注册手机号(仅用于日志留痕,脱敏输出)
     * @param referralCode 推荐码
     * @param universalReferral 是否已命中通用推荐码
     * @return 推荐人用户ID;命中通用码时为 null
     */
    private Long resolveReferrerUserId(String phone, String referralCode)
    private Long resolveReferrerUserId(String phone, String referralCode, boolean universalReferral)
    {
        String universalCode = universalCodeProperties.getUniversalReferralCode();
        // 命中通用推荐码:不绑定推荐人
        if (StringUtils.isNotEmpty(universalCode) && universalCode.equals(referralCode))
        if (universalReferral)
        {
            log.warn("[REFERRAL] universal code hit, not binding referrer, phone={}", maskPhone(phone));
            return null;
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountEarningRecordMapper.xml
New file
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.user.mapper.AccountEarningRecordMapper">
    <resultMap type="com.ruoyi.user.domain.vo.EarningSummaryVo" id="EarningSummaryResult">
        <result property="today" column="today" />
        <result property="month" column="month" />
        <result property="total" column="total" />
    </resultMap>
    <!-- 单条 SQL 聚合今日 / 本月 / 累计,避免多次数据库往返;命中 (user_id, create_time) 联合索引 -->
    <select id="selectEarningSummary" parameterType="Long" resultMap="EarningSummaryResult">
        select
            ifnull(sum(case when create_time &gt;= curdate() then amount end), 0)                         as today,
            ifnull(sum(case when create_time &gt;= date_format(curdate(), '%Y-%m-01') then amount end), 0) as month,
            ifnull(sum(amount), 0)                                                                       as total
        from account_earning_record
        where user_id = #{userId}
    </select>
</mapper>
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml
@@ -16,7 +16,8 @@
        <result property="status"     column="status"      />
        <result property="delFlag"    column="del_flag"    />
        <result property="myReferralCode"   column="my_referral_code"   />
        <result property="referrerUserId"   column="referrer_user_id"   />
        <result property="referrerId"       column="referrer_id"        />
        <result property="systemLevel"      column="system_level"       />
        <result property="createBy"   column="create_by"   />
        <result property="createTime" column="create_time" />
        <result property="updateBy"   column="update_by"   />
@@ -25,7 +26,7 @@
    </resultMap>
    <sql id="selectAccountUserVo">
        select user_id, username, phone, password, nickname, avatar, gender, birthday, status, del_flag, my_referral_code, referrer_user_id, create_by, create_time, update_by, update_time, remark
        select user_id, username, phone, password, nickname, avatar, gender, birthday, status, del_flag, my_referral_code, referrer_id, system_level, create_by, create_time, update_by, update_time, remark
        from account_user
    </sql>
@@ -79,7 +80,8 @@
            <if test="birthday != null">birthday,</if>
            <if test="status != null and status != ''">status,</if>
            <if test="myReferralCode != null and myReferralCode != ''">my_referral_code,</if>
            <if test="referrerUserId != null">referrer_user_id,</if>
            <if test="referrerId != null">referrer_id,</if>
            <if test="systemLevel != null and systemLevel != ''">system_level,</if>
            <if test="remark != null">remark,</if>
            <if test="createBy != null and createBy != ''">create_by,</if>
            create_time
@@ -93,7 +95,8 @@
            <if test="birthday != null">#{birthday},</if>
            <if test="status != null and status != ''">#{status},</if>
            <if test="myReferralCode != null and myReferralCode != ''">#{myReferralCode},</if>
            <if test="referrerUserId != null">#{referrerUserId},</if>
            <if test="referrerId != null">#{referrerId},</if>
            <if test="systemLevel != null and systemLevel != ''">#{systemLevel},</if>
            <if test="remark != null">#{remark},</if>
            <if test="createBy != null and createBy != ''">#{createBy},</if>
            sysdate()
@@ -112,7 +115,8 @@
            <if test="birthday != null">birthday = #{birthday},</if>
            <if test="status != null and status != ''">status = #{status},</if>
            <if test="myReferralCode != null and myReferralCode != ''">my_referral_code = #{myReferralCode},</if>
            <if test="referrerUserId != null">referrer_user_id = #{referrerUserId},</if>
            <if test="referrerId != null">referrer_id = #{referrerId},</if>
            <if test="systemLevel != null and systemLevel != ''">system_level = #{systemLevel},</if>
            <if test="remark != null">remark = #{remark},</if>
            <if test="updateBy != null">update_by = #{updateBy},</if>
            update_time = sysdate()
hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountUserServiceImplTest.java
@@ -21,12 +21,17 @@
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.user.config.UniversalCodeProperties;
import com.ruoyi.user.domain.AccountUser;
import com.ruoyi.user.domain.vo.AccountUserInfoVo;
import com.ruoyi.user.domain.vo.EarningSummaryVo;
import com.ruoyi.user.enums.MemberLevelEnum;
import com.ruoyi.user.mapper.AccountEarningRecordMapper;
import com.ruoyi.user.mapper.AccountUserMapper;
/**
 * 会员注册服务单元测试
 * 会员服务单元测试
 *
 * 覆盖通用推荐码放行、无效/停用推荐码拒绝、正常绑定推荐人三分支。
 * 覆盖会员注册(通用推荐码放行、无效/停用推荐码拒绝、正常绑定推荐人)
 * 与个人中心会员信息查询(正常返回、入参/会员缺失校验、收益与等级兜底)。
 *
 * @author hugray
 */
@@ -39,6 +44,9 @@
    @Mock
    private AccountUserMapper accountUserMapper;
    @Mock
    private AccountEarningRecordMapper accountEarningRecordMapper;
    @Mock
    private UniversalCodeProperties universalCodeProperties;
@@ -60,8 +68,12 @@
        ArgumentCaptor<AccountUser> captor = ArgumentCaptor.forClass(AccountUser.class);
        verify(accountUserMapper).insertAccountUser(captor.capture());
        AccountUser saved = captor.getValue();
        assertNull(saved.getReferrerUserId());
        assertNull(saved.getReferrerId());
        assertNotNull(saved.getMyReferralCode());
        // 姓名注册未采集,落库留空(不再以手机号填充)
        assertNull(saved.getNickname());
        // 通用推荐码注册直接发放 L4 会员等级
        assertEquals(MemberLevelEnum.L4.getCode(), saved.getSystemLevel());
        // 通用码不参与推荐人定位
        verify(accountUserMapper, never()).selectAccountUserByReferralCode(UNIVERSAL_CODE);
    }
@@ -113,6 +125,106 @@
        ArgumentCaptor<AccountUser> captor = ArgumentCaptor.forClass(AccountUser.class);
        verify(accountUserMapper).insertAccountUser(captor.capture());
        assertEquals(100L, captor.getValue().getReferrerUserId());
        assertEquals(100L, captor.getValue().getReferrerId());
        // 非通用码注册不设等级,走库默认值 C(普通用户)
        assertNull(captor.getValue().getSystemLevel());
    }
    @Test
    @DisplayName("会员存在时应返回昵称/头像/等级与收益汇总")
    void getAccountUserInfo_shouldReturnProfile_whenMemberExists()
    {
        AccountUser member = new AccountUser();
        member.setUserId(66L);
        member.setNickname("辉煌G891");
        member.setPhone("13355555555");
        member.setAvatar("https://cdn.example.com/a.png");
        member.setSystemLevel(MemberLevelEnum.L3.getCode());
        EarningSummaryVo summary = new EarningSummaryVo(1000L, 40000L, 12800000L);
        when(accountUserMapper.selectAccountUserById(66L)).thenReturn(member);
        when(accountEarningRecordMapper.selectEarningSummary(66L)).thenReturn(summary);
        AccountUserInfoVo vo = accountUserService.getAccountUserInfo(66L);
        assertEquals(66L, vo.getUserId());
        assertEquals("辉煌G891", vo.getNickname());
        assertEquals("133****5555", vo.getPhone());
        assertEquals("https://cdn.example.com/a.png", vo.getAvatar());
        assertEquals(MemberLevelEnum.L3.getCode(), vo.getMemberLevel());
        assertEquals(1000L, vo.getEarnings().getToday());
        assertEquals(40000L, vo.getEarnings().getMonth());
        assertEquals(12800000L, vo.getEarnings().getTotal());
    }
    @Test
    @DisplayName("会员ID为空时应抛出获取会员信息失败")
    void getAccountUserInfo_shouldThrow_whenUserIdNull()
    {
        ServiceException ex = assertThrows(ServiceException.class,
            () -> accountUserService.getAccountUserInfo(null));
        assertEquals("获取会员信息失败", ex.getMessage());
    }
    @Test
    @DisplayName("会员不存在时应抛出获取会员信息失败")
    void getAccountUserInfo_shouldThrow_whenMemberNotFound()
    {
        when(accountUserMapper.selectAccountUserById(66L)).thenReturn(null);
        ServiceException ex = assertThrows(ServiceException.class,
            () -> accountUserService.getAccountUserInfo(66L));
        assertEquals("获取会员信息失败", ex.getMessage());
    }
    @Test
    @DisplayName("无收益流水时应兜底返回全零汇总")
    void getAccountUserInfo_shouldFallbackZeroEarnings_whenNoRecord()
    {
        AccountUser member = new AccountUser();
        member.setUserId(66L);
        member.setSystemLevel(MemberLevelEnum.L1.getCode());
        when(accountUserMapper.selectAccountUserById(66L)).thenReturn(member);
        when(accountEarningRecordMapper.selectEarningSummary(66L)).thenReturn(null);
        AccountUserInfoVo vo = accountUserService.getAccountUserInfo(66L);
        assertNotNull(vo.getEarnings());
        assertEquals(0L, vo.getEarnings().getToday());
        assertEquals(0L, vo.getEarnings().getMonth());
        assertEquals(0L, vo.getEarnings().getTotal());
    }
    @Test
    @DisplayName("等级为空时应兜底为 C")
    void getAccountUserInfo_shouldFallbackC_whenSystemLevelBlank()
    {
        assertFallbackLevel(null, MemberLevelEnum.C.getCode());
        assertFallbackLevel("", MemberLevelEnum.C.getCode());
    }
    @Test
    @DisplayName("等级为非法值时应兜底为 C")
    void getAccountUserInfo_shouldFallbackC_whenSystemLevelIllegal()
    {
        assertFallbackLevel("L9", MemberLevelEnum.C.getCode());
    }
    /**
     * 校验不同等级取值下的等级兜底结果
     *
     * @param rawLevel 库中原始等级值
     * @param expectedLevel 期望下发的等级
     */
    private void assertFallbackLevel(String rawLevel, String expectedLevel)
    {
        AccountUser member = new AccountUser();
        member.setUserId(66L);
        member.setSystemLevel(rawLevel);
        when(accountUserMapper.selectAccountUserById(66L)).thenReturn(member);
        when(accountEarningRecordMapper.selectEarningSummary(66L)).thenReturn(EarningSummaryVo.zero());
        AccountUserInfoVo vo = accountUserService.getAccountUserInfo(66L);
        assertEquals(expectedLevel, vo.getMemberLevel());
    }
}
hugray-server/sql/hugray_account_20261012.sql
@@ -2,7 +2,12 @@
-- C 端会员用户表变更(ruoyi-user 用户服务)
--
-- 需求:用户小程序注册需支持「推荐码(邀请码)」。
-- 变更内容:account_user 增加「本人推荐码」与「推荐人会员ID」两个字段,并为本人推荐码建唯一索引。
-- 变更内容:account_user 增加「本人推荐码」列,并为其建唯一索引。
--
-- 说明(按权威库 192.168.110.111 的既有字段调整):
--   权威库已存在「推荐人」列 referrer_id(自连接 account_user.user_id)与
--   system_level,故本脚本不再新增 referrer_user_id / member_level 等重复列,
--   推荐关系直接复用既有 referrer_id。
--
-- 目标库 hugray-account 由使用方预先创建(utf8mb4)。
-- 注意:库名含连字符,任何引用都必须用反引号。
@@ -10,10 +15,9 @@
USE `hugray-account`;
-- 变更语句:新增两个字段
-- 变更语句:新增「本人推荐码」列(权威库无此列,推荐码机制所需)
ALTER TABLE `account_user`
  ADD COLUMN `my_referral_code` varchar(20) DEFAULT NULL COMMENT '本人推荐码(注册时生成,全局唯一)' AFTER `remark`,
  ADD COLUMN `referrer_user_id` bigint(20) DEFAULT NULL COMMENT '推荐人会员ID(关联 account_user.user_id)' AFTER `my_referral_code`;
  ADD COLUMN `my_referral_code` varchar(20) DEFAULT NULL COMMENT '本人推荐码(注册时生成,全局唯一)' AFTER `remark`;
-- 本人推荐码全局唯一:随机生成且不重复使用,逻辑删除行占用不影响业务
CREATE UNIQUE INDEX `uk_account_user_referral_code` ON `account_user` (`my_referral_code`);
@@ -22,4 +26,4 @@
-- 回滚语句(如需回滚,执行以下语句)
-- ----------------------------
-- DROP INDEX `uk_account_user_referral_code` ON `account_user`;
-- ALTER TABLE `account_user` DROP COLUMN `referrer_user_id`, DROP COLUMN `my_referral_code`;
-- ALTER TABLE `account_user` DROP COLUMN `my_referral_code`;
hugray-server/sql/hugray_account_20261013.sql
New file
@@ -0,0 +1,37 @@
-- ----------------------------
-- C 端会员 个人中心 相关变更(ruoyi-user 用户服务)
--
-- 需求:个人中心页需展示「会员等级」与「今日/本月/累计收益」。
-- 变更内容:新增「会员收益流水表」account_earning_record,收益三栏由其按时间实时聚合。
--
-- 说明(按权威库 192.168.110.111 的既有字段调整):
--   会员等级复用权威库既有列 account_user.system_level(C<L1<L2<L3<L4,从低到高),
--   故本脚本不再新增 member_level 重复列。
--
-- 目标库 hugray-account 由使用方预先创建(utf8mb4)。
-- 注意:库名含连字符,任何引用都必须用反引号。
-- ----------------------------
USE `hugray-account`;
-- 变更:新增会员收益流水表(一条流水=一次收益事件,三栏由 SQL 按时间聚合)
CREATE TABLE `account_earning_record` (
  `record_id`   bigint(20)   NOT NULL AUTO_INCREMENT COMMENT '收益记录ID',
  `user_id`     bigint(20)   NOT NULL                COMMENT '会员ID(关联 account_user.user_id)',
  `amount`      bigint(20)   NOT NULL DEFAULT 0      COMMENT '收益金额(单位:分,可正可负)',
  `source`      char(1)      NOT NULL                COMMENT '收益来源(1订单收益 2邀请奖励 3等级差价 9其他)',
  `biz_no`      varchar(64)  DEFAULT NULL            COMMENT '业务单号(同来源幂等去重)',
  `remark`      varchar(255) DEFAULT NULL            COMMENT '备注',
  `create_time` datetime     DEFAULT NULL            COMMENT '发生时间',
  `update_time` datetime     DEFAULT NULL            COMMENT '更新时间',
  PRIMARY KEY (`record_id`),
  -- 覆盖「按会员 + 时间区间」的聚合查询,避免全表扫描
  KEY `idx_earning_user_time` (`user_id`, `create_time`),
  -- 业务幂等:同来源同业务单号唯一(biz_no 为空时 MySQL 唯一索引不冲突,允许手工补录)
  UNIQUE KEY `uk_earning_source_biz` (`source`, `biz_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员收益流水表';
-- ----------------------------
-- 回滚语句(如需回滚,执行以下语句)
-- ----------------------------
-- DROP TABLE IF EXISTS `account_earning_record`;
hugray-user-miniapp/api/index.js
@@ -20,13 +20,6 @@
}
/**
 * 获取当前登录人信息(含会员等级 L1~L4 / 普通用户)
 */
export function getUserInfo() {
    return get('/miniapp/system/user/getInfo')
}
/**
 * 手机号一键登录(微信手机号快速验证,凭证换取登录态)
 * @param {Object} data { code } 手机号快速验证凭证
 */
hugray-user-miniapp/pages.json
@@ -30,6 +30,13 @@
            }
        },
        {
            "path": "pages/profile/edit",
            // 导航栏含更多 / 帮助图标,需自定义导航栏承载
            "style": {
                "navigationStyle": "custom"
            }
        },
        {
            "path": "pages/contact/index",
            // 顶部为深蓝底品牌区,与白底原生导航冲突,故使用自定义导航栏
            "style": {
hugray-user-miniapp/pages/profile/edit.vue
New file
@@ -0,0 +1,447 @@
<template>
    <view class="page">
        <!-- 自定义导航栏:状态栏留白由行内 paddingTop 撑开 -->
        <view class="nav" :style="{ paddingTop: statusBarHeight + 'px' }">
            <view class="nav-bar">
                <!-- 返回 -->
                <view class="nav-back" @tap="handleBack">
                    <view class="icon-back"></view>
                </view>
                <text class="nav-title">我的资料</text>
                <!-- 更多 / 帮助(本期占位) -->
                <view class="nav-actions">
                    <view class="nav-action" @tap="handlePlaceholder('更多')">
                        <view class="icon-more">
                            <view class="dot"></view>
                            <view class="dot"></view>
                            <view class="dot"></view>
                        </view>
                    </view>
                    <view class="nav-action" @tap="handlePlaceholder('帮助')">
                        <view class="icon-help"><text class="icon-help-text">?</text></view>
                    </view>
                </view>
            </view>
        </view>
        <view class="body">
            <view class="list">
                <!-- 头像:点击更换(本期占位) -->
                <view class="row" @tap="handlePlaceholder('头像更换')">
                    <text class="row-label">头像</text>
                    <view class="row-right">
                        <view class="avatar">
                            <image v-if="avatar" class="avatar-img" :src="avatar" mode="aspectFill" />
                            <view v-else class="icon icon-camera"></view>
                        </view>
                        <view class="icon icon-chevron-right"></view>
                    </view>
                </view>
                <!-- 手机号:小程序内暂不支持更换,仅只读展示(已脱敏),故无箭头 -->
                <view class="row">
                    <text class="row-label">手机号</text>
                    <text class="row-value">{{ phone || '—' }}</text>
                </view>
                <!-- 实名认证 -->
                <view class="row" @tap="handlePlaceholder('实名认证')">
                    <text class="row-label">实名认证</text>
                    <view class="row-right">
                        <view class="badge" :class="realNameBadge.cls">
                            <text class="badge-text">{{ realNameBadge.text }}</text>
                        </view>
                        <view class="icon icon-chevron-right"></view>
                    </view>
                </view>
                <!-- 企业 / 个体户认证 -->
                <view class="row" @tap="handlePlaceholder('企业/个体户认证')">
                    <text class="row-label">企业/个体户认证</text>
                    <view class="row-right">
                        <view class="badge" :class="companyBadge.cls">
                            <text class="badge-text">{{ companyBadge.text }}</text>
                        </view>
                        <view class="icon icon-chevron-right"></view>
                    </view>
                </view>
            </view>
            <!-- 底部操作区 -->
            <view class="footer">
                <view class="btn-logout" @tap="handleLogout">
                    <text class="btn-logout-text">退出登录</text>
                </view>
                <text class="link-cancel" @tap="handlePlaceholder('注销账号')">注销账号</text>
            </view>
        </view>
    </view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { clearToken, ensureLogin } from '@/utils/request.js'
import { getMemberInfo, logout } from '@/api/index.js'
/** 状态栏高度:自定义导航栏下用于顶部留白 */
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
/** 用户头像:登录后由 getMemberInfo 下发,为空时回退相机占位 */
const avatar = ref('')
/** 用户手机号:登录后由 getMemberInfo 下发(已脱敏,形如 133****5555) */
const phone = ref('')
/**
 * 认证状态字典:key 为后端下发的状态枚举,值含徽标文案与样式类
 * 本期为原型,两处认证均取 none(未认证)占位,后端接入后直接替换 ref 初值即可
 */
const AUTH_STATUS_MAP = {
    none: { text: '未认证', cls: 'badge--none' },
    ing: { text: '认证中', cls: 'badge--ing' },
    fail: { text: '认证失败', cls: 'badge--fail' },
    done: { text: '已认证', cls: 'badge--done' }
}
/** 实名认证状态(本期占位) */
const realNameStatus = ref('none')
/** 企业 / 个体户认证状态(本期占位) */
const companyStatus = ref('none')
/** 实名认证徽标:状态缺省兜底「未认证」 */
const realNameBadge = computed(() => AUTH_STATUS_MAP[realNameStatus.value] || AUTH_STATUS_MAP.none)
/** 企业 / 个体户认证徽标:状态缺省兜底「未认证」 */
const companyBadge = computed(() => AUTH_STATUS_MAP[companyStatus.value] || AUTH_STATUS_MAP.none)
/** 返回上一页:无上级页面(如直接进入)时回到个人中心 */
const handleBack = () => {
    const pages = getCurrentPages()
    if (pages.length > 1) {
        uni.navigateBack()
        return
    }
    uni.switchTab({ url: '/pages/profile/index' })
}
/** 占位交互:头像更换 / 实名认证 / 企业认证 / 注销 / 更多 / 帮助本期均不接后端 */
const handlePlaceholder = (name) => {
    uni.showToast({ title: `${name}功能开发中`, icon: 'none' })
}
/** 退出登录:二次确认后调用退出接口并清理本地登录态,回到个人中心 */
const handleLogout = () => {
    uni.showModal({
        title: '提示',
        content: '确定要退出登录吗?',
        confirmText: '退出',
        cancelText: '取消',
        success: async (res) => {
            if (!res.confirm) return
            try {
                await logout()
            } catch (error) {
                // 退出接口失败不阻塞本地退出,仅记录上下文日志
                console.error('[PROFILE_EDIT] 退出登录接口失败', error)
            }
            clearToken()
            uni.reLaunch({ url: '/pages/profile/index' })
        }
    })
}
/** 拉取当前会员信息(头像 / 脱敏手机号),失败时静默降级为空 */
const loadMemberInfo = async () => {
    try {
        const res = await getMemberInfo()
        const data = res.data || {}
        avatar.value = data.avatar || ''
        phone.value = data.phone || ''
    } catch (error) {
        console.error('[PROFILE_EDIT] 拉取会员信息失败', error)
        avatar.value = ''
        phone.value = ''
    }
}
// 进入页面校验登录态,未登录跳登录页;已登录则拉取会员信息
onShow(() => {
    if (!ensureLogin()) return
    loadMemberInfo()
})
</script>
<style scoped lang="scss">
.page {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    box-sizing: border-box;
    background-color: #ffffff;
}
/* ==================== 自定义导航栏 ==================== */
.nav {
    flex-shrink: 0;
    background-color: #ffffff;
}
.nav-bar {
    position: relative;
    height: 88rpx;
    display: flex;
    align-items: center;
    justify-content: center;
}
.nav-back {
    position: absolute;
    left: 16rpx;
    top: 50%;
    transform: translateY(-50%);
    width: 64rpx;
    height: 64rpx;
    display: flex;
    align-items: center;
    justify-content: center;
}
/* 纯 CSS 左向箭头:左 + 下边框旋转 45° */
.icon-back {
    width: 22rpx;
    height: 22rpx;
    border-left: 4rpx solid #1f2937;
    border-bottom: 4rpx solid #1f2937;
    transform: rotate(45deg);
}
.nav-title {
    font-size: 34rpx;
    font-weight: 700;
    line-height: 1;
    letter-spacing: 1rpx;
    color: #0f172a;
}
.nav-actions {
    position: absolute;
    right: 28rpx;
    top: 50%;
    transform: translateY(-50%);
    display: flex;
    align-items: center;
    gap: 32rpx;
}
.nav-action {
    width: 56rpx;
    height: 56rpx;
    display: flex;
    align-items: center;
    justify-content: center;
}
/* 更多:三点 */
.icon-more {
    display: flex;
    align-items: center;
    gap: 6rpx;
}
.icon-more .dot {
    width: 8rpx;
    height: 8rpx;
    border-radius: 50%;
    background: #334155;
}
/* 帮助:圆圈问号 */
.icon-help {
    width: 36rpx;
    height: 36rpx;
    display: flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    border: 3rpx solid #334155;
    border-radius: 50%;
}
.icon-help-text {
    font-size: 22rpx;
    font-weight: 700;
    line-height: 1;
    color: #334155;
}
/* ==================== 资料列表 ==================== */
.body {
    flex: 1;
    display: flex;
    flex-direction: column;
}
.list {
    padding: 8rpx 32rpx 0;
}
.row {
    min-height: 116rpx;
    display: flex;
    align-items: center;
    justify-content: space-between;
    border-bottom: 2rpx solid #f1f5f9;
}
/* 末行无分隔线 */
.row:last-child {
    border-bottom: none;
}
.row-label {
    font-size: 30rpx;
    line-height: 1;
    color: #1f2937;
}
.row-right {
    display: flex;
    align-items: center;
    gap: 16rpx;
}
.row-value {
    font-size: 30rpx;
    line-height: 1;
    letter-spacing: 1rpx;
    color: #94a3b8;
}
/* 头像容器:46pt 圆形 */
.avatar {
    width: 92rpx;
    height: 92rpx;
    display: flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    border-radius: 50%;
    background: #f1f5f9;
    border: 2rpx solid #e2e8f0;
    overflow: hidden;
}
.avatar-img {
    width: 100%;
    height: 100%;
    border-radius: 50%;
}
/* ==================== 认证状态徽标(4 态) ==================== */
.badge {
    display: flex;
    align-items: center;
    padding: 10rpx 20rpx;
    border-radius: 12rpx;
    border: 2rpx solid;
}
.badge-text {
    font-size: 26rpx;
    font-weight: 500;
    line-height: 1;
}
/* 未认证 */
.badge--none {
    color: #ea580c;
    background: #fff7ed;
    border-color: #fdba74;
}
/* 认证中 */
.badge--ing {
    color: #1e5ebc;
    background: #eff6ff;
    border-color: #93c5fd;
}
/* 认证失败 */
.badge--fail {
    color: #dc2626;
    background: #fef2f2;
    border-color: #fca5a5;
}
/* 已认证 */
.badge--done {
    color: #4d7c0f;
    background: #f7fee7;
    border-color: #bef264;
}
/* ==================== 底部操作区 ==================== */
.footer {
    /* 内容不足一屏时按钮自动贴底 */
    margin-top: auto;
    padding: 32rpx 48rpx 44rpx;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 36rpx;
}
.btn-logout {
    width: 100%;
    height: 96rpx;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 48rpx;
    background: linear-gradient(90deg, #82c236 0%, #639c1f 100%);
    box-shadow: 0 16rpx 40rpx rgba(99, 156, 31, 0.28);
}
.btn-logout-text {
    font-size: 30rpx;
    font-weight: 600;
    line-height: 1;
    color: #ffffff;
}
.link-cancel {
    font-size: 28rpx;
    line-height: 1;
    color: #1e5ebc;
    text-decoration: underline;
}
/* ==================== 图标(内联 base64 SVG) ==================== */
.icon {
    flex-shrink: 0;
    background-repeat: no-repeat;
    background-position: center center;
    background-size: 100% 100%;
}
/* 相机占位(#94A3B8) */
.icon-camera {
    width: 40rpx;
    height: 40rpx;
    background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTRBM0I4IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTE0LjUgNGgtNUw3IDdINGEyIDIgMCAwIDAtMiAydjlhMiAyIDAgMCAwIDIgMmgxNmEyIDIgMCAwIDAgMi0yVjlhMiAyIDAgMCAwLTItMmgtM2wtMi41LTN6Ii8+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMyIgcj0iMyIvPjwvc3ZnPg==');
}
/* 右箭头(#94A3B8) */
.icon-chevron-right {
    width: 32rpx;
    height: 32rpx;
    background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTRBM0I4IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0ibTkgMTggNi02LTYtNiIvPjwvc3ZnPg==');
}
</style>
hugray-user-miniapp/pages/profile/index.vue
@@ -7,11 +7,13 @@
        <view class="header">
            <view class="user-main" @tap="handleAccountTap">
                <view class="avatar">
                    <view class="icon" :class="isLoggedIn ? 'icon-camera' : 'icon-user'"></view>
                    <!-- 有头像则渲染图片,否则回退图标(已登录相机 / 未登录用户) -->
                    <image v-if="avatar" class="avatar-img" :src="avatar" mode="aspectFill" />
                    <view v-else class="icon" :class="isLoggedIn ? 'icon-camera' : 'icon-user'"></view>
                </view>
                <view class="user-text">
                    <!-- 已登录:昵称 + 会员等级;未登录:登录入口 + 引导文案 -->
                    <text class="user-name">{{ isLoggedIn ? nickname : '点击登录 / 注册' }}</text>
                    <text class="user-name">{{ isLoggedIn ? displayName : '点击登录 / 注册' }}</text>
                    <view v-if="isLoggedIn" class="level-pill">
                        <view class="level-medal">
                            <text class="level-medal-text">V</text>
@@ -26,7 +28,7 @@
            </view>
        </view>
        <!-- 收益数据展示区(343×74pt):未登录或 L0 普通用户不展示 -->
        <!-- 收益数据展示区(343×74pt):未登录或 C 普通用户不展示 -->
        <view v-if="!isRestrictedView" class="earnings-card">
            <view v-for="item in earnings" :key="item.key" class="earning-item">
                <text class="earning-label">{{ item.label }}</text>
@@ -71,6 +73,8 @@
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getToken } from '@/utils/request.js'
import { getMemberInfo } from '@/api/index.js'
import { formatYuan } from '@/utils/format.js'
import config from '@/config/index.js'
import StockOrderToast from '@/components/stock-order-toast/stock-order-toast.vue'
@@ -80,22 +84,34 @@
/** 登录态:本页需同时兼容「已登录 / 未登录」两种展示,故以本地 token 判定 */
const isLoggedIn = ref(false)
/** 用户昵称:用户信息接口未就绪,先本地 mock(联调后替换为 getUserInfo) */
const nickname = ref('辉煌G891')
/** 用户姓名/昵称:登录后由 getMemberInfo 下发;注册未采集,通常为空 */
const nickname = ref('')
/** 用户等级:取值 L0~L4,其中 L0 为普通用户;等级接口未联调,先本地 mock(联调后取 getUserInfo 下发等级) */
const userLevel = ref('L4')
/** 用户手机号:登录后由 getMemberInfo 下发(已脱敏,形如 133****5555) */
const phone = ref('')
/** 是否受限展示:未登录或 L0 普通用户,个人中心仅展示「收货地址」「联系我们」 */
const isRestrictedView = computed(() => !isLoggedIn.value || userLevel.value === 'L0')
/** 头部展示名:姓名优先,为空时回退展示脱敏手机号 */
const displayName = computed(() => nickname.value || phone.value)
/** 收益三栏:未登录态统一展示 0.00 */
/** 用户头像:登录后由 getMemberInfo 下发,为空时回退默认图标 */
const avatar = ref('')
/** 用户等级:取值 C/L1~L4,登录后由 getMemberInfo 下发;C 为普通用户 */
const userLevel = ref('C')
/** 是否受限展示:未登录或 C 普通用户,个人中心仅展示「收货地址」「联系我们」 */
const isRestrictedView = computed(() => !isLoggedIn.value || userLevel.value === 'C')
/** 收益汇总原始值(单位:分),登录后由 getMemberInfo 下发,未登录保持全零 */
const earningsRaw = ref({ today: 0, month: 0, total: 0 })
/** 收益三栏:后端下发单位为分,统一格式化为元展示;未登录统一展示 0.00 */
const earnings = computed(() => {
    const zeroValue = '0.00'
    const source = isLoggedIn.value ? earningsRaw.value : { today: 0, month: 0, total: 0 }
    return [
        { key: 'today', label: '今日收益', value: isLoggedIn.value ? '10.00' : zeroValue },
        { key: 'month', label: '本月收益', value: isLoggedIn.value ? '400.00' : zeroValue },
        { key: 'total', label: '累计收益', value: isLoggedIn.value ? '128000.00' : zeroValue }
        { key: 'today', label: '今日收益', value: formatYuan(source.today) },
        { key: 'month', label: '本月收益', value: formatYuan(source.month) },
        { key: 'total', label: '累计收益', value: formatYuan(source.total) }
    ]
})
@@ -109,18 +125,18 @@
    { key: 'contact', icon: 'contact', title: '联系我们' }
]
/** 当前可见菜单:受限展示(未登录 / L0)时仅保留「收货地址」「联系我们」 */
/** 当前可见菜单:受限展示(未登录 / C)时仅保留「收货地址」「联系我们」 */
const menuList = computed(() =>
    isRestrictedView.value ? MENU_LIST.filter((menu) => !menu.restricted) : MENU_LIST
)
/** 头像 / 名称 / 按钮点击:未登录跳登录页,已登录进入资料编辑 */
/** 头像 / 名称 / 编辑资料按钮点击:未登录跳登录页,已登录进入「我的资料」页 */
const handleAccountTap = () => {
    if (!isLoggedIn.value) {
        uni.reLaunch({ url: config.loginPage })
        return
    }
    uni.showToast({ title: '敬请期待', icon: 'none' })
    uni.navigateTo({ url: '/pages/profile/edit' })
}
/** 菜单项点击:未登录先提示登录;已登录进入二级功能页(未接入的暂为占位反馈) */
@@ -151,9 +167,37 @@
    uni.showToast({ title: '敬请期待', icon: 'none' })
}
// 每次页面显示均刷新登录态,保证登录 / 退出后展示态同步
/**
 * 拉取当前会员信息(昵称 / 头像 / 等级 / 收益汇总)
 * 失败时静默降级为普通用户态,不阻塞页面展示
 */
const loadMemberInfo = async () => {
    try {
        const res = await getMemberInfo()
        const data = res.data || {}
        nickname.value = data.nickname || ''
        // 手机号后端已脱敏(133****5555),可直接用于展示兜底
        phone.value = data.phone || ''
        avatar.value = data.avatar || ''
        // 等级缺省兜底 C,与后端 MemberLevelEnum.normalize 保持一致
        userLevel.value = data.memberLevel || 'C'
        earningsRaw.value = data.earnings || { today: 0, month: 0, total: 0 }
    } catch (error) {
        console.error('[PROFILE] 拉取会员信息失败', error)
        nickname.value = ''
        phone.value = ''
        avatar.value = ''
        userLevel.value = 'C'
        earningsRaw.value = { today: 0, month: 0, total: 0 }
    }
}
// 每次页面显示均刷新登录态,保证登录 / 退出后展示态同步;已登录则拉取会员信息
onShow(() => {
    isLoggedIn.value = Boolean(getToken())
    if (isLoggedIn.value) {
        loadMemberInfo()
    }
})
</script>
@@ -208,6 +252,13 @@
    border: 5rpx solid #ffffff;
}
/* 用户头像图片:铺满头像容器并裁为圆形 */
.avatar-img {
    width: 100%;
    height: 100%;
    border-radius: 50%;
}
/* 已登录:相机图标;未登录:用户图标 */
.icon-camera {
    width: 64rpx;
hugray-user-miniapp/utils/format.js
New file
@@ -0,0 +1,20 @@
/**
 * 通用格式化工具(用户端)
 * 约定:后端金额统一以「分」下发,前端展示统一转为「元」
 */
/**
 * 金额分 → 元
 * @param {Number|String} fen          金额(单位:分)
 * @param {Boolean}       [withThousands=false] 是否添加千分位分隔(如 128,000.00)
 * @returns {String} 保留两位小数的金额字符串,如 12800000 → '128000.00'
 */
export function formatYuan(fen, withThousands = false) {
    const num = Number(fen)
    // 非法、空值统一按 0 处理,避免页面出现 NaN
    const yuan = Number.isFinite(num) ? num / 100 : 0
    const fixed = yuan.toFixed(2)
    if (!withThousands) return fixed
    const [intPart, decPart] = fixed.split('.')
    return intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + '.' + decPart
}