feat: 新增用户小程序会员个人中心及资料编辑页面
- 实现后端C端会员个人中心查询接口,包含用户基础信息、会员等级及收益汇总
- 新增小程序端资料编辑页面并注册路由
- 新增分转元通用格式化工具统一金额展示逻辑
- 修复后端推荐人字段命名不一致问题
- 更新开发环境数据库配置,新增收益流水表SQL脚本
- 替换个人中心页面mock数据为真实接口,移除废弃API
- 为资料页配置自定义导航栏
| New file |
| | |
| | | # 用户端小程序 个人中心 接口 开发设计文档 |
| | | |
| | | - 模块: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` 一致)。 |
| New file |
| | |
| | | <!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> |
| | |
| | | |
| | | ### 2.7 表结构缺口 |
| | | |
| | | `account_user` 无推荐码、无推荐人字段 → 新增 `my_referral_code`、`referrer_user_id`。 |
| | | `account_user` 无推荐码字段 → 新增 `my_referral_code`;推荐关系复用既有 `referrer_id`(权威库已存在),不新增重复列。 |
| | | |
| | | ### 2.8 既有代码缺陷 |
| | | |
| | |
| | | |
| | | ```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`; |
| | | ``` |
| | | |
| | | 说明: |
| | |
| | | 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 释放锁 |
| | | ``` |
| | |
| | | |
| | | 通用推荐码(ruoyi-user) |
| | | AccountUserServiceImpl.registerMember 定位推荐人前先判定: |
| | | 命中「配置的通用推荐码」→ 跳过推荐人查询与校验,referrerUserId 留空(不绑定推荐人) |
| | | 否则走原有「按码查推荐人 → 不存在/停用则拒绝」逻辑 |
| | | 命中「配置的通用推荐码」→ 跳过推荐人查询与校验,referrerId 留空(不绑定推荐人); |
| | | 同时直接发放 L4 会员等级(system_level = L4) |
| | | 否则走原有「按码查推荐人 → 不存在/停用则拒绝」逻辑,且不设等级(走库默认值 C) |
| | | ``` |
| | | |
| | | 命中通用码时打印 WARN 日志留痕(手机号脱敏)。 |
| | |
| | | | 服务 | 配置项 | 示例值 | 说明 | |
| | | | --- | --- | --- | --- | |
| | | | 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 等级 | |
| | | |
| | | --- |
| | | |
| | |
| | | |
| | | | 文件 | 改动 | |
| | | | --- | --- | |
| | | | 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 脚本 | |
| | |
| | | | 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`;命中通用码时不再校验「推荐人码无效 / 推荐人账号异常」 | |
| | | |
| | | --- |
| | | |
| | |
| | | 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` 校验 → 单测。 |
| | |
| | | | 3 | 个人中心角标数量(未读)由后端下发 | 待接口(当前默认 1) | |
| | | | 4 | 缺货订单列表页真实内容 | 占位 | |
| | | | 5 | 「探索更多」产品详情页 | 占位(toast 提示) | |
| | | | 6 | 三页真实数据接口(订单列表 / 收益数值 / 会员等级与昵称) | 待接口(当前 mock) | |
| | | | 6 | 真实数据接口:个人中心「昵称 / 头像 / 等级 / 收益」已由 `getMemberInfo` 下发;订单列表仍待 `getOrderList` | 部分完成 | |
| | | | 7 | 三页图片素材(方案卡、订单缩略图、用户头像)入仓后替换渐变占位 | 待素材 | |
| | | |
| | | |
| | |
| | | | 页面底色 | 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 图标 |
| | | |
| | |
| | | - 三页模板均引入 `<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`) |
| | |
| | | # 主库数据源 |
| | | 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: <脱敏归档,真实值见配置中心> |
| | | # 从库数据源 |
| | |
| | | private String myReferralCode; |
| | | |
| | | /** 推荐人会员ID(关联 account_user.user_id) */ |
| | | private Long referrerUserId; |
| | | private Long referrerId; |
| | | |
| | | /** 状态(0正常 1停用) */ |
| | | private String status; |
| | |
| | | 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() |
| | |
| | | .append("nickname", getNickname()) |
| | | .append("avatar", getAvatar()) |
| | | .append("myReferralCode", getMyReferralCode()) |
| | | .append("referrerUserId", getReferrerUserId()) |
| | | .append("referrerId", getReferrerId()) |
| | | .append("status", getStatus()) |
| | | .toString(); |
| | | } |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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())); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | 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() |
| | | { |
| | |
| | | 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 |
| | |
| | | .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()) |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | |
| | | |
| | | import java.util.List; |
| | | import com.ruoyi.user.domain.AccountUser; |
| | | import com.ruoyi.user.domain.vo.AccountUserInfoVo; |
| | | |
| | | /** |
| | | * 会员用户 业务层 |
| | |
| | | * @return 注册成功后的会员信息(含生成的 userId 与 myReferralCode) |
| | | */ |
| | | public AccountUser registerMember(String phone, String referralCode); |
| | | |
| | | /** |
| | | * 查询C端会员个人中心信息(昵称 / 头像 / 会员等级 / 收益汇总) |
| | | * |
| | | * @param userId 会员ID(取自登录态,非前端传入) |
| | | * @return 会员信息展示对象;会员不存在时抛业务异常 |
| | | */ |
| | | public AccountUserInfoVo getAccountUserInfo(Long userId); |
| | | } |
| | |
| | | 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; |
| | | |
| | |
| | | |
| | | @Autowired |
| | | private AccountUserMapper accountUserMapper; |
| | | |
| | | @Autowired |
| | | private AccountEarningRecordMapper accountEarningRecordMapper; |
| | | |
| | | @Autowired |
| | | private UniversalCodeProperties universalCodeProperties; |
| | |
| | | { |
| | | 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); |
| | | } |
| | | |
| | | /** |
| | |
| | | * |
| | | * @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; |
| New file |
| | |
| | | <?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 >= 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} |
| | | </select> |
| | | |
| | | </mapper> |
| | |
| | | <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" /> |
| | |
| | | </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> |
| | | |
| | |
| | | <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 |
| | |
| | | <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() |
| | |
| | | <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() |
| | |
| | | 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 |
| | | */ |
| | |
| | | |
| | | @Mock |
| | | private AccountUserMapper accountUserMapper; |
| | | |
| | | @Mock |
| | | private AccountEarningRecordMapper accountEarningRecordMapper; |
| | | |
| | | @Mock |
| | | private UniversalCodeProperties universalCodeProperties; |
| | |
| | | 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); |
| | | } |
| | |
| | | |
| | | 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()); |
| | | } |
| | | } |
| | |
| | | -- 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)。 |
| | | -- 注意:库名含连字符,任何引用都必须用反引号。 |
| | |
| | | |
| | | 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`); |
| | |
| | | -- 回滚语句(如需回滚,执行以下语句) |
| | | -- ---------------------------- |
| | | -- 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`; |
| New file |
| | |
| | | -- ---------------------------- |
| | | -- 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`; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 获取当前登录人信息(含会员等级 L1~L4 / 普通用户) |
| | | */ |
| | | export function getUserInfo() { |
| | | return get('/miniapp/system/user/getInfo') |
| | | } |
| | | |
| | | /** |
| | | * 手机号一键登录(微信手机号快速验证,凭证换取登录态) |
| | | * @param {Object} data { code } 手机号快速验证凭证 |
| | | */ |
| | |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/profile/edit", |
| | | // 导航栏含更多 / 帮助图标,需自定义导航栏承载 |
| | | "style": { |
| | | "navigationStyle": "custom" |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/contact/index", |
| | | // 顶部为深蓝底品牌区,与白底原生导航冲突,故使用自定义导航栏 |
| | | "style": { |
| New file |
| | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | </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> |
| | |
| | | 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' |
| | | |
| | |
| | | /** 登录态:本页需同时兼容「已登录 / 未登录」两种展示,故以本地 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) } |
| | | ] |
| | | }) |
| | | |
| | |
| | | { 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' }) |
| | | } |
| | | |
| | | /** 菜单项点击:未登录先提示登录;已登录进入二级功能页(未接入的暂为占位反馈) */ |
| | |
| | | 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> |
| | | |
| | |
| | | border: 5rpx solid #ffffff; |
| | | } |
| | | |
| | | /* 用户头像图片:铺满头像容器并裁为圆形 */ |
| | | .avatar-img { |
| | | width: 100%; |
| | | height: 100%; |
| | | border-radius: 50%; |
| | | } |
| | | |
| | | /* 已登录:相机图标;未登录:用户图标 */ |
| | | .icon-camera { |
| | | width: 64rpx; |
| New file |
| | |
| | | /** |
| | | * 通用格式化工具(用户端) |
| | | * 约定:后端金额统一以「分」下发,前端展示统一转为「元」 |
| | | */ |
| | | |
| | | /** |
| | | * 金额分 → 元 |
| | | * @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 |
| | | } |