From 8ea6a7dd1fcdbd4e22c564ed63a5a8023510161a Mon Sep 17 00:00:00 2001
From: zjk <852185829@qq.com>
Date: 星期三, 14 十月 2026 08:56:45 +0800
Subject: [PATCH] feat: 新增实名认证、企业认证、我的邀请与头像上传功能
---
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertification.java | 182 +
doc/design/用户端_我的资料_头像上传_20261013.md | 229 ++
hugray-user-miniapp/pages/profile/certification.vue | 390 +++
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java | 142 +
hugray-user-miniapp/utils/request.js | 94
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/WeChatMiniappService.java | 140 +
hugray-server/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/CacheConstants.java | 5
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertificationApply.java | 184 +
hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImplTest.java | 292 ++
hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountUserServiceImplTest.java | 78
hugray-server/sql/hugray_account_company_certification_20261013.sql | 113
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountCertificationApplyService.java | 47
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountProfileController.java | 144 +
hugray-admin/src/utils/member.js | 6
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java | 22
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountUserMapper.java | 26
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/InviteQrcodeQuery.java | 27
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/CompanyCertificationApplyDto.java | 334 ++
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/CompanyCertificationStatusVo.java | 314 ++
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationApplyMapper.xml | 126 +
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountCertificationApplyMapper.java | 33
hugray-user-miniapp/pages.json | 21
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountUserInfoVo.java | 29
hugray-user-miniapp/pages/profile/invite.vue | 781 ++++++
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml | 47
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationMapper.xml | 55
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImpl.java | 246 ++
hugray-user-miniapp/pages/login/register.vue | 19
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java | 40
hugray-user-miniapp/pages/profile/edit.vue | 104
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountCertificationStatusVo.java | 66
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/domain/vo/InviteQrcodeVo.java | 22
hugray-user-miniapp/pages/profile/index.vue | 5
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/AccountAuthController.java | 23
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountInviteeVo.java | 104
doc/design/用户端_个人中心_20261013.md | 2
hugray-user-miniapp/api/index.js | 92
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/InviteCodeVo.java | 24
doc/design/用户端_我的邀请_20261013.md | 239 ++
hugray-admin/src/views/member/certification/detail.vue | 36
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountReferrerVo.java | 89
doc/design/用户端_实名认证_20261013.md | 305 ++
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountUser.java | 21
doc/design/用户端_企业个体认证_20261013.md | 330 ++
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountCertificationApplyDto.java | 57
hugray-user-miniapp/pages/profile/company-certification.vue | 1038 +++++++++
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountUserAvatarDto.java | 32
doc/nacos/dev/ruoyi-file-dev.yml | 34
48 files changed, 6,766 insertions(+), 23 deletions(-)
diff --git "a/doc/design/\347\224\250\346\210\267\347\253\257_\344\270\252\344\272\272\344\270\255\345\277\203_20261013.md" "b/doc/design/\347\224\250\346\210\267\347\253\257_\344\270\252\344\272\272\344\270\255\345\277\203_20261013.md"
index cf76667..8ad8db4 100644
--- "a/doc/design/\347\224\250\346\210\267\347\253\257_\344\270\252\344\272\272\344\270\255\345\277\203_20261013.md"
+++ "b/doc/design/\347\224\250\346\210\267\347\253\257_\344\270\252\344\272\272\344\270\255\345\277\203_20261013.md"
@@ -18,7 +18,7 @@
| 姓名 `nickname` | 头部文本 | mock `辉煌G891` | 是(`account_user.nickname`;注册未采集姓名字段,落库为空,前端回退展示脱敏手机号) |
| 手机号 `phone`(脱敏) | 头部名称回退 | 无 | 是(`account_user.phone`,脱敏为 `133****5555`,不下发明文) |
| 会员等级 `memberLevel` | 等级胶囊 `C`/`L1~L4` | mock `L4` | 是(复用既有 `account_user.system_level`) |
-| 头像 `avatar` | 头部头像 | 相机图标占位 | 是(已存在字段,有值前端渲染图片,无值回退图标) |
+| 头像 `avatar` | 头部头像 | 相机图标占位(「我的资料」页已支持点击上传更换) | 是(已存在字段,有值前端渲染图片,无值回退图标) |
| 今日 / 本月 / 累计收益 | 收益玻璃卡三栏 | mock `10.00 / 400.00 / 128000.00` | 是(新增收益流水表,实时聚合) |
| 仓库订单角标数量 | 菜单角标 | mock `1` | 否(见 2.5,无数据来源,本期不入接口) |
diff --git "a/doc/design/\347\224\250\346\210\267\347\253\257_\344\274\201\344\270\232\344\270\252\344\275\223\350\256\244\350\257\201_20261013.md" "b/doc/design/\347\224\250\346\210\267\347\253\257_\344\274\201\344\270\232\344\270\252\344\275\223\350\256\244\350\257\201_20261013.md"
new file mode 100644
index 0000000..1efc3b2
--- /dev/null
+++ "b/doc/design/\347\224\250\346\210\267\347\253\257_\344\274\201\344\270\232\344\270\252\344\275\223\350\256\244\350\257\201_20261013.md"
@@ -0,0 +1,330 @@
+# 用户端小程序 企业/个体商户认证 开发设计文档
+
+- 模块:ruoyi-user(会员业务)+ ruoyi-gateway(路由/鉴权头注入)+ hugray-admin(后台主体认证审核)+ hugray-user-miniapp(前端)
+- 日期:2026-10-13
+- 需求来源:"继续实现企业、个体商户的认证,字段只做最基础的必填校验,根据原型设计ui稿并实现"
+- 原型:4 张 UI 稿(企业信息 / 法人信息 / 收款信息 / 提交审核 四步向导)
+- 关联文档:`doc/design/用户端_实名认证_20261013.md`(个人实名认证,本期复用其提交/审核链路范式)
+- 数据权威源:`192.168.110.111:3306/hugray-account`(实际表结构优先于本文档)
+
+---
+
+## 一、功能概述
+
+「我的资料」页(`pages/profile/edit.vue`)「企业/个体户认证」行当前点击仅弹占位 toast,本次改为**跳转到企业/个体认证四步向导页**:
+
+1. 新增 C 端页面 `pages/profile/company-certification.vue`,顶部为**四段式步骤条**(企业信息 / 法人信息 / 收款信息 / 提交审核),已完成打勾、当前高亮,底部分步表单 + 「下一步 / 提交资料」按钮(原型见 4 张 UI 稿);
+2. 页面**只做基础必填校验**(名称/代码/姓名/电话/邮箱/卡号/图片等非空),**不接入任何格式/三方核验**;
+3. 提交后落 `account_certification_apply`(`subject_type` 取 2 个体工商户 / 3 企业、`audit_status='0'` 待审核),并联动 `account_user.company_auth_status='1'`(企业认证中),使其出现在后台**主体认证审核列表**;
+4. 「我的资料」页「企业/个体户认证」徽标改为按后端新下发的 `companyAuthStatus` 显示真实状态(未认证 / 认证中 / 已认证 / 认证失败),与「实名认证」徽标**相互独立**;
+5. 复核进入时**回填**已提交资料与状态:审核中 → 直接进入第 4 步「资料审核中」占位页;已通过 → 展示已通过提示;已驳回 → 回到第 1 步空表单并提示驳回原因,可修改重提。
+
+后台「审」链路(列表 / 详情 / 通过 / 驳回)**已存在**,本次做两处增强:审核结果按主体类型分流回写 `auth_status` / `company_auth_status`;审核详情页补展示企业认证新增字段。
+
+---
+
+## 二、现状与关键约束
+
+### 2.1 数据模型已预留主体类型
+
+`account_certification_apply.subject_type`(`char(1)`)取值语义已预置:**1个人 2个体工商户 3企业**,个人实名走 `'1'`,企业/个体走 `'2'`/`'3'`,**无需改类型语义,只需补列**。
+
+### 2.2 现有表缺列,需补 DDL
+
+`account_certification_apply` 与 `account_certification` 现有企业相关列仅到「营业执照」为止(`company_name`、`credit_code`、`legal_person`、`registered_region`、`company_address`、`register_date`、`license_valid_period`、`license_image`)。原型所需的**营业执照有效期类型、法人身份证正反面、法人身份证有效期、法人身份证详细地址、负责人姓名/电话/邮箱、收款类型、银行卡照片、银行卡号、开户城市**共 13 列缺失,需补。
+
+### 2.3 个人 / 企业认证状态必须隔离
+
+`account_user` 当前只有一个 `auth_status`(个人实名认证状态),而个人中心需**两行独立徽标**(「实名认证」/「企业个体户认证」)。若企业认证复用 `auth_status`,提交企业认证会同时把「实名认证」徽标改成「认证中」,语义错误。故**新增 `account_user.company_auth_status`**,个人 / 企业互不覆盖(已确认)。
+
+### 2.4 C 端状态查询需按主体类型隔离
+
+现有 `selectLatestApplyByUserId` / `countPendingApplyByUserId` **不区分主体类型**,企业认证上线后会把企业申请当作个人申请回填/防重。本次为两者都加主体类型过滤:个人限 `subject_type='1'`,企业限 `subject_type in ('2','3')`。
+
+### 2.5 路径规划
+
+- 网关注入 `user_id`;`SecurityUtils.getUserId()` 取会员身份,**C 端接口不接受前端传 userId**(防水平越权)。
+- 在既有 C 端个人中心控制器 `AccountProfileController`(`@RequestMapping("/user")`,外部 `/miniapp/account/user/**`)上**新增**两条企业认证接口,与个人认证接口并列。
+- 与后台 `AccountCertificationApplyController`(外部 `/account/user/certification/**`)**前缀不同域**,无冲突;网关 `/miniapp/account/**` 路由已存在,**无需改网关**。
+
+### 2.6 主键与落库策略
+
+`account_certification_apply.id` 为 `bigint AUTO_INCREMENT`,沿用既有 `useGeneratedKeys="true" keyProperty="id"`;列按 `<if>` 动态拼,未填字段不落列。
+
+---
+
+## 三、接口设计
+
+统一响应体:C 端 `R<T>`(成功 `code=200`)。
+
+### 3.1 提交企业/个体认证(本次新增)
+
+- **外部路径**:`POST /miniapp/account/user/company-certification`(网关 `StripPrefix=2` → 服务内 `POST /user/company-certification`)
+- **鉴权**:需登录,未登录由 `AuthFilter` 直接 401;会员身份取自网关注入的 `user_id` 头,**不接受前端传 userId**。
+- **入参**:JSON(`CompanyCertificationApplyDto`),字段与校验见下表:
+
+| 步骤 | 字段 | 类型 | 必填 | 校验 | 说明(对应 DB 列) |
+| --- | --- | --- | --- | --- | --- |
+| 企业信息 | subjectType | String | 是 | `@NotBlank` + `@Pattern(^[23]$)` | 主体类型:2个体工商户 / 3企业(`subject_type`) |
+| 企业信息 | companyName | String | 是 | `@NotBlank`+`@Size(max=128)` | 企业/个体名称(`company_name`) |
+| 企业信息 | licenseImage | String | 是 | `@NotBlank`+`@Size(max=500)` | 营业执照图片地址(`license_image`) |
+| 企业信息 | creditCode | String | 是 | `@NotBlank`+`@Size(max=32)` | 统一社会信用代码(`credit_code`) |
+| 企业信息 | licenseValidType | String | 是 | `@NotBlank`+`@Pattern(^[12]$)` | 营业执照有效期类型:1长期 / 2短期(`license_valid_type`) |
+| 企业信息 | licenseValidPeriod | String | 短期必填 | `@Size(max=32)`;类型=2 时服务层必填 | 营业执照有效期起止(`license_valid_period`,存 `2024-01-01~2034-01-01`) |
+| 企业信息 | registeredRegion | String | 是 | `@NotBlank`+`@Size(max=128)` | 营业执照注册地区(`registered_region`) |
+| 企业信息 | companyAddress | String | 是 | `@NotBlank`+`@Size(max=255)` | 营业执照详细地址(`company_address`) |
+| 法人信息 | legalPerson | String | 是 | `@NotBlank`+`@Size(max=64)` | 法人姓名(`legal_person`) |
+| 法人信息 | legalIdCardFront | String | 是 | `@NotBlank`+`@Size(max=500)` | 法人身份证人像面图片(`legal_id_card_front`) |
+| 法人信息 | legalIdCardBack | String | 是 | `@NotBlank`+`@Size(max=500)` | 法人身份证国徽面图片(`legal_id_card_back`) |
+| 法人信息 | legalIdValidType | String | 是 | `@NotBlank`+`@Pattern(^[12]$)` | 法人身份证有效期类型(`legal_id_valid_type`) |
+| 法人信息 | legalIdValidPeriod | String | 短期必填 | `@Size(max=32)`;类型=2 时服务层必填 | 法人身份证有效期起止(`legal_id_valid_period`) |
+| 法人信息 | legalIdAddress | String | 是 | `@NotBlank`+`@Size(max=255)` | 法人身份证详细地址(`legal_id_address`) |
+| 法人信息 | contactName | String | 是 | `@NotBlank`+`@Size(max=64)` | 负责人姓名(`contact_name`) |
+| 法人信息 | contactPhone | String | 是 | `@NotBlank`+`@Size(max=20)` | 负责人联系电话(`contact_phone`) |
+| 法人信息 | contactEmail | String | 是 | `@NotBlank`+`@Size(max=128)` | 负责人邮箱(`contact_email`) |
+| 收款信息 | payeeType | String | 是 | `@NotBlank`+`@Size(max=16)` | 收款类型(`payee_type`) |
+| 收款信息 | bankCardImage | String | 是 | `@NotBlank`+`@Size(max=500)` | 银行卡号码面照片(`bank_card_image`) |
+| 收款信息 | bankCardNo | String | 是 | `@NotBlank`+`@Size(max=32)` | 银行卡号(`bank_card_no`) |
+| 收款信息 | bankCity | String | 是 | `@NotBlank`+`@Size(max=128)` | 开户城市(`bank_city`) |
+
+- **出参**:`R<Void>`(成功即 `code=200`)
+- **错误提示**:字段级 `@NotBlank`/`@Pattern` 提示 + 业务提示「短期有效期必须填写起止日期」/「已有企业认证申请正在审核中,请勿重复提交」/「主体类型不合法」。
+
+### 3.2 查询企业/个体认证状态(本次新增)
+
+- **外部路径**:`GET /miniapp/account/user/company-certification`(→ 服务内 `GET /user/company-certification`)
+- 取该会员**最近一次**企业/个体申请(`subject_type in ('2','3')`,`del_flag='0'`,`id desc limit 1`),出参 `CompanyCertificationStatusVo`:
+
+| 字段 | 说明 |
+| --- | --- |
+| subjectType / companyName / licenseImage / creditCode / licenseValidType / licenseValidPeriod / registeredRegion / companyAddress | 企业信息回填 |
+| legalPerson / legalIdCardFront / legalIdCardBack / legalIdValidType / legalIdValidPeriod / legalIdAddress | 法人信息回填 |
+| contactName / contactPhone / contactEmail | 负责人信息回填 |
+| payeeType / bankCardImage / bankCardNo / bankCity | 收款信息回填 |
+| auditStatus | 0待审核 1已通过 2已驳回;**为空表示从未提交** |
+| auditRemark | 驳回原因(`audit_status='2'` 时有值) |
+
+无申请时返回**空字段对象**(各字段为 null),前端按「未认证」处理,不返回 404。
+
+### 3.3 查询会员信息(本次扩展)
+
+- **外部路径**:`GET /miniapp/account/user/info`(既有)
+- **改动**:出参 `AccountUserInfoVo` 新增 `companyAuthStatus` 字段(`account_user.company_auth_status` 原值 0/1/2/3),供「我的资料」页「企业/个体户认证」徽标回显。
+
+### 3.4 后台审核链路(既有,增强)
+
+`GET /account/user/certification/list`、`GET /account/user/certification/detail/{id}`、`PUT /account/user/certification/audit`(权限 `member:certification:audit`)**沿用**。企业申请同链路进入列表(`subject_type='2'/'3'`、待审核在前)。增强点:
+
+- **审核分流**:通过 / 驳回时按 `apply.subjectType` 写回——个人写 `auth_status`,企业/个体写 `company_auth_status`(通过时同步 `account_user.subject_type`);
+- **详情展示**:`detail.vue` 补展示企业/个体新增字段(见 7.3)。
+
+---
+
+## 四、数据库设计
+
+库:`hugray-account`。新增 DDL 脚本:`hugray-server/sql/hugray_account_company_certification_20261013.sql`(沿用既有幂等过程 `hugray_add_column` 风格;与代码同一次提交)。
+
+### 4.1 `account_certification_apply` / `account_certification` 各新增 13 列
+
+| 列 | 类型 | 注释 |
+| --- | --- | --- |
+| license_valid_type | varchar(16) | 营业执照有效期类型(1长期 2短期) |
+| legal_id_card_front | varchar(500) | 法人身份证人像面图片 |
+| legal_id_card_back | varchar(500) | 法人身份证国徽面图片 |
+| legal_id_valid_type | varchar(16) | 法人身份证有效期类型(1长期 2短期) |
+| legal_id_valid_period | varchar(32) | 法人身份证有效期(长期/起止文本) |
+| legal_id_address | varchar(255) | 法人身份证详细地址 |
+| contact_name | varchar(64) | 负责人姓名 |
+| contact_phone | varchar(20) | 负责人联系电话 |
+| contact_email | varchar(128) | 负责人邮箱 |
+| payee_type | varchar(16) | 收款类型 |
+| bank_card_image | varchar(500) | 银行卡号码面照片 |
+| bank_card_no | varchar(32) | 银行卡号 |
+| bank_city | varchar(128) | 开户城市 |
+
+### 4.2 `account_user` 新增 1 列
+
+| 列 | 类型 | 注释 |
+| --- | --- | --- |
+| company_auth_status | char(1) DEFAULT '0' | 企业/个体认证状态(0未认证 1审核中 2已认证 3认证失败) |
+
+> `license_valid_period` 沿用既有 `varchar(32)`:`2024-01-01~2034-01-01` 共 21 字符,够用,不改类型。
+
+---
+
+## 五、流程设计
+
+### 5.1 前端四步向导(`pages/profile/company-certification.vue`)
+
+```
+STEP 1: onShow 校验登录态(ensureLogin),未登录跳登录页
+STEP 2: 调 GET /company-certification 拉取状态与资料
+STEP 3: 按 auditStatus 决定首屏:
+ '' 未认证 → 停在第 1 步空表单
+ '2' 已驳回 → 停在第 1 步并回填资料,展示驳回原因
+ '0' 审核中 → 直接进入第 4 步「资料审核中」占位页
+ '1' 已通过 → 第 4 步展示「认证已通过」
+STEP 4: 第 1~3 步表单填写,点「下一步」逐级校验当前步必填项,通过则 step+1,否则 toast 首个缺失项
+STEP 5: 第 3 步(收款信息)按钮为「提交资料」,点后整表单再校验一次,通过则 POST 提交
+STEP 6: 成功后进入第 4 步「您的资料正在审核中,请耐心等待」;失败提示后端信息
+STEP 7: 步骤条已完成的步骤打勾,可点已完成步骤回退修改(未提交前)
+```
+
+交互细节(对齐原型):
+- **有效期类型 = 长期** 时,其**起止日期行隐藏**且不参与校验;= 短期 时显示且必填(已确认)。
+- 「营业执照注册地区」「开户城市」用 `<picker mode="region">`(沿用 `pages/address/add.vue` 范式);「营业执照详细地址」「法人身份证详细地址」为文本输入。
+- 图片项(营业执照 / 法人身份证人像面 / 法人身份证国徽面 / 银行卡号码面)统一走 `uni.chooseImage` + `uploadAvatarFile`(`/file/upload`)拿到 url 后存本地表单,**均为必填**(已确认)。
+- 有效期起止日期用两个 `<picker mode="date">`(开始 / 截止)并列在同一行内展示,回填时拼为 `起~止` 文本;存储为 `license_valid_period` / `legal_id_valid_period` 文本。
+
+### 5.2 后端提交(`submitCompanyCertification`)
+
+```
+STEP 1: 校验 userId 非空(未登录由网关拦截,此处兜底),为空抛业务异常
+STEP 2: 校验 subjectType ∈ {2 个体工商户, 3 企业};非法抛「主体类型不合法」
+STEP 3: 有效期类型=短期(2) 时,对应起止日期必填(服务层兜底,防绕过 Controller)
+STEP 4: 防重闸门:该会员若存在 subject_type in ('2','3') 且 audit_status='0' 的未删除申请,
+ 抛「已有企业认证申请正在审核中,请勿重复提交」
+STEP 5: 组装申请快照:subject_type、各资料字段、audit_status='0'、create_by=登录用户名
+STEP 6: insertCertificationApply 落 account_certification_apply(自增回填 id)
+STEP 7: 联动 account_user.company_auth_status='1'(企业认证中,单列更新语句)
+STEP 8: STEP 6/7 同事务(@Transactional),任一失败整体回滚
+```
+
+### 5.3 审核分流(`auditCertificationApply` 增强)
+
+```
+STEP 1: 既有并发闸门(where audit_status='0')+ 驳回必填意见 不变
+STEP 2: 取 apply.subjectType 判断分支:
+ subject_type='1'(个人)→ 写 account_user.auth_status(2 通过 / 3 失败),行为同现状
+ subject_type∈{'2','3'} → 写 account_user.company_auth_status(2 通过 / 3 失败)
+ 且通过时同步 account_user.subject_type = apply.subjectType
+STEP 3: 通过分支继续 saveCertificationSnapshot(补全企业新增字段)
+```
+
+### 5.4 状态流转(端到端)
+
+```
+C 端提交 ─► account_certification_apply.audit_status='0' + account_user.company_auth_status='1'
+ │
+后台「通过」─► audit_status='1' + company_auth_status='2' + subject_type 同步 + 落 account_certification
+后台「驳回」─► audit_status='2' + company_auth_status='3' + 记审核意见
+```
+
+---
+
+## 六、枚举与状态
+
+| 名称 | 取值 | 说明 |
+| --- | --- | --- |
+| `subject_type` 主体类型 | `1` 个人 / `2` 个体工商户 / `3` 企业 | 企业向导页提交 `2`/`3`(页内选择);个人页提交 `1` |
+| `audit_status` 审核状态 | `0` 待审核 / `1` 已通过 / `2` 已驳回 | 提交置 `0`,审核改 `1`/`2` |
+| `auth_status` 个人认证状态 | `0` 未认证 / `1` 审核中 / `2` 已认证 / `3` 认证失败 | 个人实名专用 |
+| `company_auth_status` 企业认证状态 | `0` 未认证 / `1` 审核中 / `2` 已认证 / `3` 认证失败 | 企业/个体专用(本期新增) |
+| `license_valid_type` / `legal_id_valid_type` 有效期类型 | `1` 长期 / `2` 短期 | 长期隐藏起止日期;短期必填 |
+
+「我的资料」页徽标映射:`auth_status` →「实名认证」行;`company_auth_status` →「企业/个体户认证」行(`0`未认证、`1`认证中、`2`已认证、`3`认证失败)。
+
+---
+
+## 七、影响范围与文件清单
+
+### 7.1 新增(ruoyi-user)
+
+| 文件 | 说明 |
+| --- | --- |
+| `domain/dto/CompanyCertificationApplyDto.java` | 企业/个体认证提交入参(含校验注解与字段注释) |
+| `domain/vo/CompanyCertificationStatusVo.java` | 企业/个体认证状态与资料回填出参 |
+| `src/test/.../AccountCertificationApplyServiceImplTest.java` | 追加提交/审核分流用例(正常、重复提交拒绝、短期缺日期、状态分流) |
+
+### 7.2 修改(ruoyi-user)
+
+| 文件 | 改动 |
+| --- | --- |
+| `domain/AccountCertificationApply.java` | 新增 13 个企业/个体字段 + getter/setter + toString |
+| `domain/AccountCertification.java` | 同步新增 13 个字段 |
+| `mapper/AccountCertificationApplyMapper.java` | 新增 `selectLatestCompanyApplyByUserId`、`countPendingCompanyApplyByUserId`;`selectLatestApplyByUserId`→`selectLatestPersonalApplyByUserId`(加 `subject_type='1'`)、`countPendingApplyByUserId`→`countPendingPersonalApplyByUserId`(加 `subject_type='1'`) |
+| `resources/mapper/user/AccountCertificationApplyMapper.xml` | resultMap / `selectCertificationApplyVo` / 列表 / insert 补新增列;主体类型过滤调整;新增企业两条查询 |
+| `resources/mapper/user/AccountCertificationMapper.xml` | resultMap / `selectCertificationVo` / insert / update 补新增列 |
+| `mapper/AccountUserMapper.java` | 新增 `updateUserCompanyAuthStatus(AccountUser)` |
+| `resources/mapper/user/AccountUserMapper.xml` | 新增 `updateUserCompanyAuthStatus`(只写 `company_auth_status`,通过时带 `<if>` 同步 `subject_type`) |
+| `service/IAccountCertificationApplyService.java` | 新增 `submitCompanyCertification(Long, CompanyCertificationApplyDto)`、`getCompanyCertificationStatus(Long)` |
+| `service/impl/AccountCertificationApplyServiceImpl.java` | 常量补 `SUBJECT_INDIVIDUAL='2'`/`SUBJECT_COMPANY='3'`;实现企业提交与状态查询;`auditCertificationApply` 按主体类型分流;`saveCertificationSnapshot` 补字段 |
+| `controller/AccountProfileController.java` | 新增 `POST /user/company-certification`、`GET /user/company-certification` |
+| `domain/vo/AccountUserInfoVo.java` | 新增 `companyAuthStatus` 字段 |
+| `service/impl/AccountUserServiceImpl.java` | `getAccountUserInfo` 填充 `companyAuthStatus` |
+
+### 7.3 修改(后台 hugray-admin)
+
+| 文件 | 改动 |
+| --- | --- |
+| `views/member/certification/detail.vue` | 「主体资料」区块补展示:主体类型、营业执照有效期类型/起止、法人身份证人像/国徽面(`image-preview`)、法人身份证有效期类型/起止、法人身份证详细地址、负责人姓名/电话/邮箱、收款类型、银行卡照片(`image-preview`)、银行卡号、开户城市 |
+
+> 列表页 `index.vue` 的主体类型/主体名称列已由 `subject_type` 与 `companyName \|\| realName` 覆盖,**无需改动**。
+
+### 7.4 新增(前端 hugray-user-miniapp)
+
+| 文件 | 说明 |
+| --- | --- |
+| `pages/profile/company-certification.vue` | 企业/个体四步向导页(步骤条 + 分步表单 + 图片上传 + 地区/日期选择) |
+
+### 7.5 修改(前端 hugray-user-miniapp)
+
+| 文件 | 改动 |
+| --- | --- |
+| `pages.json` | 注册 `pages/profile/company-certification`(`navigationStyle: custom`) |
+| `api/index.js` | 新增 `getCompanyCertificationStatus()`(→ GET)、`submitCompanyCertification(data)`(→ POST) |
+| `pages/profile/edit.vue` | 「企业/个体户认证」行由 `handlePlaceholder` 改为 `uni.navigateTo` 跳向导页;`companyStatus` 改按 `/info` 返回的 `companyAuthStatus` 映射回显 |
+
+> 网关无需改动;无需新增菜单/权限。
+
+### 7.6 新增(DDL)
+
+| 文件 | 说明 |
+| --- | --- |
+| `hugray-server/sql/hugray_account_company_certification_20261013.sql` | 两张认证表各补 13 列 + `account_user` 补 `company_auth_status`(幂等,含回滚注释) |
+
+---
+
+## 八、边界与异常清单
+
+| # | 场景 | 处理 |
+| --- | --- | --- |
+| 1 | 未登录访问 | 网关 `AuthFilter` 返回 401,前端跳登录页;后端不做分支 |
+| 2 | `user_id` 头缺失/为空 | 服务内兜底判空,抛业务异常(不 NPE) |
+| 3 | 必填项为空 / 纯空格 | DTO `@NotBlank` 拦截,返回字段级中文提示;前端「下一步」逐级预校验并 toast 首个缺失项 |
+| 4 | 有效期类型=长期 | 起止日期行隐藏且不校验;短期时必填 |
+| 5 | 有效期类型=短期但未填日期 | 服务层兜底抛「短期有效期必须填写起止日期」 |
+| 6 | 图片未上传 | 按必填拦截(toast「请上传XX图片」) |
+| 7 | 主体类型非法(非 2/3) | 服务层抛「主体类型不合法」 |
+| 8 | 已有待审核企业申请再次提交 | 服务层企业防重闸门提示「已有企业认证申请正在审核中,请勿重复提交」 |
+| 9 | 个人 / 企业申请互相干扰 | 状态查询与防重均按 `subject_type` 隔离;认证状态分列 `auth_status` / `company_auth_status` |
+| 10 | 审核通过 / 驳回 | 按 `subject_type` 分流回写,通过时同步 `subject_type` 并落 `account_certification` |
+| 11 | 落库或状态联动失败 | 同一事务回滚,返回统一错误,不产生半更新 |
+| 12 | 越权 | 会员身份取自令牌注入头,不接受前端传 `userId`,天然防水平越权 |
+| 13 | 三方核验 / 格式校验 | **本期明确不做**,仅基础必填校验 |
+| 14 | 图片上传失败 | 沿用 `uploadAvatarFile` 错误处理,toast 提示并可重试 |
+
+---
+
+## 九、规范符合性说明
+
+- 分层:Controller 只取身份 + 转发;业务在 Service;SQL 在 Mapper;Controller 不直接调 Mapper。
+- 命名:`submitCompanyCertification` / `getCompanyCertificationStatus` / `selectLatestCompanyApplyByUserId` / `countPendingCompanyApplyByUserId` / `updateUserCompanyAuthStatus` 见名知意;DTO / VO / Entity 分离。
+- 注释:DTO / VO / Mapper / Service 方法具备 Javadoc;核心流程按 `// STEP x:` 分步注释;实体新增字段附列名与取值注释。
+- 魔法值:`subject_type`、`audit_status`、`auth_status`、`company_auth_status`、`*_valid_type` 取值抽为常量/枚举语义。
+- 事务:提交接口 `insert + 状态联动` 同事务;审核 `更新申请 + 状态分流 + 落档` 同事务。
+- 幂等/防重:以待审核企业申请为闸门,拒绝重复提交。
+- 数据库:新增列显式长度 + COMMENT;无 `SELECT *`;无 `${}` 拼接;无循环查库;DDL 与代码同次提交。
+- 单测:覆盖企业提交正常、短期缺日期拒绝、重复提交拒绝、审核分流(企业写 `company_auth_status`)。
+- 文档:本文档与代码同仓库、同分支、同节奏提交。
+
+---
+
+## 十、已确认决策
+
+1. **认证状态存储**:**已定** —— 新增 `account_user.company_auth_status`,个人 / 企业徽标各自独立,互不覆盖。
+2. **主体类型**:**已定** —— 「企业信息」步骤顶部增加「主体类型」选择项(企业 / 个体工商户),`subject_type` 存 3 / 2(比原型多一个字段)。
+3. **有效期校验**:**已定** —— 「有效期类型」选**长期**时**隐藏起止日期且不必填**;选短期时显示且必填。
+4. **图片必填**:**已定** —— 营业执照、法人身份证人像面/国徽面、银行卡号码面照片**均为必填**。
+5. **校验深度**:**已定** —— 仅基础必填校验,**不做格式/三方核验**。
+6. **审核入口**:**已定** —— 复用后台「主体认证审核」列表/详情/通过/驳回链路,列表无需改,详情补展示新增字段。
diff --git "a/doc/design/\347\224\250\346\210\267\347\253\257_\345\256\236\345\220\215\350\256\244\350\257\201_20261013.md" "b/doc/design/\347\224\250\346\210\267\347\253\257_\345\256\236\345\220\215\350\256\244\350\257\201_20261013.md"
new file mode 100644
index 0000000..747e6ad
--- /dev/null
+++ "b/doc/design/\347\224\250\346\210\267\347\253\257_\345\256\236\345\220\215\350\256\244\350\257\201_20261013.md"
@@ -0,0 +1,305 @@
+# 用户端小程序 实名认证 开发设计文档
+
+- 模块:ruoyi-user(会员业务)+ ruoyi-gateway(路由/鉴权头注入)+ hugray-admin(后台主体认证审核)
+- 日期:2026-10-13
+- 需求来源:"点击实名认证跳转到实名认证页面,只做基础的身份证格式校验和姓名必填校验不做三方真实验证,提交后的数据需要到管理后台的主体认证审核列表"
+- 关联前端:`hugray-user-miniapp`(`pages/profile/edit.vue` 入口、新增 `pages/profile/certification.vue`)
+- 关联后台:`hugray-admin/src/views/member/certification/index.vue`(主体认证审核列表,菜单 2202)
+- 关联文档:`doc/design/用户端_我的资料_头像上传_20261013.md`(同类 C 端写接口范式)、`doc/design/用户端_我的资料_20261013.html`(本页 UI 参考)
+- 数据权威源:`192.168.110.111:3306/hugray-account`(实际表结构优先于本文档)
+
+---
+
+## 一、功能概述
+
+「我的资料」页(`pages/profile/edit.vue`)「实名认证」行当前点击仅弹占位 toast,本次改为**跳转到实名认证页**:
+
+1. 新增 C 端实名认证页 `pages/profile/certification.vue`(自定义导航栏 + 姓名 / 身份证号两个输入 + 底部「提交」按钮,见原型图);
+2. 页面**只做基础校验**:姓名必填、身份证号为 18 位标准格式;**不接入任何三方实名核验**;
+3. 提交后落 `account_certification_apply`(`subject_type='1'` 个人、`audit_status='0'` 待审核),并联动 `account_user.auth_status='1'`(审核中),使其出现在后台**主体认证审核列表**;
+4. 「我的资料」页实名认证徽标改为按后端下发的 `authStatus` 显示真实状态(未认证 / 认证中 / 已认证 / 认证失败),形成闭环。
+
+后台「审」链路(列表 / 详情 / 通过 / 驳回)**已存在且无需改动**:`AccountCertificationApplyController`(`/account/user/certification/**`,权限 `member:certification:*`)、`AccountCertificationApplyServiceImpl`(审核 + `auth_status` 联动 + 通过后落 `account_certification`)。本次只补 C 端"提交"这一缺口。
+
+---
+
+## 二、现状与关键约束
+
+### 2.1 缺口:只有"审"没有"增"
+
+`AccountCertificationApplyController` 类注释明确:*"本模块只有"审"没有"增删":申请由 C 端会员提交(不在本仓库范围)"*。C 端提交接口与页面均需**新建**。
+
+### 2.2 数据模型已就绪(无 DDL 变更)
+
+| 表 | 用途 | 关键列 |
+| --- | --- | --- |
+| `account_certification_apply` | 每次提交的申请(驳回重提多一条),**本次落这张表** | `id`(自增主键兼申请号)、`user_id`、`subject_type`、`real_name`、`id_card`、`audit_status`、`del_flag`、`create_by`、`create_time` |
+| `account_certification` | 当前生效认证资料,审核**通过**时由服务层落一份 | 审核通过后写入,本次不直接写 |
+| `account_user.auth_status` | 会员整体认证状态 | 0未认证 1审核中 2已认证 3认证失败 |
+
+> 状态联动口径(见 DDL 注释):**提交申请 → `auth_status='1'`;通过 → `'2'`;驳回 → `'3'`**。通过/驳回联动已由既有审核服务实现,本次只补"提交 → '1'"。
+
+### 2.3 主键策略
+
+`account_certification_apply.id` 为 `bigint AUTO_INCREMENT`(既有表,非雪花),故 insert 走 `useGeneratedKeys="true" keyProperty="id"`,与既有 `insertCertification` 一致。
+
+### 2.4 认证状态取值必须收口
+
+状态/类型取值语义已在多处存在,本次沿用既有字符串约定(与 `AccountCertificationApplyServiceImpl` 中的 `AUDIT_PASS="1"` 等常量同风格),在服务层抽为常量,不散落魔法值。
+
+### 2.5 路径规划
+
+- 网关注入 `user_id`;`SecurityUtils.getUserId()` 取会员身份,**C 端接口不接受前端传 userId**(防水平越权)。
+- C 端个人中心控制器 `AccountProfileController`(`@RequestMapping("/user")`,外部 `/miniapp/account/user/**`)已承载 `/info`、`/avatar`,本次**在其上新增** `POST /user/certification`(外部 `POST /miniapp/account/user/certification`)。
+- 与后台 `AccountCertificationApplyController`(外部 `/account/user/certification/**`)**前缀不同域**(`/miniapp/account` vs `/account`),无冲突。
+
+---
+
+## 三、接口设计
+
+统一响应体:C 端 `R<T>`(成功 `code=200`);后台 `AjaxResult` / `TableDataInfo`。
+
+### 3.1 提交实名认证(本次新增)
+
+- **外部路径**:`POST /miniapp/account/user/certification`(网关 `StripPrefix=2` → 服务内 `POST /user/certification`)
+- **鉴权**:需登录。路径不在网关白名单,未登录由 `AuthFilter` 直接返回 401,前端 `request.js` 自动跳登录页;**后端不做未登录分支**。
+- **入参**:JSON,会员身份取自网关注入的 `user_id` 头,**不接受前端传 userId**
+
+| 字段 | 类型 | 必填 | 校验 | 说明 |
+| --- | --- | --- | --- | --- |
+| realName | String | 是 | `@NotBlank` + `@Size(max=64)` | 姓名(对应 `real_name varchar(64)`) |
+| idCard | String | 是 | `@NotBlank` + 18 位格式 `@Pattern` + `@Size(max=32)` | 身份证号(对应 `id_card varchar(32)`) |
+
+- **出参**:`R<Void>`(成功即 `code=200`)
+- **错误提示**:`姓名不能为空` / `姓名过长` / `身份证号不能为空` / `身份证号格式不正确` / `已有实名认证申请正在审核中,请勿重复提交`
+
+身份证号基础格式正则(18 位:地区码 6 位 + 出生日期 + 顺序码 + 校验位;**只校验结构与日期范围,不校验校验码、不核验真实性**):
+
+```
+^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$
+```
+
+### 3.2 查询会员信息(本次扩展)
+
+- **外部路径**:`GET /miniapp/account/user/info`(既有)
+- **改动**:出参 `AccountUserInfoVo` 新增 `authStatus` 字段(`account_user.auth_status` 原值:0/1/2/3),供「我的资料」页显示实名认证真实状态。手机号仍只下发脱敏值。
+
+### 3.3 后台审核链路(既有,不改)
+
+`GET /account/user/certification/list`(`member:certification:list`)、`GET /account/user/certification/detail/{id}`、`PUT /account/user/certification/audit`(`member:certification:audit`)。C 端提交的申请将直接出现在该列表(`subject_type='1'` 个人、`audit_status='0'` 待审核,列表按待审核在前排序)。
+
+---
+
+## 四、数据库设计
+
+库:`hugray-account`。
+
+**无 DDL 变更**:`account_certification_apply` 与 `account_user.auth_status` 均已存在,本次仅插入 / 更新。
+
+---
+
+## 五、流程设计
+
+### 5.1 前端提交链路(`pages/profile/certification.vue`)
+
+```
+STEP 1: onShow 校验登录态(ensureLogin),未登录跳登录页
+STEP 2: 用户填写姓名 / 身份证号
+STEP 3: 点「提交」→ 本地校验:姓名非空(trim)且 ≤64;身份证号匹配 18 位正则
+STEP 4: 校验不通过则 toast 提示并中止,不发请求
+STEP 5: 调 submitCertification(realName, idCard) → POST /miniapp/account/user/certification
+STEP 6: 成功后 toast「提交成功,等待审核」并 navigateBack 回我的资料页;失败提示后端错误信息
+```
+
+### 5.2 后端提交(`AccountCertificationApplyServiceImpl.submitPersonalCertification`)
+
+```
+STEP 1: 校验 userId 非空(未登录由网关拦截,此处兜底),为空抛业务异常
+STEP 2: 防重复提交:该会员若存在 audit_status='0'(待审核)且未删除的申请,
+ 抛「已有实名认证申请正在审核中,请勿重复提交」
+STEP 3: 组装申请快照:subject_type='1'(个人)、real_name、id_card、
+ audit_status='0'、create_by=登录用户名
+STEP 4: insertCertificationApply 落 account_certification_apply(自增回填 id)
+STEP 5: 联动 account_user.auth_status='1'(审核中,调既有 updateUserAuthStatus 单列语句)
+STEP 6: 以上 STEP 4/5 同事务(@Transactional),任一失败整体回滚
+```
+
+### 5.3 状态流转(端到端)
+
+```
+C 端提交 ──► account_certification_apply.audit_status='0' + account_user.auth_status='1'
+ │
+后台「通过」──► audit_status='1' + auth_status='2' + 落 account_certification(既有逻辑)
+后台「驳回」──► audit_status='2' + auth_status='3' + 记审核意见(既有逻辑)
+```
+
+---
+
+## 六、枚举与状态
+
+| 名称 | 取值 | 说明 |
+| --- | --- | --- |
+| `subject_type` 主体类型 | `1` 个人 / `2` 个体工商户 / `3` 企业 | 本次固定提交 `1`(个人实名);企业认证入口本期仍为占位 |
+| `audit_status` 审核状态 | `0` 待审核 / `1` 已通过 / `2` 已驳回 | 提交置 `0`,审核改 `1`/`2` |
+| `auth_status` 会员认证状态 | `0` 未认证 / `1` 审核中 / `2` 已认证 / `3` 认证失败 | 提交置 `1`;通过置 `2`;驳回置 `3` |
+
+「我的资料」页徽标与 `auth_status` 映射:`0`→未认证、`1`→认证中、`2`→已认证、`3`→认证失败。
+
+---
+
+## 七、影响范围与文件清单
+
+### 新增(ruoyi-user)
+
+| 文件 | 说明 |
+| --- | --- |
+| `domain/dto/AccountCertificationApplyDto.java` | C 端实名认证入参(`realName` / `idCard`,含校验注解与字段注释) |
+| `src/test/.../AccountCertificationApplyServiceImplTest.java` | 提交服务单测(正常 / 待审核重复提交拒绝 / 状态联动) |
+
+### 修改(ruoyi-user)
+
+| 文件 | 改动 |
+| --- | --- |
+| `controller/AccountProfileController.java` | 注入 `IAccountCertificationApplyService`;新增 `POST /user/certification` |
+| `service/IAccountCertificationApplyService.java` | 新增 `submitPersonalCertification(Long userId, String realName, String idCard)` |
+| `service/impl/AccountCertificationApplyServiceImpl.java` | 实现提交(防重校验 + 落申请 + 联动 `auth_status='1'`,`@Transactional`) |
+| `mapper/AccountCertificationApplyMapper.java` | 新增 `insertCertificationApply(AccountCertificationApply)`、`countPendingApplyByUserId(Long userId)` |
+| `resources/mapper/user/AccountCertificationApplyMapper.xml` | 新增上述两条 SQL |
+| `domain/vo/AccountUserInfoVo.java` | 新增 `authStatus` 字段 |
+| `service/impl/AccountUserServiceImpl.java` | `getAccountUserInfo` 填充 `authStatus` |
+| `src/test/.../AccountUserServiceImplTest.java` | 补充 `authStatus` 下发断言 |
+
+### 新增(前端 hugray-user-miniapp)
+
+| 文件 | 说明 |
+| --- | --- |
+| `pages/profile/certification.vue` | 实名认证页(自定义导航栏 + 姓名/身份证号输入 + 底部提交;沿用 `edit.vue` 的导航与按钮风格) |
+
+### 修改(前端 hugray-user-miniapp)
+
+| 文件 | 改动 |
+| --- | --- |
+| `pages.json` | 注册 `pages/profile/certification`(`navigationStyle: custom`) |
+| `api/index.js` | 新增 `submitCertification(realName, idCard)`(→ `/miniapp/account/user/certification`) |
+| `pages/profile/edit.vue` | 「实名认证」行由 `handlePlaceholder` 改为 `uni.navigateTo` 跳认证页;`realNameStatus` 改为按 `/info` 返回的 `authStatus` 映射 |
+
+> 网关无需改动:`/miniapp/account/**` 路由已存在。
+
+---
+
+## 八、边界与异常清单
+
+| # | 场景 | 处理 |
+| --- | --- | --- |
+| 1 | 未登录访问 | 网关 `AuthFilter` 返回 401,前端跳登录页;后端不做分支 |
+| 2 | `user_id` 头缺失/为空 | 服务内兜底判空,抛业务异常(不 NPE) |
+| 3 | 姓名为空 / 纯空格 | DTO `@NotBlank` 拦截,提示「姓名不能为空」 |
+| 4 | 姓名超长(>64) | DTO `@Size` 拦截,提示「姓名过长」 |
+| 5 | 身份证号为空 | DTO `@NotBlank` 拦截,提示「身份证号不能为空」 |
+| 6 | 身份证号格式非法(非 18 位 / 含非法字符 / 非 X 结尾) | DTO `@Pattern` 拦截,提示「身份证号格式不正确」 |
+| 7 | 已有待审核申请再次提交 | 服务层 `countPendingApplyByUserId>0` 时抛「已有实名认证申请正在审核中,请勿重复提交」 |
+| 8 | 落库或状态联动失败 | 同一事务回滚,返回统一错误,不产生半更新 |
+| 9 | 越权 | 会员身份取自令牌注入头,不接受前端传 `userId`,天然防水平越权 |
+| 10 | 三方实名核验 | **本期明确不做**,仅基础格式校验 |
+
+---
+
+## 九、规范符合性说明
+
+- 分层:Controller 只取身份 + 转发,业务在 Service,SQL 在 Mapper;Controller 不直接调 Mapper。
+- 命名:`submitPersonalCertification` / `insertCertificationApply` / `countPendingApplyByUserId` 见名知意;DTO 与 Entity 分离。
+- 注释:DTO / Mapper 方法 / Service 方法具备 Javadoc;核心流程按 `// STEP x:` 分步注释;实体状态字段注明取值。
+- 魔法值:`subject_type`、`audit_status`、`auth_status` 取值抽为常量;错误提示集中。
+- 事务:提交接口 `insert + 联动更新` 同事务,保证一致性。
+- 幂等/防重:以待审核申请为闸门,拒绝重复提交。
+- 数据库:无 DDL;无 `SELECT *`;无 `${}` 拼接;无循环查库。
+- 单测:覆盖提交正常、重复提交拒绝、状态联动;Mockito Mock Mapper,不依赖真实库。
+
+---
+
+## 十、已确认决策
+
+1. **状态回显**:**已定** —— 扩展 `AccountUserInfoVo.authStatus`,「我的资料」页徽标显示真实状态(未认证/认证中/已认证/认证失败)。
+2. **重复提交**:**已定** —— 存在「待审核」申请时**拒绝并提示**,不新增重复单。
+3. **核验深度**:**已定** —— 仅姓名必填 + 身份证号 18 位格式校验,**不做三方实名核验**。
+4. **入口范围**:**已定** —— 本期只做「实名认证」(个人 `subject_type='1'`);「企业/个体户认证」入口维持占位。
+
+---
+
+## 十一、增量需求:认证状态回填与只读(2026-10-13 补充)
+
+### 11.1 需求描述
+
+进入实名认证页时,按当前认证状态**回填**该会员已提交的资料,并区分交互:
+
+| 当前状态 | 姓名 / 身份证号 | 底部按钮(文案 / 可点性) | 驳回原因 |
+| --- | --- | --- | --- |
+| 未认证(无申请) | 空,可编辑 | 「提交」/ 可点 | 不展示 |
+| 认证中(待审核) | 回填,**只读** | 「审核中」/ **置灰不可点** | 不展示 |
+| 认证成功(已通过) | 回填,**只读** | 「已认证」/ **置灰不可点** | 不展示 |
+| 认证失败(已驳回) | 回填,**可编辑** | 「重新提交」/ 可点 | **在身份证号下方展示** |
+
+> 已确认决策:认证成功同样置灰不可点(已认证无需重复提交);认证中 / 认证成功输入框只读不可编辑。
+
+### 11.2 接口设计(新增:查询当前认证状态)
+
+- **外部路径**:`GET /miniapp/account/user/certification`(网关 `StripPrefix=2` → 服务内 `GET /user/certification`)
+- 与既有 `POST /miniapp/account/user/certification`(提交)**同路径不同方法**,无需新增路由。
+- 鉴权:需登录;会员身份取自网关注入的登录态,**不接受前端传 userId**。
+
+取该会员**最近一次**申请(`account_certification_apply` 按 `id desc limit 1`,只取 `del_flag='0'`),出参 `AccountCertificationStatusVo`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| realName | String | 姓名(回填用) |
+| idCard | String | 身份证号(回填用) |
+| auditStatus | String | 0待审核 1已通过 2已驳回;**为空表示从未提交** |
+| auditRemark | String | 驳回原因(audit_status='2' 时有值) |
+
+无申请时返回**空字段对象**(各字段为 null),前端按「未认证」处理,不返回 404。
+
+### 11.3 前端状态映射
+
+`auditStatus` → 页面状态:`'0'`→认证中、`'1'`→认证成功、`'2'`→认证失败、空/缺省→未认证。
+
+- 只读 & 置灰条件:页面状态 ∈ {认证中, 认证成功};
+- 驳回原因展示条件:页面状态 = 认证失败 且 `auditRemark` 非空;
+- 按钮文案:未认证→「提交」、认证中→「审核中」、认证成功→「已认证」、认证失败→「重新提交」(未匹配兜底「提交」)。
+
+### 11.4 影响范围(增量文件清单)
+
+**新增(ruoyi-user)**
+
+| 文件 | 说明 |
+| --- | --- |
+| `domain/vo/AccountCertificationStatusVo.java` | C 端认证状态回填出参(realName / idCard / auditStatus / auditRemark) |
+
+**修改(ruoyi-user)**
+
+| 文件 | 改动 |
+| --- | --- |
+| `mapper/AccountCertificationApplyMapper.java` | 新增 `selectLatestApplyByUserId(Long userId)` |
+| `resources/mapper/user/AccountCertificationApplyMapper.xml` | 新增上述查询 SQL(取最近一条) |
+| `service/IAccountCertificationApplyService.java` | 新增 `getCertificationStatus(Long userId)` |
+| `service/impl/AccountCertificationApplyServiceImpl.java` | 实现查询:取最近一条申请映射为 VO,无则返回空对象 |
+| `controller/AccountProfileController.java` | 新增 `GET /user/certification` |
+| `src/test/.../AccountCertificationApplyServiceImplTest.java` | 补充查询用例(有申请回填 / 无申请空对象) |
+
+**修改(前端 hugray-user-miniapp)**
+
+| 文件 | 改动 |
+| --- | --- |
+| `api/index.js` | 新增 `getCertificationStatus()`(→ `GET /miniapp/account/user/certification`) |
+| `pages/profile/certification.vue` | 进入页面拉取状态并回填;认证中/成功输入框只读、按钮置灰且文案随状态变化(提交/审核中/已认证/重新提交);认证失败展示驳回原因 |
+
+> 无需 DDL、无需网关改动(`/miniapp/account/**` 路由已存在)。
+
+### 11.5 边界与异常(增量)
+
+| # | 场景 | 处理 |
+| --- | --- | --- |
+| 1 | 从未提交(查询返回空对象) | 页面按未认证处理,空表单可编辑可提交 |
+| 2 | 查询接口异常 | 静默降级为未认证(空表单),不阻塞页面 |
+| 3 | 认证中 / 认证成功 | 输入框 `disabled` 只读,按钮置灰且点击直接 return |
+| 4 | 认证失败但驳回原因为空 | 不展示驳回原因区域(仅可编辑重提) |
+| 5 | 认证失败后重新提交成功 | 回退上一页;再次进入时按新的「待审核」状态回填只读 |
diff --git "a/doc/design/\347\224\250\346\210\267\347\253\257_\346\210\221\347\232\204\350\265\204\346\226\231_\345\244\264\345\203\217\344\270\212\344\274\240_20261013.md" "b/doc/design/\347\224\250\346\210\267\347\253\257_\346\210\221\347\232\204\350\265\204\346\226\231_\345\244\264\345\203\217\344\270\212\344\274\240_20261013.md"
new file mode 100644
index 0000000..2be5b9e
--- /dev/null
+++ "b/doc/design/\347\224\250\346\210\267\347\253\257_\346\210\221\347\232\204\350\265\204\346\226\231_\345\244\264\345\203\217\344\270\212\344\274\240_20261013.md"
@@ -0,0 +1,229 @@
+# 用户端小程序 我的资料 头像上传 开发设计文档
+
+- 模块:ruoyi-user(会员业务)+ ruoyi-file(文件存储)+ ruoyi-gateway(路由/鉴权头注入)
+- 日期:2026-10-13
+- 需求来源:“实现头像上传功能”
+- 关联前端:`hugray-user-miniapp`(`pages/profile/edit.vue`)
+- 关联文档:`doc/design/用户端_个人中心_20261013.md`(个人中心 / 头像字段来源)、`doc/design/用户端_我的资料_20261013.html`(本页 UI 原型)
+- 数据权威源:`192.168.110.111:3306/hugray-account`(实际表结构优先于本文档)
+
+---
+
+## 一、功能概述
+
+「我的资料」页(`pages/profile/edit.vue`)头像行当前点击仅弹占位 toast,本次改为**真实上传**:点击头像 → 选图 → 上传文件 → 写回 `account_user.avatar` → 页面即时回显;个人中心页再次进入时同步展示新头像。
+
+采用**前端直传文件服务 + 会员服务只更新头像地址**两段式链路:
+
+1. 小程序 `uni.uploadFile` 直传网关 `POST /file/upload`(复用 `ruoyi-file` 现成上传能力),拿到图片可访问 URL;
+2. 小程序携 URL 调新增的 C 端接口 `POST /miniapp/account/user/avatar`,`ruoyi-user` **只更新 `avatar` 一列**。
+
+本次只新增**一个 C 端写接口**;文件上传复用既有 `/file/upload`,无需改动。
+
+---
+
+## 二、现状与关键约束
+
+### 2.1 文件服务已就绪,直接复用
+
+`ruoyi-file`(端口 9300)已提供上传入口 `SysFileController#upload`:
+
+- 服务内路径 `POST /upload`;网关路由 `/file/**` → `lb://ruoyi-file`(`StripPrefix=1`),**对外路径 `POST /file/upload`**。
+- 返回 `R<SysFile>{ name, url }`,`url` 即图片可访问地址。
+- 默认存储实现为 `LocalSysFileServiceImpl`(`@Primary`),落盘到 `file.path`,访问前缀由 `file.domain` + `file.prefix` 拼出(配置由 Nacos `ruoyi-file-dev.yml` 下发)。
+
+### 2.2 上传接口需登录态
+
+`/file/upload` **不在**网关白名单(`ignore.whites`)内,网关 `AuthFilter` 会校验令牌并注入 `user_id` 头。故小程序上传时必须携带 `Authorization: Bearer <token>`,与普通接口一致。
+
+### 2.3 前端请求封装仅支持 JSON
+
+`hugray-user-miniapp/utils/request.js` 固定 `Content-Type: application/json`,基于 `uni.request`,**不能直接传文件**。需新增一个基于 `uni.uploadFile` 的封装(单独注入令牌与错误解包),不污染现有 JSON 请求链路。
+
+### 2.4 `ruoyi-user` 不依赖 `ruoyi-api-system`
+
+`ruoyi-user/pom.xml` 未引入 `ruoyi-api-system`,**无法**直接使用 `RemoteFileService`(Feign)做服务端转存。本方案采用「前端直传」,故**无需新增依赖**。
+
+### 2.5 写头像必须走「单列更新语句」
+
+`AccountUserMapper` 既有约定:`insertAccountUser` / `updateAccountUser` 是共享入口、按 `<if>` 动态拼列,**严禁**把「只该由本模块显式改写的列」挂上去(参见 `updateUserStatus` / `updateUserAuthStatus` / `updateUserProviderFlag` 的注释)。因此头像更新**单开** `updateUserAvatar`,只写 `avatar` 一列,避免 `/account/user`(后台编辑)请求体带上 `avatar` 即改他人头像的口子。
+
+### 2.6 字段长度约束
+
+`account_user.avatar` 为 `varchar(255)`,服务端对传入 URL 做长度校验(> 255 直接拒绝),避免截断或 SQL 报错。
+
+---
+
+## 三、接口设计
+
+统一响应体:`R<T>`(`code=200` 成功),与 C 端 `/miniapp/**` 既有约定一致。
+
+### 3.1 上传图片文件(复用既有,无需改动)
+
+- **外部路径**:`POST /file/upload`(网关 `StripPrefix=1` → 服务内 `POST /upload`)
+- **鉴权**:需登录(携带 `Authorization: Bearer <token>`)
+- **请求**:`multipart/form-data`,表单字段名 `file`
+- **出参**:`R<SysFile>`
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": { "name": "2026/10/13/xxx.png", "url": "https://xxx/file/upload/2026/10/13/xxx.png" }
+}
+```
+
+### 3.2 更新当前会员头像(本次新增)
+
+- **外部路径**:`POST /miniapp/account/user/avatar`(网关 `StripPrefix=2` → 服务内 `POST /user/avatar`)
+- **鉴权**:需登录。路径不在网关白名单,未登录由 `AuthFilter` 直接返回 401,前端 `request.js` 自动跳登录页;**后端不做未登录分支**。
+- **入参**:`{ "avatar": "<图片URL>" }`(会员身份取自网关注入的 `user_id` 头,**不接受前端传 userId**)
+- **出参**:`R<Void>`(成功即 `code=200`)
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| avatar | String | 是 | 头像地址(来自 `/file/upload` 返回的 `url`);非空、长度 ≤ 255 |
+
+- **错误提示**:`头像地址不能为空` / `头像地址过长` / `更新头像失败`(会员不存在或更新异常)
+
+---
+
+## 四、数据库设计
+
+库:`hugray-account`(权威库 `192.168.110.111:3306`)。
+
+**无 DDL 变更**:`account_user.avatar varchar(255)` 已存在,本次仅写入该列。
+
+---
+
+## 五、流程设计
+
+### 5.1 前端上传链路(`pages/profile/edit.vue` → `handleAvatarChange`)
+
+```
+STEP 1: uni.chooseImage({ count:1, sizeType:['compressed'], sourceType:['album','camera'] }) 选图
+STEP 2: 上传前本地校验:临时文件大小 ≤ 5MB,否则提示「图片不能超过 5MB」并中止
+STEP 3: uploadFile('/file/upload', tempFilePath) 直传文件服务(携带 Bearer 令牌)
+STEP 4: 取响应 data.url;为空则提示「上传失败」
+STEP 5: updateAvatar(url) 调 POST /miniapp/account/user/avatar 写回
+STEP 6: 成功则更新本地 avatar 展示;失败提示且不改动已展示头像
+```
+
+### 5.2 后端更新头像(`AccountUserServiceImpl.updateAvatar`)
+
+```
+STEP 1: 校验 userId 非空(未登录由网关拦截,此处兜底),为空抛「更新头像失败」
+STEP 2: 校验 avatarUrl 非空,为空抛「头像地址不能为空」
+STEP 3: 校验 avatarUrl 长度 ≤ 255,超长抛「头像地址过长」
+STEP 4: 组装 AccountUser(userId + avatar + updateBy)调用 mapper.updateUserAvatar(只更 avatar 列)
+STEP 5: affected == 0(会员不存在/已删除)抛「更新头像失败」;否则返回
+```
+
+Mapper 单列更新语句(沿用 `updateUserStatus` 套路):
+
+```xml
+<!-- 头像更新专用:只动 avatar 一列,避免挂到共享的 updateAccountUser 上被后台编辑接口借道改头像 -->
+<update id="updateUserAvatar" parameterType="AccountUser">
+ update account_user
+ <set>
+ avatar = #{avatar},
+ <if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
+ update_time = sysdate()
+ </set>
+ where user_id = #{userId} and del_flag = '0'
+</update>
+```
+
+---
+
+## 六、枚举与状态
+
+| 名称 | 取值 | 说明 |
+| --- | --- | --- |
+| 允许图片类型 | `jpg` / `jpeg` / `png` / `webp` | 由文件服务既有校验 + 前端 `chooseImage` 共同约束 |
+| 图片大小上限 | 5 MB | 前端上传前本地校验(文件服务侧上限以其自身配置为准) |
+
+本次不涉及状态流转。
+
+---
+
+## 七、影响范围与文件清单
+
+### 新增(ruoyi-user)
+
+| 文件 | 说明 |
+| --- | --- |
+| `domain/dto/AccountUserAvatarDto.java` | 头像更新入参(`avatar`,含校验注解与字段注释) |
+
+### 修改(ruoyi-user)
+
+| 文件 | 改动 |
+| --- | --- |
+| `controller/AccountProfileController.java` | 新增 `POST /user/avatar` |
+| `service/IAccountUserService.java` | 新增 `updateAvatar(Long userId, String avatarUrl)` |
+| `service/impl/AccountUserServiceImpl.java` | 实现 `updateAvatar`(校验 + 单列更新) |
+| `mapper/AccountUserMapper.java` | 新增 `updateUserAvatar(AccountUser)` |
+| `resources/mapper/user/AccountUserMapper.xml` | 新增 `updateUserAvatar` 语句 |
+
+### 修改(前端 hugray-user-miniapp)
+
+| 文件 | 改动 |
+| --- | --- |
+| `utils/request.js` | 新增 `uploadFile(url, filePath, options)`:基于 `uni.uploadFile`,注入令牌、统一解包与错误提示,复用登录失效处理 |
+| `api/index.js` | 新增 `uploadAvatarFile(filePath)`(→ `/file/upload`)与 `updateAvatar(avatar)`(→ `/miniapp/account/user/avatar`) |
+| `pages/profile/edit.vue` | 头像行 `handlePlaceholder('头像更换')` 改为 `handleAvatarChange`(选图 → 上传 → 写回 → 回显) |
+
+### 修改(文档)
+
+| 文件 | 改动 |
+| --- | --- |
+| `doc/design/用户端_个人中心_20261013.md` | 头像字段「现状」由占位更新为「已支持上传」 |
+
+### 单测(ruoyi-user)
+
+| 文件 | 用例 |
+| --- | --- |
+| `AccountUserServiceImplTest.java` | `updateAvatar`:正常更新 / 空地址 / 超长地址 / 会员不存在(affected=0) |
+
+> 网关无需改动:`/miniapp/account/**`、`/file/**` 路由均已存在。
+
+---
+
+## 八、边界与异常清单
+
+| # | 场景 | 处理 |
+| --- | --- | --- |
+| 1 | 未登录访问 | 网关 `AuthFilter` 返回 401,前端跳登录页;后端不做分支 |
+| 2 | `user_id` 头缺失/为空 | 服务内兜底判空,抛「更新头像失败」(不 NPE) |
+| 3 | 上传的 `avatar` 为空 | 抛「头像地址不能为空」 |
+| 4 | `avatar` 长度 > 255 | 抛「头像地址过长」(列 `varchar(255)`) |
+| 5 | 用户取消选图 | `chooseImage` 成功回调不触发,静默中止(不提示、不请求) |
+| 6 | 图片超过 5MB | 前端本地提示「图片不能超过 5MB」,不发起上传 |
+| 7 | 上传失败(网络/文件服务异常) | 提示「上传失败」,不改动已展示头像 |
+| 8 | 写回失败(接口异常) | 提示后端错误信息,头像保持原值 |
+| 9 | 会员不存在 / 已逻辑删除 | `updateUserAvatar` 命中 `del_flag='0'` 过滤,affected=0 → 抛「更新头像失败」 |
+| 10 | 越权 | 会员身份取自令牌注入头,不接受前端传 `userId`,天然防水平越权 |
+| 11 | 旧头像文件 | **本期不清理**(已确认);后续可加定时清理 |
+| 12 | 并发重复上传 | 写回为整列覆盖,后写生效;无幂等诉求,不做去重 |
+
+---
+
+## 九、规范符合性说明
+
+- 分层:Controller 只做取身份 + 转发,业务在 Service,SQL 在 Mapper;Controller 不直接调 Mapper。
+- 命名:`updateAvatar` / `updateUserAvatar` / `uploadAvatarFile` 见名知意;DTO 与 Entity 分离。
+- 注释:DTO / Mapper 方法 / Service 方法具备 Javadoc;核心流程按 `// STEP x:` 分步注释。
+- 魔法值:图片大小上限等阈值抽为常量;无散落魔法值。
+- 异常:统一 `ServiceException` + 全局处理器,不散落 try-catch 拼返回值。
+- 日志:`@Slf4j` 占位符 + `[业务标记]` 前缀;头像 URL 非敏感,日志不打完整用户隐私。
+- 数据库:单列更新语句;无 DDL;无 `SELECT *`;无 `${}` 拼接;无循环查库。
+- 单测:`updateAvatar` 覆盖正常 / 空值 / 超长 / 会员不存在,Mockito Mock Mapper。
+
+---
+
+## 十、已确认决策
+
+1. **上传链路**:**已定** —— 前端 `uni.uploadFile` 直传 `/file/upload`,`ruoyi-user` 只更新 `avatar`(免加 `ruoyi-api-system` 依赖)。
+2. **存储介质**:**已定** —— 沿用 `ruoyi-file` 本地存储(`LocalSysFileServiceImpl`,`@Primary`)。
+3. **旧头像文件**:**已定** —— 本期不清理,仅覆盖 `avatar` 字段。
+4. **数据库**:**已定** —— 无 DDL 变更,复用既有 `account_user.avatar varchar(255)`。
diff --git "a/doc/design/\347\224\250\346\210\267\347\253\257_\346\210\221\347\232\204\351\202\200\350\257\267_20261013.md" "b/doc/design/\347\224\250\346\210\267\347\253\257_\346\210\221\347\232\204\351\202\200\350\257\267_20261013.md"
new file mode 100644
index 0000000..72918cc
--- /dev/null
+++ "b/doc/design/\347\224\250\346\210\267\347\253\257_\346\210\221\347\232\204\351\202\200\350\257\267_20261013.md"
@@ -0,0 +1,239 @@
+# 用户端小程序 我的邀请 开发设计文档
+
+- 模块:ruoyi-user(会员/邀请业务)+ ruoyi-auth(微信小程序码)+ hugray-user-miniapp(前端)
+- 日期:2026-10-13
+- 需求来源:"点击个人中心-我的邀请,进入我的邀请页面,需要根据pen源文件的设计风格设计ui然后实现页面和后端的业务功能"
+- 原型:3 张 UI 稿(邀请码 / 我邀请的 / 邀请我的)
+- 关联文档:`doc/design/用户端_个人中心_20261013.md`(入口所在页)、`doc/design/用户端_登录注册_20261012.md`(注册链路、扫码回填)
+- 数据权威源:`192.168.110.111:3306/hugray-account`(实际表结构优先于本文档)
+
+---
+
+## 一、功能概述
+
+个人中心「我的邀请码」行当前点击仅弹占位 toast,本次改为**跳转到「我的邀请」页**(`pages/profile/invite.vue`):
+
+1. 新增 C 端页面「我的邀请」,顶部为**三段式 Tab**(邀请码 / 我邀请的 / 邀请我的),自定义导航栏(返回箭头 + 居中标题「我的邀请」);
+2. **邀请码 Tab**:展示本人邀请码(`account_user.my_referral_code`)与**真实微信小程序码**,底部绿色「分享邀请码」按钮;
+3. **我邀请的 Tab**:按**绑定时间倒序**列出直接下级(头像、昵称、等级徽标、脱敏手机号、绑定日、合计消费);
+4. **邀请我的 Tab**:展示本人**上级**(推荐人)信息(大头像、昵称、等级徽标、脱敏手机号、绑定日);无上级时展示空态;
+5. **扫码链路**:小程序码 `scene = 邀请码`,扫码直达注册页并**自动填入邀请码**(补 `register.vue` 的 `onLoad` scene 解析);「分享邀请码」分享注册页链接(`?referralCode=xxx`),同样自动填入。
+
+> 无订单模块,故「合计消费」直接用预留列 `buy_amount_total`(决策见 2.2);
+> 等级徽标直接展示 `system_level` 原值,不扩展 `MemberLevelEnum`(决策见 2.3)。
+
+---
+
+## 二、现状与关键约束
+
+### 2.1 数据模型已预留,无需改表
+
+`account_user` 已具备本期所需列,**无需新增 DDL**:
+
+| 列 | 类型 | 用途 |
+| --- | --- | --- |
+| `referrer_id` | bigint | 上级(推荐人)会员ID,自连接 `account_user.user_id`;已有索引 `idx_account_user_referrer` |
+| `referrer_bind_time` | datetime | 绑定时间,列表/详情「绑定日」来源 |
+| `my_referral_code` | varchar(32) | 本人邀请码,唯一索引 `uk_account_user_referral_code` |
+| `system_level` | char(1) | 等级原值(C / L1~L4),等级徽标来源 |
+| `buy_amount_total` | decimal(16,2) | 购买金额合计(单位:元,现恒为 0,待订单模块回填) |
+
+### 2.2 「合计消费」复用预留列 `buy_amount_total`(已确认)
+
+无订单表,暂用 `account_user.buy_amount_total`(单位:元,与列定义一致,前端加 `¥` 前缀展示)。当前恒为 0,待订单模块上线后回填,**前端无需改动**。
+
+### 2.3 等级徽标直接展示 `system_level` 原值(已确认)
+
+`MemberLevelEnum` 仅到 L4,原型出现 L6(设计稿示意)。本期**不做 normalize**,将 `system_level` 原样下发(C / L1~L4 / 其它历史值),避免把非法值改写。前端空值兜底为 `C`。
+
+### 2.4 小程序码由 ruoyi-auth 生成(已确认「真实小程序码」)
+
+`wechat.miniapp` 配置与 `WeChatMiniappService`(access_token 获取、缓存)**均在 ruoyi-auth**;ruoyi-user 无 wechat 配置、无 `ruoyi-api-system` 依赖。为避免重复实现 access_token 逻辑(DRY),**小程序码生成放在 ruoyi-auth**,新增 C 端接口,入参邀请码、返回 base64 图片。
+
+### 2.5 扫码进入注册页需补 `scene` 解析(关键缺口)
+
+`pages/login/register.vue` **当前无 `onLoad`**(仅 `onUnload`),`form.referralCode` 仅支持手动输入。小程序码扫码后微信会把 `scene` 放入页面 `options`,需补 `onLoad(options)` 解码回填,否则「扫码自动填邀请码」不成立。
+
+### 2.6 邀请码恒定存在,上级可能为空
+
+`my_referral_code` 在注册时生成(`generateUniqueReferralCode`),C 端会员必有,故「邀请码」Tab 无须空态;而 `referrer_id` 可为空(命中通用推荐码注册、或历史数据),故「邀请我的」Tab 必须有空态。
+
+### 2.7 路径规划
+
+- 网关注入 `user_id`;`SecurityUtils.getUserId()` 取会员身份,**C 端接口不接受前端传 userId**(防水平越权)。
+- 会员侧三条接口挂在既有 `AccountProfileController`(`@RequestMapping("/user")`,外部 `/miniapp/account/user/**`),网关路由已存在,**无需改网关**。
+- 小程序码接口挂 ruoyi-auth 的 `AccountAuthController`(外部 `/miniapp/auth/invite-qrcode`),**不在网关白名单**,需登录态。
+
+### 2.8 手机号脱敏
+
+列表/详情中的手机号一律脱敏下发(复用 `AccountUserServiceImpl.maskPhone`,形如 `136****6666`),**不下发明文**。
+
+---
+
+## 三、接口设计
+
+统一响应体:C 端 `R<T>`(成功 `code=200`)。
+
+### 3.1 查询本人邀请码(本次新增 · ruoyi-user)
+
+- **外部路径**:`GET /miniapp/account/user/invite-code`(网关 `StripPrefix=2` → 服务内 `GET /user/invite-code`)
+- **鉴权**:需登录;会员身份取自登录态,不接受前端传 userId。
+- **入参**:无。
+- **出参** `InviteCodeVo`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| referralCode | String | 本人邀请码(`account_user.my_referral_code`) |
+
+### 3.2 生成微信小程序码(本次新增 · ruoyi-auth)
+
+- **外部路径**:`GET /miniapp/auth/invite-qrcode?code={邀请码}`(服务内 `GET /invite-qrcode`)
+- **鉴权**:需登录(不在网关白名单)。
+- **入参**:
+
+| 参数 | 类型 | 必填 | 校验 | 说明 |
+| --- | --- | --- | --- | --- |
+| code | String | 是 | `@NotBlank` + `@Pattern(^[A-Z0-9]{8}$)` | 邀请码,作为小程序码 `scene` |
+
+- **出参** `InviteQrcodeVo`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| qrcode | String | 小程序码 PNG 的 base64 DataURI(`data:image/png;base64,...`),前端直接作为 `<image src>` |
+
+- **缓存**:以邀请码为 key 缓存生成结果(Redis,TTL 30 天),避免重复消耗微信 `getUnlimited` 日配额。
+- **错误提示**:「邀请码格式不正确」/「邀请码二维码生成失败,请重试」。
+
+### 3.3 查询我邀请的(本次新增 · ruoyi-user)
+
+- **外部路径**:`GET /miniapp/account/user/invitees`(→ 服务内 `GET /user/invitees`)
+- **鉴权**:需登录;会员身份取自登录态。
+- **入参**:`pageNum`(默认 1)、`pageSize`(默认 10,与 `TableSupport` 默认一致)— 标准分页。
+- **查询口径**:`account_user` 中 `referrer_id = 当前用户ID` 且 `del_flag='0'`,**按 `referrer_bind_time` 倒序**(最新在最前;`referrer_bind_time` 为空的历史行排最后)。
+- **出参**:走统一分页 `TableDataInfo`,即 `{ code, msg, rows, total }`(**数据在顶层 `rows`,非 `data` 包裹**;`total` 为总数)。`rows` 元素 `AccountInviteeVo`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| userId | Long | 下级会员ID |
+| nickname | String | 昵称(为空前端回退脱敏手机号) |
+| avatar | String | 头像地址(为空前端回退默认图标) |
+| phone | String | 脱敏手机号(如 `136****6666`) |
+| systemLevel | String | 等级原值(C / L1~L4) |
+| bindDate | String | 绑定日(`yyyy-MM-dd`,源自 `referrer_bind_time`;为空不展示) |
+| buyAmountTotal | BigDecimal | 合计消费(元,源自 `buy_amount_total`) |
+
+### 3.4 查询邀请我的(上级信息)(本次新增 · ruoyi-user)
+
+- **外部路径**:`GET /miniapp/account/user/referrer`(→ 服务内 `GET /user/referrer`)
+- **鉴权**:需登录;会员身份取自登录态。
+- **入参**:无。
+- **查询口径**:取本人 `referrer_id` → 查该会员(`del_flag='0'`)。
+- **出参** `AccountReferrerVo`(无上级时各字段为 null,前端渲染空态):
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| userId | Long | 上级会员ID |
+| nickname | String | 昵称 |
+| avatar | String | 头像地址 |
+| phone | String | 脱敏手机号 |
+| systemLevel | String | 等级原值(C / L1~L4) |
+| bindDate | String | 绑定日(`yyyy-MM-dd`) |
+
+---
+
+## 四、数据库设计
+
+**本期无 DDL 变更**,仅复用 `account_user` 既有列(见 2.1)。查询 SQL 遵循规范:
+
+- 「我邀请的」按 `referrer_id` 过滤 + `referrer_bind_time` 排序,命中 `idx_account_user_referrer`;**不挂 join**(避免干扰 PageHelper 的 count SQL)。
+- 「邀请我的」按 `user_id` 主键查(复用 `selectAccountUserById`),不额外建方法。
+- 禁止 `SELECT *`:新增查询显式列出所需列。
+
+---
+
+## 五、微信小程序码设计(ruoyi-auth)
+
+### 5.1 复用与新增
+
+- 复用 `WeChatMiniappService.getAccessToken`(access_token 获取 + Redis 缓存 7000s)。当前为 `private`,在**同类内新增小程序码方法**即可调用,无需改可见性。
+- 当前 `doPost` 用 `BodyHandlers.ofString()` 仅适配 JSON;小程序码返回**二进制图片**,新增一个 `doPostForBytes`(`BodyHandlers.ofByteArray()`)。
+
+### 5.2 调用参数(`wxacode.getUnlimited`)
+
+```
+POST https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=ACCESS_TOKEN
+Body: {
+ "scene": "{邀请码}", // 8 位 A-Z0-9,符合 scene 字符集与长度限制
+ "page": "pages/login/register", // 扫码直达注册页
+ "check_path": false, // 跳过页面存在性校验(体验版/开发版可扫码)
+ "env_version": "release", // 正式版;体验/开发调试可改 trial/develop
+ "width": 430
+}
+```
+
+### 5.3 响应处理与缓存
+
+- 微信成功返回 PNG 字节流,失败返回 JSON(含 `errcode`/`errmsg`)。先按 `Content-Type` 判断:非图片 → 解析 JSON 记日志并抛业务异常。
+- 成功后将 PNG 转 base64 DataURI,按 `scene` 缓存到 Redis(TTL 30 天),命中缓存直接返回。
+- **运维注意**:`page` 需为已发布页面;正式环境扫码前,小程序需先发布含 `pages/login/register` 的版本。
+
+---
+
+## 六、前端页面设计(`pages/profile/invite.vue`)
+
+### 6.1 视觉令牌(对齐 `琥歌瑞0928.pen`)
+
+| 用途 | 取值 |
+| --- | --- |
+| 页面底色 | `#F8FAFC`(pen 根底色) |
+| 主色(草绿) | `#10B981`(pen 强调色);按钮渐变 `#34D399 → #10B981` |
+| 等级/状态徽标 | 实心绿胶囊:底 `#10B981`、字 `#FFFFFF`,圆角 6rpx(原型 L1/L6 徽标) |
+| 卡片 | 白底 `#FFFFFF`,圆角 16rpx,描边 `#E2E8F0`,阴影 `0 6rpx 16rpx rgba(15,23,42,0.06)` |
+| 主文本 / 次文本 / 弱文本 | `#0F172A` / `#64748B` / `#94A3B8` |
+| 导航 | 自定义,返回箭头黑色 `#0F172A`,标题居中 `#0F172A` 34rpx 加粗 |
+| Tab | 高亮 `#10B981` + 底部 4rpx 绿色指示条;未选中 `#64748B` |
+
+> 说明:个人中心页等级胶囊为金色渐变,本页**按原型用绿色胶囊**(更贴合 pen 的 `#10B981 / #ECFDF5` 状态徽标体系)。
+
+### 6.2 三 Tab 结构
+
+- **邀请码**:居中白色卡片,内为小程序码 `<image>`(base64);下方文案「我的邀请码」+ 邀请码原值(可点击复制);底部整宽绿色胶囊按钮「分享邀请码」(`open-type="share"`)。
+- **我邀请的**:列表,每行左为头像 + (昵称 + 等级徽标)+ 脱敏手机号;右侧为「合计消费:¥xxx」与「绑定日:xxxx-xx-xx」;按绑定时间倒序;空态「还没有邀请好友」。
+- **邀请我的**:居中大号头像 + 昵称 +(等级徽标)+ 脱敏手机号 +「绑定日:xxxx-xx-xx」;无上级时空态「暂无上级信息」。
+
+### 6.3 分享与扫码
+
+- 「分享邀请码」:`onShareAppMessage` 返回 `{ title, path: '/pages/login/register?referralCode={邀请码}', imageUrl: {小程序码} }`,分享小程序注册页。
+- 扫码进入:`wxacode.getUnlimited` 的 `scene` 落到 `pages/login/register` 的 `options.scene`,由 `register.vue` 的 `onLoad` 解码回填。
+
+---
+
+## 七、前端改动清单
+
+| 文件 | 改动 |
+| --- | --- |
+| `pages/profile/invite.vue` | **新增**:我的邀请页(三 Tab + 分享 + 空态) |
+| `pages.json` | **新增**页面注册 `pages/profile/invite`(`navigationStyle: "custom"`) |
+| `pages/profile/index.vue` | `handleMenuTap` 中 `invite` 分支由「敬请期待」改为 `uni.navigateTo({ url: '/pages/profile/invite' })` |
+| `api/index.js` | **新增** `getInviteCode` / `getInviteQrcode(code)` / `getInvitees(params)` / `getReferrer` |
+| `pages/login/register.vue` | **新增** `onLoad(options)`:读 `options.referralCode`(分享链接)与 `options.scene`(扫码,需 `decodeURIComponent`)回填 `form.referralCode` |
+
+---
+
+## 八、影响范围与测试要点
+
+- **影响面**:新增页面与接口,不改动既有表结构与既有接口出参;个人中心仅改一处菜单跳转。
+- **测试要点**:
+ 1. 邀请码 Tab:邀请码正确展示;小程序码可正常显示;分享卡片可拉起且 path 带推荐码;
+ 2. 我邀请的:按绑定时间倒序;脱敏手机号;合计消费展示(当前为 0);空态;
+ 3. 邀请我的:有上级/无上级两种状态;
+ 4. 扫码链路:扫描本人小程序码 → 进入注册页且推荐码已自动填入;手动输入仍可用;
+ 5. 边界:`referrer_bind_time` 为空的历史行排序不报错;未登录访问由网关 401 拦截。
+
+---
+
+## 九、待确认项
+
+1. 小程序码生成落在 **ruoyi-auth**(复用现成 WeChat 能力),会员侧接口落在 ruoyi-user;如希望统一前缀在 `/miniapp/account/**`,需在 ruoyi-user 补 wechat 配置与 WeChat 客户端(会引入重复实现),请确认取舍。
+2. 「我邀请的」是否**需要分页**(原型仅 4 条示意);本文默认分页(pageNum/pageSize)。
+3. 小程序码 `env_version` / `page` 取值(默认 `release` + `pages/login/register`)。
diff --git a/doc/nacos/dev/ruoyi-file-dev.yml b/doc/nacos/dev/ruoyi-file-dev.yml
new file mode 100644
index 0000000..518e0ec
--- /dev/null
+++ b/doc/nacos/dev/ruoyi-file-dev.yml
@@ -0,0 +1,34 @@
+# spring配置
+spring:
+ data:
+ redis:
+ host: localhost
+ port: 6379
+ password: <脱敏归档,真实值见配置中心>
+
+# 本地文件上传
+file:
+ domain: http://127.0.0.1:9300
+ path: D:/ruoyi/uploadPath
+ prefix: /statics
+
+# FastDFS配置
+fdfs:
+ domain: http://127.0.0.1
+ soTimeout: 3000
+ connectTimeout: 2000
+ trackerList: 127.0.0.1:22122
+
+# Minio配置
+minio:
+ url: http://127.0.0.1:9000
+ accessKey: minioadmin
+ secretKey: minioadmin
+ bucketName: test
+
+ # 防盗链配置
+referer:
+ # 防盗链开关
+ enabled: false
+ # 允许的域名列表
+ allowed-domains: localhost,127.0.0.1,ruoyi.vip,www.ruoyi.vip
diff --git a/hugray-admin/src/utils/member.js b/hugray-admin/src/utils/member.js
index cf19355..42c6566 100644
--- a/hugray-admin/src/utils/member.js
+++ b/hugray-admin/src/utils/member.js
@@ -14,6 +14,12 @@
{ value: '3', label: '企业' }
]
+// 有效期类型(营业执照有效期与法人身份证有效期共用一套取值)
+export const VALID_TYPES = [
+ { value: '1', label: '长期' },
+ { value: '2', label: '短期' }
+]
+
// 认证状态
export const AUTH_STATUS = [
{ value: '0', label: '未认证' },
diff --git a/hugray-admin/src/views/member/certification/detail.vue b/hugray-admin/src/views/member/certification/detail.vue
index 7a4ae23..4e6afb4 100644
--- a/hugray-admin/src/views/member/certification/detail.vue
+++ b/hugray-admin/src/views/member/certification/detail.vue
@@ -53,17 +53,48 @@
<el-descriptions :column="4" border>
<el-descriptions-item label="用户名称">{{ apply.realName || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户身份证号" :span="3">{{ apply.idCard || '-' }}</el-descriptions-item>
+
+ <!-- 企业信息 -->
+ <el-descriptions-item label="主体类型">{{ labelOf(SUBJECT_TYPES, apply.subjectType) }}</el-descriptions-item>
<el-descriptions-item label="企业名称">{{ apply.companyName || '-' }}</el-descriptions-item>
<el-descriptions-item label="信用代码">{{ apply.creditCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="企业法人">{{ apply.legalPerson || '-' }}</el-descriptions-item>
<el-descriptions-item label="企业注册地区">{{ apply.registeredRegion || '-' }}</el-descriptions-item>
- <el-descriptions-item label="企业地址" :span="4">{{ apply.companyAddress || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册日期">{{ apply.registerDate || '-' }}</el-descriptions-item>
- <el-descriptions-item label="营业执照有效期间" :span="3">{{ apply.licenseValidPeriod || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="营业执照有效期类型">{{ labelOf(VALID_TYPES, apply.licenseValidType) }}</el-descriptions-item>
+ <el-descriptions-item label="营业执照有效期起止">{{ apply.licenseValidPeriod || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="企业地址" :span="4">{{ apply.companyAddress || '-' }}</el-descriptions-item>
<el-descriptions-item label="营业执照" :span="4">
<!-- ImagePreview 是全局组件:内部按 VITE_APP_BASE_API 拼接 /profile 相对路径,
并自带预览与破图占位,不必在这里手拼地址 -->
<image-preview v-if="apply.licenseImage" :src="apply.licenseImage" width="120" height="120" />
+ <span v-else>-</span>
+ </el-descriptions-item>
+
+ <!-- 法人信息(营业执照有效期类型/起止已在企业信息段落展示) -->
+ <el-descriptions-item label="法人身份证有效期类型">{{ labelOf(VALID_TYPES, apply.legalIdValidType) }}</el-descriptions-item>
+ <el-descriptions-item label="法人身份证有效期起止">{{ apply.legalIdValidPeriod || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="法人身份证详细地址" :span="2">{{ apply.legalIdAddress || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="法人身份证人像面" :span="2">
+ <image-preview v-if="apply.legalIdCardFront" :src="apply.legalIdCardFront" width="120" height="120" />
+ <span v-else>-</span>
+ </el-descriptions-item>
+ <el-descriptions-item label="法人身份证国徽面" :span="2">
+ <image-preview v-if="apply.legalIdCardBack" :src="apply.legalIdCardBack" width="120" height="120" />
+ <span v-else>-</span>
+ </el-descriptions-item>
+
+ <!-- 负责人信息 -->
+ <el-descriptions-item label="负责人姓名">{{ apply.contactName || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="负责人电话">{{ apply.contactPhone || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="负责人邮箱" :span="2">{{ apply.contactEmail || '-' }}</el-descriptions-item>
+
+ <!-- 收款信息 -->
+ <el-descriptions-item label="收款类型">{{ apply.payeeType || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="银行卡号">{{ apply.bankCardNo || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="开户城市" :span="2">{{ apply.bankCity || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="银行卡照片" :span="4">
+ <image-preview v-if="apply.bankCardImage" :src="apply.bankCardImage" width="120" height="120" />
<span v-else>-</span>
</el-descriptions-item>
</el-descriptions>
@@ -106,6 +137,7 @@
import { getCertificationApplyDetail, auditCertificationApply } from '@/api/user/certification'
import {
SUBJECT_TYPES,
+ VALID_TYPES,
AUTH_STATUS,
AUDIT_STATUS,
ACCOUNT_STATUS,
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/AccountAuthController.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/AccountAuthController.java
index 468e1e7..560ad9a 100644
--- a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/AccountAuthController.java
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/AccountAuthController.java
@@ -2,15 +2,19 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
+import com.ruoyi.auth.domain.vo.InviteQrcodeVo;
import com.ruoyi.auth.form.AccountRegisterBody;
+import com.ruoyi.auth.form.InviteQrcodeQuery;
import com.ruoyi.auth.form.PhoneCodeLoginBody;
import com.ruoyi.auth.form.PhoneLoginBody;
import com.ruoyi.auth.form.SmsCodeBody;
import com.ruoyi.auth.service.AccountLoginService;
import com.ruoyi.auth.service.SmsCodeService;
+import com.ruoyi.auth.service.WeChatMiniappService;
import com.ruoyi.common.core.domain.R;
/**
@@ -28,6 +32,9 @@
@Autowired
private AccountLoginService accountLoginService;
+
+ @Autowired
+ private WeChatMiniappService weChatMiniappService;
/**
* 下发短信验证码
@@ -66,4 +73,20 @@
accountLoginService.accountRegister(body.getPhone(), body.getCode(), body.getReferralCode());
return R.ok();
}
+
+ /**
+ * 生成邀请码小程序码
+ *
+ * 以邀请码作为 scene 调用微信 getUnlimited 接口,扫码直达注册页并自动填入邀请码。
+ *
+ * @param query 邀请码入参(code)
+ * @return 小程序码 base64 DataURI
+ */
+ @GetMapping("/invite-qrcode")
+ public R<InviteQrcodeVo> inviteQrcode(@Validated InviteQrcodeQuery query)
+ {
+ InviteQrcodeVo vo = new InviteQrcodeVo();
+ vo.setQrcode(weChatMiniappService.getUnlimitedQrcode(query.getCode()));
+ return R.ok(vo);
+ }
}
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java
index 407d131..f5d14b0 100644
--- a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java
@@ -41,9 +41,31 @@
return R.ok(tokenService.createToken(userInfo));
}
+ /**
+ * 登出(后台管理端使用 DELETE)
+ */
@DeleteMapping("logout")
public R<?> logout(HttpServletRequest request)
{
+ return doLogout(request);
+ }
+
+ /**
+ * 登出(小程序端使用 POST,网关 /miniapp/auth/logout 剥前缀后落到本路径)
+ * 与 DELETE 版本共用同一套登出逻辑,仅方法不同
+ */
+ @PostMapping("logout")
+ public R<?> miniappLogout(HttpServletRequest request)
+ {
+ return doLogout(request);
+ }
+
+ /**
+ * 登出公共逻辑:删除 Redis 登录态缓存并记录退出日志
+ * @param request 当前请求(用于从请求头提取 token)
+ */
+ private R<?> doLogout(HttpServletRequest request)
+ {
String token = SecurityUtils.getToken(request);
if (StringUtils.isNotEmpty(token))
{
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/domain/vo/InviteQrcodeVo.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/domain/vo/InviteQrcodeVo.java
new file mode 100644
index 0000000..5dc28d6
--- /dev/null
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/domain/vo/InviteQrcodeVo.java
@@ -0,0 +1,22 @@
+package com.ruoyi.auth.domain.vo;
+
+/**
+ * 邀请码小程序码展示对象
+ *
+ * @author hugray
+ */
+public class InviteQrcodeVo
+{
+ /** 小程序码 PNG 的 base64 DataURI(data:image/png;base64,...),前端直接作为 image src */
+ private String qrcode;
+
+ public String getQrcode()
+ {
+ return qrcode;
+ }
+
+ public void setQrcode(String qrcode)
+ {
+ this.qrcode = qrcode;
+ }
+}
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/InviteQrcodeQuery.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/InviteQrcodeQuery.java
new file mode 100644
index 0000000..99535af
--- /dev/null
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/InviteQrcodeQuery.java
@@ -0,0 +1,27 @@
+package com.ruoyi.auth.form;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+
+/**
+ * 邀请码小程序码查询入参
+ *
+ * @author hugray
+ */
+public class InviteQrcodeQuery
+{
+ /** 邀请码,作为小程序码 scene(8 位 A-Z0-9) */
+ @NotBlank(message = "请输入邀请码")
+ @Pattern(regexp = "^[A-Z0-9]{8}$", message = "邀请码格式不正确")
+ private String code;
+
+ public String getCode()
+ {
+ return code;
+ }
+
+ public void setCode(String code)
+ {
+ this.code = code;
+ }
+}
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/WeChatMiniappService.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/WeChatMiniappService.java
index 6e84452..43de14e 100644
--- a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/WeChatMiniappService.java
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/WeChatMiniappService.java
@@ -4,7 +4,9 @@
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
+import java.util.Base64;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -47,6 +49,21 @@
/** 换取失败统一提示 */
private static final String PHONE_FAIL_MESSAGE = "微信手机号获取失败,请重试";
+
+ /** 获取不限量小程序码路径 */
+ private static final String QRCODE_PATH = "/wxa/getwxacodeunlimit";
+
+ /** 小程序码扫码直达页面 */
+ private static final String QRCODE_PAGE = "pages/login/register";
+
+ /** 小程序码图片宽度(px) */
+ private static final int QRCODE_WIDTH = 430;
+
+ /** 小程序码缓存时长(秒),30 天,避免重复消耗微信 getUnlimited 日配额 */
+ private static final long QRCODE_CACHE_SECONDS = 30L * 24 * 60 * 60;
+
+ /** 小程序码生成失败统一提示 */
+ private static final String QRCODE_FAIL_MESSAGE = "邀请码二维码生成失败,请重试";
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
@@ -91,6 +108,97 @@
throw new ServiceException(PHONE_FAIL_MESSAGE);
}
return phoneNumber;
+ }
+
+ /**
+ * 生成不限量小程序码(wxacode.getUnlimited)
+ *
+ * 以邀请码作为 scene,扫码直达注册页并由前端自动回填邀请码。
+ * 结果按 scene 缓存 30 天,命中缓存直接返回,避免重复消耗微信接口日配额。
+ *
+ * @param scene 场景值,即邀请码(8 位 A-Z0-9)
+ * @return PNG 图片的 base64 DataURI(data:image/png;base64,...)
+ */
+ public String getUnlimitedQrcode(String scene)
+ {
+ // STEP 1: 命中缓存直接返回
+ String cacheKey = CacheConstants.WECHAT_QRCODE_KEY + scene;
+ String cached = redisService.getCacheObject(cacheKey);
+ if (StringUtils.isNotEmpty(cached))
+ {
+ return cached;
+ }
+
+ // STEP 2: 校验配置是否完整
+ String appid = weChatMiniappProperties.getAppid();
+ String secret = weChatMiniappProperties.getSecret();
+ if (StringUtils.isEmpty(appid) || StringUtils.isEmpty(secret))
+ {
+ log.error("[WECHAT] miniapp appid/secret 未配置");
+ throw new ServiceException(QRCODE_FAIL_MESSAGE);
+ }
+
+ // STEP 3: 获取全局 access_token
+ String accessToken = getAccessToken(appid, secret);
+
+ // STEP 4: 调用微信生成小程序码(scene 为邀请码,已由入参正则限定为 A-Z0-9,可安全拼接)
+ String url = WECHAT_API_HOST + QRCODE_PATH + "?access_token=" + accessToken;
+ String requestBody = "{\"scene\":\"" + scene + "\",\"page\":\"" + QRCODE_PAGE
+ + "\",\"check_path\":false,\"env_version\":\"release\",\"width\":" + QRCODE_WIDTH + "}";
+ HttpResponse<byte[]> response = doPostForBytes(url, requestBody);
+
+ // STEP 5: 按响应类型解析(图片转 base64,JSON 则说明失败)
+ String dataUri = parseQrcodeResponse(response, scene);
+
+ // STEP 6: 缓存生成结果并返回
+ redisService.setCacheObject(cacheKey, dataUri, QRCODE_CACHE_SECONDS, TimeUnit.SECONDS);
+ return dataUri;
+ }
+
+ /**
+ * 解析小程序码响应
+ *
+ * 微信成功返回 PNG 字节流(Content-Type: image/*),失败返回 JSON(含 errcode/errmsg)。
+ *
+ * @param response 微信响应
+ * @param scene 邀请码(仅用于日志)
+ * @return PNG 的 base64 DataURI
+ */
+ private String parseQrcodeResponse(HttpResponse<byte[]> response, String scene)
+ {
+ String contentType = response.headers().firstValue("Content-Type").orElse("");
+ byte[] body = response.body();
+
+ // 图片响应:PNG 二进制转 base64 DataURI
+ if (contentType.startsWith("image/"))
+ {
+ if (body == null || body.length == 0)
+ {
+ log.error("[WECHAT] getUnlimitedQrcode empty image, scene={}", scene);
+ throw new ServiceException(QRCODE_FAIL_MESSAGE);
+ }
+ return "data:image/png;base64," + Base64.getEncoder().encodeToString(body);
+ }
+
+ // 非图片响应:微信以 JSON 返回错误码,解析并记日志
+ Integer errcode = null;
+ String errmsg = null;
+ try
+ {
+ String text = body == null ? null : new String(body, StandardCharsets.UTF_8);
+ JSONObject json = JSON.parseObject(text);
+ if (StringUtils.isNotNull(json))
+ {
+ errcode = json.getInteger("errcode");
+ errmsg = json.getString("errmsg");
+ }
+ }
+ catch (Exception e)
+ {
+ log.error("[WECHAT] getUnlimitedQrcode parse error, scene={}", scene, e);
+ }
+ log.error("[WECHAT] getUnlimitedQrcode failed, scene={}, errcode={}, errmsg={}", scene, errcode, errmsg);
+ throw new ServiceException(QRCODE_FAIL_MESSAGE);
}
/**
@@ -177,4 +285,36 @@
throw new ServiceException(PHONE_FAIL_MESSAGE);
}
}
+
+ /**
+ * 发送 HTTP POST 请求并按字节数组接收响应(用于小程序码等二进制接口)
+ *
+ * @param url 请求地址
+ * @param body 请求体 JSON,可为空
+ * @return 原始响应(含状态头与字节体)
+ */
+ private HttpResponse<byte[]> doPostForBytes(String url, String body)
+ {
+ try
+ {
+ HttpRequest.Builder builder = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .timeout(Duration.ofSeconds(WECHAT_REQUEST_TIMEOUT_SECONDS))
+ .header("Content-Type", "application/json");
+ if (StringUtils.isEmpty(body))
+ {
+ builder.POST(HttpRequest.BodyPublishers.noBody());
+ }
+ else
+ {
+ builder.POST(HttpRequest.BodyPublishers.ofString(body));
+ }
+ return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
+ }
+ catch (Exception e)
+ {
+ log.error("[WECHAT] request failed, url={}", url, e);
+ throw new ServiceException(QRCODE_FAIL_MESSAGE);
+ }
+ }
}
diff --git a/hugray-server/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/CacheConstants.java b/hugray-server/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/CacheConstants.java
index 2d99829..1493d8d 100644
--- a/hugray-server/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/CacheConstants.java
+++ b/hugray-server/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/CacheConstants.java
@@ -81,4 +81,9 @@
* 微信小程序全局 access_token redis key
*/
public static final String WECHAT_ACCESS_TOKEN_KEY = "wechat_miniapp_token:";
+
+ /**
+ * 微信小程序码 redis key(按 scene 缓存生成的 base64 图片)
+ */
+ public static final String WECHAT_QRCODE_KEY = "wechat_miniapp_qrcode:";
}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountProfileController.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountProfileController.java
index c2f4ea8..72258a6 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountProfileController.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountProfileController.java
@@ -1,12 +1,27 @@
package com.ruoyi.user.controller;
+import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
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.core.web.controller.BaseController;
+import com.ruoyi.common.core.web.page.TableDataInfo;
import com.ruoyi.common.security.utils.SecurityUtils;
+import com.ruoyi.user.domain.dto.AccountCertificationApplyDto;
+import com.ruoyi.user.domain.dto.AccountUserAvatarDto;
+import com.ruoyi.user.domain.dto.CompanyCertificationApplyDto;
+import com.ruoyi.user.domain.vo.AccountCertificationStatusVo;
+import com.ruoyi.user.domain.vo.AccountInviteeVo;
+import com.ruoyi.user.domain.vo.AccountReferrerVo;
import com.ruoyi.user.domain.vo.AccountUserInfoVo;
+import com.ruoyi.user.domain.vo.CompanyCertificationStatusVo;
+import com.ruoyi.user.domain.vo.InviteCodeVo;
+import com.ruoyi.user.service.IAccountCertificationApplyService;
import com.ruoyi.user.service.IAccountUserService;
/**
@@ -22,10 +37,13 @@
*/
@RestController
@RequestMapping("/user")
-public class AccountProfileController
+public class AccountProfileController extends BaseController
{
@Autowired
private IAccountUserService accountUserService;
+
+ @Autowired
+ private IAccountCertificationApplyService accountCertificationApplyService;
/**
* 查询当前登录会员的个人中心信息
@@ -39,4 +57,128 @@
{
return R.ok(accountUserService.getAccountUserInfo(SecurityUtils.getUserId()));
}
+
+ /**
+ * 更新当前登录会员头像
+ *
+ * 头像文件由小程序直传文件服务(POST /file/upload)后,将返回的 url 通过本接口写回;
+ * 会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @param dto 头像更新入参(仅含 avatar)
+ * @return 操作结果
+ */
+ @PostMapping("/avatar")
+ public R<Void> updateAvatar(@Validated @RequestBody AccountUserAvatarDto dto)
+ {
+ accountUserService.updateAvatar(SecurityUtils.getUserId(), dto.getAvatar());
+ return R.ok();
+ }
+
+ /**
+ * 查询当前登录会员的实名认证状态
+ *
+ * 供实名认证页进入时回填:姓名、身份证号、审核状态(0待审核/1已通过/2已驳回)与驳回原因。
+ * 从未提交过申请时返回各字段为空的对象,前端按「未认证」渲染空表单。
+ * 会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @return 认证状态(realName / idCard / auditStatus / auditRemark)
+ */
+ @GetMapping("/certification")
+ public R<AccountCertificationStatusVo> certification()
+ {
+ return R.ok(accountCertificationApplyService.getCertificationStatus(SecurityUtils.getUserId()));
+ }
+
+ /**
+ * 提交实名认证(个人主体)
+ *
+ * 只做基础的姓名必填与身份证号格式校验(@Validated 拦下非法入参),不做三方实名核验;
+ * 提交后落一条待审核的认证申请,出现在管理后台「主体认证审核」列表。
+ * 会员身份取自登录态,不接受前端传入 userId;已有待审核申请时拒绝重复提交。
+ *
+ * @param dto 认证申请入参(仅含 realName、idCard)
+ * @return 操作结果
+ */
+ @PostMapping("/certification")
+ public R<Void> submitCertification(@Validated @RequestBody AccountCertificationApplyDto dto)
+ {
+ accountCertificationApplyService.submitPersonalCertification(
+ SecurityUtils.getUserId(), dto.getRealName(), dto.getIdCard());
+ return R.ok();
+ }
+
+ /**
+ * 查询当前登录会员的企业/个体认证状态
+ *
+ * 供企业认证向导页进入时回填:整份企业资料 + 审核状态(0待审核/1已通过/2已驳回)与驳回原因。
+ * 从未提交过申请时返回各字段为空的对象,前端按「未认证」渲染空表单。
+ * 会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @return 企业/个体认证状态(整份资料 + auditStatus / auditRemark)
+ */
+ @GetMapping("/company-certification")
+ public R<CompanyCertificationStatusVo> companyCertification()
+ {
+ return R.ok(accountCertificationApplyService.getCompanyCertificationStatus(SecurityUtils.getUserId()));
+ }
+
+ /**
+ * 提交企业/个体工商户认证(主体类型 2/3)
+ *
+ * 只做最基础的必填校验(@Validated 拦下空值与非法枚举),不做格式与三方核验,真实性由后台人工审核把关;
+ * 提交后落一条待审核的认证申请,出现在管理后台「主体认证审核」列表。
+ * 会员身份取自登录态,不接受前端传入 userId;已有待审核的企业申请时拒绝重复提交。
+ *
+ * @param dto 企业认证申请入参
+ * @return 操作结果
+ */
+ @PostMapping("/company-certification")
+ public R<Void> submitCompanyCertification(@Validated @RequestBody CompanyCertificationApplyDto dto)
+ {
+ accountCertificationApplyService.submitCompanyCertification(SecurityUtils.getUserId(), dto);
+ return R.ok();
+ }
+
+ /**
+ * 查询本人邀请码
+ *
+ * 供「我的邀请」页「邀请码」Tab 展示;会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @return 邀请码展示对象
+ */
+ @GetMapping("/invite-code")
+ public R<InviteCodeVo> inviteCode()
+ {
+ return R.ok(accountUserService.getInviteCode(SecurityUtils.getUserId()));
+ }
+
+ /**
+ * 查询本人直接下级(被邀请人)列表
+ *
+ * 分页返回(pageNum 默认 1、pageSize 默认 10),按绑定时间倒序;
+ * 会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @return 分页数据(rows 为 AccountInviteeVo 列表,total 为总数)
+ */
+ @GetMapping("/invitees")
+ public TableDataInfo invitees()
+ {
+ startPage();
+ List<AccountInviteeVo> list = accountUserService.getInvitees(SecurityUtils.getUserId());
+ return getDataTable(list);
+ }
+
+ /**
+ * 查询本人上级(推荐人)信息
+ *
+ * 供「我的邀请」页「邀请我的」Tab 展示;无上级时返回各字段为 null 的对象;
+ * 会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @return 上级展示对象
+ */
+ @GetMapping("/referrer")
+ public R<AccountReferrerVo> referrer()
+ {
+ return R.ok(accountUserService.getReferrer(SecurityUtils.getUserId()));
+ }
}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertification.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertification.java
index d999530..1e53002 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertification.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertification.java
@@ -52,6 +52,45 @@
/** 营业执照图片地址 */
private String licenseImage;
+ /** 营业执照有效期类型(1长期 2短期;长期时 licenseValidPeriod 为空) */
+ private String licenseValidType;
+
+ /** 法人身份证人像面图片 */
+ private String legalIdCardFront;
+
+ /** 法人身份证国徽面图片 */
+ private String legalIdCardBack;
+
+ /** 法人身份证有效期类型(1长期 2短期) */
+ private String legalIdValidType;
+
+ /** 法人身份证有效期(存文本,如 2024-01-01~2034-01-01) */
+ private String legalIdValidPeriod;
+
+ /** 法人身份证详细地址 */
+ private String legalIdAddress;
+
+ /** 负责人姓名 */
+ private String contactName;
+
+ /** 负责人联系电话 */
+ private String contactPhone;
+
+ /** 负责人邮箱 */
+ private String contactEmail;
+
+ /** 收款类型 */
+ private String payeeType;
+
+ /** 银行卡号码面照片 */
+ private String bankCardImage;
+
+ /** 银行卡号 */
+ private String bankCardNo;
+
+ /** 开户城市 */
+ private String bankCity;
+
/** 删除标志(0存在 2删除) */
private String delFlag;
@@ -175,6 +214,136 @@
this.licenseImage = licenseImage;
}
+ public String getLicenseValidType()
+ {
+ return licenseValidType;
+ }
+
+ public void setLicenseValidType(String licenseValidType)
+ {
+ this.licenseValidType = licenseValidType;
+ }
+
+ public String getLegalIdCardFront()
+ {
+ return legalIdCardFront;
+ }
+
+ public void setLegalIdCardFront(String legalIdCardFront)
+ {
+ this.legalIdCardFront = legalIdCardFront;
+ }
+
+ public String getLegalIdCardBack()
+ {
+ return legalIdCardBack;
+ }
+
+ public void setLegalIdCardBack(String legalIdCardBack)
+ {
+ this.legalIdCardBack = legalIdCardBack;
+ }
+
+ public String getLegalIdValidType()
+ {
+ return legalIdValidType;
+ }
+
+ public void setLegalIdValidType(String legalIdValidType)
+ {
+ this.legalIdValidType = legalIdValidType;
+ }
+
+ public String getLegalIdValidPeriod()
+ {
+ return legalIdValidPeriod;
+ }
+
+ public void setLegalIdValidPeriod(String legalIdValidPeriod)
+ {
+ this.legalIdValidPeriod = legalIdValidPeriod;
+ }
+
+ public String getLegalIdAddress()
+ {
+ return legalIdAddress;
+ }
+
+ public void setLegalIdAddress(String legalIdAddress)
+ {
+ this.legalIdAddress = legalIdAddress;
+ }
+
+ public String getContactName()
+ {
+ return contactName;
+ }
+
+ public void setContactName(String contactName)
+ {
+ this.contactName = contactName;
+ }
+
+ public String getContactPhone()
+ {
+ return contactPhone;
+ }
+
+ public void setContactPhone(String contactPhone)
+ {
+ this.contactPhone = contactPhone;
+ }
+
+ public String getContactEmail()
+ {
+ return contactEmail;
+ }
+
+ public void setContactEmail(String contactEmail)
+ {
+ this.contactEmail = contactEmail;
+ }
+
+ public String getPayeeType()
+ {
+ return payeeType;
+ }
+
+ public void setPayeeType(String payeeType)
+ {
+ this.payeeType = payeeType;
+ }
+
+ public String getBankCardImage()
+ {
+ return bankCardImage;
+ }
+
+ public void setBankCardImage(String bankCardImage)
+ {
+ this.bankCardImage = bankCardImage;
+ }
+
+ public String getBankCardNo()
+ {
+ return bankCardNo;
+ }
+
+ public void setBankCardNo(String bankCardNo)
+ {
+ this.bankCardNo = bankCardNo;
+ }
+
+ public String getBankCity()
+ {
+ return bankCity;
+ }
+
+ public void setBankCity(String bankCity)
+ {
+ this.bankCity = bankCity;
+ }
+
public String getDelFlag()
{
return delFlag;
@@ -200,6 +369,19 @@
.append("registerDate", getRegisterDate())
.append("licenseValidPeriod", getLicenseValidPeriod())
.append("licenseImage", getLicenseImage())
+ .append("licenseValidType", getLicenseValidType())
+ .append("legalIdCardFront", getLegalIdCardFront())
+ .append("legalIdCardBack", getLegalIdCardBack())
+ .append("legalIdValidType", getLegalIdValidType())
+ .append("legalIdValidPeriod", getLegalIdValidPeriod())
+ .append("legalIdAddress", getLegalIdAddress())
+ .append("contactName", getContactName())
+ .append("contactPhone", getContactPhone())
+ .append("contactEmail", getContactEmail())
+ .append("payeeType", getPayeeType())
+ .append("bankCardImage", getBankCardImage())
+ .append("bankCardNo", getBankCardNo())
+ .append("bankCity", getBankCity())
.append("delFlag", getDelFlag())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertificationApply.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertificationApply.java
index fa35531..bc8b06c 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertificationApply.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertificationApply.java
@@ -56,11 +56,50 @@
@JsonFormat(pattern = "yyyy-MM-dd")
private Date registerDate;
- /** 营业执照有效期间 */
+ /** 营业执照有效期间(存文本,如 2024-01-01~2034-01-01) */
private String licenseValidPeriod;
/** 营业执照图片 */
private String licenseImage;
+
+ /** 营业执照有效期类型(1长期 2短期;长期时 licenseValidPeriod 为空) */
+ private String licenseValidType;
+
+ /** 法人身份证人像面图片 */
+ private String legalIdCardFront;
+
+ /** 法人身份证国徽面图片 */
+ private String legalIdCardBack;
+
+ /** 法人身份证有效期类型(1长期 2短期) */
+ private String legalIdValidType;
+
+ /** 法人身份证有效期(存文本,如 2024-01-01~2034-01-01) */
+ private String legalIdValidPeriod;
+
+ /** 法人身份证详细地址 */
+ private String legalIdAddress;
+
+ /** 负责人姓名 */
+ private String contactName;
+
+ /** 负责人联系电话 */
+ private String contactPhone;
+
+ /** 负责人邮箱 */
+ private String contactEmail;
+
+ /** 收款类型 */
+ private String payeeType;
+
+ /** 银行卡号码面照片 */
+ private String bankCardImage;
+
+ /** 银行卡号 */
+ private String bankCardNo;
+
+ /** 开户城市 */
+ private String bankCity;
/** 审核状态(0待审核 1已通过 2已驳回) */
private String auditStatus;
@@ -208,6 +247,136 @@
this.licenseImage = licenseImage;
}
+ public String getLicenseValidType()
+ {
+ return licenseValidType;
+ }
+
+ public void setLicenseValidType(String licenseValidType)
+ {
+ this.licenseValidType = licenseValidType;
+ }
+
+ public String getLegalIdCardFront()
+ {
+ return legalIdCardFront;
+ }
+
+ public void setLegalIdCardFront(String legalIdCardFront)
+ {
+ this.legalIdCardFront = legalIdCardFront;
+ }
+
+ public String getLegalIdCardBack()
+ {
+ return legalIdCardBack;
+ }
+
+ public void setLegalIdCardBack(String legalIdCardBack)
+ {
+ this.legalIdCardBack = legalIdCardBack;
+ }
+
+ public String getLegalIdValidType()
+ {
+ return legalIdValidType;
+ }
+
+ public void setLegalIdValidType(String legalIdValidType)
+ {
+ this.legalIdValidType = legalIdValidType;
+ }
+
+ public String getLegalIdValidPeriod()
+ {
+ return legalIdValidPeriod;
+ }
+
+ public void setLegalIdValidPeriod(String legalIdValidPeriod)
+ {
+ this.legalIdValidPeriod = legalIdValidPeriod;
+ }
+
+ public String getLegalIdAddress()
+ {
+ return legalIdAddress;
+ }
+
+ public void setLegalIdAddress(String legalIdAddress)
+ {
+ this.legalIdAddress = legalIdAddress;
+ }
+
+ public String getContactName()
+ {
+ return contactName;
+ }
+
+ public void setContactName(String contactName)
+ {
+ this.contactName = contactName;
+ }
+
+ public String getContactPhone()
+ {
+ return contactPhone;
+ }
+
+ public void setContactPhone(String contactPhone)
+ {
+ this.contactPhone = contactPhone;
+ }
+
+ public String getContactEmail()
+ {
+ return contactEmail;
+ }
+
+ public void setContactEmail(String contactEmail)
+ {
+ this.contactEmail = contactEmail;
+ }
+
+ public String getPayeeType()
+ {
+ return payeeType;
+ }
+
+ public void setPayeeType(String payeeType)
+ {
+ this.payeeType = payeeType;
+ }
+
+ public String getBankCardImage()
+ {
+ return bankCardImage;
+ }
+
+ public void setBankCardImage(String bankCardImage)
+ {
+ this.bankCardImage = bankCardImage;
+ }
+
+ public String getBankCardNo()
+ {
+ return bankCardNo;
+ }
+
+ public void setBankCardNo(String bankCardNo)
+ {
+ this.bankCardNo = bankCardNo;
+ }
+
+ public String getBankCity()
+ {
+ return bankCity;
+ }
+
+ public void setBankCity(String bankCity)
+ {
+ this.bankCity = bankCity;
+ }
+
public String getAuditStatus()
{
return auditStatus;
@@ -274,6 +443,19 @@
.append("registerDate", getRegisterDate())
.append("licenseValidPeriod", getLicenseValidPeriod())
.append("licenseImage", getLicenseImage())
+ .append("licenseValidType", getLicenseValidType())
+ .append("legalIdCardFront", getLegalIdCardFront())
+ .append("legalIdCardBack", getLegalIdCardBack())
+ .append("legalIdValidType", getLegalIdValidType())
+ .append("legalIdValidPeriod", getLegalIdValidPeriod())
+ .append("legalIdAddress", getLegalIdAddress())
+ .append("contactName", getContactName())
+ .append("contactPhone", getContactPhone())
+ .append("contactEmail", getContactEmail())
+ .append("payeeType", getPayeeType())
+ .append("bankCardImage", getBankCardImage())
+ .append("bankCardNo", getBankCardNo())
+ .append("bankCity", getBankCity())
.append("auditStatus", getAuditStatus())
.append("auditBy", getAuditBy())
.append("auditTime", getAuditTime())
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountUser.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountUser.java
index 67c8676..274d76f 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountUser.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountUser.java
@@ -101,6 +101,16 @@
@Excel(name = "认证状态", readConverterExp = "0=未认证,1=审核中,2=已认证,3=认证失败")
private String authStatus;
+ /**
+ * 企业/个体认证状态(0未认证 1审核中 2已认证 3认证失败),与个人 authStatus 相互独立。
+ *
+ * 刻意不加 @Excel:避免会员列表导出与个人认证状态混淆。
+ * 也刻意不进 insertAccountUser / updateAccountUser 的动态列——那两个是共享入口,
+ * 挂上去等于请求体里带个 companyAuthStatus 就能自造认证状态,绕过审核。
+ * 该列只能由 updateUserCompanyAuthStatus 改写,唯一调用方是企业认证审核流转。
+ */
+ private String companyAuthStatus;
+
/** 所属城市 */
@Excel(name = "所属城市")
private String city;
@@ -292,6 +302,16 @@
public void setAuthStatus(String authStatus)
{
this.authStatus = authStatus;
+ }
+
+ public String getCompanyAuthStatus()
+ {
+ return companyAuthStatus;
+ }
+
+ public void setCompanyAuthStatus(String companyAuthStatus)
+ {
+ this.companyAuthStatus = companyAuthStatus;
}
public String getCity()
@@ -535,6 +555,7 @@
.append("systemLevel", getSystemLevel())
.append("subjectType", getSubjectType())
.append("authStatus", getAuthStatus())
+ .append("companyAuthStatus", getCompanyAuthStatus())
.append("city", getCity())
.append("referrerId", getReferrerId())
.append("referrerBindTime", getReferrerBindTime())
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountCertificationApplyDto.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountCertificationApplyDto.java
new file mode 100644
index 0000000..d5381e7
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountCertificationApplyDto.java
@@ -0,0 +1,57 @@
+package com.ruoyi.user.domain.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+/**
+ * C端会员 主体认证申请提交入参(当前仅个人主体)
+ *
+ * 只承载「姓名」与「身份证号」两个字段:会员身份取自登录态(网关注入的 user_id 头),
+ * 不接受前端传入 userId,天然防水平越权;主体类型由服务端固定为「个人」,
+ * 不接受前端传入,避免越权提交企业主体绕开后续资料要求。
+ *
+ * 本品只做基础格式校验(姓名必填、身份证号结构校验),不做三方实名核验。
+ *
+ * @author hugray
+ */
+public class AccountCertificationApplyDto
+{
+ /** 姓名;必填,长度不超过 64(对应 account_certification_apply.real_name varchar(64)) */
+ @NotBlank(message = "姓名不能为空")
+ @Size(max = 64, message = "姓名过长")
+ private String realName;
+
+ /**
+ * 身份证号;必填,18 位。
+ *
+ * 正则只校验结构:6 位地址码 + 8 位出生日期(年份 18/19/20 开头,月 01~12,日 01~31)
+ * + 3 位顺序码 + 1 位校验位(数字或 X),不校验最后一位校验码的数学正确性,
+ * 也不做真实身份核验(后续由后台人工审核把关)。
+ */
+ @NotBlank(message = "身份证号不能为空")
+ @Size(max = 32, message = "身份证号过长")
+ @Pattern(regexp = "^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dXx]$",
+ message = "身份证号格式不正确")
+ private String idCard;
+
+ public String getRealName()
+ {
+ return realName;
+ }
+
+ public void setRealName(String realName)
+ {
+ this.realName = realName;
+ }
+
+ public String getIdCard()
+ {
+ return idCard;
+ }
+
+ public void setIdCard(String idCard)
+ {
+ this.idCard = idCard;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountUserAvatarDto.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountUserAvatarDto.java
new file mode 100644
index 0000000..62729ac
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountUserAvatarDto.java
@@ -0,0 +1,32 @@
+package com.ruoyi.user.domain.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+/**
+ * C端会员 头像更新入参
+ *
+ * 仅承载「头像地址」一个字段:会员身份取自登录态(网关注入的 user_id 头),
+ * 不接受前端传入 userId,天然防水平越权。
+ *
+ * 头像文件由小程序直传文件服务(POST /file/upload)后,将返回的 url 通过本入参写回。
+ *
+ * @author hugray
+ */
+public class AccountUserAvatarDto
+{
+ /** 头像地址(来自文件服务上传结果的 url);必填,长度不超过 255(对应 account_user.avatar varchar(255)) */
+ @NotBlank(message = "头像地址不能为空")
+ @Size(max = 255, message = "头像地址过长")
+ private String avatar;
+
+ public String getAvatar()
+ {
+ return avatar;
+ }
+
+ public void setAvatar(String avatar)
+ {
+ this.avatar = avatar;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/CompanyCertificationApplyDto.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/CompanyCertificationApplyDto.java
new file mode 100644
index 0000000..df1af91
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/CompanyCertificationApplyDto.java
@@ -0,0 +1,334 @@
+package com.ruoyi.user.domain.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+/**
+ * C端会员 企业/个体工商户认证申请提交入参
+ *
+ * 会员身份取自登录态(网关注入的 user_id 头),不接受前端传入 userId,天然防水平越权。
+ *
+ * 本期只做最基础的必填校验(各字段非空 / 枚举取值合法),不做任何格式核验与三方核验,
+ * 真实性由后台人工审核把关。
+ *
+ * 有效期字段的服务端兜底:「有效期类型」为「长期」时起止日期允许为空;为「短期」时
+ * 服务层会强制要求填写(见 AccountCertificationApplyServiceImpl#submitCompanyCertification)。
+ *
+ * @author hugray
+ */
+public class CompanyCertificationApplyDto
+{
+ /** 主体类型;必填,取 2个体工商户 / 3企业(对应 account_certification_apply.subject_type) */
+ @NotBlank(message = "请选择主体类型")
+ @Pattern(regexp = "^[23]$", message = "主体类型不合法")
+ private String subjectType;
+
+ /** 企业/个体名称;必填 */
+ @NotBlank(message = "请输入企业/个体名称")
+ @Size(max = 128, message = "企业/个体名称过长")
+ private String companyName;
+
+ /** 营业执照图片地址;必填 */
+ @NotBlank(message = "请上传营业执照")
+ @Size(max = 500, message = "营业执照图片地址过长")
+ private String licenseImage;
+
+ /** 统一社会信用代码;必填 */
+ @NotBlank(message = "请输入统一社会信用代码")
+ @Size(max = 32, message = "统一社会信用代码过长")
+ private String creditCode;
+
+ /** 营业执照有效期类型;必填,取 1长期 / 2短期 */
+ @NotBlank(message = "请选择营业执照有效期类型")
+ @Pattern(regexp = "^[12]$", message = "营业执照有效期类型不合法")
+ private String licenseValidType;
+
+ /** 营业执照有效期起止(文本);类型为「短期」时服务层必填 */
+ @Size(max = 32, message = "营业执照有效期过长")
+ private String licenseValidPeriod;
+
+ /** 营业执照注册地区;必填 */
+ @NotBlank(message = "请选择营业执照注册地区")
+ @Size(max = 128, message = "营业执照注册地区过长")
+ private String registeredRegion;
+
+ /** 营业执照详细地址;必填 */
+ @NotBlank(message = "请输入营业执照详细地址")
+ @Size(max = 255, message = "营业执照详细地址过长")
+ private String companyAddress;
+
+ /** 法人姓名;必填 */
+ @NotBlank(message = "请输入法人姓名")
+ @Size(max = 64, message = "法人姓名过长")
+ private String legalPerson;
+
+ /** 法人身份证人像面图片地址;必填 */
+ @NotBlank(message = "请上传法人身份证人像面")
+ @Size(max = 500, message = "法人身份证人像面图片地址过长")
+ private String legalIdCardFront;
+
+ /** 法人身份证国徽面图片地址;必填 */
+ @NotBlank(message = "请上传法人身份证国徽面")
+ @Size(max = 500, message = "法人身份证国徽面图片地址过长")
+ private String legalIdCardBack;
+
+ /** 法人身份证有效期类型;必填,取 1长期 / 2短期 */
+ @NotBlank(message = "请选择法人身份证有效期类型")
+ @Pattern(regexp = "^[12]$", message = "法人身份证有效期类型不合法")
+ private String legalIdValidType;
+
+ /** 法人身份证有效期起止(文本);类型为「短期」时服务层必填 */
+ @Size(max = 32, message = "法人身份证有效期过长")
+ private String legalIdValidPeriod;
+
+ /** 法人身份证详细地址;必填 */
+ @NotBlank(message = "请输入法人身份证详细地址")
+ @Size(max = 255, message = "法人身份证详细地址过长")
+ private String legalIdAddress;
+
+ /** 负责人姓名;必填 */
+ @NotBlank(message = "请输入负责人姓名")
+ @Size(max = 64, message = "负责人姓名过长")
+ private String contactName;
+
+ /** 负责人联系电话;必填(本期只校验非空,不校验格式) */
+ @NotBlank(message = "请输入负责人联系电话")
+ @Size(max = 20, message = "负责人联系电话过长")
+ private String contactPhone;
+
+ /** 负责人邮箱;必填(本期只校验非空,不校验格式) */
+ @NotBlank(message = "请输入负责人邮箱")
+ @Size(max = 128, message = "负责人邮箱过长")
+ private String contactEmail;
+
+ /** 收款类型;必填 */
+ @NotBlank(message = "请选择收款类型")
+ @Size(max = 16, message = "收款类型过长")
+ private String payeeType;
+
+ /** 银行卡号码面照片地址;必填 */
+ @NotBlank(message = "请上传银行卡号码面照片")
+ @Size(max = 500, message = "银行卡号码面照片地址过长")
+ private String bankCardImage;
+
+ /** 银行卡号;必填 */
+ @NotBlank(message = "请输入银行卡号")
+ @Size(max = 32, message = "银行卡号过长")
+ private String bankCardNo;
+
+ /** 开户城市;必填 */
+ @NotBlank(message = "请选择开户城市")
+ @Size(max = 128, message = "开户城市过长")
+ private String bankCity;
+
+ public String getSubjectType()
+ {
+ return subjectType;
+ }
+
+ public void setSubjectType(String subjectType)
+ {
+ this.subjectType = subjectType;
+ }
+
+ public String getCompanyName()
+ {
+ return companyName;
+ }
+
+ public void setCompanyName(String companyName)
+ {
+ this.companyName = companyName;
+ }
+
+ public String getLicenseImage()
+ {
+ return licenseImage;
+ }
+
+ public void setLicenseImage(String licenseImage)
+ {
+ this.licenseImage = licenseImage;
+ }
+
+ public String getCreditCode()
+ {
+ return creditCode;
+ }
+
+ public void setCreditCode(String creditCode)
+ {
+ this.creditCode = creditCode;
+ }
+
+ public String getLicenseValidType()
+ {
+ return licenseValidType;
+ }
+
+ public void setLicenseValidType(String licenseValidType)
+ {
+ this.licenseValidType = licenseValidType;
+ }
+
+ public String getLicenseValidPeriod()
+ {
+ return licenseValidPeriod;
+ }
+
+ public void setLicenseValidPeriod(String licenseValidPeriod)
+ {
+ this.licenseValidPeriod = licenseValidPeriod;
+ }
+
+ public String getRegisteredRegion()
+ {
+ return registeredRegion;
+ }
+
+ public void setRegisteredRegion(String registeredRegion)
+ {
+ this.registeredRegion = registeredRegion;
+ }
+
+ public String getCompanyAddress()
+ {
+ return companyAddress;
+ }
+
+ public void setCompanyAddress(String companyAddress)
+ {
+ this.companyAddress = companyAddress;
+ }
+
+ public String getLegalPerson()
+ {
+ return legalPerson;
+ }
+
+ public void setLegalPerson(String legalPerson)
+ {
+ this.legalPerson = legalPerson;
+ }
+
+ public String getLegalIdCardFront()
+ {
+ return legalIdCardFront;
+ }
+
+ public void setLegalIdCardFront(String legalIdCardFront)
+ {
+ this.legalIdCardFront = legalIdCardFront;
+ }
+
+ public String getLegalIdCardBack()
+ {
+ return legalIdCardBack;
+ }
+
+ public void setLegalIdCardBack(String legalIdCardBack)
+ {
+ this.legalIdCardBack = legalIdCardBack;
+ }
+
+ public String getLegalIdValidType()
+ {
+ return legalIdValidType;
+ }
+
+ public void setLegalIdValidType(String legalIdValidType)
+ {
+ this.legalIdValidType = legalIdValidType;
+ }
+
+ public String getLegalIdValidPeriod()
+ {
+ return legalIdValidPeriod;
+ }
+
+ public void setLegalIdValidPeriod(String legalIdValidPeriod)
+ {
+ this.legalIdValidPeriod = legalIdValidPeriod;
+ }
+
+ public String getLegalIdAddress()
+ {
+ return legalIdAddress;
+ }
+
+ public void setLegalIdAddress(String legalIdAddress)
+ {
+ this.legalIdAddress = legalIdAddress;
+ }
+
+ public String getContactName()
+ {
+ return contactName;
+ }
+
+ public void setContactName(String contactName)
+ {
+ this.contactName = contactName;
+ }
+
+ public String getContactPhone()
+ {
+ return contactPhone;
+ }
+
+ public void setContactPhone(String contactPhone)
+ {
+ this.contactPhone = contactPhone;
+ }
+
+ public String getContactEmail()
+ {
+ return contactEmail;
+ }
+
+ public void setContactEmail(String contactEmail)
+ {
+ this.contactEmail = contactEmail;
+ }
+
+ public String getPayeeType()
+ {
+ return payeeType;
+ }
+
+ public void setPayeeType(String payeeType)
+ {
+ this.payeeType = payeeType;
+ }
+
+ public String getBankCardImage()
+ {
+ return bankCardImage;
+ }
+
+ public void setBankCardImage(String bankCardImage)
+ {
+ this.bankCardImage = bankCardImage;
+ }
+
+ public String getBankCardNo()
+ {
+ return bankCardNo;
+ }
+
+ public void setBankCardNo(String bankCardNo)
+ {
+ this.bankCardNo = bankCardNo;
+ }
+
+ public String getBankCity()
+ {
+ return bankCity;
+ }
+
+ public void setBankCity(String bankCity)
+ {
+ this.bankCity = bankCity;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountCertificationStatusVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountCertificationStatusVo.java
new file mode 100644
index 0000000..7849194
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountCertificationStatusVo.java
@@ -0,0 +1,66 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * C端 实名认证状态回填 展示对象
+ *
+ * 取该会员最近一次认证申请的快照,供实名认证页进入时回填与交互:
+ * 认证中 / 认证成功时姓名与身份证号只读、提交按钮置灰;认证失败时在身份证号下方展示驳回原因。
+ *
+ * auditStatus 为空表示该会员从未提交过认证申请,前端按「未认证」处理(空表单可编辑)。
+ *
+ * @author hugray
+ */
+public class AccountCertificationStatusVo
+{
+ /** 姓名(回填用) */
+ private String realName;
+
+ /** 身份证号(回填用) */
+ private String idCard;
+
+ /** 审核状态(0待审核 1已通过 2已驳回;为空表示从未提交) */
+ private String auditStatus;
+
+ /** 驳回原因(审核状态为「已驳回」时有值) */
+ private String auditRemark;
+
+ public String getRealName()
+ {
+ return realName;
+ }
+
+ public void setRealName(String realName)
+ {
+ this.realName = realName;
+ }
+
+ public String getIdCard()
+ {
+ return idCard;
+ }
+
+ public void setIdCard(String idCard)
+ {
+ this.idCard = idCard;
+ }
+
+ public String getAuditStatus()
+ {
+ return auditStatus;
+ }
+
+ public void setAuditStatus(String auditStatus)
+ {
+ this.auditStatus = auditStatus;
+ }
+
+ public String getAuditRemark()
+ {
+ return auditRemark;
+ }
+
+ public void setAuditRemark(String auditRemark)
+ {
+ this.auditRemark = auditRemark;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountInviteeVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountInviteeVo.java
new file mode 100644
index 0000000..f55875e
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountInviteeVo.java
@@ -0,0 +1,104 @@
+package com.ruoyi.user.domain.vo;
+
+import java.math.BigDecimal;
+
+/**
+ * 被邀请人 VO(我的邀请页「我邀请的」列表元素)
+ *
+ * 仅暴露页面所需字段;手机号仅下发脱敏值(如 136****6666),不下发明文。
+ *
+ * @author hugray
+ */
+public class AccountInviteeVo
+{
+ /** 下级会员ID */
+ private Long userId;
+
+ /** 昵称(注册未采集,可为空;为空时前端回退展示脱敏手机号) */
+ private String nickname;
+
+ /** 头像地址(可为空,为空时前端回退默认图标) */
+ private String avatar;
+
+ /** 手机号(脱敏,形如 136****6666;仅供展示,不下发明文) */
+ private String phone;
+
+ /** 等级原值(C / L1~L4,源自 account_user.system_level;本期不做 normalize,原样下发) */
+ private String systemLevel;
+
+ /** 绑定日(yyyy-MM-dd,源自 referrer_bind_time;为空表示未记录,前端不展示) */
+ private String bindDate;
+
+ /** 合计消费(单位:元,源自 account_user.buy_amount_total;现恒为 0,待订单模块回填) */
+ private BigDecimal buyAmountTotal;
+
+ 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 getAvatar()
+ {
+ return avatar;
+ }
+
+ public void setAvatar(String avatar)
+ {
+ this.avatar = avatar;
+ }
+
+ public String getPhone()
+ {
+ return phone;
+ }
+
+ public void setPhone(String phone)
+ {
+ this.phone = phone;
+ }
+
+ public String getSystemLevel()
+ {
+ return systemLevel;
+ }
+
+ public void setSystemLevel(String systemLevel)
+ {
+ this.systemLevel = systemLevel;
+ }
+
+ public String getBindDate()
+ {
+ return bindDate;
+ }
+
+ public void setBindDate(String bindDate)
+ {
+ this.bindDate = bindDate;
+ }
+
+ public BigDecimal getBuyAmountTotal()
+ {
+ return buyAmountTotal;
+ }
+
+ public void setBuyAmountTotal(BigDecimal buyAmountTotal)
+ {
+ this.buyAmountTotal = buyAmountTotal;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountReferrerVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountReferrerVo.java
new file mode 100644
index 0000000..edbd005
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountReferrerVo.java
@@ -0,0 +1,89 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * 上级(推荐人)VO(我的邀请页「邀请我的」Tab 展示用)
+ *
+ * 无上级时各字段均为 null,由前端渲染空态;手机号仅下发脱敏值,不下发明文。
+ *
+ * @author hugray
+ */
+public class AccountReferrerVo
+{
+ /** 上级会员ID */
+ private Long userId;
+
+ /** 昵称(可为空) */
+ private String nickname;
+
+ /** 头像地址(可为空,为空时前端回退默认图标) */
+ private String avatar;
+
+ /** 手机号(脱敏,形如 136****6666;仅供展示,不下发明文) */
+ private String phone;
+
+ /** 等级原值(C / L1~L4,源自 account_user.system_level;本期不做 normalize,原样下发) */
+ private String systemLevel;
+
+ /** 绑定日(yyyy-MM-dd,源自 referrer_bind_time;为空表示未记录,前端不展示) */
+ private String bindDate;
+
+ 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 getAvatar()
+ {
+ return avatar;
+ }
+
+ public void setAvatar(String avatar)
+ {
+ this.avatar = avatar;
+ }
+
+ public String getPhone()
+ {
+ return phone;
+ }
+
+ public void setPhone(String phone)
+ {
+ this.phone = phone;
+ }
+
+ public String getSystemLevel()
+ {
+ return systemLevel;
+ }
+
+ public void setSystemLevel(String systemLevel)
+ {
+ this.systemLevel = systemLevel;
+ }
+
+ public String getBindDate()
+ {
+ return bindDate;
+ }
+
+ public void setBindDate(String bindDate)
+ {
+ this.bindDate = bindDate;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountUserInfoVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountUserInfoVo.java
index 25bd635..eaf30ab 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountUserInfoVo.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountUserInfoVo.java
@@ -25,6 +25,15 @@
/** 会员等级(对外展示字段,取值 C普通用户 / L1~L4会员,源自 account_user.system_level) */
private String memberLevel;
+ /** 认证状态(0未认证 1审核中 2已认证 3认证失败,源自 account_user.auth_status;用于个人中心「实名认证」行徽标回显) */
+ private String authStatus;
+
+ /**
+ * 企业/个体认证状态(0未认证 1审核中 2已认证 3认证失败,源自 account_user.company_auth_status),
+ * 与 authStatus 相互独立;用于个人中心「企业/个体户认证」行徽标回显。
+ */
+ private String companyAuthStatus;
+
/** 收益汇总(今日 / 本月 / 累计,单位:分) */
private EarningSummaryVo earnings;
@@ -78,6 +87,26 @@
this.memberLevel = memberLevel;
}
+ public String getAuthStatus()
+ {
+ return authStatus;
+ }
+
+ public void setAuthStatus(String authStatus)
+ {
+ this.authStatus = authStatus;
+ }
+
+ public String getCompanyAuthStatus()
+ {
+ return companyAuthStatus;
+ }
+
+ public void setCompanyAuthStatus(String companyAuthStatus)
+ {
+ this.companyAuthStatus = companyAuthStatus;
+ }
+
public EarningSummaryVo getEarnings()
{
return earnings;
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/CompanyCertificationStatusVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/CompanyCertificationStatusVo.java
new file mode 100644
index 0000000..2e6c4f8
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/CompanyCertificationStatusVo.java
@@ -0,0 +1,314 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * C端 企业/个体认证状态回填 展示对象
+ *
+ * 取该会员最近一次企业/个体认证申请(subject_type ∈ {2,3})的快照,供企业认证向导页进入时回填:
+ * 已提交的整份资料 + 审核状态 + 驳回原因。审核中时前端直接进入「资料审核中」占位页,
+ * 已驳回时回填资料并展示驳回原因,可修改重提。
+ *
+ * auditStatus 为空表示该会员从未提交过企业/个体认证申请,前端按「未认证」处理(空表单可编辑)。
+ *
+ * @author hugray
+ */
+public class CompanyCertificationStatusVo
+{
+ /** 主体类型(2个体工商户 3企业;回填用) */
+ private String subjectType;
+
+ /** 企业/个体名称(回填用) */
+ private String companyName;
+
+ /** 营业执照图片地址(回填用) */
+ private String licenseImage;
+
+ /** 统一社会信用代码(回填用) */
+ private String creditCode;
+
+ /** 营业执照有效期类型(1长期 2短期) */
+ private String licenseValidType;
+
+ /** 营业执照有效期起止(文本) */
+ private String licenseValidPeriod;
+
+ /** 营业执照注册地区(回填用) */
+ private String registeredRegion;
+
+ /** 营业执照详细地址(回填用) */
+ private String companyAddress;
+
+ /** 法人姓名(回填用) */
+ private String legalPerson;
+
+ /** 法人身份证人像面图片地址(回填用) */
+ private String legalIdCardFront;
+
+ /** 法人身份证国徽面图片地址(回填用) */
+ private String legalIdCardBack;
+
+ /** 法人身份证有效期类型(1长期 2短期) */
+ private String legalIdValidType;
+
+ /** 法人身份证有效期起止(文本) */
+ private String legalIdValidPeriod;
+
+ /** 法人身份证详细地址(回填用) */
+ private String legalIdAddress;
+
+ /** 负责人姓名(回填用) */
+ private String contactName;
+
+ /** 负责人联系电话(回填用) */
+ private String contactPhone;
+
+ /** 负责人邮箱(回填用) */
+ private String contactEmail;
+
+ /** 收款类型(回填用) */
+ private String payeeType;
+
+ /** 银行卡号码面照片地址(回填用) */
+ private String bankCardImage;
+
+ /** 银行卡号(回填用) */
+ private String bankCardNo;
+
+ /** 开户城市(回填用) */
+ private String bankCity;
+
+ /** 审核状态(0待审核 1已通过 2已驳回;为空表示从未提交) */
+ private String auditStatus;
+
+ /** 驳回原因(审核状态为「已驳回」时有值) */
+ private String auditRemark;
+
+ public String getSubjectType()
+ {
+ return subjectType;
+ }
+
+ public void setSubjectType(String subjectType)
+ {
+ this.subjectType = subjectType;
+ }
+
+ public String getCompanyName()
+ {
+ return companyName;
+ }
+
+ public void setCompanyName(String companyName)
+ {
+ this.companyName = companyName;
+ }
+
+ public String getLicenseImage()
+ {
+ return licenseImage;
+ }
+
+ public void setLicenseImage(String licenseImage)
+ {
+ this.licenseImage = licenseImage;
+ }
+
+ public String getCreditCode()
+ {
+ return creditCode;
+ }
+
+ public void setCreditCode(String creditCode)
+ {
+ this.creditCode = creditCode;
+ }
+
+ public String getLicenseValidType()
+ {
+ return licenseValidType;
+ }
+
+ public void setLicenseValidType(String licenseValidType)
+ {
+ this.licenseValidType = licenseValidType;
+ }
+
+ public String getLicenseValidPeriod()
+ {
+ return licenseValidPeriod;
+ }
+
+ public void setLicenseValidPeriod(String licenseValidPeriod)
+ {
+ this.licenseValidPeriod = licenseValidPeriod;
+ }
+
+ public String getRegisteredRegion()
+ {
+ return registeredRegion;
+ }
+
+ public void setRegisteredRegion(String registeredRegion)
+ {
+ this.registeredRegion = registeredRegion;
+ }
+
+ public String getCompanyAddress()
+ {
+ return companyAddress;
+ }
+
+ public void setCompanyAddress(String companyAddress)
+ {
+ this.companyAddress = companyAddress;
+ }
+
+ public String getLegalPerson()
+ {
+ return legalPerson;
+ }
+
+ public void setLegalPerson(String legalPerson)
+ {
+ this.legalPerson = legalPerson;
+ }
+
+ public String getLegalIdCardFront()
+ {
+ return legalIdCardFront;
+ }
+
+ public void setLegalIdCardFront(String legalIdCardFront)
+ {
+ this.legalIdCardFront = legalIdCardFront;
+ }
+
+ public String getLegalIdCardBack()
+ {
+ return legalIdCardBack;
+ }
+
+ public void setLegalIdCardBack(String legalIdCardBack)
+ {
+ this.legalIdCardBack = legalIdCardBack;
+ }
+
+ public String getLegalIdValidType()
+ {
+ return legalIdValidType;
+ }
+
+ public void setLegalIdValidType(String legalIdValidType)
+ {
+ this.legalIdValidType = legalIdValidType;
+ }
+
+ public String getLegalIdValidPeriod()
+ {
+ return legalIdValidPeriod;
+ }
+
+ public void setLegalIdValidPeriod(String legalIdValidPeriod)
+ {
+ this.legalIdValidPeriod = legalIdValidPeriod;
+ }
+
+ public String getLegalIdAddress()
+ {
+ return legalIdAddress;
+ }
+
+ public void setLegalIdAddress(String legalIdAddress)
+ {
+ this.legalIdAddress = legalIdAddress;
+ }
+
+ public String getContactName()
+ {
+ return contactName;
+ }
+
+ public void setContactName(String contactName)
+ {
+ this.contactName = contactName;
+ }
+
+ public String getContactPhone()
+ {
+ return contactPhone;
+ }
+
+ public void setContactPhone(String contactPhone)
+ {
+ this.contactPhone = contactPhone;
+ }
+
+ public String getContactEmail()
+ {
+ return contactEmail;
+ }
+
+ public void setContactEmail(String contactEmail)
+ {
+ this.contactEmail = contactEmail;
+ }
+
+ public String getPayeeType()
+ {
+ return payeeType;
+ }
+
+ public void setPayeeType(String payeeType)
+ {
+ this.payeeType = payeeType;
+ }
+
+ public String getBankCardImage()
+ {
+ return bankCardImage;
+ }
+
+ public void setBankCardImage(String bankCardImage)
+ {
+ this.bankCardImage = bankCardImage;
+ }
+
+ public String getBankCardNo()
+ {
+ return bankCardNo;
+ }
+
+ public void setBankCardNo(String bankCardNo)
+ {
+ this.bankCardNo = bankCardNo;
+ }
+
+ public String getBankCity()
+ {
+ return bankCity;
+ }
+
+ public void setBankCity(String bankCity)
+ {
+ this.bankCity = bankCity;
+ }
+
+ public String getAuditStatus()
+ {
+ return auditStatus;
+ }
+
+ public void setAuditStatus(String auditStatus)
+ {
+ this.auditStatus = auditStatus;
+ }
+
+ public String getAuditRemark()
+ {
+ return auditRemark;
+ }
+
+ public void setAuditRemark(String auditRemark)
+ {
+ this.auditRemark = auditRemark;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/InviteCodeVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/InviteCodeVo.java
new file mode 100644
index 0000000..223c7ec
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/InviteCodeVo.java
@@ -0,0 +1,24 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * 邀请码 VO(我的邀请页「邀请码」Tab 展示用)
+ *
+ * 仅下发本人邀请码原值;会员身份由服务端从登录态解析,不接受前端传入。
+ *
+ * @author hugray
+ */
+public class InviteCodeVo
+{
+ /** 本人邀请码(源自 account_user.my_referral_code,注册时生成,C 端会员必有) */
+ private String referralCode;
+
+ public String getReferralCode()
+ {
+ return referralCode;
+ }
+
+ public void setReferralCode(String referralCode)
+ {
+ this.referralCode = referralCode;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountCertificationApplyMapper.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountCertificationApplyMapper.java
index 12411ef..d2ad5e7 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountCertificationApplyMapper.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountCertificationApplyMapper.java
@@ -25,4 +25,37 @@
* 审核(只更新审核结果 4 列,条件带 audit_status='0' 作并发闸门)
*/
public int auditCertificationApply(AccountCertificationApply apply);
+
+ /**
+ * 新增认证申请(C端会员提交,回填自增主键作为申请号)
+ */
+ public int insertCertificationApply(AccountCertificationApply apply);
+
+ /**
+ * 统计该会员待审核的「个人」认证申请数(>0 即视为重复提交,用于提交前防重)
+ *
+ * 限 subject_type='1':个人与企业认证各自独立防重,互不干扰。
+ */
+ public int countPendingPersonalApplyByUserId(Long userId);
+
+ /**
+ * 统计该会员待审核的「企业/个体」认证申请数(>0 即视为重复提交,用于提交前防重)
+ *
+ * 限 subject_type in ('2','3')。
+ */
+ public int countPendingCompanyApplyByUserId(Long userId);
+
+ /**
+ * 查询该会员最近一次「个人」认证申请(C端进入认证页时回填资料与状态;无申请返回 null)
+ *
+ * 限 subject_type='1'。
+ */
+ public AccountCertificationApply selectLatestPersonalApplyByUserId(Long userId);
+
+ /**
+ * 查询该会员最近一次「企业/个体」认证申请(C端进入企业认证向导页时回填;无申请返回 null)
+ *
+ * 限 subject_type in ('2','3')。
+ */
+ public AccountCertificationApply selectLatestCompanyApplyByUserId(Long userId);
}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountUserMapper.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountUserMapper.java
index 0ccb430..37ca413 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountUserMapper.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountUserMapper.java
@@ -79,6 +79,24 @@
public int updateUserAuthStatus(AccountUser accountUser);
/**
+ * 修改企业/个体认证状态(只更新 company_auth_status 列,企业认证审核通过/驳回时回写)
+ *
+ * 与 updateUserAuthStatus 的区别:个人认证写 auth_status,企业/个体认证写 company_auth_status,
+ * 两者互不覆盖。审核通过时一并把 subject_type 同步为 2/3(由 XML 的 <if> 控制),
+ * 驳回则不动 subject_type——避免审核未通过就把会员标成企业主体。
+ */
+ public int updateUserCompanyAuthStatus(AccountUser accountUser);
+
+ /**
+ * 修改会员头像(只更新 avatar 一列)
+ *
+ * 刻意单开:avatar 若挂到共享的 updateAccountUser 上,后台编辑接口(/account/user)
+ * 的请求体带个 avatar 就能改任意会员头像。与 updateUserStatus / updateUserAuthStatus 同一套路,
+ * 头像只能由会员本人在 C 端接口里改写。
+ */
+ public int updateUserAvatar(AccountUser accountUser);
+
+ /**
* 修改服务商标识(只更新 provider_flag 列,服务商建档时置 '1')
*
* 刻意不做进 insertAccountUser / updateAccountUser 的动态列:那两个是共享入口,
@@ -88,6 +106,14 @@
public int updateUserProviderFlag(AccountUser accountUser);
/**
+ * 查询某会员的直接下级(被邀请人)列表
+ *
+ * 按 referrer_id 过滤(命中 idx_account_user_referrer)+ referrer_bind_time 倒序;
+ * 不挂 join,避免干扰 PageHelper 的 count SQL。
+ */
+ public List<AccountUser> selectInvitees(Long referrerId);
+
+ /**
* 搜索可绑定为城市公司档案关联用户的会员(限 L4,最多 20 条)
*
* keyword 与 keywordId 由服务层同时算好:keywordId 仅在 keyword 为纯数字时非空,
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountCertificationApplyService.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountCertificationApplyService.java
index 0340896..cdd2a59 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountCertificationApplyService.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountCertificationApplyService.java
@@ -1,8 +1,11 @@
package com.ruoyi.user.service;
import java.util.List;
+import com.ruoyi.user.domain.dto.CompanyCertificationApplyDto;
import com.ruoyi.user.domain.vo.AccountCertificationApplyDetailVo;
import com.ruoyi.user.domain.vo.AccountCertificationApplyVo;
+import com.ruoyi.user.domain.vo.AccountCertificationStatusVo;
+import com.ruoyi.user.domain.vo.CompanyCertificationStatusVo;
/**
* 会员主体认证申请 业务层
@@ -30,4 +33,48 @@
* @return 影响行数
*/
public int auditCertificationApply(Long id, String auditStatus, String auditRemark);
+
+ /**
+ * C端会员提交个人主体认证申请
+ *
+ * 只做基础校验(姓名非空、身份证号非空),不做三方实名核验;提交后落一条
+ * account_certification_apply(个人主体、待审核),并把会员认证状态联动为「审核中」。
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @param realName 姓名
+ * @param idCard 身份证号
+ */
+ public void submitPersonalCertification(Long userId, String realName, String idCard);
+
+ /**
+ * 查询该会员当前认证状态(C端进入认证页回填用)
+ *
+ * 取最近一次申请快照映射为出参;从未提交过申请时返回各字段为空的出参对象(不返回 null)。
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @return 认证状态出参(realName / idCard / auditStatus / auditRemark)
+ */
+ public AccountCertificationStatusVo getCertificationStatus(Long userId);
+
+ /**
+ * C端会员提交企业/个体工商户认证申请
+ *
+ * 只做基础必填校验(各字段非空 / 枚举取值合法),不做格式与三方核验;提交后落一条
+ * account_certification_apply(主体类型取入参 2/3、待审核),并把会员企业认证状态联动为「审核中」。
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @param dto 企业/个体认证资料(含基础必填校验)
+ */
+ public void submitCompanyCertification(Long userId, CompanyCertificationApplyDto dto);
+
+ /**
+ * 查询该会员当前企业/个体认证状态(C端进入向导页回填用)
+ *
+ * 取最近一次企业/个体申请(subject_type ∈ {2,3})快照映射为出参;从未提交过时
+ * 返回各字段为空的出参对象(不返回 null),前端据此渲染空表单。
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @return 企业/个体认证状态出参(整份资料 + auditStatus + auditRemark)
+ */
+ public CompanyCertificationStatusVo getCompanyCertificationStatus(Long userId);
}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java
index 9691fa2..aacd34d 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java
@@ -5,7 +5,10 @@
import com.ruoyi.user.domain.AccountUser;
import com.ruoyi.user.domain.vo.AccountUserDetailVo;
import com.ruoyi.user.domain.vo.MemberOptionVo;
+import com.ruoyi.user.domain.vo.AccountInviteeVo;
+import com.ruoyi.user.domain.vo.AccountReferrerVo;
import com.ruoyi.user.domain.vo.AccountUserInfoVo;
+import com.ruoyi.user.domain.vo.InviteCodeVo;
/**
* 会员用户 业务层
@@ -114,4 +117,41 @@
* @return 会员信息展示对象;会员不存在时抛业务异常
*/
public AccountUserInfoVo getAccountUserInfo(Long userId);
+
+ /**
+ * 更新C端会员头像
+ *
+ * 头像文件由小程序直传文件服务后,将返回的 url 写回 account_user.avatar;
+ * 只更新 avatar 一列,不触碰其它资料。
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @param avatarUrl 头像地址(来自文件服务上传结果)
+ */
+ public void updateAvatar(Long userId, String avatarUrl);
+
+ /**
+ * 查询本人邀请码(我的邀请页「邀请码」Tab)
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @return 邀请码展示对象;会员不存在时抛业务异常
+ */
+ public InviteCodeVo getInviteCode(Long userId);
+
+ /**
+ * 查询本人直接下级(被邀请人)列表(我的邀请页「我邀请的」Tab)
+ *
+ * 按绑定时间倒序(最新在前);手机号脱敏下发;配合 Controller 的分页生效。
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @return 被邀请人列表(无下级时返回空集合)
+ */
+ public List<AccountInviteeVo> getInvitees(Long userId);
+
+ /**
+ * 查询本人上级(推荐人)信息(我的邀请页「邀请我的」Tab)
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @return 上级展示对象;无上级时返回各字段为 null 的对象(前端渲染空态)
+ */
+ public AccountReferrerVo getReferrer(Long userId);
}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImpl.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImpl.java
index ed932dd..379c627 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImpl.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImpl.java
@@ -10,8 +10,11 @@
import com.ruoyi.user.domain.AccountCertification;
import com.ruoyi.user.domain.AccountCertificationApply;
import com.ruoyi.user.domain.AccountUser;
+import com.ruoyi.user.domain.dto.CompanyCertificationApplyDto;
import com.ruoyi.user.domain.vo.AccountCertificationApplyDetailVo;
import com.ruoyi.user.domain.vo.AccountCertificationApplyVo;
+import com.ruoyi.user.domain.vo.AccountCertificationStatusVo;
+import com.ruoyi.user.domain.vo.CompanyCertificationStatusVo;
import com.ruoyi.user.mapper.AccountCertificationApplyMapper;
import com.ruoyi.user.mapper.AccountCertificationMapper;
import com.ruoyi.user.mapper.AccountUserMapper;
@@ -31,11 +34,32 @@
/** 审核结果:驳回 */
private static final String AUDIT_REJECT = "2";
+ /** 审核状态:待审核(提交后的初始状态) */
+ private static final String AUDIT_PENDING = "0";
+
+ /** account_user.auth_status:审核中 */
+ private static final String AUTH_AUDITING = "1";
+
/** account_user.auth_status:已认证 */
private static final String AUTH_VERIFIED = "2";
/** account_user.auth_status:认证失败 */
private static final String AUTH_FAILED = "3";
+
+ /** 主体类型:个人(C端实名认证固定为该类型) */
+ private static final String SUBJECT_PERSONAL = "1";
+
+ /** 主体类型:个体工商户 */
+ private static final String SUBJECT_INDIVIDUAL = "2";
+
+ /** 主体类型:企业 */
+ private static final String SUBJECT_COMPANY = "3";
+
+ /** 有效期类型:长期(此时起止日期可空) */
+ private static final String VALID_TYPE_LONG_TERM = "1";
+
+ /** 有效期类型:短期(此时起止日期必填) */
+ private static final String VALID_TYPE_SHORT_TERM = "2";
@Autowired
private AccountCertificationApplyMapper certificationApplyMapper;
@@ -102,18 +126,221 @@
throw new ServiceException("该申请已审核,不能重复处理");
}
- // 同步到 account_user.auth_status:会员列表的「认证状态」列取的就是它
+ // 同步认证状态到 account_user:个人写 auth_status,企业/个体写 company_auth_status,两列互不覆盖。
AccountUser user = new AccountUser();
user.setUserId(apply.getUserId());
- user.setAuthStatus(pass ? AUTH_VERIFIED : AUTH_FAILED);
user.setUpdateBy(operator);
- accountUserMapper.updateUserAuthStatus(user);
+ if (isCompanySubject(apply.getSubjectType()))
+ {
+ user.setCompanyAuthStatus(pass ? AUTH_VERIFIED : AUTH_FAILED);
+ // 审核通过时才把主体类型落为 2/3;驳回不动,避免未通过审核就被标成企业主体
+ if (pass)
+ {
+ user.setSubjectType(apply.getSubjectType());
+ }
+ accountUserMapper.updateUserCompanyAuthStatus(user);
+ }
+ else
+ {
+ user.setAuthStatus(pass ? AUTH_VERIFIED : AUTH_FAILED);
+ accountUserMapper.updateUserAuthStatus(user);
+ }
if (pass)
{
saveCertificationSnapshot(apply, operator);
}
return 1;
+ }
+
+ @Override
+ @Transactional
+ public void submitPersonalCertification(Long userId, String realName, String idCard)
+ {
+ // STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属写入)
+ if (StringUtils.isNull(userId))
+ {
+ throw new ServiceException("提交实名认证失败");
+ }
+ // STEP 2: 姓名/身份证号二次校验:Controller 已用 @Validated 拦过一遍,
+ // 这里是服务层兜底,防止绕过 Controller 直接调用服务时落脏数据
+ if (StringUtils.isEmpty(realName) || StringUtils.isEmpty(idCard))
+ {
+ throw new ServiceException("姓名与身份证号不能为空");
+ }
+ // STEP 3: 防重闸门——该会员已有待审核申请时拒绝,避免后台审核列表堆积重复单
+ if (certificationApplyMapper.countPendingPersonalApplyByUserId(userId) > 0)
+ {
+ throw new ServiceException("已有实名认证申请正在审核中,请勿重复提交");
+ }
+ // STEP 4: 组装申请快照并落库(主体类型固定「个人」;审核状态置「待审核」;
+ // 身份证号统一转大写,兼容末位小写 x)
+ String operator = SecurityUtils.getUsername();
+ AccountCertificationApply apply = new AccountCertificationApply();
+ apply.setUserId(userId);
+ apply.setSubjectType(SUBJECT_PERSONAL);
+ apply.setRealName(realName.trim());
+ apply.setIdCard(idCard.trim().toUpperCase());
+ apply.setAuditStatus(AUDIT_PENDING);
+ apply.setCreateBy(operator);
+ if (certificationApplyMapper.insertCertificationApply(apply) <= 0)
+ {
+ throw new ServiceException("提交实名认证失败,请稍后重试");
+ }
+ // STEP 5: 联动 account_user.auth_status 为「审核中」,个人中心与会员列表据此显示
+ AccountUser user = new AccountUser();
+ user.setUserId(userId);
+ user.setAuthStatus(AUTH_AUDITING);
+ user.setUpdateBy(operator);
+ accountUserMapper.updateUserAuthStatus(user);
+ }
+
+ @Override
+ public AccountCertificationStatusVo getCertificationStatus(Long userId)
+ {
+ AccountCertificationStatusVo vo = new AccountCertificationStatusVo();
+ // 登录态缺失视为从未提交:返回空对象,前端按「未认证」渲染空表单(不返回 null)
+ if (StringUtils.isNull(userId))
+ {
+ return vo;
+ }
+ AccountCertificationApply apply = certificationApplyMapper.selectLatestPersonalApplyByUserId(userId);
+ if (StringUtils.isNull(apply))
+ {
+ return vo;
+ }
+ // 回填最近一次申请的资料与状态:认证失败时前端据 auditRemark 展示驳回原因
+ vo.setRealName(apply.getRealName());
+ vo.setIdCard(apply.getIdCard());
+ vo.setAuditStatus(apply.getAuditStatus());
+ vo.setAuditRemark(apply.getAuditRemark());
+ return vo;
+ }
+
+ @Override
+ @Transactional
+ public void submitCompanyCertification(Long userId, CompanyCertificationApplyDto dto)
+ {
+ // STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属写入)
+ if (StringUtils.isNull(userId))
+ {
+ throw new ServiceException("提交企业认证失败");
+ }
+ // STEP 2: 入参兜底:Controller 已用 @Validated 拦过,这里防止绕过 Controller 直接落脏数据
+ if (StringUtils.isNull(dto))
+ {
+ throw new ServiceException("认证资料不能为空");
+ }
+ // STEP 3: 有效期必填兜底——「短期」必须填起止日期,「长期」允许为空(前端此时隐藏该输入)
+ checkValidPeriod(dto.getLicenseValidType(), dto.getLicenseValidPeriod(), "营业执照");
+ checkValidPeriod(dto.getLegalIdValidType(), dto.getLegalIdValidPeriod(), "法人身份证");
+ // STEP 4: 防重闸门——该会员已有待审核的企业申请时拒绝,避免后台审核列表堆积重复单
+ if (certificationApplyMapper.countPendingCompanyApplyByUserId(userId) > 0)
+ {
+ throw new ServiceException("已有企业认证申请正在审核中,请勿重复提交");
+ }
+ // STEP 5: 组装申请快照(主体类型取入参 2/3;审核状态置「待审核」)
+ String operator = SecurityUtils.getUsername();
+ AccountCertificationApply apply = new AccountCertificationApply();
+ apply.setUserId(userId);
+ apply.setSubjectType(dto.getSubjectType());
+ apply.setCompanyName(dto.getCompanyName().trim());
+ apply.setLicenseImage(dto.getLicenseImage());
+ apply.setCreditCode(dto.getCreditCode().trim());
+ apply.setLicenseValidType(dto.getLicenseValidType());
+ apply.setLicenseValidPeriod(dto.getLicenseValidPeriod());
+ apply.setRegisteredRegion(dto.getRegisteredRegion());
+ apply.setCompanyAddress(dto.getCompanyAddress());
+ apply.setLegalPerson(dto.getLegalPerson().trim());
+ apply.setLegalIdCardFront(dto.getLegalIdCardFront());
+ apply.setLegalIdCardBack(dto.getLegalIdCardBack());
+ apply.setLegalIdValidType(dto.getLegalIdValidType());
+ apply.setLegalIdValidPeriod(dto.getLegalIdValidPeriod());
+ apply.setLegalIdAddress(dto.getLegalIdAddress());
+ apply.setContactName(dto.getContactName().trim());
+ apply.setContactPhone(dto.getContactPhone().trim());
+ apply.setContactEmail(dto.getContactEmail().trim());
+ apply.setPayeeType(dto.getPayeeType());
+ apply.setBankCardImage(dto.getBankCardImage());
+ apply.setBankCardNo(dto.getBankCardNo().trim());
+ apply.setBankCity(dto.getBankCity());
+ apply.setAuditStatus(AUDIT_PENDING);
+ apply.setCreateBy(operator);
+ // STEP 6: 落库
+ if (certificationApplyMapper.insertCertificationApply(apply) <= 0)
+ {
+ throw new ServiceException("提交企业认证失败,请稍后重试");
+ }
+ // STEP 7: 联动 account_user.company_auth_status 为「审核中」(与个人 auth_status 相互独立)
+ AccountUser user = new AccountUser();
+ user.setUserId(userId);
+ user.setCompanyAuthStatus(AUTH_AUDITING);
+ user.setUpdateBy(operator);
+ accountUserMapper.updateUserCompanyAuthStatus(user);
+ }
+
+ @Override
+ public CompanyCertificationStatusVo getCompanyCertificationStatus(Long userId)
+ {
+ CompanyCertificationStatusVo vo = new CompanyCertificationStatusVo();
+ // 登录态缺失视为从未提交:返回空对象,前端按「未认证」渲染空表单(不返回 null)
+ if (StringUtils.isNull(userId))
+ {
+ return vo;
+ }
+ AccountCertificationApply apply = certificationApplyMapper.selectLatestCompanyApplyByUserId(userId);
+ if (StringUtils.isNull(apply))
+ {
+ return vo;
+ }
+ // 一次性回填整份资料与审核状态:审核中进占位页、已驳回展示原因并允许修改重提
+ vo.setSubjectType(apply.getSubjectType());
+ vo.setCompanyName(apply.getCompanyName());
+ vo.setLicenseImage(apply.getLicenseImage());
+ vo.setCreditCode(apply.getCreditCode());
+ vo.setLicenseValidType(apply.getLicenseValidType());
+ vo.setLicenseValidPeriod(apply.getLicenseValidPeriod());
+ vo.setRegisteredRegion(apply.getRegisteredRegion());
+ vo.setCompanyAddress(apply.getCompanyAddress());
+ vo.setLegalPerson(apply.getLegalPerson());
+ vo.setLegalIdCardFront(apply.getLegalIdCardFront());
+ vo.setLegalIdCardBack(apply.getLegalIdCardBack());
+ vo.setLegalIdValidType(apply.getLegalIdValidType());
+ vo.setLegalIdValidPeriod(apply.getLegalIdValidPeriod());
+ vo.setLegalIdAddress(apply.getLegalIdAddress());
+ vo.setContactName(apply.getContactName());
+ vo.setContactPhone(apply.getContactPhone());
+ vo.setContactEmail(apply.getContactEmail());
+ vo.setPayeeType(apply.getPayeeType());
+ vo.setBankCardImage(apply.getBankCardImage());
+ vo.setBankCardNo(apply.getBankCardNo());
+ vo.setBankCity(apply.getBankCity());
+ vo.setAuditStatus(apply.getAuditStatus());
+ vo.setAuditRemark(apply.getAuditRemark());
+ return vo;
+ }
+
+ /**
+ * 判定主体类型是否为企业/个体工商户(subject_type ∈ {2,3})。
+ */
+ private boolean isCompanySubject(String subjectType)
+ {
+ return SUBJECT_INDIVIDUAL.equals(subjectType) || SUBJECT_COMPANY.equals(subjectType);
+ }
+
+ /**
+ * 有效期必填兜底:类型为「短期」时,起止日期不得为空。
+ *
+ * @param validType 有效期类型(1长期 2短期)
+ * @param validPeriod 有效期起止(文本)
+ * @param label 字段中文名,用于拼接错误提示
+ */
+ private void checkValidPeriod(String validType, String validPeriod, String label)
+ {
+ if (VALID_TYPE_SHORT_TERM.equals(validType) && StringUtils.isEmpty(validPeriod))
+ {
+ throw new ServiceException("请选择" + label + "有效期起止日期");
+ }
}
/**
@@ -144,6 +371,19 @@
certification.setRegisterDate(apply.getRegisterDate());
certification.setLicenseValidPeriod(apply.getLicenseValidPeriod());
certification.setLicenseImage(apply.getLicenseImage());
+ certification.setLicenseValidType(apply.getLicenseValidType());
+ certification.setLegalIdCardFront(apply.getLegalIdCardFront());
+ certification.setLegalIdCardBack(apply.getLegalIdCardBack());
+ certification.setLegalIdValidType(apply.getLegalIdValidType());
+ certification.setLegalIdValidPeriod(apply.getLegalIdValidPeriod());
+ certification.setLegalIdAddress(apply.getLegalIdAddress());
+ certification.setContactName(apply.getContactName());
+ certification.setContactPhone(apply.getContactPhone());
+ certification.setContactEmail(apply.getContactEmail());
+ certification.setPayeeType(apply.getPayeeType());
+ certification.setBankCardImage(apply.getBankCardImage());
+ certification.setBankCardNo(apply.getBankCardNo());
+ certification.setBankCity(apply.getBankCity());
certification.setCreateBy(operator);
certification.setRemark("由认证申请(申请号 " + apply.getId() + ")审核通过生成");
certificationMapper.insertCertification(certification);
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java
index 47b2cec..f939d2e 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java
@@ -1,5 +1,6 @@
package com.ruoyi.user.service.impl;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.security.SecureRandom;
@@ -13,14 +14,16 @@
import com.ruoyi.common.core.constant.UserConstants;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.utils.DateUtils;
-import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.user.domain.AccountAddress;
import com.ruoyi.user.config.UniversalCodeProperties;
import com.ruoyi.user.constant.ReferralConstants;
import com.ruoyi.user.domain.AccountUser;
+import com.ruoyi.user.domain.vo.AccountInviteeVo;
+import com.ruoyi.user.domain.vo.AccountReferrerVo;
import com.ruoyi.user.domain.vo.AccountUserDetailVo;
+import com.ruoyi.user.domain.vo.InviteCodeVo;
import com.ruoyi.user.domain.vo.MemberOptionVo;
import com.ruoyi.user.mapper.AccountAddressMapper;
import com.ruoyi.user.mapper.AccountAgreementMapper;
@@ -66,6 +69,12 @@
/** 推荐码随机源(SecureRandom 线程安全,避免每次创建) */
private static final SecureRandom RANDOM = new SecureRandom();
+
+ /** 头像地址最大长度(对应 account_user.avatar varchar(255),超长直接拒绝避免截断) */
+ private static final int AVATAR_MAX_LENGTH = 255;
+
+ /** 认证状态:未认证(auth_status 为空时的兜底值) */
+ private static final String AUTH_STATUS_NONE = "0";
@Override
public AccountUser selectAccountUserById(Long userId)
@@ -364,11 +373,142 @@
vo.setPhone(maskPhone(accountUser.getPhone()));
vo.setAvatar(accountUser.getAvatar());
vo.setMemberLevel(MemberLevelEnum.normalize(accountUser.getSystemLevel()));
+ // 认证状态为空(历史数据)时兜底为「未认证」,避免前端徽标无值可判
+ vo.setAuthStatus(StringUtils.isEmpty(accountUser.getAuthStatus())
+ ? AUTH_STATUS_NONE : accountUser.getAuthStatus());
+ // 企业/个体认证状态同样兜底为「未认证」(与个人认证状态相互独立)
+ vo.setCompanyAuthStatus(StringUtils.isEmpty(accountUser.getCompanyAuthStatus())
+ ? AUTH_STATUS_NONE : accountUser.getCompanyAuthStatus());
vo.setEarnings(earnings);
// STEP 5: 返回个人中心展示数据
return vo;
}
+ @Override
+ public void updateAvatar(Long userId, String avatarUrl)
+ {
+ // STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属更新)
+ if (StringUtils.isNull(userId))
+ {
+ throw new ServiceException("更新头像失败");
+ }
+ // STEP 2: 校验头像地址非空
+ if (StringUtils.isEmpty(avatarUrl))
+ {
+ throw new ServiceException("头像地址不能为空");
+ }
+ // STEP 3: 校验长度,超出列长直接拒绝,避免落库截断
+ if (avatarUrl.length() > AVATAR_MAX_LENGTH)
+ {
+ throw new ServiceException("头像地址过长");
+ }
+ // STEP 4: 只更新 avatar 一列(单列语句,不触碰其它资料)
+ AccountUser update = new AccountUser();
+ update.setUserId(userId);
+ update.setAvatar(avatarUrl);
+ update.setUpdateBy(SecurityUtils.getUsername());
+ // STEP 5: 影响行数为 0 说明会员不存在或已删除(SQL 自带 del_flag='0' 过滤)
+ if (accountUserMapper.updateUserAvatar(update) == 0)
+ {
+ throw new ServiceException("更新头像失败");
+ }
+ }
+
+ @Override
+ public InviteCodeVo getInviteCode(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: 组装展示对象(邀请码注册时生成,C 端会员必有)
+ InviteCodeVo vo = new InviteCodeVo();
+ vo.setReferralCode(accountUser.getMyReferralCode());
+ return vo;
+ }
+
+ @Override
+ public List<AccountInviteeVo> getInvitees(Long userId)
+ {
+ // STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属查询)
+ if (StringUtils.isNull(userId))
+ {
+ throw new ServiceException("获取邀请列表失败");
+ }
+ // STEP 2: 按 referrer_id 查询直接下级,SQL 已按绑定时间倒序(配合 Controller 分页)
+ List<AccountUser> invitees = accountUserMapper.selectInvitees(userId);
+ // STEP 3: 逐条组装展示对象;手机号脱敏、绑定日格式化,等级原值直出
+ List<AccountInviteeVo> result = new ArrayList<>(invitees.size());
+ for (AccountUser invitee : invitees)
+ {
+ AccountInviteeVo vo = new AccountInviteeVo();
+ vo.setUserId(invitee.getUserId());
+ vo.setNickname(invitee.getNickname());
+ vo.setAvatar(invitee.getAvatar());
+ vo.setPhone(maskPhone(invitee.getPhone()));
+ vo.setSystemLevel(invitee.getSystemLevel());
+ vo.setBindDate(formatBindDate(invitee.getReferrerBindTime()));
+ vo.setBuyAmountTotal(invitee.getBuyAmountTotal());
+ result.add(vo);
+ }
+ return result;
+ }
+
+ @Override
+ public AccountReferrerVo getReferrer(Long userId)
+ {
+ // STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属查询)
+ if (StringUtils.isNull(userId))
+ {
+ throw new ServiceException("获取上级信息失败");
+ }
+ // STEP 2: 查询会员本人,取其 referrer_id 与绑定时间
+ AccountUser self = accountUserMapper.selectAccountUserById(userId);
+ if (StringUtils.isNull(self))
+ {
+ throw new ServiceException("获取上级信息失败");
+ }
+ AccountReferrerVo vo = new AccountReferrerVo();
+ Long referrerId = self.getReferrerId();
+ // STEP 3: 无上级(命中通用推荐码注册或历史数据)时返回空对象,前端渲染空态
+ if (StringUtils.isNull(referrerId))
+ {
+ return vo;
+ }
+ // STEP 4: 按主键查上级;上级已被逻辑删除时同样返回空对象
+ AccountUser referrer = accountUserMapper.selectAccountUserById(referrerId);
+ if (StringUtils.isNull(referrer))
+ {
+ return vo;
+ }
+ // STEP 5: 组装展示对象;绑定时间取本人记录上的 referrer_bind_time
+ vo.setUserId(referrer.getUserId());
+ vo.setNickname(referrer.getNickname());
+ vo.setAvatar(referrer.getAvatar());
+ vo.setPhone(maskPhone(referrer.getPhone()));
+ vo.setSystemLevel(referrer.getSystemLevel());
+ vo.setBindDate(formatBindDate(self.getReferrerBindTime()));
+ return vo;
+ }
+
+ /**
+ * 绑定时间格式化(yyyy-MM-dd)
+ *
+ * @param bindTime 绑定时间,可为空
+ * @return 格式化后的日期字符串;入参为空时返回 null
+ */
+ private String formatBindDate(Date bindTime)
+ {
+ return StringUtils.isNull(bindTime) ? null : DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD, bindTime);
+ }
+
/**
* 判定是否为通用推荐码
*
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationApplyMapper.xml b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationApplyMapper.xml
index 951a55c..0734737 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationApplyMapper.xml
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationApplyMapper.xml
@@ -18,6 +18,19 @@
<result property="registerDate" column="register_date" />
<result property="licenseValidPeriod" column="license_valid_period" />
<result property="licenseImage" column="license_image" />
+ <result property="licenseValidType" column="license_valid_type" />
+ <result property="legalIdCardFront" column="legal_id_card_front" />
+ <result property="legalIdCardBack" column="legal_id_card_back" />
+ <result property="legalIdValidType" column="legal_id_valid_type" />
+ <result property="legalIdValidPeriod" column="legal_id_valid_period"/>
+ <result property="legalIdAddress" column="legal_id_address" />
+ <result property="contactName" column="contact_name" />
+ <result property="contactPhone" column="contact_phone" />
+ <result property="contactEmail" column="contact_email" />
+ <result property="payeeType" column="payee_type" />
+ <result property="bankCardImage" column="bank_card_image" />
+ <result property="bankCardNo" column="bank_card_no" />
+ <result property="bankCity" column="bank_city" />
<result property="auditStatus" column="audit_status" />
<result property="auditBy" column="audit_by" />
<result property="auditTime" column="audit_time" />
@@ -38,6 +51,9 @@
<sql id="selectCertificationApplyVo">
select id, user_id, subject_type, real_name, id_card, company_name, credit_code, legal_person,
registered_region, company_address, register_date, license_valid_period, license_image,
+ license_valid_type, legal_id_card_front, legal_id_card_back, legal_id_valid_type,
+ legal_id_valid_period, legal_id_address, contact_name, contact_phone, contact_email,
+ payee_type, bank_card_image, bank_card_no, bank_city,
audit_status, audit_by, audit_time, audit_remark,
del_flag, create_by, create_time, update_by, update_time, remark
from account_certification_apply
@@ -53,6 +69,9 @@
select a.id, a.user_id, a.subject_type, a.real_name, a.id_card, a.company_name, a.credit_code,
a.legal_person, a.registered_region, a.company_address, a.register_date,
a.license_valid_period, a.license_image,
+ a.license_valid_type, a.legal_id_card_front, a.legal_id_card_back, a.legal_id_valid_type,
+ a.legal_id_valid_period, a.legal_id_address, a.contact_name, a.contact_phone, a.contact_email,
+ a.payee_type, a.bank_card_image, a.bank_card_no, a.bank_city,
a.audit_status, a.audit_by, a.audit_time, a.audit_remark,
a.del_flag, a.create_by, a.create_time, a.update_by, a.update_time, a.remark,
u.phone, u.system_level
@@ -113,4 +132,111 @@
where id = #{id} and del_flag = '0' and audit_status = '0'
</update>
+ <!--
+ C端会员提交认证申请:把资料整份快照落库,审核结果/审核人/审核时间此时为空,
+ 由后台审核时回写。id 为自增主键,用 useGeneratedKeys 回填后作为申请号,
+ 后台列表「申请号」列展示的就是它。
+ 各列均带 <if>:个人主体只填姓名与身份证号,企业字段留空即不落列。
+ -->
+ <insert id="insertCertificationApply" parameterType="AccountCertificationApply" useGeneratedKeys="true" keyProperty="id">
+ insert into account_certification_apply(
+ <if test="userId != null">user_id,</if>
+ <if test="subjectType != null">subject_type,</if>
+ <if test="realName != null">real_name,</if>
+ <if test="idCard != null">id_card,</if>
+ <if test="companyName != null">company_name,</if>
+ <if test="creditCode != null">credit_code,</if>
+ <if test="legalPerson != null">legal_person,</if>
+ <if test="registeredRegion != null">registered_region,</if>
+ <if test="companyAddress != null">company_address,</if>
+ <if test="registerDate != null">register_date,</if>
+ <if test="licenseValidPeriod != null">license_valid_period,</if>
+ <if test="licenseImage != null">license_image,</if>
+ <if test="licenseValidType != null">license_valid_type,</if>
+ <if test="legalIdCardFront != null">legal_id_card_front,</if>
+ <if test="legalIdCardBack != null">legal_id_card_back,</if>
+ <if test="legalIdValidType != null">legal_id_valid_type,</if>
+ <if test="legalIdValidPeriod != null">legal_id_valid_period,</if>
+ <if test="legalIdAddress != null">legal_id_address,</if>
+ <if test="contactName != null">contact_name,</if>
+ <if test="contactPhone != null">contact_phone,</if>
+ <if test="contactEmail != null">contact_email,</if>
+ <if test="payeeType != null">payee_type,</if>
+ <if test="bankCardImage != null">bank_card_image,</if>
+ <if test="bankCardNo != null">bank_card_no,</if>
+ <if test="bankCity != null">bank_city,</if>
+ <if test="auditStatus != null">audit_status,</if>
+ <if test="remark != null">remark,</if>
+ <if test="createBy != null and createBy != ''">create_by,</if>
+ create_time
+ )values(
+ <if test="userId != null">#{userId},</if>
+ <if test="subjectType != null">#{subjectType},</if>
+ <if test="realName != null">#{realName},</if>
+ <if test="idCard != null">#{idCard},</if>
+ <if test="companyName != null">#{companyName},</if>
+ <if test="creditCode != null">#{creditCode},</if>
+ <if test="legalPerson != null">#{legalPerson},</if>
+ <if test="registeredRegion != null">#{registeredRegion},</if>
+ <if test="companyAddress != null">#{companyAddress},</if>
+ <if test="registerDate != null">#{registerDate},</if>
+ <if test="licenseValidPeriod != null">#{licenseValidPeriod},</if>
+ <if test="licenseImage != null">#{licenseImage},</if>
+ <if test="licenseValidType != null">#{licenseValidType},</if>
+ <if test="legalIdCardFront != null">#{legalIdCardFront},</if>
+ <if test="legalIdCardBack != null">#{legalIdCardBack},</if>
+ <if test="legalIdValidType != null">#{legalIdValidType},</if>
+ <if test="legalIdValidPeriod != null">#{legalIdValidPeriod},</if>
+ <if test="legalIdAddress != null">#{legalIdAddress},</if>
+ <if test="contactName != null">#{contactName},</if>
+ <if test="contactPhone != null">#{contactPhone},</if>
+ <if test="contactEmail != null">#{contactEmail},</if>
+ <if test="payeeType != null">#{payeeType},</if>
+ <if test="bankCardImage != null">#{bankCardImage},</if>
+ <if test="bankCardNo != null">#{bankCardNo},</if>
+ <if test="bankCity != null">#{bankCity},</if>
+ <if test="auditStatus != null">#{auditStatus},</if>
+ <if test="remark != null">#{remark},</if>
+ <if test="createBy != null and createBy != ''">#{createBy},</if>
+ sysdate()
+ )
+ </insert>
+
+ <!--
+ 统计该会员「待审核」的申请数:C端提交前用它做防重闸门。
+ 只数 audit_status='0' 的行——已通过(auth_status 已是 '2')或已驳回
+ (会员要改资料重提)都不该拦住新的提交,否则会员被驳回一次就再也交不了。
+ 个人与企业/个体按主体类型隔离:subject_type='1' 为个人,个人与企业的待审互不干扰。
+ -->
+ <select id="countPendingPersonalApplyByUserId" parameterType="Long" resultType="int">
+ select count(1) from account_certification_apply
+ where del_flag = '0' and audit_status = '0' and subject_type = '1' and user_id = #{userId}
+ </select>
+
+ <!-- 企业/个体待审核申请数:subject_type in ('2','3') -->
+ <select id="countPendingCompanyApplyByUserId" parameterType="Long" resultType="int">
+ select count(1) from account_certification_apply
+ where del_flag = '0' and audit_status = '0' and subject_type in ('2','3') and user_id = #{userId}
+ </select>
+
+ <!--
+ C端进入认证页时回填:取该会员最近一次个人认证申请(按主键倒序取一条)。
+ 驳回后重提会多一条,最近一条即当前应展示的状态与资料;
+ 此处不过滤 audit_status——未提交/待审/通过/驳回都需要能回填。
+ -->
+ <select id="selectLatestPersonalApplyByUserId" parameterType="Long" resultMap="AccountCertificationApplyResult">
+ <include refid="selectCertificationApplyVo"/>
+ where user_id = #{userId} and subject_type = '1' and del_flag = '0'
+ order by id desc
+ limit 1
+ </select>
+
+ <!-- 企业/个体最近一次认证申请:subject_type in ('2','3') -->
+ <select id="selectLatestCompanyApplyByUserId" parameterType="Long" resultMap="AccountCertificationApplyResult">
+ <include refid="selectCertificationApplyVo"/>
+ where user_id = #{userId} and subject_type in ('2','3') and del_flag = '0'
+ order by id desc
+ limit 1
+ </select>
+
</mapper>
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationMapper.xml b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationMapper.xml
index 82b0ef7..94efa62 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationMapper.xml
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationMapper.xml
@@ -17,6 +17,19 @@
<result property="registerDate" column="register_date" />
<result property="licenseValidPeriod" column="license_valid_period" />
<result property="licenseImage" column="license_image" />
+ <result property="licenseValidType" column="license_valid_type" />
+ <result property="legalIdCardFront" column="legal_id_card_front" />
+ <result property="legalIdCardBack" column="legal_id_card_back" />
+ <result property="legalIdValidType" column="legal_id_valid_type" />
+ <result property="legalIdValidPeriod" column="legal_id_valid_period"/>
+ <result property="legalIdAddress" column="legal_id_address" />
+ <result property="contactName" column="contact_name" />
+ <result property="contactPhone" column="contact_phone" />
+ <result property="contactEmail" column="contact_email" />
+ <result property="payeeType" column="payee_type" />
+ <result property="bankCardImage" column="bank_card_image" />
+ <result property="bankCardNo" column="bank_card_no" />
+ <result property="bankCity" column="bank_city" />
<result property="delFlag" column="del_flag" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
@@ -28,6 +41,9 @@
<sql id="selectCertificationVo">
select id, user_id, real_name, id_card, company_name, credit_code, legal_person,
registered_region, company_address, register_date, license_valid_period, license_image,
+ license_valid_type, legal_id_card_front, legal_id_card_back, legal_id_valid_type,
+ legal_id_valid_period, legal_id_address, contact_name, contact_phone, contact_email,
+ payee_type, bank_card_image, bank_card_no, bank_city,
del_flag, create_by, create_time, update_by, update_time, remark
from account_certification
</sql>
@@ -55,6 +71,19 @@
<if test="registerDate != null">register_date,</if>
<if test="licenseValidPeriod != null">license_valid_period,</if>
<if test="licenseImage != null">license_image,</if>
+ <if test="licenseValidType != null">license_valid_type,</if>
+ <if test="legalIdCardFront != null">legal_id_card_front,</if>
+ <if test="legalIdCardBack != null">legal_id_card_back,</if>
+ <if test="legalIdValidType != null">legal_id_valid_type,</if>
+ <if test="legalIdValidPeriod != null">legal_id_valid_period,</if>
+ <if test="legalIdAddress != null">legal_id_address,</if>
+ <if test="contactName != null">contact_name,</if>
+ <if test="contactPhone != null">contact_phone,</if>
+ <if test="contactEmail != null">contact_email,</if>
+ <if test="payeeType != null">payee_type,</if>
+ <if test="bankCardImage != null">bank_card_image,</if>
+ <if test="bankCardNo != null">bank_card_no,</if>
+ <if test="bankCity != null">bank_city,</if>
<if test="remark != null">remark,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
create_time
@@ -70,6 +99,19 @@
<if test="registerDate != null">#{registerDate},</if>
<if test="licenseValidPeriod != null">#{licenseValidPeriod},</if>
<if test="licenseImage != null">#{licenseImage},</if>
+ <if test="licenseValidType != null">#{licenseValidType},</if>
+ <if test="legalIdCardFront != null">#{legalIdCardFront},</if>
+ <if test="legalIdCardBack != null">#{legalIdCardBack},</if>
+ <if test="legalIdValidType != null">#{legalIdValidType},</if>
+ <if test="legalIdValidPeriod != null">#{legalIdValidPeriod},</if>
+ <if test="legalIdAddress != null">#{legalIdAddress},</if>
+ <if test="contactName != null">#{contactName},</if>
+ <if test="contactPhone != null">#{contactPhone},</if>
+ <if test="contactEmail != null">#{contactEmail},</if>
+ <if test="payeeType != null">#{payeeType},</if>
+ <if test="bankCardImage != null">#{bankCardImage},</if>
+ <if test="bankCardNo != null">#{bankCardNo},</if>
+ <if test="bankCity != null">#{bankCity},</if>
<if test="remark != null">#{remark},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
sysdate()
@@ -89,6 +131,19 @@
<if test="registerDate != null">register_date = #{registerDate},</if>
<if test="licenseValidPeriod != null">license_valid_period = #{licenseValidPeriod},</if>
<if test="licenseImage != null">license_image = #{licenseImage},</if>
+ <if test="licenseValidType != null">license_valid_type = #{licenseValidType},</if>
+ <if test="legalIdCardFront != null">legal_id_card_front = #{legalIdCardFront},</if>
+ <if test="legalIdCardBack != null">legal_id_card_back = #{legalIdCardBack},</if>
+ <if test="legalIdValidType != null">legal_id_valid_type = #{legalIdValidType},</if>
+ <if test="legalIdValidPeriod != null">legal_id_valid_period = #{legalIdValidPeriod},</if>
+ <if test="legalIdAddress != null">legal_id_address = #{legalIdAddress},</if>
+ <if test="contactName != null">contact_name = #{contactName},</if>
+ <if test="contactPhone != null">contact_phone = #{contactPhone},</if>
+ <if test="contactEmail != null">contact_email = #{contactEmail},</if>
+ <if test="payeeType != null">payee_type = #{payeeType},</if>
+ <if test="bankCardImage != null">bank_card_image = #{bankCardImage},</if>
+ <if test="bankCardNo != null">bank_card_no = #{bankCardNo},</if>
+ <if test="bankCity != null">bank_city = #{bankCity},</if>
<if test="remark != null">remark = #{remark},</if>
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
update_time = sysdate()
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml
index 12f7ab4..be38ef6 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml
@@ -17,6 +17,7 @@
<result property="systemLevel" column="system_level" />
<result property="subjectType" column="subject_type" />
<result property="authStatus" column="auth_status" />
+ <result property="companyAuthStatus" column="company_auth_status" />
<result property="city" column="city" />
<result property="referrerId" column="referrer_id" />
<result property="referrerBindTime" column="referrer_bind_time" />
@@ -53,7 +54,7 @@
<sql id="selectAccountUserVo">
select user_id, username, phone, password, nickname, avatar, gender, birthday, status,
- system_level, subject_type, auth_status, city,
+ system_level, subject_type, auth_status, company_auth_status, city,
referrer_id, referrer_bind_time, supplier_id, supplier_bind_time,
buy_order_count, buy_amount_total, sell_order_count, sell_amount_total,
purchase_order_count, purchase_amount_total, sell_profit_total,
@@ -245,6 +246,38 @@
</update>
<!--
+ 企业/个体认证审核通过/驳回后回写,只动 company_auth_status 一列;
+ 与 updateUserAuthStatus 分开是因为企业认证状态与个人认证状态是两列,不能互相覆盖。
+ 审核通过时服务层把 subjectType 一起带上(=2/3),此处 <if> 才写 subject_type;
+ 驳回时服务层不传 subjectType,列保持不变——否则会把未通过审核的会员标成企业主体。
+ -->
+ <update id="updateUserCompanyAuthStatus" parameterType="AccountUser">
+ update account_user
+ <set>
+ company_auth_status = #{companyAuthStatus},
+ <if test="subjectType != null and subjectType != ''">subject_type = #{subjectType},</if>
+ <if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
+ update_time = sysdate()
+ </set>
+ where user_id = #{userId} and del_flag = '0'
+ </update>
+
+ <!--
+ 头像更新专用:只动 avatar 一列。绝不能挂到共享的 updateAccountUser 上——
+ 那是后台编辑接口(/account/user)的入口,挂上去等于请求体带个 avatar 就能改任意会员头像。
+ 与 updateUserStatus / updateUserAuthStatus 同一套路,头像只能由会员本人在 C 端接口里改写。
+ -->
+ <update id="updateUserAvatar" parameterType="AccountUser">
+ update account_user
+ <set>
+ avatar = #{avatar},
+ <if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
+ update_time = sysdate()
+ </set>
+ where user_id = #{userId} and del_flag = '0'
+ </update>
+
+ <!--
服务商标识专用:与 updateUserAuthStatus 同一个套路——别的模块要给会员打标识,
就单开一条只写该列的语句,绝不挂到共享的 insertAccountUser / updateAccountUser 上
(那两个按 <if> 拼列,挂上去等于 /account/user 的请求体里带个 providerFlag 就能自己打标)。
@@ -272,6 +305,18 @@
</foreach>
</update>
+ <!--
+ 「我邀请的」列表:按 referrer_id 过滤 + referrer_bind_time 倒序,命中 idx_account_user_referrer。
+ 显式列出所需列(禁 SELECT *);不挂 join,避免干扰 PageHelper 的 count SQL。
+ referrer_bind_time 为空的历史行,MySQL 中 NULL 视为最小值,倒序时自然排在最后。
+ -->
+ <select id="selectInvitees" parameterType="Long" resultMap="AccountUserResult">
+ select user_id, nickname, avatar, phone, system_level, referrer_bind_time, buy_amount_total
+ from account_user
+ where del_flag = '0' and referrer_id = #{referrerId}
+ order by referrer_bind_time desc
+ </select>
+
<resultMap type="MemberOptionVo" id="MemberOptionVoResult">
<id property="userId" column="user_id" />
<result property="memberName" column="member_name" />
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImplTest.java b/hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImplTest.java
new file mode 100644
index 0000000..afc1492
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImplTest.java
@@ -0,0 +1,292 @@
+package com.ruoyi.user.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import com.ruoyi.common.core.exception.ServiceException;
+import com.ruoyi.user.domain.AccountCertificationApply;
+import com.ruoyi.user.domain.AccountUser;
+import com.ruoyi.user.domain.dto.CompanyCertificationApplyDto;
+import com.ruoyi.user.domain.vo.AccountCertificationStatusVo;
+import com.ruoyi.user.domain.vo.CompanyCertificationStatusVo;
+import com.ruoyi.user.mapper.AccountCertificationApplyMapper;
+import com.ruoyi.user.mapper.AccountCertificationMapper;
+import com.ruoyi.user.mapper.AccountUserMapper;
+
+/**
+ * 会员主体认证申请 服务单元测试
+ *
+ * 覆盖 C 端个人主体认证提交:正常提交(落待审核申请 + 联动认证状态为审核中)、
+ * 待审核申请存在时防重拒绝、以及会员ID/姓名/身份证号缺失时的入参校验;
+ * 另覆盖认证状态查询:有申请时回填资料与驳回原因、无申请/无登录态时返回空对象。
+ *
+ * @author hugray
+ */
+@ExtendWith(MockitoExtension.class)
+class AccountCertificationApplyServiceImplTest
+{
+ private static final Long USER_ID = 66L;
+
+ private static final String REAL_NAME = "张三";
+
+ private static final String ID_CARD = "110101199003071234";
+
+ @Mock
+ private AccountCertificationApplyMapper certificationApplyMapper;
+
+ @Mock
+ private AccountCertificationMapper certificationMapper;
+
+ @Mock
+ private AccountUserMapper accountUserMapper;
+
+ @InjectMocks
+ private AccountCertificationApplyServiceImpl certificationApplyService;
+
+ @Test
+ @DisplayName("正常提交应落一条待审核的个人申请并把认证状态置为审核中")
+ void submitPersonalCertification_shouldInsertPendingApplyAndSetAuditing()
+ {
+ when(certificationApplyMapper.countPendingPersonalApplyByUserId(USER_ID)).thenReturn(0);
+ when(certificationApplyMapper.insertCertificationApply(any(AccountCertificationApply.class))).thenReturn(1);
+
+ certificationApplyService.submitPersonalCertification(USER_ID, " 张三 ", "11010119900307123x");
+
+ // 申请快照:个人主体、待审核、姓名去空白、身份证号末位小写 x 统一转大写
+ ArgumentCaptor<AccountCertificationApply> applyCaptor = ArgumentCaptor.forClass(AccountCertificationApply.class);
+ verify(certificationApplyMapper).insertCertificationApply(applyCaptor.capture());
+ AccountCertificationApply saved = applyCaptor.getValue();
+ assertEquals(USER_ID, saved.getUserId());
+ assertEquals("1", saved.getSubjectType());
+ assertEquals("0", saved.getAuditStatus());
+ assertEquals(REAL_NAME, saved.getRealName());
+ assertEquals("11010119900307123X", saved.getIdCard());
+
+ // 状态联动:account_user.auth_status = '1'(审核中)
+ ArgumentCaptor<AccountUser> userCaptor = ArgumentCaptor.forClass(AccountUser.class);
+ verify(accountUserMapper).updateUserAuthStatus(userCaptor.capture());
+ assertEquals(USER_ID, userCaptor.getValue().getUserId());
+ assertEquals("1", userCaptor.getValue().getAuthStatus());
+ }
+
+ @Test
+ @DisplayName("已有待审核申请时应拒绝重复提交且不落库")
+ void submitPersonalCertification_shouldThrow_whenPendingApplyExists()
+ {
+ when(certificationApplyMapper.countPendingPersonalApplyByUserId(USER_ID)).thenReturn(1);
+
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> certificationApplyService.submitPersonalCertification(USER_ID, REAL_NAME, ID_CARD));
+
+ assertEquals("已有实名认证申请正在审核中,请勿重复提交", ex.getMessage());
+ verify(certificationApplyMapper, never()).insertCertificationApply(any(AccountCertificationApply.class));
+ }
+
+ @Test
+ @DisplayName("会员ID为空时应抛出提交失败")
+ void submitPersonalCertification_shouldThrow_whenUserIdNull()
+ {
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> certificationApplyService.submitPersonalCertification(null, REAL_NAME, ID_CARD));
+
+ assertEquals("提交实名认证失败", ex.getMessage());
+ verify(certificationApplyMapper, never()).insertCertificationApply(any(AccountCertificationApply.class));
+ }
+
+ @Test
+ @DisplayName("姓名为空时应抛出提示且不落库")
+ void submitPersonalCertification_shouldThrow_whenRealNameBlank()
+ {
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> certificationApplyService.submitPersonalCertification(USER_ID, "", ID_CARD));
+
+ assertEquals("姓名与身份证号不能为空", ex.getMessage());
+ verify(certificationApplyMapper, never()).insertCertificationApply(any(AccountCertificationApply.class));
+ }
+
+ @Test
+ @DisplayName("身份证号为空时应抛出提示且不落库")
+ void submitPersonalCertification_shouldThrow_whenIdCardBlank()
+ {
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> certificationApplyService.submitPersonalCertification(USER_ID, REAL_NAME, null));
+
+ assertEquals("姓名与身份证号不能为空", ex.getMessage());
+ verify(certificationApplyMapper, never()).insertCertificationApply(any(AccountCertificationApply.class));
+ }
+
+ @Test
+ @DisplayName("查询认证状态应回填最近一次申请的资料与驳回原因")
+ void getCertificationStatus_shouldFillFromLatestApply()
+ {
+ AccountCertificationApply apply = new AccountCertificationApply();
+ apply.setRealName(REAL_NAME);
+ apply.setIdCard(ID_CARD);
+ apply.setAuditStatus("2");
+ apply.setAuditRemark("身份证照片不清晰,请重新提交");
+ when(certificationApplyMapper.selectLatestPersonalApplyByUserId(USER_ID)).thenReturn(apply);
+
+ AccountCertificationStatusVo vo = certificationApplyService.getCertificationStatus(USER_ID);
+
+ assertEquals(REAL_NAME, vo.getRealName());
+ assertEquals(ID_CARD, vo.getIdCard());
+ assertEquals("2", vo.getAuditStatus());
+ assertEquals("身份证照片不清晰,请重新提交", vo.getAuditRemark());
+ }
+
+ @Test
+ @DisplayName("从未提交时查询认证状态应返回空对象而非 null")
+ void getCertificationStatus_shouldReturnEmpty_whenNoApply()
+ {
+ when(certificationApplyMapper.selectLatestPersonalApplyByUserId(USER_ID)).thenReturn(null);
+
+ AccountCertificationStatusVo vo = certificationApplyService.getCertificationStatus(USER_ID);
+
+ assertNull(vo.getRealName());
+ assertNull(vo.getIdCard());
+ assertNull(vo.getAuditStatus());
+ assertNull(vo.getAuditRemark());
+ }
+
+ @Test
+ @DisplayName("会员ID为空时查询认证状态应返回空对象且不查库")
+ void getCertificationStatus_shouldReturnEmpty_whenUserIdNull()
+ {
+ AccountCertificationStatusVo vo = certificationApplyService.getCertificationStatus(null);
+
+ assertNull(vo.getAuditStatus());
+ verify(certificationApplyMapper, never()).selectLatestPersonalApplyByUserId(any());
+ }
+
+ @Test
+ @DisplayName("正常提交应落一条待审核的企业申请并把企业认证状态置为审核中")
+ void submitCompanyCertification_shouldInsertPendingApplyAndSetCompanyAuditing()
+ {
+ when(certificationApplyMapper.countPendingCompanyApplyByUserId(USER_ID)).thenReturn(0);
+ when(certificationApplyMapper.insertCertificationApply(any(AccountCertificationApply.class))).thenReturn(1);
+
+ certificationApplyService.submitCompanyCertification(USER_ID, buildCompanyDto("3", "1", null));
+
+ // 申请快照:企业主体(3)、待审核、无有效期(长期)
+ ArgumentCaptor<AccountCertificationApply> applyCaptor = ArgumentCaptor.forClass(AccountCertificationApply.class);
+ verify(certificationApplyMapper).insertCertificationApply(applyCaptor.capture());
+ AccountCertificationApply saved = applyCaptor.getValue();
+ assertEquals(USER_ID, saved.getUserId());
+ assertEquals("3", saved.getSubjectType());
+ assertEquals("0", saved.getAuditStatus());
+ assertEquals("测试企业有限公司", saved.getCompanyName());
+
+ // 状态联动:account_user.company_auth_status = '1'(审核中),且不动个人 auth_status
+ ArgumentCaptor<AccountUser> userCaptor = ArgumentCaptor.forClass(AccountUser.class);
+ verify(accountUserMapper).updateUserCompanyAuthStatus(userCaptor.capture());
+ assertEquals("1", userCaptor.getValue().getCompanyAuthStatus());
+ verify(accountUserMapper, never()).updateUserAuthStatus(any(AccountUser.class));
+ }
+
+ @Test
+ @DisplayName("有效期选「短期」但未填起止日期时应抛出提示且不落库")
+ void submitCompanyCertification_shouldThrow_whenShortTermPeriodMissing()
+ {
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> certificationApplyService.submitCompanyCertification(USER_ID, buildCompanyDto("3", "2", null)));
+
+ assertEquals("请选择营业执照有效期起止日期", ex.getMessage());
+ verify(certificationApplyMapper, never()).insertCertificationApply(any(AccountCertificationApply.class));
+ }
+
+ @Test
+ @DisplayName("已有待审核的企业申请时应拒绝重复提交且不落库")
+ void submitCompanyCertification_shouldThrow_whenPendingApplyExists()
+ {
+ when(certificationApplyMapper.countPendingCompanyApplyByUserId(USER_ID)).thenReturn(1);
+
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> certificationApplyService.submitCompanyCertification(USER_ID, buildCompanyDto("2", "1", null)));
+
+ assertEquals("已有企业认证申请正在审核中,请勿重复提交", ex.getMessage());
+ verify(certificationApplyMapper, never()).insertCertificationApply(any(AccountCertificationApply.class));
+ }
+
+ @Test
+ @DisplayName("会员ID为空时提交企业认证应抛出提交失败")
+ void submitCompanyCertification_shouldThrow_whenUserIdNull()
+ {
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> certificationApplyService.submitCompanyCertification(null, buildCompanyDto("3", "1", null)));
+
+ assertEquals("提交企业认证失败", ex.getMessage());
+ verify(certificationApplyMapper, never()).insertCertificationApply(any(AccountCertificationApply.class));
+ }
+
+ @Test
+ @DisplayName("查询企业认证状态应回填最近一次申请的资料与驳回原因")
+ void getCompanyCertificationStatus_shouldFillFromLatestApply()
+ {
+ AccountCertificationApply apply = new AccountCertificationApply();
+ apply.setSubjectType("3");
+ apply.setCompanyName("测试企业有限公司");
+ apply.setAuditStatus("2");
+ apply.setAuditRemark("营业执照照片不清晰,请重新提交");
+ when(certificationApplyMapper.selectLatestCompanyApplyByUserId(USER_ID)).thenReturn(apply);
+
+ CompanyCertificationStatusVo vo = certificationApplyService.getCompanyCertificationStatus(USER_ID);
+
+ assertEquals("3", vo.getSubjectType());
+ assertEquals("测试企业有限公司", vo.getCompanyName());
+ assertEquals("2", vo.getAuditStatus());
+ assertEquals("营业执照照片不清晰,请重新提交", vo.getAuditRemark());
+ }
+
+ @Test
+ @DisplayName("从未提交企业认证时查询状态应返回空对象而非 null")
+ void getCompanyCertificationStatus_shouldReturnEmpty_whenNoApply()
+ {
+ when(certificationApplyMapper.selectLatestCompanyApplyByUserId(USER_ID)).thenReturn(null);
+
+ CompanyCertificationStatusVo vo = certificationApplyService.getCompanyCertificationStatus(USER_ID);
+
+ assertNull(vo.getCompanyName());
+ assertNull(vo.getAuditStatus());
+ assertNull(vo.getAuditRemark());
+ }
+
+ /** 构造一份字段齐全的企业认证入参:validType 控制营业执照有效期类型,licensePeriod 为起止日期 */
+ private CompanyCertificationApplyDto buildCompanyDto(String subjectType, String validType, String licensePeriod)
+ {
+ CompanyCertificationApplyDto dto = new CompanyCertificationApplyDto();
+ dto.setSubjectType(subjectType);
+ dto.setCompanyName("测试企业有限公司");
+ dto.setLicenseImage("/upload/license.png");
+ dto.setCreditCode("91310000MA1FL0XXXX");
+ dto.setLicenseValidType(validType);
+ dto.setLicenseValidPeriod(licensePeriod);
+ dto.setRegisteredRegion("上海市 上海市 浦东新区");
+ dto.setCompanyAddress("浦东新区世纪大道 1 号");
+ dto.setLegalPerson("李四");
+ dto.setLegalIdCardFront("/upload/id-front.png");
+ dto.setLegalIdCardBack("/upload/id-back.png");
+ dto.setLegalIdValidType("1");
+ dto.setLegalIdValidPeriod(null);
+ dto.setLegalIdAddress("浦东新区世纪大道 1 号");
+ dto.setContactName("王五");
+ dto.setContactPhone("13800000000");
+ dto.setContactEmail("test@example.com");
+ dto.setPayeeType("1");
+ dto.setBankCardImage("/upload/bank.png");
+ dto.setBankCardNo("6222020000000000000");
+ dto.setBankCity("上海市");
+ return dto;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountUserServiceImplTest.java b/hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountUserServiceImplTest.java
index e638e85..5aff6da 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountUserServiceImplTest.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountUserServiceImplTest.java
@@ -140,6 +140,7 @@
member.setPhone("13355555555");
member.setAvatar("https://cdn.example.com/a.png");
member.setSystemLevel(MemberLevelEnum.L3.getCode());
+ member.setAuthStatus("2");
EarningSummaryVo summary = new EarningSummaryVo(1000L, 40000L, 12800000L);
when(accountUserMapper.selectAccountUserById(66L)).thenReturn(member);
when(accountEarningRecordMapper.selectEarningSummary(66L)).thenReturn(summary);
@@ -151,6 +152,8 @@
assertEquals("133****5555", vo.getPhone());
assertEquals("https://cdn.example.com/a.png", vo.getAvatar());
assertEquals(MemberLevelEnum.L3.getCode(), vo.getMemberLevel());
+ // 认证状态原样下发,供个人中心「实名认证」行徽标回显
+ assertEquals("2", vo.getAuthStatus());
assertEquals(1000L, vo.getEarnings().getToday());
assertEquals(40000L, vo.getEarnings().getMonth());
assertEquals(12800000L, vo.getEarnings().getTotal());
@@ -209,6 +212,21 @@
assertFallbackLevel("L9", MemberLevelEnum.C.getCode());
}
+ @Test
+ @DisplayName("认证状态为空时应兜底为未认证(0)")
+ void getAccountUserInfo_shouldFallbackNoneAuthStatus_whenBlank()
+ {
+ AccountUser member = new AccountUser();
+ member.setUserId(66L);
+ member.setSystemLevel(MemberLevelEnum.C.getCode());
+ when(accountUserMapper.selectAccountUserById(66L)).thenReturn(member);
+ when(accountEarningRecordMapper.selectEarningSummary(66L)).thenReturn(EarningSummaryVo.zero());
+
+ AccountUserInfoVo vo = accountUserService.getAccountUserInfo(66L);
+
+ assertEquals("0", vo.getAuthStatus());
+ }
+
/**
* 校验不同等级取值下的等级兜底结果
*
@@ -227,4 +245,64 @@
assertEquals(expectedLevel, vo.getMemberLevel());
}
+
+ @Test
+ @DisplayName("更新头像时应只写入 avatar 列")
+ void updateAvatar_shouldUpdateAvatar_whenValid()
+ {
+ when(accountUserMapper.updateUserAvatar(any(AccountUser.class))).thenReturn(1);
+
+ accountUserService.updateAvatar(66L, "https://cdn.example.com/new.png");
+
+ ArgumentCaptor<AccountUser> captor = ArgumentCaptor.forClass(AccountUser.class);
+ verify(accountUserMapper).updateUserAvatar(captor.capture());
+ assertEquals(66L, captor.getValue().getUserId());
+ assertEquals("https://cdn.example.com/new.png", captor.getValue().getAvatar());
+ }
+
+ @Test
+ @DisplayName("头像地址为空时应抛出提示且不落库")
+ void updateAvatar_shouldThrow_whenAvatarBlank()
+ {
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> accountUserService.updateAvatar(66L, ""));
+
+ assertEquals("头像地址不能为空", ex.getMessage());
+ verify(accountUserMapper, never()).updateUserAvatar(any(AccountUser.class));
+ }
+
+ @Test
+ @DisplayName("头像地址超长时应抛出提示且不落库")
+ void updateAvatar_shouldThrow_whenAvatarTooLong()
+ {
+ String longUrl = "a".repeat(256);
+
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> accountUserService.updateAvatar(66L, longUrl));
+
+ assertEquals("头像地址过长", ex.getMessage());
+ verify(accountUserMapper, never()).updateUserAvatar(any(AccountUser.class));
+ }
+
+ @Test
+ @DisplayName("会员不存在(影响行数为 0)时应抛出更新失败")
+ void updateAvatar_shouldThrow_whenMemberNotFound()
+ {
+ when(accountUserMapper.updateUserAvatar(any(AccountUser.class))).thenReturn(0);
+
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> accountUserService.updateAvatar(66L, "https://cdn.example.com/new.png"));
+
+ assertEquals("更新头像失败", ex.getMessage());
+ }
+
+ @Test
+ @DisplayName("会员ID为空时应抛出更新失败")
+ void updateAvatar_shouldThrow_whenUserIdNull()
+ {
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> accountUserService.updateAvatar(null, "https://cdn.example.com/new.png"));
+
+ assertEquals("更新头像失败", ex.getMessage());
+ }
}
diff --git a/hugray-server/sql/hugray_account_company_certification_20261013.sql b/hugray-server/sql/hugray_account_company_certification_20261013.sql
new file mode 100644
index 0000000..eeac27f
--- /dev/null
+++ b/hugray-server/sql/hugray_account_company_certification_20261013.sql
@@ -0,0 +1,113 @@
+-- ----------------------------
+-- 企业/个体工商户认证:认证资料表扩充
+--
+-- 目标库 hugray-account(库名含连字符,任何引用都必须用反引号)。
+-- 本脚本为扩充脚本,两张认证表已有数据,一律用 ALTER,绝不 DROP。
+--
+-- 背景:原型「企业/个体认证」四步向导(企业信息 / 法人信息 / 收款信息 / 提交审核)
+-- 所需字段中,营业执照有效期类型、法人身份证正反面、法人身份证有效期、法人身份证详细地址、
+-- 负责人姓名/电话/邮箱、收款类型、银行卡照片、银行卡号、开户城市共 13 列缺失,本次补齐。
+--
+-- 涉及表:
+-- account_certification_apply 存"每次提交的申请",提交企业认证时 subject_type 取 2/3
+-- account_certification 存"当前生效"的认证资料,审核通过时落一份
+-- account_user 新增 company_auth_status,企业/个体认证状态独立于 auth_status
+--
+-- 【枚举取值】
+-- 主体类型 subject_type :1个人 2个体工商户 3企业
+-- 有效期类型 license_valid_type :1长期 2短期
+-- 有效期类型 legal_id_valid_type :1长期 2短期
+-- 企业认证状态 company_auth_status :0未认证 1审核中 2已认证 3认证失败
+--
+-- 【幂等说明】复用 hugray_add_column 临时存储过程做存在性判断,脚本可安全重复执行。
+--
+-- 【回滚语句】(如需回滚,在 hugray-account 库执行)
+-- ALTER TABLE `account_certification_apply` DROP COLUMN `license_valid_type`,
+-- DROP COLUMN `legal_id_card_front`, DROP COLUMN `legal_id_card_back`,
+-- DROP COLUMN `legal_id_valid_type`, DROP COLUMN `legal_id_valid_period`,
+-- DROP COLUMN `legal_id_address`, DROP COLUMN `contact_name`, DROP COLUMN `contact_phone`,
+-- DROP COLUMN `contact_email`, DROP COLUMN `payee_type`, DROP COLUMN `bank_card_image`,
+-- DROP COLUMN `bank_card_no`, DROP COLUMN `bank_city`;
+-- ALTER TABLE `account_certification` DROP COLUMN `license_valid_type`,
+-- DROP COLUMN `legal_id_card_front`, DROP COLUMN `legal_id_card_back`,
+-- DROP COLUMN `legal_id_valid_type`, DROP COLUMN `legal_id_valid_period`,
+-- DROP COLUMN `legal_id_address`, DROP COLUMN `contact_name`, DROP COLUMN `contact_phone`,
+-- DROP COLUMN `contact_email`, DROP COLUMN `payee_type`, DROP COLUMN `bank_card_image`,
+-- DROP COLUMN `bank_card_no`, DROP COLUMN `bank_city`;
+-- ALTER TABLE `account_user` DROP COLUMN `company_auth_status`;
+-- ----------------------------
+
+USE `hugray-account`;
+
+-- ---------- 幂等辅助过程 ----------
+DROP PROCEDURE IF EXISTS `hugray_add_column`;
+DROP PROCEDURE IF EXISTS `hugray_add_index`;
+
+DELIMITER $$
+CREATE PROCEDURE `hugray_add_column`(IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT)
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = p_table
+ AND COLUMN_NAME = p_column) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition);
+ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+
+CREATE PROCEDURE `hugray_add_index`(IN p_table VARCHAR(64), IN p_index VARCHAR(64), IN p_columns TEXT)
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = p_table
+ AND INDEX_NAME = p_index) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD INDEX `', p_index, '` (', p_columns, ')');
+ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+DELIMITER ;
+
+-- ---------- 1. account_certification_apply:企业/个体认证字段 ----------
+CALL `hugray_add_column`('account_certification_apply', 'license_valid_type', "varchar(16) DEFAULT NULL COMMENT '营业执照有效期类型(1长期 2短期)'");
+CALL `hugray_add_column`('account_certification_apply', 'legal_id_card_front', "varchar(500) DEFAULT NULL COMMENT '法人身份证人像面图片'");
+CALL `hugray_add_column`('account_certification_apply', 'legal_id_card_back', "varchar(500) DEFAULT NULL COMMENT '法人身份证国徽面图片'");
+CALL `hugray_add_column`('account_certification_apply', 'legal_id_valid_type', "varchar(16) DEFAULT NULL COMMENT '法人身份证有效期类型(1长期 2短期)'");
+CALL `hugray_add_column`('account_certification_apply', 'legal_id_valid_period',"varchar(32) DEFAULT NULL COMMENT '法人身份证有效期(存文本,如 2024-01-01~2034-01-01)'");
+CALL `hugray_add_column`('account_certification_apply', 'legal_id_address', "varchar(255) DEFAULT NULL COMMENT '法人身份证详细地址'");
+CALL `hugray_add_column`('account_certification_apply', 'contact_name', "varchar(64) DEFAULT NULL COMMENT '负责人姓名'");
+CALL `hugray_add_column`('account_certification_apply', 'contact_phone', "varchar(20) DEFAULT NULL COMMENT '负责人联系电话'");
+CALL `hugray_add_column`('account_certification_apply', 'contact_email', "varchar(128) DEFAULT NULL COMMENT '负责人邮箱'");
+CALL `hugray_add_column`('account_certification_apply', 'payee_type', "varchar(16) DEFAULT NULL COMMENT '收款类型'");
+CALL `hugray_add_column`('account_certification_apply', 'bank_card_image', "varchar(500) DEFAULT NULL COMMENT '银行卡号码面照片'");
+CALL `hugray_add_column`('account_certification_apply', 'bank_card_no', "varchar(32) DEFAULT NULL COMMENT '银行卡号'");
+CALL `hugray_add_column`('account_certification_apply', 'bank_city', "varchar(128) DEFAULT NULL COMMENT '开户城市'");
+
+-- ---------- 2. account_certification:企业/个体认证字段(与申请表同构) ----------
+CALL `hugray_add_column`('account_certification', 'license_valid_type', "varchar(16) DEFAULT NULL COMMENT '营业执照有效期类型(1长期 2短期)'");
+CALL `hugray_add_column`('account_certification', 'legal_id_card_front', "varchar(500) DEFAULT NULL COMMENT '法人身份证人像面图片'");
+CALL `hugray_add_column`('account_certification', 'legal_id_card_back', "varchar(500) DEFAULT NULL COMMENT '法人身份证国徽面图片'");
+CALL `hugray_add_column`('account_certification', 'legal_id_valid_type', "varchar(16) DEFAULT NULL COMMENT '法人身份证有效期类型(1长期 2短期)'");
+CALL `hugray_add_column`('account_certification', 'legal_id_valid_period',"varchar(32) DEFAULT NULL COMMENT '法人身份证有效期(存文本,如 2024-01-01~2034-01-01)'");
+CALL `hugray_add_column`('account_certification', 'legal_id_address', "varchar(255) DEFAULT NULL COMMENT '法人身份证详细地址'");
+CALL `hugray_add_column`('account_certification', 'contact_name', "varchar(64) DEFAULT NULL COMMENT '负责人姓名'");
+CALL `hugray_add_column`('account_certification', 'contact_phone', "varchar(20) DEFAULT NULL COMMENT '负责人联系电话'");
+CALL `hugray_add_column`('account_certification', 'contact_email', "varchar(128) DEFAULT NULL COMMENT '负责人邮箱'");
+CALL `hugray_add_column`('account_certification', 'payee_type', "varchar(16) DEFAULT NULL COMMENT '收款类型'");
+CALL `hugray_add_column`('account_certification', 'bank_card_image', "varchar(500) DEFAULT NULL COMMENT '银行卡号码面照片'");
+CALL `hugray_add_column`('account_certification', 'bank_card_no', "varchar(32) DEFAULT NULL COMMENT '银行卡号'");
+CALL `hugray_add_column`('account_certification', 'bank_city', "varchar(128) DEFAULT NULL COMMENT '开户城市'");
+
+-- ---------- 3. account_user:企业/个体认证状态(独立于 auth_status) ----------
+-- 个人中心需两行独立徽标(实名认证 / 企业个体户认证),故企业状态单独一列,互不覆盖。
+CALL `hugray_add_column`('account_user', 'company_auth_status', "char(1) DEFAULT '0' COMMENT '企业/个体认证状态(0未认证 1审核中 2已认证 3认证失败)'");
+CALL `hugray_add_index`('account_user', 'idx_account_user_company_auth', '`company_auth_status`');
+
+-- ---------- 4. 清理辅助过程 ----------
+DROP PROCEDURE IF EXISTS `hugray_add_column`;
+DROP PROCEDURE IF EXISTS `hugray_add_index`;
+
+-- ---------- 5. 结果核对 ----------
+SELECT `COLUMN_NAME`, `COLUMN_TYPE`, `COLUMN_DEFAULT`, `COLUMN_COMMENT`
+FROM INFORMATION_SCHEMA.COLUMNS
+WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'account_certification_apply'
+ORDER BY `ORDINAL_POSITION`;
diff --git a/hugray-user-miniapp/api/index.js b/hugray-user-miniapp/api/index.js
index cc7e5ec..372c062 100644
--- a/hugray-user-miniapp/api/index.js
+++ b/hugray-user-miniapp/api/index.js
@@ -7,7 +7,7 @@
* /miniapp/product/** -> ruoyi-product 商品/订单
* /miniapp/system/** -> ruoyi-system 系统信息
*/
-import { get, post } from '@/utils/request.js'
+import { get, post, uploadFile } from '@/utils/request.js'
// ======================== 认证相关 ========================
@@ -109,6 +109,96 @@
return get('/miniapp/account/user/stock')
}
+/**
+ * 上传头像文件到文件服务,返回 { data: { name, url } }
+ * 说明:走网关 /file/** -> ruoyi-file,需登录态(网关白名单不含 /file/upload)
+ * @param {String} filePath 本地临时文件路径(uni.chooseImage 返回)
+ */
+export function uploadAvatarFile(filePath) {
+ return uploadFile('/file/upload', filePath)
+}
+
+/**
+ * 更新当前登录会员头像(需登录态,userId 由网关注入,前端只传头像地址)
+ * @param {String} avatar 头像地址(来自 uploadAvatarFile 返回的 url)
+ */
+export function updateAvatar(avatar) {
+ return post('/miniapp/account/user/avatar', { avatar })
+}
+
+/**
+ * 查询当前会员实名认证状态(进入认证页回填资料与状态用)
+ * 返回 { realName, idCard, auditStatus, auditRemark }:
+ * auditStatus 0待审核 1已通过 2已驳回;为空表示从未提交
+ */
+export function getCertificationStatus() {
+ return get('/miniapp/account/user/certification')
+}
+
+/**
+ * 提交实名认证(个人主体,只做基础格式校验,不做三方实名核验)
+ * 说明:会员身份由网关注入,前端只传姓名与身份证号;提交后进入后台「主体认证审核」待审
+ * @param {String} realName 姓名
+ * @param {String} idCard 身份证号
+ */
+export function submitCertification(realName, idCard) {
+ return post('/miniapp/account/user/certification', { realName, idCard })
+}
+
+/**
+ * 查询当前会员企业/个体认证状态与资料(进入向导页回填用)
+ * 返回最近一次企业/个体申请快照(subject_type ∈ {2,3}):
+ * 21 个资料字段 + auditStatus(0待审核 1已通过 2已驳回,为空表示从未提交)+ auditRemark(驳回原因)
+ */
+export function getCompanyCertificationStatus() {
+ return get('/miniapp/account/user/company-certification')
+}
+
+/**
+ * 提交企业/个体认证(只做基础必填校验,不做格式/三方核验)
+ * 说明:会员身份由网关注入,前端不传 userId;提交后进入后台「主体认证审核」待审队列
+ * @param {Object} data 21 个字段(见 CompanyCertificationApplyDto),如 subjectType/companyName/licenseImage 等
+ */
+export function submitCompanyCertification(data) {
+ return post('/miniapp/account/user/company-certification', data)
+}
+
+// ======================== 我的邀请 ========================
+
+/**
+ * 查询本人邀请码(「我的邀请」页邀请码 Tab)
+ * 返回 { referralCode }:邀请码注册时生成,C 端会员必有
+ */
+export function getInviteCode() {
+ return get('/miniapp/account/user/invite-code')
+}
+
+/**
+ * 查询本人邀请码小程序码(扫码直达注册页并自动填入邀请码)
+ * @param {String} code 邀请码(8 位 A-Z0-9),作为小程序码 scene
+ * 返回 { qrcode }:PNG 的 base64 DataURI(data:image/png;base64,...),可直接作为 image src
+ */
+export function getInviteQrcode(code) {
+ return get('/miniapp/auth/invite-qrcode', { code })
+}
+
+/**
+ * 查询我邀请的(直接下级)列表(分页,按绑定时间倒序)
+ * @param {Object} params { pageNum, pageSize }
+ * 说明:后端返回 TableDataInfo,数据在顶层 rows(非 data 包裹),总数为 total
+ */
+export function getInvitees(params) {
+ return get('/miniapp/account/user/invitees', params)
+}
+
+/**
+ * 查询邀请我的(上级/推荐人)信息
+ * 返回 { userId, nickname, avatar, phone, systemLevel, bindDate };无上级时各字段为 null
+ */
+export function getReferrer() {
+ return get('/miniapp/account/user/referrer')
+}
+
// ======================== 订单相关 ========================
/**
diff --git a/hugray-user-miniapp/pages.json b/hugray-user-miniapp/pages.json
index fdb3bde..a117e14 100644
--- a/hugray-user-miniapp/pages.json
+++ b/hugray-user-miniapp/pages.json
@@ -37,6 +37,27 @@
}
},
{
+ "path": "pages/profile/certification",
+ // 导航栏含更多 / 帮助图标,需自定义导航栏承载
+ "style": {
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/profile/company-certification",
+ // 顶部为四段式步骤条,需自定义导航栏承载
+ "style": {
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/profile/invite",
+ // 顶部为三段式 Tab + 居中标题,需自定义导航栏承载
+ "style": {
+ "navigationStyle": "custom"
+ }
+ },
+ {
"path": "pages/contact/index",
// 顶部为深蓝底品牌区,与白底原生导航冲突,故使用自定义导航栏
"style": {
diff --git a/hugray-user-miniapp/pages/login/register.vue b/hugray-user-miniapp/pages/login/register.vue
index c02f96d..62353d8 100644
--- a/hugray-user-miniapp/pages/login/register.vue
+++ b/hugray-user-miniapp/pages/login/register.vue
@@ -100,7 +100,7 @@
<script setup>
import { ref, reactive, computed } from 'vue'
-import { onUnload } from '@dcloudio/uni-app'
+import { onLoad, onUnload } from '@dcloudio/uni-app'
import { sendSmsCode, register } from '@/api/index.js'
// ======================== 数据定义区域 ========================
@@ -211,6 +211,23 @@
uni.navigateBack()
}
+// 页面加载时解析邀请码并回填:
+// 1)分享链接进入 -> options.referralCode
+// 2)扫描小程序码进入 -> options.scene(微信下发的 scene 为 URL 编码,需解码)
+// 统一转大写以匹配后端邀请码字符集(8 位 A-Z0-9),用户后续仍可手动修改
+onLoad((options) => {
+ const raw = (options && (options.referralCode || options.scene)) || ''
+ if (!raw) return
+ let code = raw
+ try {
+ code = decodeURIComponent(raw)
+ } catch (e) {
+ // 非法编码时按原值处理,不阻塞页面
+ console.error('[REGISTER] 邀请码解码失败', e)
+ }
+ form.referralCode = code.trim().toUpperCase()
+})
+
// 页面卸载时清理倒计时,避免定时器泄漏
onUnload(() => {
if (countdownTimer) clearInterval(countdownTimer)
diff --git a/hugray-user-miniapp/pages/profile/certification.vue b/hugray-user-miniapp/pages/profile/certification.vue
new file mode 100644
index 0000000..2688d4e
--- /dev/null
+++ b/hugray-user-miniapp/pages/profile/certification.vue
@@ -0,0 +1,390 @@
+<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="field">
+ <text class="field-label">姓名</text>
+ <input
+ class="field-input"
+ :class="{ 'field-input--readonly': readonly }"
+ v-model="realName"
+ type="text"
+ placeholder="请输入姓名"
+ placeholder-class="field-placeholder"
+ maxlength="64"
+ :disabled="readonly"
+ />
+ </view>
+
+ <!-- 身份证号 -->
+ <view class="field">
+ <text class="field-label">身份证号</text>
+ <input
+ class="field-input"
+ :class="{ 'field-input--readonly': readonly }"
+ v-model="idCard"
+ type="idcard"
+ placeholder="请输入身份证号"
+ placeholder-class="field-placeholder"
+ maxlength="18"
+ :disabled="readonly"
+ />
+ <!-- 认证失败:紧贴身份证号下方展示驳回原因 -->
+ <view v-if="showRejectReason" class="reject-tip">
+ <text class="reject-tip-text">驳回原因:{{ rejectReason }}</text>
+ </view>
+ </view>
+ </view>
+
+ <!-- 底部提交 -->
+ <view class="footer">
+ <view class="btn-submit" :class="{ 'btn-submit--disabled': submitDisabled }" @tap="handleSubmit">
+ <text class="btn-submit-text">{{ submitText }}</text>
+ </view>
+ </view>
+ </view>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import { ensureLogin } from '@/utils/request.js'
+import { getCertificationStatus, submitCertification } from '@/api/index.js'
+
+/** 状态栏高度:自定义导航栏下用于顶部留白 */
+const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
+
+/**
+ * 身份证号格式(与后端 AccountCertificationApplyDto 保持一致)
+ * 只校验结构:6 位地址码 + 8 位出生日期 + 3 位顺序码 + 1 位校验位(数字或 X),
+ * 不校验末位校验码,也不做三方实名核验。
+ */
+const ID_CARD_PATTERN = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/
+
+/** 姓名 */
+const realName = ref('')
+
+/** 身份证号 */
+const idCard = ref('')
+
+/** 提交中标记:防止重复点击造成重复提交 */
+const submitting = ref(false)
+
+/**
+ * 认证审核状态(取自最近一次申请,进入页面时回填)
+ * '0' 待审核 / '1' 已通过 / '2' 已驳回 / '' 从未提交(未认证)
+ */
+const auditStatus = ref('')
+
+/** 驳回原因:仅审核状态为「已驳回」时有值 */
+const rejectReason = ref('')
+
+/** 表单只读:认证中 / 认证成功时姓名与身份证号不可编辑 */
+const readonly = computed(() => auditStatus.value === '0' || auditStatus.value === '1')
+
+/** 提交按钮置灰:只读状态或正在提交时不可点 */
+const submitDisabled = computed(() => readonly.value || submitting.value)
+
+/** 是否展示驳回原因:认证失败且后端返回了驳回原因 */
+const showRejectReason = computed(() => auditStatus.value === '2' && !!rejectReason.value)
+
+/**
+ * 按钮文案字典:按审核状态区分
+ * '' 未认证→提交 / '0' 认证中→审核中 / '1' 认证成功→已认证 / '2' 认证失败→重新提交
+ */
+const SUBMIT_TEXT_MAP = {
+ '': '提交',
+ '0': '审核中',
+ '1': '已认证',
+ '2': '重新提交'
+}
+
+/** 底部按钮文案:随认证状态变化,未匹配时兜底「提交」 */
+const submitText = computed(() => SUBMIT_TEXT_MAP[auditStatus.value] || '提交')
+
+/** 返回上一页:无上级页面(如直接进入)时回到个人中心 */
+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' })
+}
+
+/**
+ * 拉取当前认证状态并回填表单
+ * 认证中 / 认证成功回填姓名与身份证号(只读);认证失败回填资料并展示驳回原因;
+ * 从未提交(auditStatus 为空)保持空表单可编辑。查询失败静默降级为「未认证」,不阻断用户填报。
+ */
+const loadCertificationStatus = async () => {
+ try {
+ // request 返回完整响应体 { code, msg, data },业务数据在 data 字段
+ const res = await getCertificationStatus()
+ const data = res.data || {}
+ // 从未提交时后端返回空字段对象,此处保持空值即为「未认证」
+ auditStatus.value = data.auditStatus || ''
+ realName.value = data.realName || ''
+ idCard.value = data.idCard || ''
+ rejectReason.value = data.auditRemark || ''
+ } catch (error) {
+ console.error('[CERTIFICATION] 查询认证状态失败', error)
+ auditStatus.value = ''
+ rejectReason.value = ''
+ }
+}
+
+/**
+ * 提交实名认证:本地基础校验(姓名必填、身份证号格式)通过后调接口
+ * 成功提示等待审核并返回上一页;失败展示后端返回的业务提示
+ */
+const handleSubmit = async () => {
+ // STEP 0: 只读 / 提交中状态直接拦截,避免重复提交
+ if (submitDisabled.value) return
+ const name = realName.value.trim()
+ const card = idCard.value.trim()
+ // STEP 1: 姓名必填校验
+ if (!name) {
+ uni.showToast({ title: '请输入姓名', icon: 'none' })
+ return
+ }
+ // STEP 2: 身份证号格式校验
+ if (!ID_CARD_PATTERN.test(card)) {
+ uni.showToast({ title: '身份证号格式不正确', icon: 'none' })
+ return
+ }
+ // STEP 3: 锁定提交态并提交到后端,落后台「主体认证审核」待审队列
+ submitting.value = true
+ uni.showLoading({ title: '提交中', mask: true })
+ try {
+ await submitCertification(name, card)
+ uni.hideLoading()
+ uni.showToast({ title: '提交成功,等待审核', icon: 'none' })
+ setTimeout(() => uni.navigateBack(), 1500)
+ } catch (error) {
+ uni.hideLoading()
+ console.error('[CERTIFICATION] 提交实名认证失败', error)
+ uni.showToast({ title: (error && error.message) || '提交失败,请重试', icon: 'none' })
+ } finally {
+ submitting.value = false
+ }
+}
+
+// 进入页面校验登录态并拉取认证状态回填(未登录跳登录页)
+onShow(() => {
+ if (!ensureLogin()) return
+ loadCertificationStatus()
+})
+</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;
+ padding: 24rpx 32rpx 0;
+}
+
+.field {
+ margin-bottom: 40rpx;
+}
+
+.field-label {
+ display: block;
+ font-size: 30rpx;
+ line-height: 1;
+ color: #1f2937;
+}
+
+.field-input {
+ height: 88rpx;
+ margin-top: 8rpx;
+ font-size: 30rpx;
+ color: #1f2937;
+ border-bottom: 2rpx solid #e2e8f0;
+}
+
+.field-placeholder {
+ font-size: 30rpx;
+ color: #cbd5e1;
+}
+
+/* 只读态:认证中 / 认证成功时置灰文字,视觉上表达不可编辑 */
+.field-input--readonly {
+ color: #9ca3af;
+}
+
+/* ==================== 驳回原因 ==================== */
+
+.reject-tip {
+ margin-top: 12rpx;
+}
+
+.reject-tip-text {
+ font-size: 26rpx;
+ line-height: 1.4;
+ color: #ef4444;
+}
+
+/* ==================== 底部提交 ==================== */
+
+.footer {
+ /* 内容不足一屏时按钮自动贴底 */
+ margin-top: auto;
+ padding: 32rpx 48rpx calc(44rpx + env(safe-area-inset-bottom));
+}
+
+.btn-submit {
+ width: 100%;
+ height: 96rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 12rpx;
+ background: linear-gradient(90deg, #82c236 0%, #639c1f 100%);
+ box-shadow: 0 16rpx 40rpx rgba(99, 156, 31, 0.28);
+}
+
+.btn-submit--disabled {
+ opacity: 0.6;
+}
+
+.btn-submit-text {
+ font-size: 32rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #ffffff;
+}
+</style>
diff --git a/hugray-user-miniapp/pages/profile/company-certification.vue b/hugray-user-miniapp/pages/profile/company-certification.vue
new file mode 100644
index 0000000..62c1b8a
--- /dev/null
+++ b/hugray-user-miniapp/pages/profile/company-certification.vue
@@ -0,0 +1,1038 @@
+<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>
+ </view>
+
+ <!-- 四段式步骤条:已完成打勾、当前高亮(提交后锁定不可点) -->
+ <view class="steps">
+ <view v-for="(label, idx) in STEP_LABELS" :key="idx" class="step" @tap="handleStepTap(idx)">
+ <view class="step-dot" :class="{ 'step-dot--done': idx < step, 'step-dot--active': idx === step }">
+ <text v-if="idx < step" class="step-dot-check">✓</text>
+ <text v-else class="step-dot-num">{{ idx + 1 }}</text>
+ </view>
+ <text class="step-label" :class="{ 'step-label--done': idx < step, 'step-label--active': idx === step }">{{ label }}</text>
+ <view v-if="idx < STEP_LABELS.length - 1" class="step-line" :class="{ 'step-line--done': idx < step }"></view>
+ </view>
+ </view>
+
+ <scroll-view class="body" scroll-y>
+ <!-- 第 4 步:提交审核 / 结果占位 -->
+ <view v-if="step === 3" class="result">
+ <view class="result-icon" :class="resultState === 'passed' ? 'result-icon--done' : 'result-icon--audit'">
+ <text class="result-icon-text">{{ resultState === 'passed' ? '✓' : '···' }}</text>
+ </view>
+ <text class="result-title">{{ resultInfo.title }}</text>
+ <text class="result-tip">{{ resultInfo.tip }}</text>
+ </view>
+
+ <!-- 第 1 步:企业信息 -->
+ <view v-else-if="step === 0" class="form">
+ <!-- 认证失败:展示驳回原因,可修改后重提 -->
+ <view v-if="showRejectReason" class="reject-tip">
+ <text class="reject-tip-text">驳回原因:{{ rejectReason }}</text>
+ </view>
+
+ <!-- 主体类型 -->
+ <view class="field">
+ <text class="field-label">主体类型</text>
+ <picker :range="SUBJECT_TYPE_LABELS" @change="(e) => handleSelectChange('subjectType', SUBJECT_TYPE_VALUES, e)">
+ <view class="field-select">
+ <text :class="form.subjectType ? 'field-select-text' : 'field-select-placeholder'">{{ subjectTypeLabel || '请选择主体类型' }}</text>
+ <view class="icon-chevron"></view>
+ </view>
+ </picker>
+ </view>
+
+ <!-- 企业/个体名称 -->
+ <view class="field">
+ <text class="field-label">企业/个体名称</text>
+ <input class="field-input" v-model="form.companyName" type="text" placeholder="请输入企业/个体名称" :placeholder-style="PLACEHOLDER_STYLE" maxlength="128" />
+ </view>
+
+ <!-- 营业执照 -->
+ <view class="field">
+ <text class="field-label">营业执照</text>
+ <view class="upload" @tap="handleChooseImage('licenseImage', '营业执照')">
+ <image v-if="form.licenseImage" class="upload-img" :src="form.licenseImage" mode="aspectFill" />
+ <view v-else class="upload-empty">
+ <view class="icon-camera"></view>
+ <text class="upload-empty-text">上传营业执照</text>
+ </view>
+ </view>
+ </view>
+
+ <!-- 统一社会信用代码 -->
+ <view class="field">
+ <text class="field-label">统一社会信用代码</text>
+ <input class="field-input" v-model="form.creditCode" type="text" placeholder="请输入统一社会信用代码" :placeholder-style="PLACEHOLDER_STYLE" maxlength="32" />
+ </view>
+
+ <!-- 营业执照有效期类型:长期时隐藏起止日期 -->
+ <view class="field">
+ <text class="field-label">营业执照有效期类型</text>
+ <picker :range="VALID_TYPE_LABELS" @change="(e) => handleSelectChange('licenseValidType', VALID_TYPE_VALUES, e)">
+ <view class="field-select">
+ <text :class="form.licenseValidType ? 'field-select-text' : 'field-select-placeholder'">{{ labelOf(VALID_TYPE_VALUES, VALID_TYPE_LABELS, form.licenseValidType) || '请选择有效期类型' }}</text>
+ <view class="icon-chevron"></view>
+ </view>
+ </picker>
+ </view>
+
+ <!-- 营业执照有效期起止:仅短期展示且必填 -->
+ <view v-if="form.licenseValidType === '2'" class="field">
+ <text class="field-label">营业执照有效期</text>
+ <view class="date-range">
+ <picker mode="date" :value="form.licenseValidStart" @change="(e) => handleDateChange('licenseValidStart', e)">
+ <view class="date-value" :class="{ 'date-value--placeholder': !form.licenseValidStart }">{{ form.licenseValidStart || '开始日期' }}</view>
+ </picker>
+ <text class="date-sep">~</text>
+ <picker mode="date" :value="form.licenseValidEnd" @change="(e) => handleDateChange('licenseValidEnd', e)">
+ <view class="date-value" :class="{ 'date-value--placeholder': !form.licenseValidEnd }">{{ form.licenseValidEnd || '截止日期' }}</view>
+ </picker>
+ </view>
+ </view>
+
+ <!-- 营业执照注册地区 -->
+ <view class="field">
+ <text class="field-label">营业执照注册地区</text>
+ <picker mode="region" :value="companyRegionValue" @change="(e) => handleRegionChange('registeredRegion', 'company', e)">
+ <view class="field-select">
+ <text :class="form.registeredRegion ? 'field-select-text' : 'field-select-placeholder'">{{ form.registeredRegion || '请选择注册地区' }}</text>
+ <view class="icon-chevron"></view>
+ </view>
+ </picker>
+ </view>
+
+ <!-- 营业执照详细地址 -->
+ <view class="field">
+ <text class="field-label">营业执照详细地址</text>
+ <input class="field-input" v-model="form.companyAddress" type="text" placeholder="请输入营业执照详细地址" :placeholder-style="PLACEHOLDER_STYLE" maxlength="255" />
+ </view>
+ </view>
+
+ <!-- 第 2 步:法人信息 -->
+ <view v-else-if="step === 1" class="form">
+ <!-- 法人姓名 -->
+ <view class="field">
+ <text class="field-label">法人姓名</text>
+ <input class="field-input" v-model="form.legalPerson" type="text" placeholder="请输入法人姓名" :placeholder-style="PLACEHOLDER_STYLE" maxlength="64" />
+ </view>
+
+ <!-- 法人身份证人像面 / 国徽面 -->
+ <view class="field">
+ <text class="field-label">法人身份证</text>
+ <view class="upload-row">
+ <view class="upload upload--half" @tap="handleChooseImage('legalIdCardFront', '法人身份证人像面')">
+ <image v-if="form.legalIdCardFront" class="upload-img" :src="form.legalIdCardFront" mode="aspectFill" />
+ <view v-else class="upload-empty">
+ <view class="icon-camera"></view>
+ <text class="upload-empty-text">人像面</text>
+ </view>
+ </view>
+ <view class="upload upload--half" @tap="handleChooseImage('legalIdCardBack', '法人身份证国徽面')">
+ <image v-if="form.legalIdCardBack" class="upload-img" :src="form.legalIdCardBack" mode="aspectFill" />
+ <view v-else class="upload-empty">
+ <view class="icon-camera"></view>
+ <text class="upload-empty-text">国徽面</text>
+ </view>
+ </view>
+ </view>
+ </view>
+
+ <!-- 法人身份证有效期类型 -->
+ <view class="field">
+ <text class="field-label">法人身份证有效期类型</text>
+ <picker :range="VALID_TYPE_LABELS" @change="(e) => handleSelectChange('legalIdValidType', VALID_TYPE_VALUES, e)">
+ <view class="field-select">
+ <text :class="form.legalIdValidType ? 'field-select-text' : 'field-select-placeholder'">{{ labelOf(VALID_TYPE_VALUES, VALID_TYPE_LABELS, form.legalIdValidType) || '请选择有效期类型' }}</text>
+ <view class="icon-chevron"></view>
+ </view>
+ </picker>
+ </view>
+
+ <!-- 法人身份证有效期起止:仅短期展示且必填 -->
+ <view v-if="form.legalIdValidType === '2'" class="field">
+ <text class="field-label">法人身份证有效期</text>
+ <view class="date-range">
+ <picker mode="date" :value="form.legalIdValidStart" @change="(e) => handleDateChange('legalIdValidStart', e)">
+ <view class="date-value" :class="{ 'date-value--placeholder': !form.legalIdValidStart }">{{ form.legalIdValidStart || '开始日期' }}</view>
+ </picker>
+ <text class="date-sep">~</text>
+ <picker mode="date" :value="form.legalIdValidEnd" @change="(e) => handleDateChange('legalIdValidEnd', e)">
+ <view class="date-value" :class="{ 'date-value--placeholder': !form.legalIdValidEnd }">{{ form.legalIdValidEnd || '截止日期' }}</view>
+ </picker>
+ </view>
+ </view>
+
+ <!-- 法人身份证详细地址 -->
+ <view class="field">
+ <text class="field-label">法人身份证详细地址</text>
+ <input class="field-input" v-model="form.legalIdAddress" type="text" placeholder="请输入法人身份证详细地址" :placeholder-style="PLACEHOLDER_STYLE" maxlength="255" />
+ </view>
+
+ <!-- 负责人姓名 -->
+ <view class="field">
+ <text class="field-label">负责人姓名</text>
+ <input class="field-input" v-model="form.contactName" type="text" placeholder="请输入负责人姓名" :placeholder-style="PLACEHOLDER_STYLE" maxlength="64" />
+ </view>
+
+ <!-- 负责人联系电话 -->
+ <view class="field">
+ <text class="field-label">负责人联系电话</text>
+ <input class="field-input" v-model="form.contactPhone" type="number" placeholder="请输入负责人联系电话" :placeholder-style="PLACEHOLDER_STYLE" maxlength="20" />
+ </view>
+
+ <!-- 负责人邮箱 -->
+ <view class="field">
+ <text class="field-label">负责人邮箱</text>
+ <input class="field-input" v-model="form.contactEmail" type="text" placeholder="请输入负责人邮箱" :placeholder-style="PLACEHOLDER_STYLE" maxlength="128" />
+ </view>
+ </view>
+
+ <!-- 第 3 步:收款信息 -->
+ <view v-else class="form">
+ <!-- 收款类型 -->
+ <view class="field">
+ <text class="field-label">收款类型</text>
+ <picker :range="PAYEE_TYPE_OPTIONS" @change="(e) => handlePayeeTypeChange(e)">
+ <view class="field-select">
+ <text :class="form.payeeType ? 'field-select-text' : 'field-select-placeholder'">{{ form.payeeType || '请选择收款类型' }}</text>
+ <view class="icon-chevron"></view>
+ </view>
+ </picker>
+ </view>
+
+ <!-- 银行卡号码面照片 -->
+ <view class="field">
+ <text class="field-label">银行卡号码面照片</text>
+ <view class="upload" @tap="handleChooseImage('bankCardImage', '银行卡号码面照片')">
+ <image v-if="form.bankCardImage" class="upload-img" :src="form.bankCardImage" mode="aspectFill" />
+ <view v-else class="upload-empty">
+ <view class="icon-camera"></view>
+ <text class="upload-empty-text">上传银行卡照片</text>
+ </view>
+ </view>
+ </view>
+
+ <!-- 银行卡号 -->
+ <view class="field">
+ <text class="field-label">银行卡号</text>
+ <input class="field-input" v-model="form.bankCardNo" type="number" placeholder="请输入银行卡号" :placeholder-style="PLACEHOLDER_STYLE" maxlength="32" />
+ </view>
+
+ <!-- 开户城市 -->
+ <view class="field">
+ <text class="field-label">开户城市</text>
+ <picker mode="region" :value="bankRegionValue" @change="(e) => handleRegionChange('bankCity', 'bank', e)">
+ <view class="field-select">
+ <text :class="form.bankCity ? 'field-select-text' : 'field-select-placeholder'">{{ form.bankCity || '请选择开户城市' }}</text>
+ <view class="icon-chevron"></view>
+ </view>
+ </picker>
+ </view>
+ </view>
+ </scroll-view>
+
+ <!-- 底部按钮:前两步「下一步」,第 3 步「提交资料」;结果页隐藏 -->
+ <view v-if="step < 3" class="footer">
+ <view class="btn-submit" :class="{ 'btn-submit--disabled': submitting }" @tap="handlePrimary">
+ <text class="btn-submit-text">{{ step === 2 ? '提交资料' : '下一步' }}</text>
+ </view>
+ </view>
+ </view>
+</template>
+
+<script setup>
+import { computed, reactive, ref } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import { ensureLogin } from '@/utils/request.js'
+import { getCompanyCertificationStatus, submitCompanyCertification, uploadAvatarFile } from '@/api/index.js'
+
+/** 状态栏高度:自定义导航栏下用于顶部留白 */
+const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
+
+/** 图片大小上限:5MB(选图后前置校验,超限直接拒绝,避免无效上传) */
+const IMAGE_MAX_SIZE = 5 * 1024 * 1024
+
+/** 输入框占位色:与 picker/日期占位色统一(小程序 scoped 下 placeholder-class 不生效,改用行内样式) */
+const PLACEHOLDER_STYLE = 'color:#94a3b8;font-size:30rpx'
+
+/** 步骤标题:与原型四步一一对应 */
+const STEP_LABELS = ['企业信息', '法人信息', '收款信息', '提交审核']
+
+/** 主体类型:值 2个体工商户 / 3企业(与后端 subject_type 语义一致) */
+const SUBJECT_TYPE_VALUES = ['2', '3']
+const SUBJECT_TYPE_LABELS = ['个体工商户', '企业']
+
+/** 有效期类型:值 1长期 / 2短期;长期隐藏起止日期且不校验 */
+const VALID_TYPE_VALUES = ['1', '2']
+const VALID_TYPE_LABELS = ['长期', '短期']
+
+/** 收款类型选项:本期仅基础必填,直接存中文文本 */
+const PAYEE_TYPE_OPTIONS = ['对公账户', '对私账户']
+
+/** 结果页文案:审核中 / 已通过 */
+const RESULT_MAP = {
+ auditing: { title: '资料审核中', tip: '您的资料正在审核中,请耐心等待' },
+ passed: { title: '认证已通过', tip: '您的企业/个体户认证已通过审核' }
+}
+
+/** 当前步骤:0~2 为表单步骤,3 为提交审核结果页 */
+const step = ref(0)
+
+/** 结果页类型:'auditing' 审核中 / 'passed' 已通过 */
+const resultState = ref('')
+
+/** 提交中标记:防止重复点击造成重复提交 */
+const submitting = ref(false)
+
+/** 审核状态(0待审核 1已通过 2已驳回;空表示从未提交) */
+const auditStatus = ref('')
+
+/** 驳回原因:仅审核状态为「已驳回」时有值 */
+const rejectReason = ref('')
+
+/** 全部表单字段(有效期起止拆为起/止两个值,提交时再拼接为文本) */
+const form = reactive({
+ subjectType: '',
+ companyName: '',
+ licenseImage: '',
+ creditCode: '',
+ licenseValidType: '',
+ licenseValidStart: '',
+ licenseValidEnd: '',
+ registeredRegion: '',
+ companyAddress: '',
+ legalPerson: '',
+ legalIdCardFront: '',
+ legalIdCardBack: '',
+ legalIdValidType: '',
+ legalIdValidStart: '',
+ legalIdValidEnd: '',
+ legalIdAddress: '',
+ contactName: '',
+ contactPhone: '',
+ contactEmail: '',
+ payeeType: '',
+ bankCardImage: '',
+ bankCardNo: '',
+ bankCity: ''
+})
+
+/** 地区选择器回显值:picker 的 value 需与三级数组保持一致 */
+const companyRegionValue = ref([])
+const bankRegionValue = ref([])
+
+/** 值 → 文案:按同下标映射,未命中返回空串 */
+const labelOf = (values, labels, value) => {
+ const index = values.indexOf(value)
+ return index > -1 ? labels[index] : ''
+}
+
+/** 解析「起~止」文本为起止两个日期;长期或未填时均返回空 */
+const parseValidPeriod = (period) => {
+ if (!period) return { start: '', end: '' }
+ const [start = '', end = ''] = period.split('~')
+ return { start, end }
+}
+
+/** 拼接「起~止」文本:任一项缺失则返回空(长期提交空值) */
+const buildValidPeriod = (start, end) => (start && end ? `${start}~${end}` : '')
+
+/** 是否展示驳回原因 */
+const showRejectReason = computed(() => auditStatus.value === '2' && !!rejectReason.value)
+
+/** 是否已提交(结果页):锁定时不可回退修改 */
+const locked = computed(() => step.value === 3)
+
+/** 结果页文案对象 */
+const resultInfo = computed(() => RESULT_MAP[resultState.value] || RESULT_MAP.auditing)
+
+/** 主体类型文案(用于选择项回显) */
+const subjectTypeLabel = computed(() => labelOf(SUBJECT_TYPE_VALUES, SUBJECT_TYPE_LABELS, form.subjectType))
+
+/** 返回上一页:无上级页面(如直接进入)时回到个人中心 */
+const handleBack = () => {
+ const pages = getCurrentPages()
+ if (pages.length > 1) {
+ uni.navigateBack()
+ return
+ }
+ uni.switchTab({ url: '/pages/profile/index' })
+}
+
+/** 点击步骤条:已提交(结果页)不可回退,仅允许回退到已完成的步骤 */
+const handleStepTap = (idx) => {
+ if (locked.value) return
+ if (idx < step.value) step.value = idx
+}
+
+/** 通用下拉选择回写(主体类型 / 有效期类型) */
+const handleSelectChange = (field, values, e) => {
+ form[field] = values[e.detail.value] || ''
+}
+
+/** 收款类型选择(直接存中文文本) */
+const handlePayeeTypeChange = (e) => {
+ form.payeeType = PAYEE_TYPE_OPTIONS[e.detail.value] || ''
+}
+
+/** 地区选择:回写文本并按空格拆分为数组用于 picker 回显 */
+const handleRegionChange = (field, target, e) => {
+ const region = e.detail.value || []
+ form[field] = region.join(' ')
+ if (target === 'company') {
+ companyRegionValue.value = region
+ } else {
+ bankRegionValue.value = region
+ }
+}
+
+/** 日期选择回写(有效期起 / 止) */
+const handleDateChange = (field, e) => {
+ form[field] = e.detail.value
+}
+
+/** 图片来源选项:相册上传 / 拍照上传,供用户按场景选择 */
+const IMAGE_SOURCE_OPTIONS = [
+ { label: '拍照', sourceType: ['camera'] },
+ { label: '从相册选择', sourceType: ['album'] }
+]
+
+/**
+ * 选图并上传:先选来源(相册/拍照)-> 选图 -> 大小校验 -> 上传文件服务 -> 写回表单字段
+ * @param {String} field 表单字段名
+ * @param {String} label 字段中文名(用于失败提示)
+ */
+const handleChooseImage = (field, label) => {
+ uni.showActionSheet({
+ itemList: IMAGE_SOURCE_OPTIONS.map((item) => item.label),
+ success: (sheetRes) => {
+ const option = IMAGE_SOURCE_OPTIONS[sheetRes.tapIndex]
+ if (option) chooseAndUploadImage(field, label, option.sourceType)
+ }
+ })
+}
+
+/**
+ * 按指定来源选图并上传(相册 / 拍照)
+ * @param {String} field 表单字段名
+ * @param {String} label 字段中文名(用于失败提示)
+ * @param {Array} sourceType 图片来源:['camera'] 拍照 / ['album'] 相册
+ */
+const chooseAndUploadImage = (field, label, sourceType) => {
+ uni.chooseImage({
+ count: 1,
+ sizeType: ['compressed'],
+ sourceType,
+ success: async (res) => {
+ const filePath = (res.tempFilePaths || [])[0]
+ const file = (res.tempFiles || [])[0]
+ if (!filePath) return
+ // 前置大小校验:超限直接提示,避免无效上传
+ if (file && file.size > IMAGE_MAX_SIZE) {
+ uni.showToast({ title: '图片不能超过5MB', icon: 'none' })
+ return
+ }
+ uni.showLoading({ title: '上传中', mask: true })
+ try {
+ const uploadRes = await uploadAvatarFile(filePath)
+ const url = uploadRes.data && uploadRes.data.url
+ uni.hideLoading()
+ if (!url) {
+ uni.showToast({ title: '上传失败,请重试', icon: 'none' })
+ return
+ }
+ form[field] = url
+ } catch (error) {
+ uni.hideLoading()
+ console.error(`[COMPANY_CERT] 上传${label}失败`, error)
+ uni.showToast({ title: '上传失败,请重试', icon: 'none' })
+ }
+ }
+ })
+}
+
+/** 单步必填校验:返回失败提示(通过返回空串) */
+const validateStep = (idx) => {
+ // 第 1 步:企业信息
+ if (idx === 0) {
+ if (!form.subjectType) return '请选择主体类型'
+ if (!form.companyName.trim()) return '请输入企业/个体名称'
+ if (!form.licenseImage) return '请上传营业执照'
+ if (!form.creditCode.trim()) return '请输入统一社会信用代码'
+ if (!form.licenseValidType) return '请选择营业执照有效期类型'
+ // 短期必须填写起止日期;长期隐藏且不校验
+ if (form.licenseValidType === '2' && (!form.licenseValidStart || !form.licenseValidEnd)) {
+ return '请选择营业执照有效期起止日期'
+ }
+ if (!form.registeredRegion) return '请选择营业执照注册地区'
+ if (!form.companyAddress.trim()) return '请输入营业执照详细地址'
+ return ''
+ }
+ // 第 2 步:法人信息
+ if (idx === 1) {
+ if (!form.legalPerson.trim()) return '请输入法人姓名'
+ if (!form.legalIdCardFront) return '请上传法人身份证人像面'
+ if (!form.legalIdCardBack) return '请上传法人身份证国徽面'
+ if (!form.legalIdValidType) return '请选择法人身份证有效期类型'
+ if (form.legalIdValidType === '2' && (!form.legalIdValidStart || !form.legalIdValidEnd)) {
+ return '请选择法人身份证有效期起止日期'
+ }
+ if (!form.legalIdAddress.trim()) return '请输入法人身份证详细地址'
+ if (!form.contactName.trim()) return '请输入负责人姓名'
+ if (!form.contactPhone.trim()) return '请输入负责人联系电话'
+ if (!form.contactEmail.trim()) return '请输入负责人邮箱'
+ return ''
+ }
+ // 第 3 步:收款信息
+ if (idx === 2) {
+ if (!form.payeeType) return '请选择收款类型'
+ if (!form.bankCardImage) return '请上传银行卡号码面照片'
+ if (!form.bankCardNo.trim()) return '请输入银行卡号'
+ if (!form.bankCity) return '请选择开户城市'
+ return ''
+ }
+ return ''
+}
+
+/** 整表单校验:按步骤顺序返回首个缺失项提示 */
+const validateAll = () => validateStep(0) || validateStep(1) || validateStep(2)
+
+/** 组装提交入参(字段名与后端 CompanyCertificationApplyDto 一致) */
+const buildPayload = () => ({
+ subjectType: form.subjectType,
+ companyName: form.companyName.trim(),
+ licenseImage: form.licenseImage,
+ creditCode: form.creditCode.trim(),
+ licenseValidType: form.licenseValidType,
+ licenseValidPeriod: form.licenseValidType === '2' ? buildValidPeriod(form.licenseValidStart, form.licenseValidEnd) : '',
+ registeredRegion: form.registeredRegion,
+ companyAddress: form.companyAddress.trim(),
+ legalPerson: form.legalPerson.trim(),
+ legalIdCardFront: form.legalIdCardFront,
+ legalIdCardBack: form.legalIdCardBack,
+ legalIdValidType: form.legalIdValidType,
+ legalIdValidPeriod: form.legalIdValidType === '2' ? buildValidPeriod(form.legalIdValidStart, form.legalIdValidEnd) : '',
+ legalIdAddress: form.legalIdAddress.trim(),
+ contactName: form.contactName.trim(),
+ contactPhone: form.contactPhone.trim(),
+ contactEmail: form.contactEmail.trim(),
+ payeeType: form.payeeType,
+ bankCardImage: form.bankCardImage,
+ bankCardNo: form.bankCardNo.trim(),
+ bankCity: form.bankCity
+})
+
+/**
+ * 拉取企业认证状态并回填表单
+ * 从未提交 → 停在第 1 步空表单;已驳回 → 停在第 1 步并展示驳回原因;
+ * 审核中 / 已通过 → 直接进入第 4 步结果页(提交后不可再编辑)。
+ * 查询失败静默降级为「未认证」,不阻断用户填报。
+ */
+const loadCompanyCertificationStatus = async () => {
+ try {
+ const res = await getCompanyCertificationStatus()
+ const data = res.data || {}
+ auditStatus.value = data.auditStatus || ''
+ rejectReason.value = data.auditRemark || ''
+ // 回填企业信息
+ form.subjectType = data.subjectType || ''
+ form.companyName = data.companyName || ''
+ form.licenseImage = data.licenseImage || ''
+ form.creditCode = data.creditCode || ''
+ form.licenseValidType = data.licenseValidType || ''
+ form.registeredRegion = data.registeredRegion || ''
+ form.companyAddress = data.companyAddress || ''
+ // 回填法人信息
+ form.legalPerson = data.legalPerson || ''
+ form.legalIdCardFront = data.legalIdCardFront || ''
+ form.legalIdCardBack = data.legalIdCardBack || ''
+ form.legalIdValidType = data.legalIdValidType || ''
+ form.legalIdAddress = data.legalIdAddress || ''
+ form.contactName = data.contactName || ''
+ form.contactPhone = data.contactPhone || ''
+ form.contactEmail = data.contactEmail || ''
+ // 回填收款信息
+ form.payeeType = data.payeeType || ''
+ form.bankCardImage = data.bankCardImage || ''
+ form.bankCardNo = data.bankCardNo || ''
+ form.bankCity = data.bankCity || ''
+ // 有效期文本拆回起止日期
+ const licensePeriod = parseValidPeriod(data.licenseValidPeriod)
+ form.licenseValidStart = licensePeriod.start
+ form.licenseValidEnd = licensePeriod.end
+ const legalPeriod = parseValidPeriod(data.legalIdValidPeriod)
+ form.legalIdValidStart = legalPeriod.start
+ form.legalIdValidEnd = legalPeriod.end
+ // 地区文本按空格拆回三级数组,供 picker 回显
+ if (form.registeredRegion) companyRegionValue.value = form.registeredRegion.split(' ')
+ if (form.bankCity) bankRegionValue.value = form.bankCity.split(' ')
+ // 决定首屏:审核中 / 已通过锁定在结果页
+ if (auditStatus.value === '0') {
+ resultState.value = 'auditing'
+ step.value = 3
+ } else if (auditStatus.value === '1') {
+ resultState.value = 'passed'
+ step.value = 3
+ } else {
+ step.value = 0
+ }
+ } catch (error) {
+ console.error('[COMPANY_CERT] 查询企业认证状态失败', error)
+ auditStatus.value = ''
+ rejectReason.value = ''
+ step.value = 0
+ }
+}
+
+/** 主按钮:第 3 步提交,前两步逐级校验后进入下一步 */
+const handlePrimary = () => {
+ if (submitting.value) return
+ if (step.value === 2) {
+ handleSubmit()
+ return
+ }
+ const errorMsg = validateStep(step.value)
+ if (errorMsg) {
+ uni.showToast({ title: errorMsg, icon: 'none' })
+ return
+ }
+ step.value += 1
+}
+
+/** 提交企业/个体认证:整表单校验通过后调接口,成功后进入结果页 */
+const handleSubmit = async () => {
+ const errorMsg = validateAll()
+ if (errorMsg) {
+ uni.showToast({ title: errorMsg, icon: 'none' })
+ return
+ }
+ submitting.value = true
+ uni.showLoading({ title: '提交中', mask: true })
+ try {
+ await submitCompanyCertification(buildPayload())
+ uni.hideLoading()
+ auditStatus.value = '0'
+ resultState.value = 'auditing'
+ step.value = 3
+ uni.showToast({ title: '提交成功,等待审核', icon: 'none' })
+ } catch (error) {
+ uni.hideLoading()
+ console.error('[COMPANY_CERT] 提交企业认证失败', error)
+ uni.showToast({ title: (error && error.message) || '提交失败,请重试', icon: 'none' })
+ } finally {
+ submitting.value = false
+ }
+}
+
+// 进入页面校验登录态并拉取认证状态回填(未登录跳登录页)
+// 注意:选图/拍照(chooseImage)返回时会再次触发 onShow,若重复拉取会覆盖用户已填内容,
+// 故用 hasLoadedStatus 保证状态回填仅在首次显示时执行一次
+let hasLoadedStatus = false
+onShow(() => {
+ if (!ensureLogin()) return
+ if (hasLoadedStatus) return
+ hasLoadedStatus = true
+ loadCompanyCertificationStatus()
+})
+</script>
+
+<style scoped lang="scss">
+.page {
+ 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;
+}
+
+/* ==================== 四段式步骤条 ==================== */
+
+.steps {
+ flex-shrink: 0;
+ display: flex;
+ align-items: flex-start;
+ padding: 24rpx 32rpx 28rpx;
+ border-bottom: 2rpx solid #f1f5f9;
+}
+
+.step {
+ position: relative;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.step-dot {
+ position: relative;
+ z-index: 1;
+ width: 44rpx;
+ height: 44rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background: #e2e8f0;
+}
+
+/* 已完成:草绿实心 + 打勾 */
+.step-dot--done {
+ background: #82c236;
+}
+
+/* 当前:草绿渐变高亮 */
+.step-dot--active {
+ background: linear-gradient(90deg, #82c236 0%, #639c1f 100%);
+}
+
+.step-dot-check {
+ font-size: 24rpx;
+ font-weight: 700;
+ line-height: 1;
+ color: #ffffff;
+}
+
+.step-dot-num {
+ font-size: 24rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+.step-dot--active .step-dot-num {
+ color: #ffffff;
+}
+
+/* 连接线:从当前步骤圆心延伸到下一步骤圆心 */
+.step-line {
+ position: absolute;
+ top: 21rpx;
+ left: 50%;
+ width: 100%;
+ height: 4rpx;
+ background: #e2e8f0;
+}
+
+.step-line--done {
+ background: #82c236;
+}
+
+.step-label {
+ margin-top: 12rpx;
+ font-size: 24rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+.step-label--active {
+ font-weight: 600;
+ color: #639c1f;
+}
+
+.step-label--done {
+ color: #334155;
+}
+
+/* ==================== 内容区 ==================== */
+
+.body {
+ flex: 1;
+ min-height: 0;
+}
+
+.form {
+ padding: 32rpx 32rpx 40rpx;
+}
+
+/* ==================== 表单字段 ==================== */
+
+.field {
+ margin-bottom: 40rpx;
+}
+
+.field-label {
+ display: block;
+ font-size: 28rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #334155;
+}
+
+.field-input {
+ height: 88rpx;
+ margin-top: 8rpx;
+ font-size: 30rpx;
+ color: #1f2937;
+ border-bottom: 2rpx solid #e2e8f0;
+}
+
+/* 下拉选择行:文本 + 右箭头 */
+.field-select {
+ height: 88rpx;
+ margin-top: 8rpx;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 2rpx solid #e2e8f0;
+}
+
+.field-select-text {
+ font-size: 30rpx;
+ color: #1f2937;
+}
+
+.field-select-placeholder {
+ font-size: 30rpx;
+ color: #94a3b8;
+}
+
+.icon-chevron {
+ width: 32rpx;
+ height: 32rpx;
+ flex-shrink: 0;
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: 100% 100%;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTRBM0I4IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0ibTkgMTggNi02LTYtNiIvPjwvc3ZnPg==');
+}
+
+/* ==================== 有效期起止日期 ==================== */
+
+.date-range {
+ height: 88rpx;
+ margin-top: 8rpx;
+ display: flex;
+ align-items: center;
+ border-bottom: 2rpx solid #e2e8f0;
+}
+
+.date-range picker {
+ flex: 1;
+}
+
+.date-value {
+ font-size: 30rpx;
+ color: #1f2937;
+}
+
+.date-value--placeholder {
+ color: #94a3b8;
+}
+
+.date-sep {
+ flex-shrink: 0;
+ padding: 0 20rpx;
+ font-size: 30rpx;
+ color: #94a3b8;
+}
+
+/* ==================== 图片上传 ==================== */
+
+.upload {
+ width: 100%;
+ height: 260rpx;
+ margin-top: 16rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ border: 2rpx dashed #cbd5e1;
+ border-radius: 12rpx;
+ background: #f8fafc;
+ overflow: hidden;
+}
+
+/* 法人身份证正反面并排 */
+.upload-row {
+ display: flex;
+ gap: 24rpx;
+ margin-top: 16rpx;
+}
+
+.upload--half {
+ flex: 1;
+ width: auto;
+ height: 220rpx;
+ margin-top: 0;
+}
+
+.upload-img {
+ width: 100%;
+ height: 100%;
+}
+
+.upload-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16rpx;
+}
+
+.upload-empty-text {
+ font-size: 26rpx;
+ color: #94a3b8;
+}
+
+.icon-camera {
+ width: 48rpx;
+ height: 48rpx;
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: 100% 100%;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTRBM0I4IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTE0LjUgNGgtNUw3IDdINGEyIDIgMCAwIDAtMiAydjlhMiAyIDAgMCAwIDIgMmgxNmEyIDIgMCAwIDAgMi0yVjlhMiAyIDAgMCAwLTItMmgtM2wtMi41LTN6Ii8+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMyIgcj0iMyIvPjwvc3ZnPg==');
+}
+
+/* ==================== 驳回原因 ==================== */
+
+.reject-tip {
+ margin-bottom: 32rpx;
+ padding: 20rpx 24rpx;
+ border-radius: 12rpx;
+ background: #fef2f2;
+}
+
+.reject-tip-text {
+ font-size: 26rpx;
+ line-height: 1.5;
+ color: #ef4444;
+}
+
+/* ==================== 结果页 ==================== */
+
+.result {
+ padding: 140rpx 64rpx 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.result-icon {
+ width: 140rpx;
+ height: 140rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+}
+
+.result-icon--audit {
+ background: #eff6ff;
+}
+
+.result-icon--done {
+ background: #f7fee7;
+}
+
+.result-icon-text {
+ font-size: 40rpx;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.result-icon--audit .result-icon-text {
+ color: #1e5ebc;
+}
+
+.result-icon--done .result-icon-text {
+ color: #4d7c0f;
+}
+
+.result-title {
+ margin-top: 40rpx;
+ font-size: 34rpx;
+ font-weight: 700;
+ line-height: 1;
+ color: #0f172a;
+}
+
+.result-tip {
+ margin-top: 20rpx;
+ font-size: 28rpx;
+ line-height: 1.5;
+ text-align: center;
+ color: #94a3b8;
+}
+
+/* ==================== 底部提交 ==================== */
+
+.footer {
+ flex-shrink: 0;
+ padding: 24rpx 48rpx calc(44rpx + env(safe-area-inset-bottom));
+}
+
+.btn-submit {
+ width: 100%;
+ height: 96rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 12rpx;
+ background: linear-gradient(90deg, #82c236 0%, #639c1f 100%);
+ box-shadow: 0 16rpx 40rpx rgba(99, 156, 31, 0.28);
+}
+
+.btn-submit--disabled {
+ opacity: 0.6;
+}
+
+.btn-submit-text {
+ font-size: 32rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #ffffff;
+}
+</style>
diff --git a/hugray-user-miniapp/pages/profile/edit.vue b/hugray-user-miniapp/pages/profile/edit.vue
index 9cf013f..f6ae495 100644
--- a/hugray-user-miniapp/pages/profile/edit.vue
+++ b/hugray-user-miniapp/pages/profile/edit.vue
@@ -26,8 +26,8 @@
<view class="body">
<view class="list">
- <!-- 头像:点击更换(本期占位) -->
- <view class="row" @tap="handlePlaceholder('头像更换')">
+ <!-- 头像:点击更换 -->
+ <view class="row" @tap="handleAvatarChange">
<text class="row-label">头像</text>
<view class="row-right">
<view class="avatar">
@@ -45,7 +45,7 @@
</view>
<!-- 实名认证 -->
- <view class="row" @tap="handlePlaceholder('实名认证')">
+ <view class="row" @tap="handleRealNameCertification">
<text class="row-label">实名认证</text>
<view class="row-right">
<view class="badge" :class="realNameBadge.cls">
@@ -56,7 +56,7 @@
</view>
<!-- 企业 / 个体户认证 -->
- <view class="row" @tap="handlePlaceholder('企业/个体户认证')">
+ <view class="row" @tap="handleCompanyCertification">
<text class="row-label">企业/个体户认证</text>
<view class="row-right">
<view class="badge" :class="companyBadge.cls">
@@ -82,10 +82,13 @@
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'
+import { getMemberInfo, logout, uploadAvatarFile, updateAvatar } from '@/api/index.js'
/** 状态栏高度:自定义导航栏下用于顶部留白 */
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
+
+/** 头像文件大小上限:5MB(选图后前置校验,超限直接拒绝,避免无效上传) */
+const AVATAR_MAX_SIZE = 5 * 1024 * 1024
/** 用户头像:登录后由 getMemberInfo 下发,为空时回退相机占位 */
const avatar = ref('')
@@ -94,8 +97,7 @@
const phone = ref('')
/**
- * 认证状态字典:key 为后端下发的状态枚举,值含徽标文案与样式类
- * 本期为原型,两处认证均取 none(未认证)占位,后端接入后直接替换 ref 初值即可
+ * 认证状态字典:key 为本地徽标状态,值含徽标文案与样式类
*/
const AUTH_STATUS_MAP = {
none: { text: '未认证', cls: 'badge--none' },
@@ -104,10 +106,21 @@
done: { text: '已认证', cls: 'badge--done' }
}
-/** 实名认证状态(本期占位) */
+/**
+ * 后端认证状态码 → 本地徽标状态(0未认证 1审核中 2已认证 3认证失败)
+ * 个人中心「实名认证」行据此回显真实状态
+ */
+const AUTH_STATUS_CODE_MAP = {
+ '0': 'none',
+ '1': 'ing',
+ '2': 'done',
+ '3': 'fail'
+}
+
+/** 实名认证状态:由会员信息接口下发的 authStatus 映射,缺省「未认证」 */
const realNameStatus = ref('none')
-/** 企业 / 个体户认证状态(本期占位) */
+/** 企业 / 个体户认证状态:由会员信息接口下发的 companyAuthStatus 映射,缺省「未认证」 */
const companyStatus = ref('none')
/** 实名认证徽标:状态缺省兜底「未认证」 */
@@ -126,9 +139,72 @@
uni.switchTab({ url: '/pages/profile/index' })
}
-/** 占位交互:头像更换 / 实名认证 / 企业认证 / 注销 / 更多 / 帮助本期均不接后端 */
+/** 占位交互:注销 / 更多 / 帮助本期均不接后端 */
const handlePlaceholder = (name) => {
uni.showToast({ title: `${name}功能开发中`, icon: 'none' })
+}
+
+/** 跳转实名认证页(个人主体) */
+const handleRealNameCertification = () => {
+ uni.navigateTo({ url: '/pages/profile/certification' })
+}
+
+/** 跳转企业/个体认证向导页(企业 / 个体工商户主体) */
+const handleCompanyCertification = () => {
+ uni.navigateTo({ url: '/pages/profile/company-certification' })
+}
+
+/**
+ * 更换头像入口:先弹出选择方式(相册 / 拍照 / 取消)
+ * 说明:取消按钮由系统 ActionSheet 自带,用户取消时不触发 success,无需额外处理
+ */
+const handleAvatarChange = () => {
+ uni.showActionSheet({
+ itemList: ['从相册选择', '拍照上传'],
+ success: (res) => {
+ // tapIndex 0=相册 1=拍照
+ chooseAndUploadAvatar(res.tapIndex === 1 ? 'camera' : 'album')
+ }
+ })
+}
+
+/**
+ * 选图并上传:选图 -> 大小校验 -> 上传文件服务 -> 写回会员资料 -> 本地即时回显
+ * @param {String} sourceType 图片来源:'album' 相册 / 'camera' 拍照
+ */
+const chooseAndUploadAvatar = (sourceType) => {
+ uni.chooseImage({
+ count: 1,
+ sizeType: ['compressed'],
+ sourceType: [sourceType],
+ success: async (res) => {
+ const filePath = (res.tempFilePaths || [])[0]
+ const file = (res.tempFiles || [])[0]
+ if (!filePath) return
+ // 前置大小校验:超限直接提示,避免无效上传
+ if (file && file.size > AVATAR_MAX_SIZE) {
+ uni.showToast({ title: '图片不能超过5MB', icon: 'none' })
+ return
+ }
+ try {
+ // STEP 1: 上传文件到文件服务,拿到可访问 url
+ const uploadRes = await uploadAvatarFile(filePath)
+ const url = uploadRes.data && uploadRes.data.url
+ if (!url) {
+ uni.showToast({ title: '上传失败,请重试', icon: 'none' })
+ return
+ }
+ // STEP 2: 把 url 写回会员资料
+ await updateAvatar(url)
+ // STEP 3: 本地即时回显,无需再拉接口
+ avatar.value = url
+ uni.showToast({ title: '头像已更新', icon: 'success' })
+ } catch (error) {
+ console.error('[PROFILE_EDIT] 更新头像失败', error)
+ uni.showToast({ title: (error && error.message) || '更新头像失败', icon: 'none' })
+ }
+ }
+ })
}
/** 退出登录:二次确认后调用退出接口并清理本地登录态,回到个人中心 */
@@ -152,17 +228,23 @@
})
}
-/** 拉取当前会员信息(头像 / 脱敏手机号),失败时静默降级为空 */
+/** 拉取当前会员信息(头像 / 脱敏手机号 / 实名认证状态),失败时静默降级为空 */
const loadMemberInfo = async () => {
try {
const res = await getMemberInfo()
const data = res.data || {}
avatar.value = data.avatar || ''
phone.value = data.phone || ''
+ // 实名认证徽标据后端 authStatus 映射回显(审核通过/驳回后再次进入本页即刷新)
+ realNameStatus.value = AUTH_STATUS_CODE_MAP[data.authStatus] || 'none'
+ // 企业/个体户认证徽标据后端 companyAuthStatus 映射回显,与实名认证相互独立
+ companyStatus.value = AUTH_STATUS_CODE_MAP[data.companyAuthStatus] || 'none'
} catch (error) {
console.error('[PROFILE_EDIT] 拉取会员信息失败', error)
avatar.value = ''
phone.value = ''
+ realNameStatus.value = 'none'
+ companyStatus.value = 'none'
}
}
diff --git a/hugray-user-miniapp/pages/profile/index.vue b/hugray-user-miniapp/pages/profile/index.vue
index 38ce0af..d41373d 100644
--- a/hugray-user-miniapp/pages/profile/index.vue
+++ b/hugray-user-miniapp/pages/profile/index.vue
@@ -164,6 +164,11 @@
uni.navigateTo({ url: '/pages/address/list' })
return
}
+ // 我的邀请:跳转「我的邀请」页(邀请码 / 我邀请的 / 邀请我的)
+ if (menu.key === 'invite') {
+ uni.navigateTo({ url: '/pages/profile/invite' })
+ return
+ }
uni.showToast({ title: '敬请期待', icon: 'none' })
}
diff --git a/hugray-user-miniapp/pages/profile/invite.vue b/hugray-user-miniapp/pages/profile/invite.vue
new file mode 100644
index 0000000..5459889
--- /dev/null
+++ b/hugray-user-miniapp/pages/profile/invite.vue
@@ -0,0 +1,781 @@
+<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>
+ </view>
+
+ <!-- 三段式 Tab:邀请码 / 我邀请的 / 邀请我的 -->
+ <view class="tabs">
+ <view
+ v-for="tab in TAB_LIST"
+ :key="tab.key"
+ class="tab-item"
+ @tap="handleTabChange(tab.key)"
+ >
+ <text class="tab-text" :class="{ 'tab-text--active': activeTab === tab.key }">
+ {{ tab.label }}
+ </text>
+ <!-- 选中态底部指示条 -->
+ <view v-if="activeTab === tab.key" class="tab-indicator"></view>
+ </view>
+ </view>
+
+ <view class="body">
+ <!-- ==================== Tab1:邀请码 ==================== -->
+ <view v-if="activeTab === 'code'" class="code-panel">
+ <view class="qrcode-card">
+ <!-- 真实小程序码(base64 DataURI),扫码直达注册页并自动填入邀请码 -->
+ <image v-if="qrcode" class="qrcode-img" :src="qrcode" mode="aspectFit" />
+ <view v-else class="qrcode-placeholder">
+ <text class="qrcode-placeholder-text">二维码生成中…</text>
+ </view>
+ <text class="qrcode-caption">我的邀请码</text>
+ <!-- 点击复制邀请码 -->
+ <view class="code-value" @tap="handleCopyCode">
+ <text class="code-value-text">{{ inviteCode || '—' }}</text>
+ </view>
+ </view>
+
+ <!-- 分享按钮:open-type=share 触发 onShareAppMessage -->
+ <button class="btn-share" open-type="share">
+ <text class="btn-share-text">分享邀请码</text>
+ </button>
+ </view>
+
+ <!-- ==================== Tab2:我邀请的 ==================== -->
+ <view v-else-if="activeTab === 'invitees'" class="invitee-panel">
+ <view v-if="invitees.length > 0" class="invitee-list">
+ <view v-for="item in invitees" :key="item.userId" class="invitee-row">
+ <!-- 头像:为空回退默认用户图标 -->
+ <view class="invitee-avatar">
+ <image
+ v-if="item.avatar"
+ class="invitee-avatar-img"
+ :src="item.avatar"
+ mode="aspectFill"
+ />
+ <view v-else class="icon icon-user"></view>
+ </view>
+ <!-- 中部:昵称 + 等级徽标 / 脱敏手机号 -->
+ <view class="invitee-main">
+ <view class="invitee-name-line">
+ <text class="invitee-name">{{ displayName(item) }}</text>
+ <view class="level-badge">
+ <text class="level-badge-text">{{ item.systemLevel || 'C' }}</text>
+ </view>
+ </view>
+ <text class="invitee-phone">{{ item.phone || '—' }}</text>
+ </view>
+ <!-- 右侧:合计消费 / 绑定日 -->
+ <view class="invitee-side">
+ <text class="invitee-amount">合计消费:¥{{ formatYuanAmount(item.buyAmountTotal) }}</text>
+ <text v-if="item.bindDate" class="invitee-date">绑定日:{{ item.bindDate }}</text>
+ </view>
+ </view>
+ </view>
+
+ <!-- 空态:无邀请记录 -->
+ <view v-else-if="!listLoading" class="empty">
+ <view class="empty-icon-wrap">
+ <view class="icon icon-empty-invite"></view>
+ </view>
+ <text class="empty-title">还没有邀请好友</text>
+ <text class="empty-desc">分享邀请码,邀请好友加入</text>
+ </view>
+
+ <!-- 加载更多提示 -->
+ <view v-if="invitees.length > 0" class="list-foot">
+ <text class="list-foot-text">{{ hasMore ? '加载中…' : '没有更多了' }}</text>
+ </view>
+ </view>
+
+ <!-- ==================== Tab3:邀请我的 ==================== -->
+ <view v-else class="referrer-panel">
+ <view v-if="referrer && referrer.userId" class="referrer-card">
+ <view class="referrer-avatar">
+ <image
+ v-if="referrer.avatar"
+ class="referrer-avatar-img"
+ :src="referrer.avatar"
+ mode="aspectFill"
+ />
+ <view v-else class="icon icon-user-lg"></view>
+ </view>
+ <view class="referrer-name-line">
+ <text class="referrer-name">{{ displayName(referrer) }}</text>
+ <view class="level-badge">
+ <text class="level-badge-text">{{ referrer.systemLevel || 'C' }}</text>
+ </view>
+ </view>
+ <text class="referrer-phone">{{ referrer.phone || '—' }}</text>
+ <text v-if="referrer.bindDate" class="referrer-date">绑定日:{{ referrer.bindDate }}</text>
+ </view>
+
+ <!-- 空态:无上级信息 -->
+ <view v-else-if="!referrerLoading" class="empty">
+ <view class="empty-icon-wrap">
+ <view class="icon icon-empty-referrer"></view>
+ </view>
+ <text class="empty-title">暂无上级信息</text>
+ <text class="empty-desc">绑定上级后这里会展示ta的信息</text>
+ </view>
+ </view>
+ </view>
+ </view>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { onLoad, onReachBottom, onShareAppMessage } from '@dcloudio/uni-app'
+import { ensureLogin } from '@/utils/request.js'
+import { getInviteCode, getInviteQrcode, getInvitees, getReferrer } from '@/api/index.js'
+
+/** 状态栏高度:自定义导航栏下用于顶部留白 */
+const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
+
+/** 每页条数:与后端 TableSupport 默认值保持一致 */
+const PAGE_SIZE = 10
+
+/** 三 Tab 配置:key 与后端无关,仅前端切换用 */
+const TAB_LIST = [
+ { key: 'code', label: '邀请码' },
+ { key: 'invitees', label: '我邀请的' },
+ { key: 'referrer', label: '邀请我的' }
+]
+
+/** 当前 Tab,默认「邀请码」 */
+const activeTab = ref('code')
+
+/** 本人邀请码原值(8 位 A-Z0-9) */
+const inviteCode = ref('')
+
+/** 本人邀请码小程序码(PNG base64 DataURI),直接作为 image src */
+const qrcode = ref('')
+
+/** 我邀请的 列表数据 */
+const invitees = ref([])
+/** 我邀请的 当前页码(下一页待请求页码) */
+const pageNum = ref(1)
+/** 我邀请的 是否还有下一页 */
+const hasMore = ref(true)
+/** 我邀请的 加载中标志(防止重复请求) */
+const listLoading = ref(false)
+
+/** 邀请我的 上级信息,无上级时为 null */
+const referrer = ref(null)
+/** 邀请我的 加载中标志 */
+const referrerLoading = ref(false)
+/** 邀请我的 是否已加载过(懒加载,避免重复请求) */
+const referrerLoaded = ref(false)
+
+/**
+ * 展示名:昵称优先,为空时回退脱敏手机号,均无则兜底「会员」
+ * @param {Object} user 含 nickname / phone 的用户对象
+ */
+const displayName = (user) => (user && (user.nickname || user.phone)) || '会员'
+
+/**
+ * 合计消费格式化:后端 buyAmountTotal 单位为「元」,保留两位小数
+ * 注意:不可复用 formatYuan(后者按「分」处理)
+ * @param {Number|String} val 金额(元)
+ */
+const formatYuanAmount = (val) => {
+ const num = Number(val)
+ return Number.isFinite(num) ? num.toFixed(2) : '0.00'
+}
+
+/** 拉取本人邀请码,成功后继续拉取小程序码 */
+const loadInviteInfo = async () => {
+ try {
+ const res = await getInviteCode()
+ inviteCode.value = (res.data && res.data.referralCode) || ''
+ if (inviteCode.value) {
+ loadQrcode(inviteCode.value)
+ }
+ } catch (e) {
+ console.error('[INVITE] 获取邀请码失败', e)
+ }
+}
+
+/**
+ * 拉取小程序码(以邀请码为 scene)
+ * @param {String} code 邀请码
+ */
+const loadQrcode = async (code) => {
+ try {
+ const res = await getInviteQrcode(code)
+ qrcode.value = (res.data && res.data.qrcode) || ''
+ } catch (e) {
+ console.error('[INVITE] 获取邀请二维码失败', e)
+ uni.showToast({ title: '二维码生成失败,请重试', icon: 'none' })
+ }
+}
+
+/**
+ * 拉取我邀请的列表(分页)
+ * @param {Boolean} reset 是否重置为第一页(首次进入 / 下拉刷新)
+ */
+const loadInvitees = async (reset = false) => {
+ if (listLoading.value) return
+ // 非重置且已无更多数据时直接返回,避免无效请求
+ if (!reset && !hasMore.value) return
+ if (reset) {
+ pageNum.value = 1
+ hasMore.value = true
+ }
+ listLoading.value = true
+ try {
+ const res = await getInvitees({ pageNum: pageNum.value, pageSize: PAGE_SIZE })
+ // 分页接口返回 TableDataInfo:数据在顶层 rows(非 data 包裹)
+ const rows = res.rows || []
+ invitees.value = reset ? rows : invitees.value.concat(rows)
+ const total = res.total || 0
+ // 已加载条数达到总数或本页返回空,视为无更多
+ hasMore.value = rows.length > 0 && invitees.value.length < total
+ if (hasMore.value) pageNum.value += 1
+ } catch (e) {
+ console.error('[INVITE] 获取邀请列表失败', e)
+ } finally {
+ listLoading.value = false
+ }
+}
+
+/** 拉取邀请我的(上级)信息 */
+const loadReferrer = async () => {
+ referrerLoading.value = true
+ try {
+ const res = await getReferrer()
+ referrer.value = res.data || null
+ } catch (e) {
+ console.error('[INVITE] 获取上级信息失败', e)
+ referrer.value = null
+ } finally {
+ referrerLoading.value = false
+ referrerLoaded.value = true
+ }
+}
+
+/** Tab 切换:进入「我邀请的」「邀请我的」时按需懒加载 */
+const handleTabChange = (key) => {
+ if (activeTab.value === key) return
+ activeTab.value = key
+ if (key === 'invitees' && invitees.value.length === 0) {
+ loadInvitees(true)
+ }
+ if (key === 'referrer' && !referrerLoaded.value) {
+ loadReferrer()
+ }
+}
+
+/** 点击邀请码:复制到剪贴板 */
+const handleCopyCode = () => {
+ if (!inviteCode.value) return
+ uni.setClipboardData({
+ data: inviteCode.value,
+ success: () => uni.showToast({ title: '邀请码已复制', icon: 'none' })
+ })
+}
+
+/** 返回:优先返回上一页,无上一页时回个人中心(Tab 页) */
+const handleBack = () => {
+ const pages = getCurrentPages()
+ if (pages.length > 1) {
+ uni.navigateBack()
+ return
+ }
+ uni.switchTab({ url: '/pages/profile/index' })
+}
+
+/** 页面加载:校验登录态并拉取邀请码与小程序码 */
+onLoad(() => {
+ if (!ensureLogin()) return
+ loadInviteInfo()
+})
+
+/** 触底加载更多(仅「我邀请的」Tab 生效) */
+onReachBottom(() => {
+ if (activeTab.value === 'invitees') {
+ loadInvitees(false)
+ }
+})
+
+/** 分享:携带邀请码直达注册页,扫码/点开注册即自动绑定 */
+onShareAppMessage(() => {
+ const path = inviteCode.value
+ ? `/pages/login/register?referralCode=${inviteCode.value}`
+ : '/pages/login/register'
+ return {
+ title: '邀请你加入代谢300,注册自动绑定邀请码',
+ path
+ }
+})
+</script>
+
+<style scoped lang="scss">
+.page {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ background-color: #f8fafc;
+}
+
+/* ==================== 自定义导航栏 ==================== */
+
+.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 #0f172a;
+ border-bottom: 4rpx solid #0f172a;
+ transform: rotate(45deg);
+}
+
+.nav-title {
+ font-size: 34rpx;
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: 1rpx;
+ color: #0f172a;
+}
+
+/* ==================== 三段式 Tab ==================== */
+
+.tabs {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ background-color: #ffffff;
+ border-bottom: 2rpx solid #e2e8f0;
+}
+
+.tab-item {
+ flex: 1;
+ height: 88rpx;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.tab-text {
+ font-size: 30rpx;
+ line-height: 1;
+ color: #64748b;
+}
+
+.tab-text--active {
+ font-weight: 600;
+ color: #10b981;
+}
+
+/* 选中态底部指示条 */
+.tab-indicator {
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 96rpx;
+ height: 4rpx;
+ border-radius: 4rpx;
+ background-color: #10b981;
+}
+
+.body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ==================== Tab1:邀请码 ==================== */
+
+.code-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: 40rpx 40rpx 60rpx;
+}
+
+.qrcode-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 60rpx 32rpx 56rpx;
+ border-radius: 16rpx;
+ background-color: #ffffff;
+ border: 2rpx solid #e2e8f0;
+ box-shadow: 0 6rpx 16rpx rgba(15, 23, 42, 0.06);
+}
+
+.qrcode-img {
+ width: 400rpx;
+ height: 400rpx;
+}
+
+.qrcode-placeholder {
+ width: 400rpx;
+ height: 400rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 12rpx;
+ background-color: #f1f5f9;
+}
+
+.qrcode-placeholder-text {
+ font-size: 26rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+.qrcode-caption {
+ margin-top: 40rpx;
+ font-size: 30rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #0f172a;
+}
+
+.code-value {
+ margin-top: 20rpx;
+ padding: 12rpx 32rpx;
+ border-radius: 12rpx;
+ background-color: #ecfdf5;
+}
+
+.code-value-text {
+ font-size: 32rpx;
+ font-weight: 700;
+ letter-spacing: 6rpx;
+ line-height: 1;
+ color: #10b981;
+}
+
+/* 分享按钮:整宽绿色胶囊 */
+.btn-share {
+ margin-top: auto;
+ width: 100%;
+ height: 96rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: none;
+ border-radius: 48rpx;
+ background: linear-gradient(90deg, #34d399 0%, #10b981 100%);
+ box-shadow: 0 12rpx 32rpx rgba(16, 185, 129, 0.28);
+}
+
+/* 覆盖小程序 button 默认伪元素边框 */
+.btn-share::after {
+ border: none;
+}
+
+.btn-share-text {
+ font-size: 32rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #ffffff;
+}
+
+/* ==================== Tab2:我邀请的 ==================== */
+
+.invitee-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: 24rpx 32rpx 0;
+}
+
+.invitee-list {
+ border-radius: 16rpx;
+ overflow: hidden;
+ background-color: #ffffff;
+ border: 2rpx solid #e2e8f0;
+ box-shadow: 0 6rpx 16rpx rgba(15, 23, 42, 0.06);
+}
+
+.invitee-row {
+ display: flex;
+ align-items: center;
+ padding: 28rpx 28rpx;
+ border-bottom: 2rpx solid #f1f5f9;
+}
+
+/* 末行无分隔线 */
+.invitee-row:last-child {
+ border-bottom: none;
+}
+
+.invitee-avatar {
+ flex-shrink: 0;
+ width: 88rpx;
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ overflow: hidden;
+ background-color: #f1f5f9;
+}
+
+.invitee-avatar-img {
+ width: 100%;
+ height: 100%;
+}
+
+.invitee-main {
+ flex: 1;
+ min-width: 0;
+ margin-left: 24rpx;
+ display: flex;
+ flex-direction: column;
+ gap: 12rpx;
+}
+
+.invitee-name-line {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+}
+
+.invitee-name {
+ max-width: 240rpx;
+ font-size: 30rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #0f172a;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.invitee-phone {
+ font-size: 26rpx;
+ line-height: 1;
+ color: #64748b;
+}
+
+.invitee-side {
+ flex-shrink: 0;
+ margin-left: 16rpx;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 12rpx;
+}
+
+.invitee-amount {
+ font-size: 28rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #0f172a;
+}
+
+.invitee-date {
+ font-size: 24rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+/* ==================== Tab3:邀请我的 ==================== */
+
+.referrer-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 80rpx 40rpx 0;
+}
+
+.referrer-card {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 64rpx 32rpx;
+ border-radius: 16rpx;
+ background-color: #ffffff;
+ border: 2rpx solid #e2e8f0;
+ box-shadow: 0 6rpx 16rpx rgba(15, 23, 42, 0.06);
+}
+
+.referrer-avatar {
+ width: 160rpx;
+ height: 160rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ overflow: hidden;
+ background-color: #f1f5f9;
+}
+
+.referrer-avatar-img {
+ width: 100%;
+ height: 100%;
+}
+
+.referrer-name-line {
+ margin-top: 32rpx;
+ display: flex;
+ align-items: center;
+ gap: 16rpx;
+}
+
+.referrer-name {
+ font-size: 36rpx;
+ font-weight: 700;
+ line-height: 1;
+ color: #0f172a;
+}
+
+.referrer-phone {
+ margin-top: 24rpx;
+ font-size: 30rpx;
+ line-height: 1;
+ letter-spacing: 1rpx;
+ color: #334155;
+}
+
+.referrer-date {
+ margin-top: 20rpx;
+ font-size: 26rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+/* ==================== 等级徽标(实心绿胶囊) ==================== */
+
+.level-badge {
+ display: flex;
+ align-items: center;
+ padding: 4rpx 14rpx;
+ border-radius: 6rpx;
+ background-color: #10b981;
+}
+
+.level-badge-text {
+ font-size: 22rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #ffffff;
+}
+
+/* ==================== 空态 / 加载更多 ==================== */
+
+.empty {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 140rpx 0 0;
+}
+
+/* 圆形底衬 + 空态图标 */
+.empty-icon-wrap {
+ width: 200rpx;
+ height: 200rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background-color: #f1f5f9;
+}
+
+.empty-title {
+ margin-top: 36rpx;
+ font-size: 30rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #475569;
+}
+
+.empty-desc {
+ margin-top: 16rpx;
+ font-size: 26rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+.list-foot {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 32rpx 0;
+}
+
+.list-foot-text {
+ font-size: 24rpx;
+ line-height: 1;
+ color: #cbd5e1;
+}
+
+/* ==================== 图标(内联 base64 SVG) ==================== */
+
+.icon {
+ flex-shrink: 0;
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: 100% 100%;
+}
+
+/* 默认用户占位(#CBD5E1) */
+.icon-user {
+ width: 68rpx;
+ height: 68rpx;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjQ0JENUUxIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTE5IDIxdi0yYTQgNCAwIDAgMC00LTRIOWE0IDQgMCAwIDAtNCA0djIiLz48Y2lyY2xlIGN4PSIxMiIgY3k9IjciIHI9IjQiLz48L3N2Zz4=');
+}
+
+/* 上级大头像占位(#CBD5E1) */
+.icon-user-lg {
+ width: 120rpx;
+ height: 120rpx;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjQ0JENUUxIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTE5IDIxdi0yYTQgNCAwIDAgMC00LTRIOWE0IDQgMCAwIDAtNCA0djIiLz48Y2lyY2xlIGN4PSIxMiIgY3k9IjciIHI9IjQiLz48L3N2Zz4=');
+}
+
+/* 空态图标:邀请好友(#CBD5E1) */
+.icon-empty-invite {
+ width: 96rpx;
+ height: 96rpx;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjY2JkNWUxIiBzdHJva2Utd2lkdGg9IjEuNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTUgMjF2LTJhNCA0IDAgMCAwLTQtNEg2YTQgNCAwIDAgMC00IDR2MiIvPjxjaXJjbGUgY3g9IjguNSIgY3k9IjciIHI9IjQiLz48cGF0aCBkPSJNMTkgOHY2Ii8+PHBhdGggZD0iTTIyIDExaC02Ii8+PC9zdmc+');
+}
+
+/* 空态图标:暂无上级(#CBD5E1) */
+.icon-empty-referrer {
+ width: 96rpx;
+ height: 96rpx;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjY2JkNWUxIiBzdHJva2Utd2lkdGg9IjEuNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTkgMjF2LTJhNCA0IDAgMCAwLTQtNEg5YTQgNCAwIDAgMC00IDR2MiIvPjxjaXJjbGUgY3g9IjEyIiBjeT0iNyIgcj0iNCIvPjwvc3ZnPg==');
+}
+</style>
diff --git a/hugray-user-miniapp/utils/request.js b/hugray-user-miniapp/utils/request.js
index 6e51d28..9bfa7a8 100644
--- a/hugray-user-miniapp/utils/request.js
+++ b/hugray-user-miniapp/utils/request.js
@@ -136,6 +136,100 @@
}, 1500)
}
+// ======================== 文件上传方法 ========================
+
+/**
+ * 统一文件上传方法(基于 uni.uploadFile)
+ * 与 request 保持一致的令牌注入、响应解包与登录失效处理,供图片/文件上传使用
+ * @param {String} url 上传接口路径(不含根地址;传完整 http 地址则直接使用)
+ * @param {String} filePath 本地文件路径(如 chooseImage 返回的 tempFilePath)
+ * @param {Object} [options] 可选配置
+ * @param {String} [options.name] 表单文件字段名,默认 file
+ * @param {Object} [options.formData] 附加表单参数
+ * @param {Object} [options.header] 自定义请求头
+ * @param {Boolean} [options.auth] 是否需要携带 token,默认 true
+ * @param {Boolean} [options.loading] 是否显示上传中提示,默认 true
+ * @param {String} [options.loadingText] 上传提示文案
+ * @returns {Promise<Object>} 解析后的响应体 { code, msg, data }
+ */
+export function uploadFile(url = '', filePath = '', options = {}) {
+ const {
+ name = 'file',
+ formData = {},
+ header = {},
+ auth = true,
+ loading = true,
+ loadingText = '上传中...'
+ } = options
+
+ // 拼接完整请求地址
+ const fullUrl = /^https?:\/\//i.test(url) ? url : config.baseUrl + url
+
+ // 组装请求头并注入令牌(multipart 边界由底层自动生成,此处不设 Content-Type)
+ const finalHeader = { ...header }
+ const token = getToken()
+ if (auth && token) {
+ finalHeader.Authorization = 'Bearer ' + token
+ }
+
+ if (loading) {
+ uni.showLoading({ title: loadingText, mask: true })
+ }
+
+ return new Promise((resolve, reject) => {
+ uni.uploadFile({
+ url: fullUrl,
+ filePath,
+ name,
+ formData,
+ header: finalHeader,
+ timeout: config.timeout,
+ success: (res) => {
+ const { statusCode } = res
+
+ // 非 HTTP 200 视为网络异常
+ if (statusCode !== 200) {
+ reject(new Error('网络请求异常:' + statusCode))
+ return
+ }
+
+ // uploadFile 返回的 data 为字符串,需解析为 JSON
+ let body
+ try {
+ body = JSON.parse(res.data)
+ } catch (e) {
+ reject(new Error('上传响应解析失败'))
+ return
+ }
+
+ // 登录失效:清理登录态并跳转登录页
+ if (body && body.code === config.unauthorizedCode) {
+ _handleUnauthorized()
+ reject(new Error(body.msg || '登录状态已过期'))
+ return
+ }
+
+ // 业务成功:返回完整响应体,调用方按需取 data
+ if (body && body.code === config.successCode) {
+ resolve(body)
+ return
+ }
+
+ // 业务失败:统一抛出错误信息
+ reject(new Error((body && body.msg) || '上传失败'))
+ },
+ fail: (err) => {
+ reject(new Error((err && err.errMsg) || '上传失败'))
+ },
+ complete: () => {
+ if (loading) {
+ uni.hideLoading()
+ }
+ }
+ })
+ })
+}
+
// ======================== 快捷方法 ========================
/** GET 请求 */
--
Gitblit v1.7.1